Skip to content

Commit 8cbc768

Browse files
os-zhuanghotlongCopilot
authored
chore: document cloud-split (changeset + CLI stub comment) (#1260)
* 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> * fix(storage): enable presigned upload + stable bytes endpoint - 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, <img src>, 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 <img src>. - 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> * chore: sync pnpm-lock.yaml for @object-ui/providers Regenerate lockfile after apps/console added @object-ui/providers in 3f5f826. Unblocks 'Check Changeset' CI which uses --frozen-lockfile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6170164 commit 8cbc768

7 files changed

Lines changed: 168 additions & 6 deletions

File tree

.changeset/cloud-split-cleanup.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
CLI no longer hard-depends on `@objectstack/service-cloud`. The control plane
6+
(`apps/cloud` + `@objectstack/service-cloud`) and tenant runtime (`apps/objectos`)
7+
have been split into a private companion repo `objectstack-ai/cloud`. Framework
8+
remains pure open-core.
9+
10+
User impact:
11+
- `os serve --mode=cloud` keeps working in cloud-aware distributions — the CLI
12+
loads `@objectstack/service-cloud` via dynamic `import()` with try/catch and
13+
surfaces a clear "install the cloud distribution" hint when absent.
14+
- Root `pnpm dev` / `pnpm start` / `pnpm doctor` scripts in this repo are
15+
removed (they were thin filters of `@objectstack/objectos`, which no longer
16+
lives here). For a runnable local stack, use one of the examples
17+
(`pnpm --filter @example/app-crm dev`).

apps/console/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@object-ui/plugin-report": "^4.8.0",
4242
"@object-ui/plugin-timeline": "^4.8.0",
4343
"@object-ui/plugin-view": "^4.8.0",
44+
"@object-ui/providers": "^4.8.0",
4445
"@object-ui/react": "^4.8.0",
4546
"@object-ui/types": "^4.8.0",
4647
"clsx": "^2.1.1",

apps/console/src/App.tsx

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
1111
import type { ReactNode } from 'react';
1212
import { AuthProvider, AuthGuard } from '@object-ui/auth';
1313
import { Toaster } from 'sonner';
14+
import { UploadProvider, type UploadAdapter } from '@object-ui/providers';
1415
import {
1516
ConsoleShell,
1617
ConnectedShell,
@@ -30,11 +31,87 @@ import {
3031
gotoAccountRegister,
3132
gotoAccountForgotPassword,
3233
} from './lib/auth-redirect';
33-
import { useEffect } from 'react';
34+
import { useEffect, useMemo } from 'react';
3435

3536
const AUTH_URL = `${import.meta.env.VITE_SERVER_URL || ''}/api/v1/auth`;
37+
const STORAGE_BASE_URL = import.meta.env.VITE_SERVER_URL || '';
38+
const STORAGE_PATH = '/api/v1/storage';
3639
const BASENAME = (import.meta.env.BASE_URL || '/').replace(/\/$/, '') || '/';
3740

41+
/**
42+
* Inline ObjectStack presigned-upload adapter.
43+
*
44+
* Mirrors `@object-ui/providers/createObjectStackUploadAdapter` (introduced
45+
* post-4.8.0). Kept local so this console can ship against the published
46+
* 4.8.x runtime without bumping every workspace.
47+
*/
48+
function createStorageUploadAdapter(): UploadAdapter {
49+
const base = STORAGE_BASE_URL.replace(/\/$/, '');
50+
const apiUrl = (segment: string) =>
51+
/^https?:/i.test(segment) ? segment : `${base}${segment}`;
52+
return {
53+
name: 'objectstack-presigned',
54+
async upload(file: Blob, options: { signal?: AbortSignal } = {}) {
55+
const f = file as File;
56+
const name = ('name' in f && f.name) || 'upload';
57+
const mimeType = file.type || 'application/octet-stream';
58+
const presignRes = await fetch(apiUrl(`${STORAGE_PATH}/upload/presigned`), {
59+
method: 'POST',
60+
credentials: 'include',
61+
headers: { 'Content-Type': 'application/json' },
62+
signal: options.signal,
63+
body: JSON.stringify({ filename: name, mimeType, size: file.size }),
64+
});
65+
if (!presignRes.ok) {
66+
throw new Error(
67+
`Presigned upload failed (${presignRes.status}): ${await presignRes.text().catch(() => '')}`,
68+
);
69+
}
70+
const presignBody = await presignRes.json();
71+
const descriptor = presignBody?.data ?? presignBody;
72+
const { uploadUrl, fileId, headers: putHeaders } = descriptor as {
73+
uploadUrl: string;
74+
fileId: string;
75+
headers?: Record<string, string>;
76+
};
77+
if (!uploadUrl || !fileId) {
78+
throw new Error('Presigned upload response missing uploadUrl/fileId');
79+
}
80+
const putRes = await fetch(apiUrl(uploadUrl), {
81+
method: 'PUT',
82+
signal: options.signal,
83+
headers: { 'Content-Type': mimeType, ...(putHeaders ?? {}) },
84+
body: file,
85+
});
86+
if (!putRes.ok) {
87+
throw new Error(
88+
`Raw PUT failed (${putRes.status}): ${await putRes.text().catch(() => '')}`,
89+
);
90+
}
91+
const completeRes = await fetch(apiUrl(`${STORAGE_PATH}/upload/complete`), {
92+
method: 'POST',
93+
credentials: 'include',
94+
headers: { 'Content-Type': 'application/json' },
95+
signal: options.signal,
96+
body: JSON.stringify({ fileId }),
97+
});
98+
if (!completeRes.ok) {
99+
throw new Error(
100+
`Upload completion failed (${completeRes.status}): ${await completeRes.text().catch(() => '')}`,
101+
);
102+
}
103+
const stableUrl = apiUrl(`${STORAGE_PATH}/files/${encodeURIComponent(fileId)}`);
104+
return {
105+
url: stableUrl,
106+
name,
107+
size: file.size,
108+
mimeType,
109+
meta: { fileId },
110+
};
111+
},
112+
};
113+
}
114+
38115
/**
39116
* ProtectedRoute — replaces app-shell's AuthenticatedRoute. Same composition
40117
* (AuthGuard + ConnectedShell + optional RequireOrganization) but with an
@@ -81,9 +158,11 @@ function ForgotPasswordRedirect() {
81158
}
82159

83160
export function App() {
161+
const uploadAdapter = useMemo(() => createStorageUploadAdapter(), []);
84162
return (
85163
<AuthProvider authUrl={AUTH_URL}>
86-
<Toaster position="bottom-right" />
164+
<UploadProvider adapter={uploadAdapter}>
165+
<Toaster position="bottom-right" />
87166
<BrowserRouter basename={BASENAME}>
88167
<ConsoleShell>
89168
<Routes>
@@ -111,6 +190,7 @@ export function App() {
111190
</Routes>
112191
</ConsoleShell>
113192
</BrowserRouter>
193+
</UploadProvider>
114194
</AuthProvider>
115195
);
116196
}
Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1-
// Stub ambient declaration for the @objectstack/service-cloud package.
2-
// The package's tsup build cannot emit .d.ts yet (pre-existing typecheck
3-
// errors in upstream dependencies). The CLI only consumes the runtime
4-
// `createBootStack()` factory, so a loose `any` type suffices.
1+
// Ambient stub for `@objectstack/service-cloud`.
2+
//
3+
// `service-cloud` ships from the private `objectstack-ai/cloud` repo and is
4+
// NOT in this open-core workspace. The CLI's `serve --mode=cloud` boot path
5+
// dynamically `import()`s it inside a try/catch — when absent we surface a
6+
// clear "install the cloud-aware distribution" hint to the user.
7+
//
8+
// This declaration keeps the optional path typechecking. `any` is intentional
9+
// (the CLI only consumes the runtime factory; full types live in the cloud
10+
// repo).
511
declare module '@objectstack/service-cloud' {
612
export function createBootStack(config?: any): Promise<any>;
713
}

packages/services/service-storage/src/storage-routes.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,42 @@ export function registerStorageRoutes(
395395
}
396396
});
397397

398+
// ---------------------------------------------------------------------------
399+
// GET /storage/files/:fileId — stable redirect to the actual bytes.
400+
//
401+
// Frontend widgets (`ImageField`, `<img src>`, user avatars, org logos)
402+
// need a URL that:
403+
// - is stable (won't expire — records may live for years)
404+
// - serves the bytes directly when followed
405+
// The `/url` endpoint above returns JSON. This sibling endpoint resolves
406+
// to the same short-lived signed URL and 302-redirects so it can be used
407+
// verbatim in any browser context.
408+
// ---------------------------------------------------------------------------
409+
httpServer.get(`${basePath}/files/:fileId`, async (req: IHttpRequest, res: IHttpResponse) => {
410+
try {
411+
const { fileId } = req.params;
412+
const file = await store.getFile(fileId);
413+
if (!file || file.status !== 'committed') {
414+
res.status(404).json({ error: 'File not found or not committed' });
415+
return;
416+
}
417+
418+
let url: string;
419+
if (storage.getPresignedDownload) {
420+
const desc = await storage.getPresignedDownload(file.key, presignedTtl);
421+
url = desc.downloadUrl;
422+
} else if (storage.getSignedUrl) {
423+
url = await storage.getSignedUrl(file.key, presignedTtl);
424+
} else {
425+
url = `${basePath}/_local/file/${encodeURIComponent(file.key)}`;
426+
}
427+
428+
res.status(302).header('Location', url).send('');
429+
} catch (err: any) {
430+
res.status(500).json({ error: err.message ?? 'Internal error' });
431+
}
432+
});
433+
398434
// ---------------------------------------------------------------------------
399435
// PUT /storage/_local/raw/:token — presigned raw upload (LocalStorageAdapter)
400436
// ---------------------------------------------------------------------------

packages/services/service-storage/src/swappable-storage-service.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,4 +132,23 @@ export class SwappableStorageService implements IStorageService {
132132
}
133133
return this.inner.abortChunkedUpload(uploadId);
134134
}
135+
136+
/**
137+
* Verify a presigned HMAC token (LocalStorageAdapter-specific).
138+
*
139+
* `IStorageService` does not declare this method, but `storage-routes`
140+
* type-narrows the active storage to `LocalStorageAdapter` to handle the
141+
* `/_local/raw/:token` PUT and GET endpoints. Without a passthrough on
142+
* the swappable wrapper, the route sees `verifyToken === undefined` and
143+
* returns 501 even though the underlying local adapter supports it.
144+
*/
145+
verifyToken(token: string, expectedOp?: 'put' | 'get'): { k: string; ct?: string; op: string; exp: number } {
146+
const inner = this.inner as unknown as {
147+
verifyToken?: (token: string, expectedOp?: 'put' | 'get') => { k: string; ct?: string; op: string; exp: number };
148+
};
149+
if (typeof inner.verifyToken !== 'function') {
150+
throw new Error('Active storage adapter does not support verifyToken()');
151+
}
152+
return inner.verifyToken(token, expectedOp);
153+
}
135154
}

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)