From 1b0da7203d74a3669f70df8ecc5bf7f9c905f8ac Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Thu, 21 May 2026 21:15:08 +0800 Subject: [PATCH 1/3] chore: document cloud-split (changeset + CLI stub comment) - Add a changeset capturing the cloud-split cleanup so release notes explain the @objectstack/cli minor (no longer hard-deps service-cloud) and the removed root scripts. - Refresh packages/cli/src/types/service-cloud.d.ts top-of-file comment: the old wording ('pre-existing typecheck errors in upstream deps') predates the split. The accurate story is now: the package ships from objectstack-ai/cloud, the CLI loads it via dynamic import inside a try/catch, and this stub keeps the optional path typechecking. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/cloud-split-cleanup.md | 17 +++++++++++++++++ packages/cli/src/types/service-cloud.d.ts | 14 ++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 .changeset/cloud-split-cleanup.md 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/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; } From 3f5f826465dc2fdaafd4437d7f3c22374da609d5 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Thu, 21 May 2026 21:15:27 +0800 Subject: [PATCH 2/3] fix(storage): enable presigned upload + stable bytes endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SwappableStorageService now forwards verifyToken() to the inner LocalStorageAdapter. Previously the proxy dropped the method, causing PUT /api/v1/storage/_local/raw/:token to always return 501 'Presigned raw upload not supported by this adapter' even when the active adapter was Local. - Add GET /api/v1/storage/files/:fileId — a stable, non-JSON sibling of /files/:fileId/url that 302-redirects to a freshly-signed download URL. Frontend widgets (ImageField, , user avatars, org logos) need a URL that is stable across signed-URL expiry AND serves the bytes directly. The existing /url variant returns JSON and cannot be used verbatim as . - Wire UploadProvider into @objectstack/console with an inline ObjectStack presigned-upload adapter so every ImageField/FileField inside the console now hits the real storage service instead of falling back to blob: URLs that disappear on reload. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/console/package.json | 1 + apps/console/src/App.tsx | 84 ++++++++++++++++++- .../service-storage/src/storage-routes.ts | 36 ++++++++ .../src/swappable-storage-service.ts | 19 +++++ 4 files changed, 138 insertions(+), 2 deletions(-) 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/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); + } } From d0208e6d6807dc2c7685777852287ee762dbdcd9 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Thu, 21 May 2026 21:17:55 +0800 Subject: [PATCH 3/3] chore: sync pnpm-lock.yaml for @object-ui/providers Regenerate lockfile after apps/console added @object-ui/providers in 3f5f8264. Unblocks 'Check Changeset' CI which uses --frozen-lockfile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) 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)