From 92dd71661895d83866ea17a6232d75bef391e8be Mon Sep 17 00:00:00 2001 From: Andrej Guran Date: Mon, 10 Aug 2026 12:35:24 +0000 Subject: [PATCH] Release agent-first OpenCloud CLI v3 --- CHANGELOG.md | 14 ++ README.md | 41 +++- package-lock.json | 4 +- package.json | 2 +- src/bundle.test.ts | 8 +- src/dev-data.test.ts | 56 +++++ src/dev-data.ts | 98 ++++++++ src/index.ts | 58 ++--- vendor/browser-client/package.json | 2 +- vendor/browser-client/src/index.test.ts | 130 ++++++---- vendor/browser-client/src/index.ts | 243 +++++++++++-------- vendor/bundler/src/index.ts | 15 +- vendor/contracts/src/control-plane.test.ts | 27 ++- vendor/contracts/src/control-plane.ts | 7 +- vendor/contracts/src/index.ts | 1 + vendor/contracts/src/manifest.test.ts | 43 +++- vendor/contracts/src/manifest.ts | 45 ++-- vendor/contracts/src/sql-conventions.test.ts | 31 +++ vendor/contracts/src/sql-conventions.ts | 66 +++++ 19 files changed, 651 insertions(+), 240 deletions(-) create mode 100644 src/dev-data.test.ts create mode 100644 src/dev-data.ts create mode 100644 vendor/contracts/src/sql-conventions.test.ts create mode 100644 vendor/contracts/src/sql-conventions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 56b79af..f5cfed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 3.0.0 + +- Vendor the hard-cut OpenCloud browser SDK 2.0.0 and final schema-2 manifest + contract. Legacy SDK surfaces and `requiredSecrets` are rejected instead of + translated. +- Replace raw-path development fixture writes with SDK-shaped table actions: + `create`, `createMany`, `updateById`, and `deleteById`. +- Make secret intent declarative through `generated`, `required`, and + `optional` manifest modes. Generated values are provisioned automatically; + the CLI exposes only explicit `secret rotate` and secure `secret configure` + workflows. +- Generate new projects with the stable singleton browser SDK and an empty + declarative secret map. + ## 2.0.0 - Make manifest schema 2 the only application contract. Projects now use diff --git a/README.md b/README.md index b49a7a2..343aab0 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,12 @@ offline source bundle, but cannot connect to or deploy through OpenCloud. ## Install a pinned release -OpenCloud application skills pin an exact CLI release. To install `v2.0.0` in +OpenCloud application skills pin an exact CLI release. To install `v3.0.0` in an isolated task directory: ```bash -OPENCLOUD_CLI_VERSION="v2.0.0" -OPENCLOUD_CLI_PACKAGE="opencloud-cli-2.0.0.tgz" +OPENCLOUD_CLI_VERSION="v3.0.0" +OPENCLOUD_CLI_PACKAGE="opencloud-cli-3.0.0.tgz" OPENCLOUD_CLI_DIR="$(mktemp -d)" curl -fsSLo "$OPENCLOUD_CLI_DIR/$OPENCLOUD_CLI_PACKAGE" \ @@ -128,15 +128,26 @@ If the email remains unverified when that window ends, OpenCloud pauses every linked app, function, and cron schedule while preserving data and releases. Email verification resumes them. -Secrets never need to cross the terminal transcript: +Declare secret intent in `opencloud.yaml`; values never cross the terminal +transcript: + +```yaml +secrets: + SESSION_KEY: generated + PAYMENT_API_KEY: required + ORGANIZATION_LABEL: optional +``` + +Generated values are provisioned automatically. Use these commands only to +rotate a generated value or securely configure a required/optional value: ```bash -"$OPENCLOUD_CLI" secret generate "$APP_ID" SESSION_KEY -"$OPENCLOUD_CLI" secret entry-link "$APP_ID" PAYMENT_API_KEY +"$OPENCLOUD_CLI" secret rotate "$APP_ID" SESSION_KEY +"$OPENCLOUD_CLI" secret configure "$APP_ID" PAYMENT_API_KEY ``` -The first command creates a server-generated value. The second returns a -one-time browser URL where the user enters a value directly into OpenCloud. +Rotation never returns the generated value. Configuration returns a one-time +browser URL where the owner enters a value directly into OpenCloud. Existing installations can still supply `OPENCLOUD_API_URL` and `OPENCLOUD_TOKEN` explicitly. @@ -152,8 +163,10 @@ Use the stable capability preview and isolated migration-replayed database befor "$OPENCLOUD_CLI" app dev start . "$OPENCLOUD_CLI" app dev sync . "$OPENCLOUD_CLI" app dev request . / -"$OPENCLOUD_CLI" app dev data . /rest/v1/items \ - --method POST --body '[{"title":"Preview item"}]' +"$OPENCLOUD_CLI" app dev data . items create \ + --values '{"title":"Preview item"}' +"$OPENCLOUD_CLI" app dev data . items updateById \ + --id "$ITEM_ID" --values '{"title":"Updated preview item"}' "$OPENCLOUD_CLI" app dev invoke . function-name --body '{"example":true}' "$OPENCLOUD_CLI" app dev requests . "$OPENCLOUD_CLI" app dev verify . @@ -163,8 +176,10 @@ Use the stable capability preview and isolated migration-replayed database befor ``` Development data is isolated from production and uses dummy records. Auth, -Files, and Functions are available; Realtime, cron, and production secrets are -not. Functions +Files, and Functions are available; Realtime and cron are not. Manifest- +generated secrets receive isolated synthetic development values, while owner- +configured required values remain unavailable and optional values may be +absent. Functions imported from `@opencloud/server` remain dormant until `app dev invoke` or a deliberate preview interaction calls them. Exact-revision verification requires every declared Function to have a successful explicit invocation. @@ -205,7 +220,7 @@ app-declared interaction contract on the server: "$OPENCLOUD_CLI" app verify "$APP_ID" ``` -CLI v2 has one release-verification command. The former local smoke, Chromium, +CLI v3 has one release-verification command. The former local smoke, Chromium, session, and verification-contract commands were removed so agents cannot mistake a partial diagnostic for the authoritative gate. diff --git a/package-lock.json b/package-lock.json index 255211d..56cb788 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opencloud/cli", - "version": "2.0.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opencloud/cli", - "version": "2.0.0", + "version": "3.0.0", "dependencies": { "@napi-rs/keyring": "1.3.0" }, diff --git a/package.json b/package.json index 8649338..8ef37d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opencloud/cli", - "version": "2.0.0", + "version": "3.0.0", "description": "Versioned command-line client for building, deploying, and verifying OpenCloud applications", "type": "module", "bin": { diff --git a/src/bundle.test.ts b/src/bundle.test.ts index 2b3792b..9b33610 100644 --- a/src/bundle.test.ts +++ b/src/bundle.test.ts @@ -89,7 +89,7 @@ functions: expect(first.manifest.migrations[0]?.sha256).toMatch(/^[a-f0-9]{64}$/); expect(first.manifest.runtime).toEqual({ sdk: { - version: "1.0.0", + version: "2.0.0", }, }); expect(first.files).toEqual([ @@ -199,7 +199,7 @@ functions: [] expect(bundle.files).not.toContain("functions/forgotten/index.ts"); }); - it("preserves an explicit SDK pin instead of replacing it with current", async () => { + it("accepts the explicit installed SDK pin", async () => { const root = await temporaryDirectory(); await mkdir(path.join(root, "frontend")); await writeFile(path.join(root, "frontend", "index.html"), "hello"); @@ -210,14 +210,14 @@ frontend: directory: frontend runtime: sdk: - version: 9.8.7 + version: 2.0.0 `, ); const bundle = await buildBundle(root); expect(bundle.manifest.runtime).toEqual({ sdk: { - version: "9.8.7", + version: "2.0.0", }, }); }); diff --git a/src/dev-data.test.ts b/src/dev-data.test.ts new file mode 100644 index 0000000..bfc662b --- /dev/null +++ b/src/dev-data.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { devDataRequest } from "./dev-data.js"; + +describe("development fixture data", () => { + it.each([ + [ + "create", + { values: '{"title":"One"}' }, + { path: "/rest/v1/items", method: "POST", body: { title: "One" } }, + ], + [ + "createMany", + { values: '[{"title":"One"},{"title":"Two"}]' }, + { + path: "/rest/v1/items", + method: "POST", + body: [{ title: "One" }, { title: "Two" }], + }, + ], + [ + "updateById", + { id: "row/1", values: '{"title":"Changed"}' }, + { + path: "/rest/v1/items?id=eq.row%2F1", + method: "PATCH", + body: { title: "Changed" }, + }, + ], + [ + "deleteById", + { id: "row-1" }, + { path: "/rest/v1/items?id=eq.row-1", method: "DELETE" }, + ], + ] as const)("maps %s without exposing REST", (action, options, expected) => { + expect(devDataRequest("items", action, options)).toEqual(expected); + }); + + it("rejects incomplete, malformed, and extraneous inputs", () => { + expect(() => devDataRequest("Items", "create", { values: "{}" })).toThrow( + /lowercase SQL identifier/, + ); + expect(() => devDataRequest("items", "create", {})).toThrow(); + expect(() => + devDataRequest("items", "createMany", { values: "{}" }), + ).toThrow(); + expect(() => + devDataRequest("items", "deleteById", { id: "row-1", values: "{}" }), + ).toThrow(); + expect(() => + devDataRequest("items", "updateById", { + id: "row-1", + values: "not-json", + }), + ).toThrow("--values must be valid JSON"); + }); +}); diff --git a/src/dev-data.ts b/src/dev-data.ts new file mode 100644 index 0000000..95d3405 --- /dev/null +++ b/src/dev-data.ts @@ -0,0 +1,98 @@ +import { z } from "zod"; + +const values = z.record(z.string(), z.unknown()); +const table = z.string().regex(/^[a-z_][a-z0-9_]{0,62}$/, { + message: "table must be a lowercase SQL identifier", +}); + +const actionSchema = z.discriminatedUnion("action", [ + z + .object({ + table, + action: z.literal("create"), + values, + }) + .strict(), + z + .object({ + table, + action: z.literal("createMany"), + values: z.array(values).min(1).max(100), + }) + .strict(), + z + .object({ + table, + action: z.literal("updateById"), + id: z.string().min(1).max(512), + values, + }) + .strict(), + z + .object({ + table, + action: z.literal("deleteById"), + id: z.string().min(1).max(512), + }) + .strict(), +]); + +export type DevDataAction = + | "create" + | "createMany" + | "updateById" + | "deleteById"; + +interface DevDataOptions { + id?: string | undefined; + values?: string | undefined; +} + +function parseValues(value: string | undefined): unknown { + if (value === undefined) return undefined; + try { + return JSON.parse(value) as unknown; + } catch { + throw new Error("--values must be valid JSON"); + } +} + +export function devDataRequest( + tableName: string, + action: DevDataAction, + options: DevDataOptions, +): { + path: string; + method: "POST" | "PATCH" | "DELETE"; + body?: unknown; +} { + const parsed = actionSchema.safeParse({ + table: tableName, + action, + ...(options.id === undefined ? {} : { id: options.id }), + ...(options.values === undefined + ? {} + : { values: parseValues(options.values) }), + }); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const location = issue?.path.length ? ` at ${issue.path.join(".")}` : ""; + throw new Error( + `Invalid ${action} fixture${location}: ${issue?.message ?? "invalid input"}`, + ); + } + const input = parsed.data; + const suffix = + "id" in input ? `?id=eq.${encodeURIComponent(input.id)}` : ""; + const method = + input.action === "updateById" + ? "PATCH" + : input.action === "deleteById" + ? "DELETE" + : "POST"; + return { + path: `/rest/v1/${input.table}${suffix}`, + method, + ...(input.action === "deleteById" ? {} : { body: input.values }), + }; +} diff --git a/src/index.ts b/src/index.ts index 5325b0a..d6d8400 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto"; import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; -import { Command, Option } from "commander"; +import { Argument, Command, Option } from "commander"; import YAML from "yaml"; import { OPEN_CLOUD_SDK_VERSION } from "@opencloud/js"; import { @@ -24,6 +24,7 @@ import { import { buildBundle } from "./bundle.js"; import { CredentialStore } from "./credential-store.js"; import { doctorDiagnostics } from "./doctor.js"; +import { devDataRequest, type DevDataAction } from "./dev-data.js"; import { deleteSession, loadSession, @@ -40,7 +41,7 @@ import { resolveWorkspaceFile, } from "./workspace-store.js"; -const CLI_VERSION = "2.0.0"; +const CLI_VERSION = "3.0.0"; const program = new Command() .name("opencloud") @@ -1072,32 +1073,33 @@ dev dev .command("data") - .description("Write bounded fixture data only to the isolated dev database") + .description( + "Create, update, or delete typed fixtures in the isolated dev database", + ) .argument("") - .argument("", "a /rest/v1/... path") - .addOption( - new Option("--method ", "HTTP method") - .choices(["POST", "PUT", "PATCH", "DELETE"]) - .default("POST"), + .argument("", "lowercase table name") + .addArgument( + new Argument("", "SDK-shaped fixture action").choices([ + "create", + "createMany", + "updateById", + "deleteById", + ]), ) - .option("--body ", "JSON request body", "{}") - .action(async (directory, requestPath, options) => { + .option("--values ", "row object or row array, as JSON") + .option("--id ", "row id for updateById or deleteById") + .action(async (directory, table, action, options) => { const state = await requireDevState(callerPath(directory)); - let body: unknown; - try { - body = JSON.parse(String(options.body)); - } catch { - throw new Error("--body must be valid JSON"); - } + const body = devDataRequest(String(table), action as DevDataAction, { + id: options.id === undefined ? undefined : String(options.id), + values: + options.values === undefined ? undefined : String(options.values), + }); output( await client().call("mutateDevData", { appId: state.appId, sessionId: state.sessionId, - body: { - path: String(requestPath), - method: options.method as "POST" | "PUT" | "PATCH" | "DELETE", - body, - }, + body, }), ); }); @@ -1508,7 +1510,7 @@ program functions: [], cron: [], health: { path: "/" }, - requiredSecrets: [], + secrets: {}, }), { flag: "wx" }, ); @@ -1610,7 +1612,7 @@ program migrations: bundle.manifest.migrations.length, functions: bundle.manifest.functions.length, cron: bundle.manifest.cron.filter((item) => item.enabled).length, - requiredSecrets: bundle.manifest.requiredSecrets, + secrets: bundle.manifest.secrets, files: bundle.files, warnings: bundle.warnings, archivePath, @@ -1764,8 +1766,10 @@ const secret = program .description("Manage app-scoped secrets"); secret - .command("generate") - .description("Generate and store a random secret without returning its value") + .command("rotate") + .description( + "Rotate a manifest-generated secret without returning its value", + ) .argument("") .argument("") .option("--bytes ", "random byte count", "32") @@ -1784,9 +1788,9 @@ secret ); secret - .command("entry-link") + .command("configure") .description( - "Create a one-time browser link for entering a secret outside the agent conversation", + "Create a one-time browser link for configuring a required or optional secret", ) .argument("") .argument("") diff --git a/vendor/browser-client/package.json b/vendor/browser-client/package.json index 79d3b0c..e827745 100644 --- a/vendor/browser-client/package.json +++ b/vendor/browser-client/package.json @@ -1,6 +1,6 @@ { "name": "@opencloud/js", - "version": "1.0.0", + "version": "2.0.0", "private": true, "type": "module" } diff --git a/vendor/browser-client/src/index.test.ts b/vendor/browser-client/src/index.test.ts index 45672dc..3e297e6 100644 --- a/vendor/browser-client/src/index.test.ts +++ b/vendor/browser-client/src/index.test.ts @@ -12,6 +12,19 @@ const origin = "https://tasks.opencloud.test"; const appId = "11111111-1111-4111-8111-111111111111"; const userId = "22222222-2222-4222-8222-222222222222"; const fileId = "33333333-3333-4333-8333-333333333333"; +const fileCreatedAt = "2026-08-10T00:00:00.000Z"; + +function managedFile(overrides: Record = {}) { + return { + id: fileId, + name: "proof.txt", + contentType: "text/plain", + size: 5, + createdAt: fileCreatedAt, + updatedAt: fileCreatedAt, + ...overrides, + }; +} const runtimeConfig = { appId, @@ -20,7 +33,7 @@ const runtimeConfig = { environment: "production", sdk: { package: "@opencloud/js", - version: "1.0.0", + version: "2.0.0", module: "/_opencloud/sdk.js", types: "/_opencloud/sdk.d.ts", docs: "https://docs.opencloud.ai/sdk/javascript/", @@ -163,9 +176,9 @@ afterEach(() => { vi.unstubAllGlobals(); }); -describe("@opencloud/js v1", () => { +describe("@opencloud/js v2", () => { it("exports one stable singleton contract without legacy factories or raw namespaces", () => { - expect(OPEN_CLOUD_SDK_VERSION).toBe("1.0.0"); + expect(OPEN_CLOUD_SDK_VERSION).toBe("2.0.0"); expect("OPEN_CLOUD_JS_VERSION" in sdk).toBe(false); expect(opencloud).toMatchObject({ app: { info: expect.any(Function) }, @@ -177,6 +190,7 @@ describe("@opencloud/js v1", () => { data: { table: expect.any(Function) }, files: { upload: expect.any(Function), + info: expect.any(Function), download: expect.any(Function), save: expect.any(Function), replace: expect.any(Function), @@ -243,7 +257,7 @@ describe("@opencloud/js v1", () => { expect(JSON.stringify(user)).not.toContain("private-access-token"); }); - it("rejects non-v1 runtime config shapes instead of interpreting compatibility aliases", async () => { + it("rejects non-v2 runtime config shapes instead of interpreting compatibility aliases", async () => { const incompatibleConfigs = [ { ...runtimeConfig, sdk: undefined, javascriptSdk: runtimeConfig.sdk }, { ...runtimeConfig, environment: "prod" }, @@ -363,12 +377,18 @@ describe("@opencloud/js v1", () => { standardFetch((url, init) => { calls.push({ url, init }); if (url.pathname === "/_opencloud/files" && init.method === "POST") { - return json({ - id: fileId, + return json(managedFile({ name: "résumé.pdf", contentType: "application/pdf", size: 7, - }, { status: 201 }); + }), { status: 201 }); + } + if (url.pathname === `/_opencloud/files/${fileId}/metadata`) { + return json(managedFile({ + name: "résumé.pdf", + contentType: "application/pdf", + size: 7, + })); } if (url.pathname === `/_opencloud/files/${fileId}` && !init.method) { return new Response("content", { @@ -379,12 +399,11 @@ describe("@opencloud/js v1", () => { }); } if (url.pathname === `/_opencloud/files/${fileId}` && init.method === "PUT") { - return json({ - id: fileId, + return json(managedFile({ name: "new.pdf", contentType: "application/pdf", size: 3, - }); + })); } if (url.pathname === `/_opencloud/files/${fileId}` && init.method === "DELETE") { return new Response(null, { status: 204 }); @@ -392,24 +411,26 @@ describe("@opencloud/js v1", () => { return undefined; }); - const file = await opencloud.files.upload( - new Blob(["content"], { type: "application/pdf" }), - { name: "résumé.pdf" }, - ); - const downloaded = await opencloud.files.download(file.id); - const replaced = await opencloud.files.replace( - file, - new Blob(["new"], { type: "application/pdf" }), - { name: "new.pdf" }, - ); + const file = await opencloud.files.upload({ + data: new Blob(["content"], { type: "application/pdf" }), + name: "résumé.pdf", + }); + const info = await opencloud.files.info({ id: file.id }); + const downloaded = await opencloud.files.download(file); + const replaced = await opencloud.files.replace(file, { + data: new Blob(["new"], { type: "application/pdf" }), + name: "new.pdf", + }); await opencloud.files.remove(file.id); expect(file.id).toBe(fileId); - expect(await downloaded.blob.text()).toBe("content"); + expect(info).toMatchObject({ id: fileId, name: "résumé.pdf" }); + expect(await downloaded.data.text()).toBe("content"); expect(downloaded.name).toBe("résumé.pdf"); expect(replaced).toMatchObject({ id: fileId, name: "new.pdf", size: 3 }); expect(calls.map((call) => call.url.pathname)).toEqual([ "/_opencloud/files", + `/_opencloud/files/${fileId}/metadata`, `/_opencloud/files/${fileId}`, `/_opencloud/files/${fileId}`, `/_opencloud/files/${fileId}`, @@ -420,7 +441,7 @@ describe("@opencloud/js v1", () => { expect(new Headers(calls[0]?.init.headers).get("idempotency-key")).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, ); - expect(new Headers(calls[2]?.init.headers).get("idempotency-key")).toMatch( + expect(new Headers(calls[3]?.init.headers).get("idempotency-key")).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, ); for (const call of calls) { @@ -442,16 +463,16 @@ describe("@opencloud/js v1", () => { new Headers(init.headers).get("idempotency-key") ?? "", ); if (attempts === 1) throw new TypeError("connection reset after commit"); - return json({ - id: fileId, + return json(managedFile({ name: "proof.txt", contentType: "text/plain", size: 5, - }); + })); }); await expect( - opencloud.files.upload(new Blob(["proof"], { type: "text/plain" }), { + opencloud.files.upload({ + data: new Blob(["proof"], { type: "text/plain" }), name: "proof.txt", onProgress: ({ percent }) => progress.push(percent), }), @@ -469,7 +490,7 @@ describe("@opencloud/js v1", () => { it("rejects oversized files before contacting the managed gateway", async () => { const fetchMock = standardFetch(); await expect( - opencloud.files.upload(new Blob(["123456"]), { maxBytes: 5 }), + opencloud.files.upload({ data: new Blob(["123456"]), maxBytes: 5 }), ).rejects.toMatchObject({ code: "FILE_TOO_LARGE", surface: "files" }); expect(fetchMock).toHaveBeenCalledTimes(1); }); @@ -478,7 +499,8 @@ describe("@opencloud/js v1", () => { const fetchMock = standardFetch(); await expect( - opencloud.files.upload(new Blob(["proof"]), { + opencloud.files.upload({ + data: new Blob(["proof"]), clientKey: "guessed-retry-key", } as never), ).rejects.toMatchObject({ @@ -486,7 +508,8 @@ describe("@opencloud/js v1", () => { surface: "files", }); await expect( - opencloud.files.attach(new Blob(["proof"]), { + opencloud.files.attach({ + data: new Blob(["proof"]), table: "evidence", row: { case_id: "claim-1" }, } as never), @@ -501,7 +524,7 @@ describe("@opencloud/js v1", () => { const writes: Record[] = []; standardFetch((url, init) => { if (url.pathname === "/_opencloud/files") { - return json({ id: fileId, name: "proof.txt", contentType: "text/plain", size: 5 }); + return json(managedFile()); } if (url.pathname === "/rest/v1/evidence" && init.method === "POST") { const value = JSON.parse(String(init.body)) as Record; @@ -511,14 +534,12 @@ describe("@opencloud/js v1", () => { return undefined; }); - const result = await opencloud.files.attach<{ id: string }>( - new Blob(["proof"], { type: "text/plain" }), - { - table: "evidence", - values: { claim_id: "claim-1" }, - upload: { name: "proof.txt" }, - }, - ); + const result = await opencloud.files.attach<{ id: string }>({ + data: new Blob(["proof"], { type: "text/plain" }), + table: "evidence", + values: { claim_id: "claim-1" }, + name: "proof.txt", + }); expect(result.record.id).toBe("evidence-1"); expect(writes).toEqual([{ @@ -535,7 +556,7 @@ describe("@opencloud/js v1", () => { let deletes = 0; standardFetch((url, init) => { if (url.pathname === "/_opencloud/files") { - return json({ id: fileId, name: "proof.txt", contentType: "text/plain", size: 5 }); + return json(managedFile()); } if (url.pathname === "/rest/v1/evidence" && init.method === "POST") { metadataWrites += 1; @@ -552,10 +573,11 @@ describe("@opencloud/js v1", () => { return undefined; }); - await expect(opencloud.files.attach( - new Blob(["proof"], { type: "text/plain" }), - { table: "evidence", upload: { name: "proof.txt" } }, - )).resolves.toMatchObject({ record: { id: "evidence-1" } }); + await expect(opencloud.files.attach({ + data: new Blob(["proof"], { type: "text/plain" }), + table: "evidence", + name: "proof.txt", + })).resolves.toMatchObject({ record: { id: "evidence-1" } }); expect(metadataWrites).toBe(1); expect(deletes).toBe(0); }); @@ -563,7 +585,7 @@ describe("@opencloud/js v1", () => { it("cleans up a definite attachment failure and reports unconfirmed cleanup", async () => { standardFetch((url, init) => { if (url.pathname === "/_opencloud/files") { - return json({ id: fileId, name: "proof.txt", contentType: "text/plain", size: 5 }); + return json(managedFile()); } if (url.pathname === "/rest/v1/evidence") { return json({ message: "claim does not exist" }, { status: 400 }); @@ -574,10 +596,11 @@ describe("@opencloud/js v1", () => { return undefined; }); - const promise = opencloud.files.attach( - new Blob(["proof"], { type: "text/plain" }), - { table: "evidence", upload: { name: "proof.txt" } }, - ); + const promise = opencloud.files.attach({ + data: new Blob(["proof"], { type: "text/plain" }), + table: "evidence", + name: "proof.txt", + }); await expect(promise).rejects.toMatchObject({ code: "FILE_ATTACHMENT_INCOMPLETE", surface: "files", @@ -724,7 +747,7 @@ describe("@opencloud/js v1", () => { throw new Error(`Unexpected request ${url}`); }); - await expect(opencloud.files.upload(new Blob(["x"]))).rejects.toMatchObject({ + await expect(opencloud.files.upload({ data: new Blob(["x"]) })).rejects.toMatchObject({ code: "CAPABILITY_UNAVAILABLE", surface: "files", }); @@ -772,7 +795,6 @@ describe("@opencloud/js v1", () => { await expect(opencloud.telemetry.summary()).resolves.toMatchObject({ appId }); await opencloud.telemetry.increment("tasks_created", 1, { dimensions: { actor: "member" }, - idempotencyKey: "task:1", }); await opencloud.telemetry.gauge("tasks_open", 7); expect(calls.map((call) => call.path)).toEqual([ @@ -781,5 +803,13 @@ describe("@opencloud/js v1", () => { "/_opencloud/telemetry/metrics", ]); expect(calls.every((call) => call.init.credentials === "same-origin")).toBe(true); + const writes = calls.slice(1).map((call) => + JSON.parse(String(call.init.body)).measurements[0], + ); + expect(writes.map(({ idempotencyKey }) => idempotencyKey)).toEqual([ + expect.stringMatching(/^[0-9a-f-]{36}$/), + expect.stringMatching(/^[0-9a-f-]{36}$/), + ]); + expect(writes[0].idempotencyKey).not.toBe(writes[1].idempotencyKey); }); }); diff --git a/vendor/browser-client/src/index.ts b/vendor/browser-client/src/index.ts index f2f45e7..c2bef18 100644 --- a/vendor/browser-client/src/index.ts +++ b/vendor/browser-client/src/index.ts @@ -6,7 +6,7 @@ * HTTP responses deliberately stay behind this module. */ -export const OPEN_CLOUD_SDK_VERSION = "1.0.0"; +export const OPEN_CLOUD_SDK_VERSION = "2.0.0"; export type OpenCloudEnvironment = "dev" | "production"; export type OpenCloudVisibility = "public" | "private"; @@ -97,14 +97,20 @@ export interface OpenCloudGetOptions { select?: string[]; } -export interface OpenCloudFile { +export interface OpenCloudFileRef { id: string; +} + +export interface OpenCloudFile extends OpenCloudFileRef { name: string; contentType: string; size: number; + createdAt: string; + updatedAt: string; } -export interface OpenCloudFileUploadOptions { +export interface OpenCloudFileInput { + data: Blob; name?: string; contentType?: string; maxBytes?: number; @@ -118,7 +124,7 @@ export interface OpenCloudFileUploadProgress { } export interface OpenCloudFileDownload { - blob: Blob; + data: Blob; name: string; contentType: string; size: number; @@ -131,11 +137,10 @@ export interface OpenCloudFileAttachmentColumns { size?: string; } -export interface OpenCloudFileAttachmentOptions { +export interface OpenCloudFileAttachmentInput extends OpenCloudFileInput { table: string; values?: Record; columns?: OpenCloudFileAttachmentColumns; - upload?: OpenCloudFileUploadOptions; } export interface OpenCloudRealtimeMessage { @@ -193,7 +198,6 @@ export type OpenCloudMetricDimensions = Record; export interface OpenCloudMetricWriteOptions { dimensions?: OpenCloudMetricDimensions; - idempotencyKey?: string; } export interface OpenCloudMetricWriteResult { @@ -227,21 +231,17 @@ export interface OpenCloudDataClient { } export interface OpenCloudFilesClient { - upload( - source: Blob, - options?: OpenCloudFileUploadOptions, - ): Promise; - download(value: OpenCloudFile | string): Promise; - save(value: OpenCloudFile | string): Promise; + upload(input: OpenCloudFileInput): Promise; + info(value: OpenCloudFileRef | string): Promise; + download(value: OpenCloudFileRef | string): Promise; + save(value: OpenCloudFileRef | string): Promise; replace( - value: OpenCloudFile | string, - source: Blob, - options?: OpenCloudFileUploadOptions, + value: OpenCloudFileRef | string, + input: OpenCloudFileInput, ): Promise; - remove(value: OpenCloudFile | string): Promise; + remove(value: OpenCloudFileRef | string): Promise; attach>( - source: Blob, - options: OpenCloudFileAttachmentOptions, + input: OpenCloudFileAttachmentInput, ): Promise<{ file: OpenCloudFile; record: Row }>; } @@ -1237,21 +1237,28 @@ function plainArgumentObject( return value as Record; } -function fileUploadOptions(value: unknown): OpenCloudFileUploadOptions { +function fileInput( + value: unknown, + extraFields: readonly string[] = [], + scope = "file input", +): OpenCloudFileInput { const input = argumentObject( value, - ["name", "contentType", "maxBytes", "onProgress"], - "file upload options", + ["data", "name", "contentType", "maxBytes", "onProgress", ...extraFields], + scope, ); + if (!(input.data instanceof Blob)) { + throw invalidArgument(`${scope}.data must be a File or Blob`, "files"); + } if (input.name !== undefined && typeof input.name !== "string") { - throw invalidArgument("file upload options.name must be a string", "files"); + throw invalidArgument(`${scope}.name must be a string`, "files"); } if ( input.contentType !== undefined && typeof input.contentType !== "string" ) { throw invalidArgument( - "file upload options.contentType must be a string", + `${scope}.contentType must be a string`, "files", ); } @@ -1260,11 +1267,11 @@ function fileUploadOptions(value: unknown): OpenCloudFileUploadOptions { typeof input.onProgress !== "function" ) { throw invalidArgument( - "file upload options.onProgress must be a function", + `${scope}.onProgress must be a function`, "files", ); } - return input as OpenCloudFileUploadOptions; + return input as unknown as OpenCloudFileInput; } function fileName(source: Blob, explicit?: string): string { @@ -1277,17 +1284,17 @@ function fileName(source: Blob, explicit?: string): string { return safe || "file"; } -function operationUuid(surface: OpenCloudErrorSurface): string { +function operationUuid(capability: "files" | "telemetry"): string { const value = globalThis.crypto?.randomUUID?.(); if (!value || !UUID.test(value)) { - throw capabilityUnavailable(surface === "files" ? "files" : "data", surface); + throw capabilityUnavailable(capability, capability); } return value; } -function fileReference(value: OpenCloudFile | string): OpenCloudFile { +function fileReference(value: OpenCloudFileRef | string): OpenCloudFileRef { const reference = typeof value === "string" - ? { id: value, name: value, contentType: "application/octet-stream", size: 0 } + ? { id: value } : value; if (!UUID.test(reference.id)) { throw invalidArgument("OpenCloud file id is invalid", "files"); @@ -1310,6 +1317,8 @@ function parseFile(value: unknown): OpenCloudFile { name: string(input.name, "file.name", true), contentType: string(input.contentType, "file.contentType"), size, + createdAt: iso(input.createdAt, "file.createdAt"), + updatedAt: iso(input.updatedAt, "file.updatedAt"), }; } @@ -1319,12 +1328,10 @@ class FilesClient { private readonly data: DataClient, ) {} - async upload( - source: Blob, - options: OpenCloudFileUploadOptions = {}, - ): Promise { - const checkedOptions = fileUploadOptions(options); - const metadata = await this.uploadMetadata(source, checkedOptions); + async upload(input: OpenCloudFileInput): Promise { + const checkedInput = fileInput(input); + const source = checkedInput.data; + const metadata = await this.uploadMetadata(source, checkedInput); await this.runtime.requireUser(); const idempotencyKey = operationUuid("files"); const response = await this.uploadRequest( @@ -1337,13 +1344,25 @@ class FilesClient { "x-opencloud-file-name": encodeURIComponent(metadata.name), "idempotency-key": idempotencyKey, }, - checkedOptions.onProgress, + checkedInput.onProgress, + ); + await requireOk(response, "files"); + return parseFile(await response.json()); + } + + async info(value: OpenCloudFileRef | string): Promise { + const file = fileReference(value); + await this.assertAvailable(); + const response = await this.runtime.hostRequest( + `/_opencloud/files/${encodeURIComponent(file.id)}/metadata`, + { headers: { accept: "application/json" } }, + "files", ); await requireOk(response, "files"); return parseFile(await response.json()); } - async download(value: OpenCloudFile | string): Promise { + async download(value: OpenCloudFileRef | string): Promise { const file = fileReference(value); await this.assertAvailable(); const response = await this.runtime.hostRequest( @@ -1363,14 +1382,14 @@ class FilesClient { } } return { - blob, - name: responseName ?? file.name, - contentType: response.headers.get("content-type") || file.contentType || blob.type, + data: blob, + name: responseName ?? "file", + contentType: response.headers.get("content-type") || blob.type || "application/octet-stream", size: blob.size, }; } - async save(value: OpenCloudFile | string): Promise { + async save(value: OpenCloudFileRef | string): Promise { if ( typeof document !== "object" || typeof URL.createObjectURL !== "function" || @@ -1379,7 +1398,7 @@ class FilesClient { throw capabilityUnavailable("files", "files"); } const result = await this.download(value); - const url = URL.createObjectURL(result.blob); + const url = URL.createObjectURL(result.data); try { const anchor = document.createElement("a"); anchor.href = url; @@ -1394,17 +1413,13 @@ class FilesClient { } async replace( - value: OpenCloudFile | string, - source: Blob, - options: OpenCloudFileUploadOptions = {}, + value: OpenCloudFileRef | string, + input: OpenCloudFileInput, ): Promise { const current = fileReference(value); - const checkedOptions = fileUploadOptions(options); - const metadata = await this.uploadMetadata(source, { - ...checkedOptions, - name: checkedOptions.name ?? current.name, - contentType: checkedOptions.contentType ?? current.contentType, - }); + const checkedInput = fileInput(input); + const source = checkedInput.data; + const metadata = await this.uploadMetadata(source, checkedInput); await this.runtime.requireUser(); const idempotencyKey = operationUuid("files"); const response = await this.uploadRequest( @@ -1417,13 +1432,13 @@ class FilesClient { "x-opencloud-file-name": encodeURIComponent(metadata.name), "idempotency-key": idempotencyKey, }, - checkedOptions.onProgress, + checkedInput.onProgress, ); await requireOk(response, "files"); return parseFile(await response.json()); } - async remove(value: OpenCloudFile | string): Promise { + async remove(value: OpenCloudFileRef | string): Promise { const file = fileReference(value); await this.assertAvailable(); await this.runtime.requireUser(); @@ -1436,46 +1451,53 @@ class FilesClient { } async attach>( - source: Blob, - options: OpenCloudFileAttachmentOptions, + input: OpenCloudFileAttachmentInput, ): Promise<{ file: OpenCloudFile; record: Row }> { - argumentObject( - options, - ["table", "values", "columns", "upload"], - "file attachment options", - ); - if (typeof options.table !== "string") { + const checkedInput = fileInput( + input, + ["table", "values", "columns"], + "file attachment input", + ) as OpenCloudFileAttachmentInput; + if (typeof checkedInput.table !== "string") { throw invalidArgument( - "file attachment options.table must be a string", + "file attachment input.table must be a string", "files", ); } - if (options.values !== undefined) { - plainArgumentObject(options.values, "file attachment options.values"); + if (checkedInput.values !== undefined) { + plainArgumentObject(checkedInput.values, "file attachment input.values"); } - if (options.columns) { + if (checkedInput.columns) { argumentObject( - options.columns, + checkedInput.columns, ["id", "name", "contentType", "size"], - "file attachment options.columns", + "file attachment input.columns", ); } const columns = { - id: options.columns?.id ?? "file_id", - name: options.columns?.name ?? "file_name", - contentType: options.columns?.contentType ?? "file_type", - size: options.columns?.size ?? "file_size", + id: checkedInput.columns?.id ?? "file_id", + name: checkedInput.columns?.name ?? "file_name", + contentType: checkedInput.columns?.contentType ?? "file_type", + size: checkedInput.columns?.size ?? "file_size", }; for (const column of Object.values(columns)) assertIdentifier(column, "attachment column"); - const file = await this.upload(source, options.upload); + const file = await this.upload({ + data: checkedInput.data, + ...(checkedInput.name ? { name: checkedInput.name } : {}), + ...(checkedInput.contentType ? { contentType: checkedInput.contentType } : {}), + ...(checkedInput.maxBytes !== undefined + ? { maxBytes: checkedInput.maxBytes } + : {}), + ...(checkedInput.onProgress ? { onProgress: checkedInput.onProgress } : {}), + }); const values = { - ...(options.values ?? {}), + ...(checkedInput.values ?? {}), [columns.id]: file.id, [columns.name]: file.name, [columns.contentType]: file.contentType, [columns.size]: file.size, }; - const table = this.data.table(options.table); + const table = this.data.table(checkedInput.table); try { const record = await table.create(values); return { file, record }; @@ -1559,8 +1581,8 @@ class FilesClient { private async uploadMetadata( source: Blob, - options: OpenCloudFileUploadOptions, - ): Promise> { + input: OpenCloudFileInput, + ): Promise> { const config = await this.runtime.config(); if (!config.capabilities.files) throw capabilityUnavailable("files", "files"); if ( @@ -1572,7 +1594,7 @@ class FilesClient { throw invalidArgument("OpenCloud file source must be a Blob or File", "files"); } if (!config.files) throw capabilityUnavailable("files", "files"); - const requestedLimit = options.maxBytes ?? config.files.maxUploadBytes; + const requestedLimit = input.maxBytes ?? config.files.maxUploadBytes; if (!Number.isInteger(requestedLimit) || requestedLimit < 1) { throw invalidArgument("OpenCloud file maxBytes must be a positive integer", "files"); } @@ -1585,8 +1607,8 @@ class FilesClient { }); } return { - name: fileName(source, options.name), - contentType: options.contentType || source.type || "application/octet-stream", + name: fileName(source, input.name), + contentType: input.contentType || source.type || "application/octet-stream", size: source.size, }; } @@ -2082,29 +2104,44 @@ class TelemetryClient { if (!config.capabilities.telemetry) { throw capabilityUnavailable("telemetry", "telemetry"); } - const response = await this.runtime.hostRequest( - "/_opencloud/telemetry/metrics", - { - method: "POST", - headers: { accept: "application/json", "content-type": "application/json" }, - body: JSON.stringify({ - measurements: [{ - name, - value, - dimensions: options.dimensions ?? {}, - ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}), - }], - }), - }, - "telemetry", - ); - await requireOk(response, "telemetry"); - const result = object(await response.json(), "metric result"); - return { - accepted: integer(result.accepted, "accepted"), - duplicates: integer(result.duplicates, "duplicates"), - recordedAt: iso(result.recordedAt, "recordedAt"), - }; + const idempotencyKey = operationUuid("telemetry"); + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const response = await this.runtime.hostRequest( + "/_opencloud/telemetry/metrics", + { + method: "POST", + headers: { accept: "application/json", "content-type": "application/json" }, + body: JSON.stringify({ + measurements: [{ + name, + value, + dimensions: options.dimensions ?? {}, + idempotencyKey, + }], + }), + }, + "telemetry", + ); + await requireOk(response, "telemetry"); + const result = object(await response.json(), "metric result"); + return { + accepted: integer(result.accepted, "accepted"), + duplicates: integer(result.duplicates, "duplicates"), + recordedAt: iso(result.recordedAt, "recordedAt"), + }; + } catch (error) { + if ( + attempt === 0 && + error instanceof OpenCloudError && + error.retryable + ) { + continue; + } + throw error; + } + } + throw invalidResponse("OpenCloud telemetry retry ended unexpectedly", "telemetry"); } } diff --git a/vendor/bundler/src/index.ts b/vendor/bundler/src/index.ts index 134f112..c7ae12e 100644 --- a/vendor/bundler/src/index.ts +++ b/vendor/bundler/src/index.ts @@ -13,7 +13,11 @@ import { } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { parseManifest, type OpenCloudManifest } from "@opencloud/contracts"; +import { + parseManifest, + validateMigrationIdConvention, + type OpenCloudManifest, +} from "@opencloud/contracts"; import { OPEN_CLOUD_SDK_VERSION } from "@opencloud/js"; import * as tar from "tar"; import YAML from "yaml"; @@ -40,7 +44,7 @@ interface AuthorManifest { functions?: unknown[]; cron?: unknown[]; health?: unknown; - requiredSecrets?: unknown[]; + secrets?: Record; } export interface BuiltBundle { @@ -121,6 +125,13 @@ export async function buildBundle( `Migration file ${migration.file}`, ); await assertFile(migrationFile, `Migration file ${migration.file}`); + try { + validateMigrationIdConvention(await readFile(migrationFile, "utf8")); + } catch (error) { + throw new Error( + `Migration file ${migration.file}: ${error instanceof Error ? error.message : String(error)}`, + ); + } migration.sha256 = await sha256File(migrationFile); } const manifest = parseManifest(raw); diff --git a/vendor/contracts/src/control-plane.test.ts b/vendor/contracts/src/control-plane.test.ts index a2a7a9e..9115b7e 100644 --- a/vendor/contracts/src/control-plane.test.ts +++ b/vendor/contracts/src/control-plane.test.ts @@ -122,11 +122,12 @@ describe("controlPlaneOperations", () => { for (const [name, annotations] of Object.entries(expected)) { expect(tools.get(name), `${name} annotations`).toMatchObject(annotations); } - for (const name of ["request_dev_app", "mutate_dev_data"]) { - expect(tools.get(name)?.description, `${name} API reference`).toContain( - "https://docs.opencloud.ai/openapi.yaml", - ); - } + expect(tools.get("request_dev_app")?.description).toContain( + "https://docs.opencloud.ai/openapi.yaml", + ); + expect(tools.get("mutate_dev_data")?.description).toContain( + "raw REST paths are not accepted", + ); for (const operation of Object.values(controlPlaneOperations)) { if (operation.method === "DELETE" && operation.mcp) { expect( @@ -189,6 +190,22 @@ describe("controlPlaneOperations", () => { }); }); + it("allows an empty draft-file selection and documents normalized dev data paths", () => { + const appId = "22222222-2222-4222-8222-222222222222"; + const draftId = "11111111-1111-4111-8111-111111111111"; + + expect( + controlPlaneOperations.readDraftFiles.input.parse({ + appId, + draftId, + body: { paths: [] }, + }), + ).toEqual({ appId, draftId, body: { paths: [] } }); + expect(controlPlaneOperations.mutateDevData.mcp?.description).toContain( + "synthetic-user-A fixture rows", + ); + }); + it("requires the exact development capability vector", () => { const session = { id: "11111111-1111-4111-8111-111111111111", diff --git a/vendor/contracts/src/control-plane.ts b/vendor/contracts/src/control-plane.ts index 88fdc1e..7d24907 100644 --- a/vendor/contracts/src/control-plane.ts +++ b/vendor/contracts/src/control-plane.ts @@ -703,7 +703,7 @@ export const controlPlaneOperations = { scopes: ["app:read"], input: draftPath.extend({ body: z.object({ - paths: z.array(z.string().min(1).max(512)).min(1).max(100), + paths: z.array(z.string().min(1).max(512)).max(100), }), }), output: z.array(draftFileOutput), @@ -712,7 +712,8 @@ export const controlPlaneOperations = { mcp: { toolName: "read_files", title: "Read draft files", - description: "Read selected source files from a draft.", + description: + "Read selected source files from a draft. An empty paths array returns an empty result, so an empty draft can be inspected without a special case.", readOnlyHint: true, destructiveHint: false, idempotentHint: true, @@ -987,7 +988,7 @@ export const controlPlaneOperations = { toolName: "mutate_dev_data", title: "Write dev fixture data", description: - "Create, replace, update, or delete bounded fixture data through the OpenCloud app runtime REST API only in an isolated dev schema; see https://docs.opencloud.ai/openapi.yaml.", + "Create, createMany, updateById, or deleteById synthetic-user-A fixture rows in one named table in the isolated development schema. Pass table, action, values, and id as applicable; raw REST paths are not accepted.", readOnlyHint: false, destructiveHint: true, idempotentHint: false, diff --git a/vendor/contracts/src/index.ts b/vendor/contracts/src/index.ts index 8c45c91..cf49a33 100644 --- a/vendor/contracts/src/index.ts +++ b/vendor/contracts/src/index.ts @@ -2,3 +2,4 @@ export * from "./api.js"; export * from "./brand.generated.js"; export * from "./control-plane.js"; export * from "./manifest.js"; +export * from "./sql-conventions.js"; diff --git a/vendor/contracts/src/manifest.test.ts b/vendor/contracts/src/manifest.test.ts index 108ca7d..b7a31d7 100644 --- a/vendor/contracts/src/manifest.test.ts +++ b/vendor/contracts/src/manifest.test.ts @@ -6,7 +6,7 @@ const valid = { appId: "aeea1c71-72a3-4b1d-a32e-213900735091", version: "2026.07.27-1", frontend: { directory: "frontend", spa: true }, - runtime: { sdk: { version: "1.0.0" } }, + runtime: { sdk: { version: "2.0.0" } }, migrations: [ { id: "0001_create_notes", @@ -17,7 +17,7 @@ const valid = { functions: [], cron: [], health: { path: "/" }, - requiredSecrets: [], + secrets: {}, }; describe("OpenCloud manifest", () => { @@ -43,22 +43,22 @@ describe("OpenCloud manifest", () => { parseManifest({ ...valid, runtime: { - sdk: { version: "1.0.0" }, + sdk: { version: "2.0.0" }, }, }).runtime, ).toEqual({ - sdk: { version: "1.0.0" }, + sdk: { version: "2.0.0" }, }); }); - it("rejects moving SDK ranges and tags", () => { - for (const version of ["^1.0.0", "latest", "1.0"]) { + it("rejects legacy versions, moving ranges, and tags", () => { + for (const version of ["1.0.0", "^2.0.0", "latest", "2.0"]) { expect(() => parseManifest({ ...valid, runtime: { sdk: { version } }, }), - ).toThrow(/exact semantic version/); + ).toThrow(/installed SDK version 2\.0\.0/); } }); @@ -248,11 +248,34 @@ describe("OpenCloud manifest", () => { ).toBe("system"); }); - it("rejects required secrets that collide with runtime-owned names", () => { + it("declares generated, required, and optional secrets without values", () => { + expect( + parseManifest({ + ...valid, + secrets: { + SIGNING_SECRET: "generated", + PROVIDER_KEY: "required", + ORGANIZATION_LABEL: "optional", + }, + }).secrets, + ).toEqual({ + SIGNING_SECRET: "generated", + PROVIDER_KEY: "required", + ORGANIZATION_LABEL: "optional", + }); + }); + + it("rejects legacy requiredSecrets with direct migration guidance", () => { + expect(() => + parseManifest({ ...valid, requiredSecrets: ["SIGNING_SECRET"] }), + ).toThrow(/replaces requiredSecrets with declarative secrets/); + }); + + it("rejects secrets that collide with runtime-owned names", () => { for (const name of ["OPENCLOUD_FILES_GRANT", "SUPABASE_SERVICE_ROLE_KEY"]) { expect(() => - parseManifest({ ...valid, requiredSecrets: [name] }), - ).toThrow(/reserved OpenCloud runtime secret prefix/); + parseManifest({ ...valid, secrets: { [name]: "required" } }), + ).toThrow(/reserved OpenCloud runtime prefix/); } }); diff --git a/vendor/contracts/src/manifest.ts b/vendor/contracts/src/manifest.ts index 6d6a238..85a0767 100644 --- a/vendor/contracts/src/manifest.ts +++ b/vendor/contracts/src/manifest.ts @@ -14,9 +14,10 @@ const relativePath = z const digest = z.string().regex(/^[a-f0-9]{64}$/, "expected a SHA-256 digest"); -export const sdkVersionSchema = z - .string() - .regex(/^\d+\.\d+\.\d+$/, "expected an exact semantic version"); +/** The pre-production hard cutover deliberately installs one SDK contract. */ +export const sdkVersionSchema = z.literal("2.0.0", { + error: "expected the installed SDK version 2.0.0", +}); export const migrationSchema = z .object({ @@ -47,6 +48,16 @@ export const cronSchema = z export const filesAccessSchema = z.enum(["user", "app"]); +export const secretModeSchema = z.enum(["generated", "required", "optional"]); + +const secretNameSchema = z + .string() + .regex(/^[A-Z][A-Z0-9_]{0,127}$/) + .refine( + (name) => !name.startsWith("OPENCLOUD_") && !name.startsWith("SUPABASE_"), + "secret uses a reserved OpenCloud runtime prefix", + ); + export const customMetricNameSchema = z .string() .min(1) @@ -141,10 +152,12 @@ export const openCloudManifestSchema = z .object({ path: z.string().startsWith("/").max(200).default("/") }) .strict() .default({ path: "/" }), - requiredSecrets: z - .array(z.string().regex(/^[A-Z][A-Z0-9_]{0,127}$/)) - .max(100) - .default([]), + secrets: z + .record(secretNameSchema, secretModeSchema) + .refine((secrets) => Object.keys(secrets).length <= 100, { + message: "apps may declare at most 100 secrets", + }) + .default({}), observability: z .object({ metrics: z.array(customMetricDefinitionSchema).max(20).default([]), @@ -160,7 +173,6 @@ export const openCloudManifestSchema = z | "migrations" | "functions" | "cron" - | "requiredSecrets" | "observability", ) => { const seen = new Set(); @@ -184,7 +196,6 @@ export const openCloudManifestSchema = z "functions", ); assertUnique(manifest.cron.map((cron) => cron.name), "cron"); - assertUnique(manifest.requiredSecrets, "requiredSecrets"); assertUnique( (manifest.observability?.metrics ?? []).map((metric) => metric.name), "observability", @@ -233,22 +244,13 @@ export const openCloudManifestSchema = z }); } }); - manifest.requiredSecrets.forEach((name, index) => { - if (name.startsWith("OPENCLOUD_") || name.startsWith("SUPABASE_")) { - context.addIssue({ - code: "custom", - path: ["requiredSecrets", index], - message: - `${name} uses a reserved OpenCloud runtime secret prefix`, - }); - } - }); }); export type OpenCloudManifest = z.infer; export type OpenCloudMigration = z.infer; export type FilesAccess = z.infer; export type FunctionAccess = z.infer; +export type SecretMode = z.infer; export type SdkVersion = z.infer; export type CustomMetricDefinition = z.infer< typeof customMetricDefinitionSchema @@ -262,6 +264,11 @@ export function parseManifest(value: unknown): OpenCloudManifest { "Manifest schema 2 replaces storage with files; use files.access: user or app", ); } + if ("requiredSecrets" in manifest) { + throw new Error( + "Manifest schema 2 replaces requiredSecrets with declarative secrets: NAME: generated, required, or optional", + ); + } const runtime = manifest.runtime; if ( runtime && diff --git a/vendor/contracts/src/sql-conventions.test.ts b/vendor/contracts/src/sql-conventions.test.ts new file mode 100644 index 0000000..94fc86d --- /dev/null +++ b/vendor/contracts/src/sql-conventions.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { validateMigrationIdConvention } from "./sql-conventions.js"; + +describe("migration id convention", () => { + it.each([ + "create table notes (id uuid primary key, body text not null)", + "create table notes (id uuid not null, body text, primary key (id))", + 'create table "notes" ("id" bigint, constraint notes_pk primary key ("id"))', + ])("accepts one canonical id primary key: %s", (sql) => { + expect(() => validateMigrationIdConvention(sql)).not.toThrow(); + }); + + it.each([ + "create table notes (note_id uuid primary key, body text)", + "create table notes (id uuid, body text)", + 'create table notes ("ID" uuid primary key, body text)', + "create table memberships (user_id uuid, team_id uuid, primary key (user_id, team_id))", + ])("rejects an app table without the canonical id primary key: %s", (sql) => { + expect(() => validateMigrationIdConvention(sql)).toThrow( + /Table notes|Table memberships/, + ); + }); + + it("ignores create-table examples in comments and strings", () => { + expect(() => + validateMigrationIdConvention( + "-- create table bad (key uuid primary key)\nselect 'create table nope (key uuid)';", + ), + ).not.toThrow(); + }); +}); diff --git a/vendor/contracts/src/sql-conventions.ts b/vendor/contracts/src/sql-conventions.ts new file mode 100644 index 0000000..b72d77b --- /dev/null +++ b/vendor/contracts/src/sql-conventions.ts @@ -0,0 +1,66 @@ +function stripCommentsAndLiterals(sql: string): string { + return sql + .replace(/--[^\n]*(?:\n|$)/g, "\n") + .replace(/\/\*[\s\S]*?\*\//g, " ") + .replace(/'(?:''|[^'])*'/g, "''") + .replace(/\$(?[A-Za-z_][A-Za-z0-9_]*)?\$[\s\S]*?\$\k\$/g, "$$"); +} + +function tableBody(source: string, opening: number): string | null { + let depth = 1; + for (let index = opening + 1; index < source.length; index += 1) { + if (source[index] === "(") depth += 1; + if (source[index] === ")") depth -= 1; + if (depth === 0) return source.slice(opening + 1, index); + } + return null; +} + +function topLevelFields(body: string): string[] { + const fields: string[] = []; + let start = 0; + let depth = 0; + for (let index = 0; index < body.length; index += 1) { + if (body[index] === "(") depth += 1; + if (body[index] === ")") depth -= 1; + if (body[index] === "," && depth === 0) { + fields.push(body.slice(start, index)); + start = index + 1; + } + } + fields.push(body.slice(start)); + return fields; +} + +/** + * Every app table uses the same `id` primary-key convention so all browser and + * Function row helpers have one deterministic contract. + */ +export function validateMigrationIdConvention(sql: string): void { + const source = stripCommentsAndLiterals(sql); + const createTable = + /\bcreate\s+(?:unlogged\s+)?table\s+(?:if\s+not\s+exists\s+)?(?:"([a-z_][a-z0-9_]*)"|([a-z_][a-z0-9_]*))\s*\(/gi; + for (const match of source.matchAll(createTable)) { + const opening = (match.index ?? 0) + match[0].lastIndexOf("("); + const body = tableBody(source, opening); + if (body === null) continue; + const fields = topLevelFields(body); + const id = fields.find((field) => /^\s*(?:[iI][dD]|"id")\s+/.test(field)); + const inlinePrimaryKey = Boolean(id && /\bprimary\s+key\b/i.test(id)); + const tablePrimaryKey = fields.some((field) => { + const primaryKey = field.match( + /^\s*(?:constraint\s+(?:"[^"]+"|[a-z_][a-z0-9_]*)\s+)?primary\s+key\s*\(\s*([^,\s)]+)\s*\)\s*$/i, + ); + const column = primaryKey?.[1]; + return Boolean( + column && (column === '"id"' || /^[iI][dD]$/.test(column)), + ); + }); + if (!inlinePrimaryKey && !tablePrimaryKey) { + const table = match[1] ?? match[2] ?? "unknown"; + throw new Error( + `Table ${table} must declare id as its primary key; OpenCloud row helpers always address records by id`, + ); + } + } +}