diff --git a/.changeset/cloud-split-cleanup.md b/.changeset/cloud-split-cleanup.md new file mode 100644 index 000000000..3fe6d33e0 --- /dev/null +++ b/.changeset/cloud-split-cleanup.md @@ -0,0 +1,17 @@ +--- +'@objectstack/cli': minor +--- + +CLI no longer hard-depends on `@objectstack/service-cloud`. The control plane +(`apps/cloud` + `@objectstack/service-cloud`) and tenant runtime (`apps/objectos`) +have been split into a private companion repo `objectstack-ai/cloud`. Framework +remains pure open-core. + +User impact: +- `os serve --mode=cloud` keeps working in cloud-aware distributions — the CLI + loads `@objectstack/service-cloud` via dynamic `import()` with try/catch and + surfaces a clear "install the cloud distribution" hint when absent. +- Root `pnpm dev` / `pnpm start` / `pnpm doctor` scripts in this repo are + removed (they were thin filters of `@objectstack/objectos`, which no longer + lives here). For a runnable local stack, use one of the examples + (`pnpm --filter @example/app-crm dev`). diff --git a/apps/console/package.json b/apps/console/package.json index 2ce363a3d..e3c09ccdc 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -41,6 +41,7 @@ "@object-ui/plugin-report": "^4.8.0", "@object-ui/plugin-timeline": "^4.8.0", "@object-ui/plugin-view": "^4.8.0", + "@object-ui/providers": "^4.8.0", "@object-ui/react": "^4.8.0", "@object-ui/types": "^4.8.0", "clsx": "^2.1.1", diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index e4d3d5cde..52fb9c9a7 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -11,6 +11,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import type { ReactNode } from 'react'; import { AuthProvider, AuthGuard } from '@object-ui/auth'; import { Toaster } from 'sonner'; +import { UploadProvider, type UploadAdapter } from '@object-ui/providers'; import { ConsoleShell, ConnectedShell, @@ -30,11 +31,87 @@ import { gotoAccountRegister, gotoAccountForgotPassword, } from './lib/auth-redirect'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; const AUTH_URL = `${import.meta.env.VITE_SERVER_URL || ''}/api/v1/auth`; +const STORAGE_BASE_URL = import.meta.env.VITE_SERVER_URL || ''; +const STORAGE_PATH = '/api/v1/storage'; const BASENAME = (import.meta.env.BASE_URL || '/').replace(/\/$/, '') || '/'; +/** + * Inline ObjectStack presigned-upload adapter. + * + * Mirrors `@object-ui/providers/createObjectStackUploadAdapter` (introduced + * post-4.8.0). Kept local so this console can ship against the published + * 4.8.x runtime without bumping every workspace. + */ +function createStorageUploadAdapter(): UploadAdapter { + const base = STORAGE_BASE_URL.replace(/\/$/, ''); + const apiUrl = (segment: string) => + /^https?:/i.test(segment) ? segment : `${base}${segment}`; + return { + name: 'objectstack-presigned', + async upload(file: Blob, options: { signal?: AbortSignal } = {}) { + const f = file as File; + const name = ('name' in f && f.name) || 'upload'; + const mimeType = file.type || 'application/octet-stream'; + const presignRes = await fetch(apiUrl(`${STORAGE_PATH}/upload/presigned`), { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + signal: options.signal, + body: JSON.stringify({ filename: name, mimeType, size: file.size }), + }); + if (!presignRes.ok) { + throw new Error( + `Presigned upload failed (${presignRes.status}): ${await presignRes.text().catch(() => '')}`, + ); + } + const presignBody = await presignRes.json(); + const descriptor = presignBody?.data ?? presignBody; + const { uploadUrl, fileId, headers: putHeaders } = descriptor as { + uploadUrl: string; + fileId: string; + headers?: Record; + }; + if (!uploadUrl || !fileId) { + throw new Error('Presigned upload response missing uploadUrl/fileId'); + } + const putRes = await fetch(apiUrl(uploadUrl), { + method: 'PUT', + signal: options.signal, + headers: { 'Content-Type': mimeType, ...(putHeaders ?? {}) }, + body: file, + }); + if (!putRes.ok) { + throw new Error( + `Raw PUT failed (${putRes.status}): ${await putRes.text().catch(() => '')}`, + ); + } + const completeRes = await fetch(apiUrl(`${STORAGE_PATH}/upload/complete`), { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + signal: options.signal, + body: JSON.stringify({ fileId }), + }); + if (!completeRes.ok) { + throw new Error( + `Upload completion failed (${completeRes.status}): ${await completeRes.text().catch(() => '')}`, + ); + } + const stableUrl = apiUrl(`${STORAGE_PATH}/files/${encodeURIComponent(fileId)}`); + return { + url: stableUrl, + name, + size: file.size, + mimeType, + meta: { fileId }, + }; + }, + }; +} + /** * ProtectedRoute — replaces app-shell's AuthenticatedRoute. Same composition * (AuthGuard + ConnectedShell + optional RequireOrganization) but with an @@ -81,9 +158,11 @@ function ForgotPasswordRedirect() { } export function App() { + const uploadAdapter = useMemo(() => createStorageUploadAdapter(), []); return ( - + + @@ -111,6 +190,7 @@ export function App() { + ); } diff --git a/packages/cli/src/types/service-cloud.d.ts b/packages/cli/src/types/service-cloud.d.ts index 75e220e82..0bb993c94 100644 --- a/packages/cli/src/types/service-cloud.d.ts +++ b/packages/cli/src/types/service-cloud.d.ts @@ -1,7 +1,13 @@ -// Stub ambient declaration for the @objectstack/service-cloud package. -// The package's tsup build cannot emit .d.ts yet (pre-existing typecheck -// errors in upstream dependencies). The CLI only consumes the runtime -// `createBootStack()` factory, so a loose `any` type suffices. +// Ambient stub for `@objectstack/service-cloud`. +// +// `service-cloud` ships from the private `objectstack-ai/cloud` repo and is +// NOT in this open-core workspace. The CLI's `serve --mode=cloud` boot path +// dynamically `import()`s it inside a try/catch — when absent we surface a +// clear "install the cloud-aware distribution" hint to the user. +// +// This declaration keeps the optional path typechecking. `any` is intentional +// (the CLI only consumes the runtime factory; full types live in the cloud +// repo). declare module '@objectstack/service-cloud' { export function createBootStack(config?: any): Promise; } diff --git a/packages/services/service-storage/src/storage-routes.ts b/packages/services/service-storage/src/storage-routes.ts index df5548744..7bfcac9ee 100644 --- a/packages/services/service-storage/src/storage-routes.ts +++ b/packages/services/service-storage/src/storage-routes.ts @@ -395,6 +395,42 @@ export function registerStorageRoutes( } }); + // --------------------------------------------------------------------------- + // GET /storage/files/:fileId — stable redirect to the actual bytes. + // + // Frontend widgets (`ImageField`, ``, user avatars, org logos) + // need a URL that: + // - is stable (won't expire — records may live for years) + // - serves the bytes directly when followed + // The `/url` endpoint above returns JSON. This sibling endpoint resolves + // to the same short-lived signed URL and 302-redirects so it can be used + // verbatim in any browser context. + // --------------------------------------------------------------------------- + httpServer.get(`${basePath}/files/:fileId`, async (req: IHttpRequest, res: IHttpResponse) => { + try { + const { fileId } = req.params; + const file = await store.getFile(fileId); + if (!file || file.status !== 'committed') { + res.status(404).json({ error: 'File not found or not committed' }); + return; + } + + let url: string; + if (storage.getPresignedDownload) { + const desc = await storage.getPresignedDownload(file.key, presignedTtl); + url = desc.downloadUrl; + } else if (storage.getSignedUrl) { + url = await storage.getSignedUrl(file.key, presignedTtl); + } else { + url = `${basePath}/_local/file/${encodeURIComponent(file.key)}`; + } + + res.status(302).header('Location', url).send(''); + } catch (err: any) { + res.status(500).json({ error: err.message ?? 'Internal error' }); + } + }); + // --------------------------------------------------------------------------- // PUT /storage/_local/raw/:token — presigned raw upload (LocalStorageAdapter) // --------------------------------------------------------------------------- diff --git a/packages/services/service-storage/src/swappable-storage-service.ts b/packages/services/service-storage/src/swappable-storage-service.ts index 540971699..670e290b9 100644 --- a/packages/services/service-storage/src/swappable-storage-service.ts +++ b/packages/services/service-storage/src/swappable-storage-service.ts @@ -132,4 +132,23 @@ export class SwappableStorageService implements IStorageService { } return this.inner.abortChunkedUpload(uploadId); } + + /** + * Verify a presigned HMAC token (LocalStorageAdapter-specific). + * + * `IStorageService` does not declare this method, but `storage-routes` + * type-narrows the active storage to `LocalStorageAdapter` to handle the + * `/_local/raw/:token` PUT and GET endpoints. Without a passthrough on + * the swappable wrapper, the route sees `verifyToken === undefined` and + * returns 501 even though the underlying local adapter supports it. + */ + verifyToken(token: string, expectedOp?: 'put' | 'get'): { k: string; ct?: string; op: string; exp: number } { + const inner = this.inner as unknown as { + verifyToken?: (token: string, expectedOp?: 'put' | 'get') => { k: string; ct?: string; op: string; exp: number }; + }; + if (typeof inner.verifyToken !== 'function') { + throw new Error('Active storage adapter does not support verifyToken()'); + } + return inner.verifyToken(token, expectedOp); + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b635ce346..883110892 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -237,6 +237,9 @@ importers: '@object-ui/plugin-view': specifier: ^4.8.0 version: 4.8.0(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(ai@6.0.185(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react-is@17.0.2)(react@19.2.6)(redux@5.0.1)(tailwindcss@4.3.0)(typescript@6.0.3) + '@object-ui/providers': + specifier: ^4.8.0 + version: 4.8.0(ai@6.0.185(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@object-ui/react': specifier: ^4.8.0 version: 4.8.0(ai@6.0.185(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)