Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/cloud-split-cleanup.md
Original file line number Diff line number Diff line change
@@ -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`).
1 change: 1 addition & 0 deletions apps/console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
84 changes: 82 additions & 2 deletions apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, string>;
};
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
Expand Down Expand Up @@ -81,9 +158,11 @@ function ForgotPasswordRedirect() {
}

export function App() {
const uploadAdapter = useMemo(() => createStorageUploadAdapter(), []);
return (
<AuthProvider authUrl={AUTH_URL}>
<Toaster position="bottom-right" />
<UploadProvider adapter={uploadAdapter}>
<Toaster position="bottom-right" />
<BrowserRouter basename={BASENAME}>
<ConsoleShell>
<Routes>
Expand Down Expand Up @@ -111,6 +190,7 @@ export function App() {
</Routes>
</ConsoleShell>
</BrowserRouter>
</UploadProvider>
</AuthProvider>
);
}
14 changes: 10 additions & 4 deletions packages/cli/src/types/service-cloud.d.ts
Original file line number Diff line number Diff line change
@@ -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<any>;
}
36 changes: 36 additions & 0 deletions packages/services/service-storage/src/storage-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,42 @@ export function registerStorageRoutes(
}
});

// ---------------------------------------------------------------------------
// GET /storage/files/:fileId — stable redirect to the actual bytes.
//
// Frontend widgets (`ImageField`, `<img src>`, 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)
// ---------------------------------------------------------------------------
Expand Down
19 changes: 19 additions & 0 deletions packages/services/service-storage/src/swappable-storage-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.