diff --git a/CHANGELOG.md b/CHANGELOG.md index c8f5a08..56b79af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 2.0.0 + +- Make manifest schema 2 the only application contract. Projects now use + `runtime.sdk`, `files.access`, and Function `access`; schema-1 fields fail + with focused migration errors instead of being preserved or translated. +- Vendor OpenCloud SDK 1.0.0 and generate apps that import the stable + `opencloud` singleton from `/_opencloud/sdk.js`. +- Replace deployment and draft response aliases with the canonical + `sdkVersion` and `artifactFiles` fields, and expose the exact development + capability contract. +- Generate a working SDK-connected starter with Files enabled and make + `app sdk-inspect` report only the stable SDK module and declarations paths. +- Remove the partial local smoke, Chromium, session, and runtime-contract + verification commands. `app verify` is now the single authoritative release + gate. + ## 1.0.0 - Add reusable account login through an explicit browser approval flow with diff --git a/README.md b/README.md index bd3a540..b49a7a2 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 `v1.0.0` in +OpenCloud application skills pin an exact CLI release. To install `v2.0.0` in an isolated task directory: ```bash -OPENCLOUD_CLI_VERSION="v1.0.0" -OPENCLOUD_CLI_PACKAGE="opencloud-cli-1.0.0.tgz" +OPENCLOUD_CLI_VERSION="v2.0.0" +OPENCLOUD_CLI_PACKAGE="opencloud-cli-2.0.0.tgz" OPENCLOUD_CLI_DIR="$(mktemp -d)" curl -fsSLo "$OPENCLOUD_CLI_DIR/$OPENCLOUD_CLI_PACKAGE" \ @@ -79,10 +79,10 @@ user work safely across multiple app directories. "$OPENCLOUD_CLI" logout ``` -## Legacy passwordless project onboarding +## Passwordless project onboarding -The pre-1.0 email onboarding flow remains available for compatibility. New -terminal workflows should use `login` and `app connect`. +For a new project, agents can request passwordless onboarding directly. For an +existing account, use `login` and `app connect`. Give the CLI the user's email and agreed project title: @@ -163,7 +163,8 @@ Use the stable capability preview and isolated migration-replayed database befor ``` Development data is isolated from production and uses dummy records. Auth, -Storage, Realtime, cron, and production secrets are unavailable. Functions +Files, and Functions are available; Realtime, cron, and production secrets are +not. 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. @@ -204,8 +205,9 @@ app-declared interaction contract on the server: "$OPENCLOUD_CLI" app verify "$APP_ID" ``` -The lower-level `app smoke` and `app verify-ui` commands remain diagnostic -helpers for platform development; they are not substitutes for `app verify`. +CLI v2 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. ## Develop diff --git a/package-lock.json b/package-lock.json index 8cccce3..255211d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,14 @@ { "name": "@opencloud/cli", - "version": "1.0.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opencloud/cli", - "version": "1.0.0", + "version": "2.0.0", "dependencies": { - "@napi-rs/keyring": "1.3.0", - "playwright": "1.62.0" + "@napi-rs/keyring": "1.3.0" }, "bin": { "opencloud": "dist/index.cjs" @@ -1386,20 +1385,6 @@ } } }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -1764,36 +1749,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/playwright": { - "version": "1.62.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", - "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.62.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.62.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", - "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/postcss": { "version": "8.5.24", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz", diff --git a/package.json b/package.json index a19213c..8649338 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opencloud/cli", - "version": "1.0.0", + "version": "2.0.0", "description": "Versioned command-line client for building, deploying, and verifying OpenCloud applications", "type": "module", "bin": { @@ -24,8 +24,7 @@ "prepack": "npm run build" }, "dependencies": { - "@napi-rs/keyring": "1.3.0", - "playwright": "1.62.0" + "@napi-rs/keyring": "1.3.0" }, "devDependencies": { "@types/node": "26.1.2", diff --git a/scripts/build.mjs b/scripts/build.mjs index 97cb753..f97696e 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -17,7 +17,7 @@ await build({ target: "node22", sourcemap: false, legalComments: "none", - external: ["@napi-rs/keyring", "playwright"], + external: ["@napi-rs/keyring"], alias: { "@opencloud/contracts": path.join( root, diff --git a/src/bundle.test.ts b/src/bundle.test.ts index abad967..2b3792b 100644 --- a/src/bundle.test.ts +++ b/src/bundle.test.ts @@ -25,7 +25,7 @@ async function writeManifest(root: string, body: string): Promise { await writeFile( path.join(root, "opencloud.yaml"), ` -schemaVersion: 1 +schemaVersion: 2 appId: aeea1c71-72a3-4b1d-a32e-213900735091 version: test-1 ${body.trim()} @@ -45,7 +45,7 @@ describe("bundle builder", () => { await writeFile(path.join(root, "frontend", "index.html"), "hello"); await writeFile( path.join(root, "frontend", "assets", "app.js"), - "const sdk = runtime.javascriptSdk.module; createOpenCloudClient(sdk);", + 'import { opencloud } from "/_opencloud/sdk.js"; void opencloud.app.info();', ); await writeFile( path.join(root, "migrations", "0001_notes.sql"), @@ -88,8 +88,8 @@ functions: const first = await buildBundle(root); expect(first.manifest.migrations[0]?.sha256).toMatch(/^[a-f0-9]{64}$/); expect(first.manifest.runtime).toEqual({ - javascriptSdk: { - version: "0.2.2", + sdk: { + version: "1.0.0", }, }); expect(first.files).toEqual([ @@ -199,7 +199,7 @@ functions: [] expect(bundle.files).not.toContain("functions/forgotten/index.ts"); }); - it("preserves an explicit older SDK pin instead of replacing it with current", async () => { + it("preserves an explicit SDK pin instead of replacing it with current", async () => { const root = await temporaryDirectory(); await mkdir(path.join(root, "frontend")); await writeFile(path.join(root, "frontend", "index.html"), "hello"); @@ -209,15 +209,15 @@ functions: [] frontend: directory: frontend runtime: - javascriptSdk: - version: 0.2.0 + sdk: + version: 9.8.7 `, ); const bundle = await buildBundle(root); expect(bundle.manifest.runtime).toEqual({ - javascriptSdk: { - version: "0.2.0", + sdk: { + version: "9.8.7", }, }); }); diff --git a/src/index.ts b/src/index.ts index 4cc9ac8..5325b0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,14 +4,14 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { Command, Option } from "commander"; import YAML from "yaml"; -import { OPEN_CLOUD_JS_VERSION } from "@opencloud/js"; +import { OPEN_CLOUD_SDK_VERSION } from "@opencloud/js"; import { OPEN_CLOUD_FAVICON_DATA_URI, OPEN_CLOUD_LOGO_DATA_URI, type AgentOnboardingResponse, } from "@opencloud/contracts"; import { OpenCloudClient } from "./api-client.js"; -import { requestApp, smokeApp } from "./app-edge.js"; +import { requestApp } from "./app-edge.js"; import { beginDeviceAuthorization, completeDeviceAuthorization, @@ -24,12 +24,6 @@ import { import { buildBundle } from "./bundle.js"; import { CredentialStore } from "./credential-store.js"; import { doctorDiagnostics } from "./doctor.js"; -import { - parseRuntimeVerificationSpec, - verifyRuntime, - verifySessions, -} from "./runtime-verify.js"; -import { verifyAppUi } from "./ui-verify.js"; import { deleteSession, loadSession, @@ -46,7 +40,7 @@ import { resolveWorkspaceFile, } from "./workspace-store.js"; -const CLI_VERSION = "1.0.0"; +const CLI_VERSION = "2.0.0"; const program = new Command() .name("opencloud") @@ -920,26 +914,26 @@ app )) as { id?: string; version?: string; - javascriptSdkVersion?: string; + sdkVersion?: string; }; if ( !deployment.id || !deployment.version || - !deployment.javascriptSdkVersion + !deployment.sdkVersion ) { throw new Error( - "The active deployment does not expose a JavaScript SDK pin", + "The active deployment does not expose an OpenCloud SDK pin", ); } output({ appId, deploymentId: deployment.id, deploymentVersion: deployment.version, - javascriptSdk: { + sdk: { package: "@opencloud/js", - version: deployment.javascriptSdkVersion, - module: `/_opencloud/sdk/js/v${deployment.javascriptSdkVersion}/index.js`, - types: `/_opencloud/sdk/js/v${deployment.javascriptSdkVersion}/index.d.ts`, + version: deployment.sdkVersion, + module: "/_opencloud/sdk.js", + types: "/_opencloud/sdk.d.ts", }, }); }); @@ -1333,59 +1327,6 @@ app ); }); -app - .command("smoke") - .description( - "Legacy local edge check; use `app verify` for the authoritative gate", - ) - .argument("") - .action(async (appId) => { - const value = await client().get(`/v1/apps/${appId}`); - const edgeUrl = process.env.OPENCLOUD_EDGE_URL; - const result = await smokeApp(value, edgeUrl ? { edgeUrl } : {}); - output(result); - if (!result.passed) process.exitCode = 1; - }); - -app - .command("verify-ui") - .description( - "Legacy local Chromium check; use `app verify` for the authoritative gate", - ) - .argument("") - .option("--timeout-seconds ", "navigation timeout", "30") - .option( - "--require-interaction", - "require view-transition and state-assertion UI coverage", - ) - .option( - "--chromium-path ", - "Chromium executable; defaults to Playwright's installed browser", - ) - .action(async (appId, options) => { - const timeoutSeconds = Number(options.timeoutSeconds); - if ( - !Number.isFinite(timeoutSeconds) || - timeoutSeconds < 5 || - timeoutSeconds > 120 - ) { - throw new Error("--timeout-seconds must be between 5 and 120"); - } - const value = await client().get(`/v1/apps/${appId}`); - output( - await verifyAppUi(value, { - timeoutMs: timeoutSeconds * 1_000, - requireInteraction: options.requireInteraction === true, - ...((options.chromiumPath ?? process.env.OPENCLOUD_CHROMIUM_PATH) - ? { - chromiumPath: - options.chromiumPath ?? process.env.OPENCLOUD_CHROMIUM_PATH, - } - : {}), - }), - ); - }); - app .command("verify") .description("Run the authoritative OpenCloud release verification gate") @@ -1536,8 +1477,16 @@ program

Your OpenCloud app is running

+

Connecting to the OpenCloud runtime…

Edit frontend/index.html and deploy again with a new version.

+ `, { flag: "wx" }, @@ -1545,16 +1494,16 @@ program await writeFile( path.join(root, "opencloud.yaml"), YAML.stringify({ - schemaVersion: 1, + schemaVersion: 2, appId, version: options.version, frontend: { directory: "frontend", spa: true }, runtime: { - javascriptSdk: { - version: OPEN_CLOUD_JS_VERSION, + sdk: { + version: OPEN_CLOUD_SDK_VERSION, }, }, - storage: { authorization: "app" }, + files: { access: "user", maxUploadBytes: 50 * 1024 * 1024 }, migrations: [], functions: [], cron: [], @@ -1773,20 +1722,6 @@ deployment ), ); -const session = program - .command("session") - .description("Verify brokered app sessions without printing credentials"); - -session - .command("verify") - .argument("") - .action(async (appId) => { - const control = client(); - const value = await control.get(`/v1/apps/${appId}`); - const edgeUrl = process.env.OPENCLOUD_EDGE_URL; - output(await verifySessions(value, edgeUrl ? { edgeUrl } : {})); - }); - const cron = program .command("cron") .description("Inspect durable cron invocation history"); @@ -1824,36 +1759,6 @@ cron ); }); -program - .command("verify") - .description( - "Run two-user runtime verification from an opencloud.verify.yaml contract", - ) - .argument("") - .argument("") - .action(async (appId, verificationFile) => { - const control = client(); - const appValue = await control.get(`/v1/apps/${appId}`); - const verificationPath = callerPath(verificationFile); - const source = await readFile(verificationPath, "utf8"); - const bundle = await buildBundle(path.dirname(verificationPath)); - if (bundle.manifest.appId !== appId) { - throw new Error( - `Verification manifest appId ${bundle.manifest.appId} does not match ${appId}`, - ); - } - const spec = parseRuntimeVerificationSpec(source, bundle.manifest); - const edgeUrl = process.env.OPENCLOUD_EDGE_URL; - const result = await verifyRuntime( - control, - appValue, - spec, - edgeUrl ? { edgeUrl } : {}, - ); - output(result); - if (result.passed !== true) process.exitCode = 1; - }); - const secret = program .command("secret") .description("Manage app-scoped secrets"); diff --git a/src/runtime-verify.test.ts b/src/runtime-verify.test.ts deleted file mode 100644 index 58e17fe..0000000 --- a/src/runtime-verify.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - parseRuntimeVerificationSpec, - runtimeVerificationApplicability, - verifyStorage, -} from "./runtime-verify.js"; - -describe("runtime verification contract", () => { - const manifest = (authorization: "app" | "owner-prefix" = "app") => ({ - schemaVersion: 1 as const, - appId: "aeea1c71-72a3-4b1d-a32e-213900735091", - version: "test", - frontend: { directory: "frontend", spa: true }, - storage: { authorization }, - migrations: [], - functions: [ - { - name: "reading-probe", - entrypoint: "functions/reading-probe/index.ts", - verifyJwt: true, - }, - ], - cron: [ - { - name: "reading-heartbeat", - schedule: "* * * * *", - function: "reading-probe", - enabled: true, - }, - ], - health: { path: "/" }, - requiredSecrets: ["READING_VERIFY_SECRET"], - }); - - it("parses a reusable full-runtime verification contract", () => { - expect( - parseRuntimeVerificationSpec(` -schemaVersion: 1 -data: - table: reading_items - markerColumn: title - insert: - status: unread -storage: - objectPrefix: opencloud-verify/reading -realtime: - topic: reading -function: - name: reading-probe - secretName: READING_VERIFY_SECRET - digestField: secretDigest - presentField: secretPresent -cron: - name: reading-heartbeat -`, manifest()), - ).toMatchObject({ - data: { - mode: "owner", - table: "reading_items", - ownerColumn: "owner_id", - markerColumn: "title", - }, - function: { - digestField: "secretDigest", - presentField: "secretPresent", - }, - storage: { - authorization: "app", - }, - cron: { timeoutSeconds: 120 }, - }); - }); - - it("parses owner-prefixed Storage verification semantics", () => { - expect( - parseRuntimeVerificationSpec(` -schemaVersion: 1 -data: - table: reading_items - markerColumn: title -storage: - authorization: owner-prefix -realtime: - topic: reading -function: - name: reading-probe - secretName: READING_VERIFY_SECRET -cron: - name: reading-heartbeat -`, manifest("owner-prefix")).storage, - ).toEqual({ - authorization: "owner-prefix", - objectPrefix: "opencloud-verify", - }); - }); - - it("accepts documented camelCase function response fields", () => { - expect( - parseRuntimeVerificationSpec(` -schemaVersion: 1 -data: - table: reading_items - markerColumn: title -realtime: - topic: reading -function: - name: reading-probe - secretName: READING_VERIFY_SECRET - digestField: secretDigest - presentField: secretPresent -cron: - name: reading-heartbeat -`, manifest()).function, - ).toMatchObject({ - digestField: "secretDigest", - presentField: "secretPresent", - }); - }); - - it("rejects identifiers that could escape a same-origin runtime path", () => { - expect(() => - parseRuntimeVerificationSpec(` -schemaVersion: 1 -data: - table: "../other" - markerColumn: title -realtime: - topic: reading -function: - name: reading-probe - secretName: READING_VERIFY_SECRET -cron: - name: reading-heartbeat -`, manifest()), - ).toThrow(); - }); - - it("rejects verifier settings that contradict the manifest", () => { - expect(() => - parseRuntimeVerificationSpec(` -schemaVersion: 1 -data: - table: reading_items - markerColumn: title -storage: - authorization: app -realtime: - topic: reading -function: - name: reading-probe - secretName: READING_VERIFY_SECRET -cron: - name: reading-heartbeat -`, manifest("owner-prefix")), - ).toThrow(/manifest declares owner-prefix/); - }); - - it("rejects unknown verifier fields before live execution", () => { - expect(() => - parseRuntimeVerificationSpec(` -schemaVersion: 1 -data: - table: reading_items - markerColumn: title - inventedMode: unsafe -realtime: - topic: reading -function: - name: reading-probe - secretName: READING_VERIFY_SECRET -cron: - name: reading-heartbeat -`, manifest()), - ).toThrow(); - }); - - it("marks absent optional capabilities not applicable", () => { - const withoutOptionalCapabilities = { - ...manifest(), - functions: [], - cron: [], - requiredSecrets: [], - }; - const spec = parseRuntimeVerificationSpec(` -schemaVersion: 1 -data: - table: reading_items - markerColumn: title -`, withoutOptionalCapabilities); - - expect(runtimeVerificationApplicability(spec)).toMatchObject({ - storage: { disposition: "not_applicable" }, - realtime: { disposition: "not_applicable" }, - function: { disposition: "not_applicable" }, - cron: { disposition: "not_applicable" }, - }); - }); - - it("blocks completion when a declared Function or cron is not tested", () => { - const spec = parseRuntimeVerificationSpec(` -schemaVersion: 1 -data: - table: reading_items - markerColumn: title -`, manifest()); - - expect(runtimeVerificationApplicability(spec)).toMatchObject({ - function: { - disposition: "not_tested", - detail: expect.stringContaining("reading-probe"), - }, - cron: { - disposition: "not_tested", - detail: expect.stringContaining("reading-heartbeat"), - }, - }); - }); -}); - -describe("owner-prefixed Storage verification", () => { - const firstUserId = "193da750-5dbb-4aac-872c-4d67a78f1d49"; - const secondUserId = "28248e8f-19ac-4bb7-88bb-b1d86739ae58"; - const response = (ok: boolean, body = "") => ({ - ok, - status: ok ? 200 : 403, - headers: {}, - body, - value: body, - }); - - it("requires the owner path and observes cross-user read and write denial", async () => { - const payload = "OpenCloud verification marker"; - const request = vi - .fn() - .mockResolvedValueOnce(response(true)) - .mockResolvedValueOnce(response(true, payload)) - .mockResolvedValueOnce(response(false)) - .mockResolvedValueOnce(response(false)) - .mockResolvedValueOnce(response(false)) - .mockResolvedValueOnce(response(false)) - .mockResolvedValueOnce(response(true)); - - await expect( - verifyStorage( - { request } as never, - { appUrl: "https://reading.example.test" } as never, - { - appId: "aeea1c71-72a3-4b1d-a32e-213900735091", - supabaseAnonKey: "anon-key", - storageBucket: "app-aeea1c71-72a3-4b1d-a32e-213900735091", - }, - { - first: { - cookie: "first-cookie", - accessToken: "first-token", - userId: firstUserId, - }, - second: { - cookie: "second-cookie", - accessToken: "second-token", - userId: secondUserId, - }, - }, - { - authorization: "owner-prefix", - objectPrefix: "opencloud-verify", - }, - "marker", - ), - ).resolves.toMatchObject({ status: "passed" }); - - expect(request.mock.calls[0]?.[1]).toContain( - `/${firstUserId}/opencloud-verify/marker.txt`, - ); - expect(request.mock.calls[4]?.[1]).toContain( - `/${secondUserId}/opencloud-verify/marker-forged.txt`, - ); - expect(request).toHaveBeenCalledTimes(7); - }); - - it("fails if a second user can read an owner-prefixed object", async () => { - const payload = "OpenCloud verification marker"; - const request = vi - .fn() - .mockResolvedValueOnce(response(true)) - .mockResolvedValueOnce(response(true, payload)) - .mockResolvedValueOnce(response(true, payload)); - - await expect( - verifyStorage( - { request } as never, - { appUrl: "https://reading.example.test" } as never, - { - appId: "aeea1c71-72a3-4b1d-a32e-213900735091", - supabaseAnonKey: "anon-key", - storageBucket: "app-aeea1c71-72a3-4b1d-a32e-213900735091", - }, - { - first: { - cookie: "first-cookie", - accessToken: "first-token", - userId: firstUserId, - }, - second: { - cookie: "second-cookie", - accessToken: "second-token", - userId: secondUserId, - }, - }, - { - authorization: "owner-prefix", - objectPrefix: "opencloud-verify", - }, - "marker", - ), - ).rejects.toThrow(/second authenticated user/); - }); -}); diff --git a/src/runtime-verify.ts b/src/runtime-verify.ts deleted file mode 100644 index bb0420b..0000000 --- a/src/runtime-verify.ts +++ /dev/null @@ -1,1117 +0,0 @@ -import { createHash, randomBytes, randomUUID } from "node:crypto"; -import WebSocket from "ws"; -import YAML from "yaml"; -import { z } from "zod"; -import type { - OpenCloudManifest, - StorageAuthorization, -} from "@opencloud/contracts"; -import type { OpenCloudClient } from "./api-client.js"; -import { EdgeTransport } from "./edge-transport.js"; - -const identifier = z.string().regex(/^[a-z_][a-z0-9_]{0,62}$/); -const responseField = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,62}$/); -const logicalName = z.string().regex(/^[a-z][a-z0-9-]{0,62}$/); - -const dataVerificationSchema = z - .object({ - mode: z.enum(["owner", "shared"]).default("owner"), - table: identifier, - ownerColumn: identifier.optional(), - markerColumn: identifier, - insert: z.record(z.string(), z.unknown()).default({}), - }) - .strict(); - -export const runtimeVerificationSchema = z - .object({ - schemaVersion: z.literal(1), - data: dataVerificationSchema, - storage: z - .object({ - authorization: z.enum(["app", "owner-prefix"]).optional(), - objectPrefix: z - .string() - .regex(/^[a-zA-Z0-9][a-zA-Z0-9/_-]{0,120}$/) - .default("opencloud-verify"), - }) - .strict() - .optional(), - realtime: z - .object({ - topic: logicalName, - }) - .strict() - .optional(), - function: z - .object({ - name: logicalName, - secretName: z.string().regex(/^[A-Z][A-Z0-9_]{0,127}$/), - digestField: responseField.default("secretDigest"), - presentField: responseField.default("secretPresent"), - }) - .strict() - .optional(), - cron: z - .object({ - name: logicalName, - timeoutSeconds: z.coerce.number().int().min(10).max(300).default(120), - }) - .strict() - .optional(), - }) - .strict(); - -type RuntimeVerificationInput = z.infer; - -export interface RuntimeVerificationSpec - extends Omit { - data: RuntimeVerificationInput["data"] & { - ownerColumn?: string; - }; - storage?: { - authorization: StorageAuthorization; - objectPrefix: string; - }; - manifest: { - version: string; - storageAuthorization: StorageAuthorization; - functionNames: string[]; - enabledCronNames: string[]; - }; -} - -export type RuntimeVerificationDisposition = - | "test" - | "not_applicable" - | "not_tested"; - -export interface RuntimeVerificationApplicability { - storage: { disposition: RuntimeVerificationDisposition; detail: string }; - realtime: { disposition: RuntimeVerificationDisposition; detail: string }; - function: { disposition: RuntimeVerificationDisposition; detail: string }; - cron: { disposition: RuntimeVerificationDisposition; detail: string }; -} - -export function runtimeVerificationApplicability( - spec: RuntimeVerificationSpec, -): RuntimeVerificationApplicability { - return { - storage: spec.storage - ? { disposition: "test", detail: "The verification contract declares Storage." } - : { - disposition: "not_applicable", - detail: "The verification contract declares no Storage workflow.", - }, - realtime: spec.realtime - ? { disposition: "test", detail: "The verification contract declares Realtime." } - : { - disposition: "not_applicable", - detail: "The verification contract declares no Realtime workflow.", - }, - function: spec.function - ? { disposition: "test", detail: "The verification contract covers a Function." } - : spec.manifest.functionNames.length > 0 - ? { - disposition: "not_tested", - detail: `The manifest declares Function(s) ${spec.manifest.functionNames.join(", ")}, but the verification contract tests none.`, - } - : { - disposition: "not_applicable", - detail: "The manifest declares no Functions.", - }, - cron: spec.cron - ? { disposition: "test", detail: "The verification contract covers a cron job." } - : spec.manifest.enabledCronNames.length > 0 - ? { - disposition: "not_tested", - detail: `The manifest declares enabled cron(s) ${spec.manifest.enabledCronNames.join(", ")}, but the verification contract tests none.`, - } - : { - disposition: "not_applicable", - detail: "The manifest declares no enabled cron jobs.", - }, - }; -} - -interface RuntimeApp { - id: string; - slug: string; - visibility: "public" | "private"; - state: string; - activeDeploymentId: string | null; - appUrl: string; - authUrl: string; - apiUrl: string; -} - -interface VerificationUser { - email: string; - password: string; -} - -interface UserSession { - cookie: string; - accessToken: string; - userId: string; -} - -interface AppConfig { - appId: string; - supabaseAnonKey: string; - storageBucket: string; -} - -interface JoinedChannel { - socket: WebSocket; - topic: string; - joinRef: string; - status: string | undefined; -} - -const runtimeAppSchema = z.object({ - id: z.uuid(), - slug: z.string(), - visibility: z.enum(["public", "private"]), - state: z.string(), - activeDeploymentId: z.uuid().nullable(), - appUrl: z.url(), - authUrl: z.url(), - apiUrl: z.url(), -}); - -const appConfigSchema = z.object({ - appId: z.uuid(), - supabaseAnonKey: z.string().min(1), - storageBucket: z.string().min(1), -}); - -const appSessionSchema = z.object({ - userId: z.uuid(), - accessToken: z.string().min(1), -}); - -const cronInvocationSchema = z.object({ - id: z.uuid(), - appId: z.uuid(), - deploymentId: z.uuid(), - cronName: z.string(), - functionName: z.string(), - state: z.enum(["running", "succeeded", "failed"]), - scheduledAt: z.iso.datetime({ offset: true }), - startedAt: z.iso.datetime({ offset: true }), - finishedAt: z.iso.datetime({ offset: true }).nullable(), - responseStatus: z.number().int().nullable(), - error: z.record(z.string(), z.unknown()).nullable(), -}); - -function assert( - condition: unknown, - message: string, -): asserts condition { - if (!condition) throw new Error(message); -} - -function containsValue(value: unknown, target: string): boolean { - if (value === target) return true; - if (Array.isArray(value)) { - return value.some((item) => containsValue(item, target)); - } - if (value && typeof value === "object") { - return Object.values(value).some((item) => containsValue(item, target)); - } - return false; -} - -function verificationUsers( - environment: NodeJS.ProcessEnv = process.env, -): { first: VerificationUser; second: VerificationUser } { - const names = [ - "OPENCLOUD_VERIFY_USER_A_EMAIL", - "OPENCLOUD_VERIFY_USER_A_PASSWORD", - "OPENCLOUD_VERIFY_USER_B_EMAIL", - "OPENCLOUD_VERIFY_USER_B_PASSWORD", - ] as const; - const missing = names.filter((name) => !environment[name]); - if (missing.length) { - throw new Error( - `Runtime verification requires environment variables: ${missing.join(", ")}`, - ); - } - return { - first: { - email: environment.OPENCLOUD_VERIFY_USER_A_EMAIL!, - password: environment.OPENCLOUD_VERIFY_USER_A_PASSWORD!, - }, - second: { - email: environment.OPENCLOUD_VERIFY_USER_B_EMAIL!, - password: environment.OPENCLOUD_VERIFY_USER_B_PASSWORD!, - }, - }; -} - -async function login( - transport: EdgeTransport, - app: RuntimeApp, - user: VerificationUser, -): Promise { - const response = await transport.request( - app.authUrl, - "/v1/auth/_internal/verifier-session", - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ email: user.email, password: user.password }), - }, - ); - assert(response.ok, `Central login returned HTTP ${response.status}`); - const rawCookies = response.headers["set-cookie"]; - const rawCookie = Array.isArray(rawCookies) ? rawCookies[0] : rawCookies; - const cookie = rawCookie?.match(/(?:^|;\s*)(oc_session=[^;]+)/)?.[1]; - assert(cookie, "Central login did not return an opaque broker session"); - - const sessionResponse = await transport.request( - app.appUrl, - "/_opencloud/session", - { headers: { cookie } }, - ); - assert( - sessionResponse.ok, - `App session returned HTTP ${sessionResponse.status}`, - ); - const session = appSessionSchema.parse(sessionResponse.value); - return { - cookie, - accessToken: session.accessToken, - userId: session.userId, - }; -} - -async function config( - transport: EdgeTransport, - app: RuntimeApp, - cookie: string, -): Promise { - const response = await transport.request( - app.appUrl, - "/_opencloud/config", - { headers: { cookie } }, - ); - assert(response.ok, `App config returned HTTP ${response.status}`); - return appConfigSchema.parse(response.value); -} - -function userHeaders( - configValue: AppConfig, - session: UserSession, - extra: Record = {}, -): Record { - return { - apikey: configValue.supabaseAnonKey, - authorization: `Bearer ${session.accessToken}`, - cookie: session.cookie, - ...extra, - }; -} - -async function establishSessions( - transport: EdgeTransport, - app: RuntimeApp, -): Promise<{ - first: UserSession; - second: UserSession; - config: AppConfig; - redirectStatus: number; -}> { - const anonymous = await transport.request(app.appUrl, "/"); - if (app.visibility === "private") { - assert( - anonymous.status === 302, - `Private app anonymous request returned HTTP ${anonymous.status}, expected 302`, - ); - const location = String(anonymous.headers.location ?? ""); - assert( - location.startsWith(app.authUrl), - "Private app redirect did not target the canonical auth origin", - ); - } - - const users = verificationUsers(); - const first = await login(transport, app, users.first); - const second = await login(transport, app, users.second); - assert(first.userId !== second.userId, "Verification users are not distinct"); - const configValue = await config(transport, app, first.cookie); - assert(configValue.appId === app.id, "Runtime config returned the wrong app ID"); - return { - first, - second, - config: configValue, - redirectStatus: anonymous.status, - }; -} - -export function parseRuntimeVerificationSpec( - source: string, - manifest: OpenCloudManifest, -): RuntimeVerificationSpec { - const input = runtimeVerificationSchema.parse(YAML.parse(source)); - const ownerColumn = - input.data.ownerColumn ?? - (input.data.mode === "owner" ? "owner_id" : undefined); - const requestedAuthorization = input.storage?.authorization; - if ( - requestedAuthorization && - requestedAuthorization !== manifest.storage.authorization - ) { - throw new Error( - `Verification storage.authorization is ${requestedAuthorization}, but the app manifest declares ${manifest.storage.authorization}`, - ); - } - if (input.function) { - const verifiedFunction = manifest.functions.find( - (definition) => definition.name === input.function?.name, - ); - if (!verifiedFunction) { - throw new Error( - `Verification function ${input.function.name} is not declared in the app manifest`, - ); - } - if (!verifiedFunction.verifyJwt) { - throw new Error( - `Verification function ${input.function.name} must declare verifyJwt: true`, - ); - } - if (!manifest.requiredSecrets.includes(input.function.secretName)) { - throw new Error( - `Verification secret ${input.function.secretName} is not declared in requiredSecrets`, - ); - } - } - if (input.cron) { - const verifiedCron = manifest.cron.find( - (definition) => definition.name === input.cron?.name, - ); - if (!verifiedCron) { - throw new Error( - `Verification cron ${input.cron.name} is not declared in the app manifest`, - ); - } - if (!verifiedCron.enabled) { - throw new Error(`Verification cron ${input.cron.name} is disabled`); - } - } - const { ownerColumn: _inputOwnerColumn, ...data } = input.data; - return { - schemaVersion: input.schemaVersion, - data: { - ...data, - ...(ownerColumn ? { ownerColumn } : {}), - }, - ...(input.storage - ? { - storage: { - authorization: manifest.storage.authorization, - objectPrefix: input.storage.objectPrefix, - }, - } - : {}), - ...(input.realtime ? { realtime: input.realtime } : {}), - ...(input.function ? { function: input.function } : {}), - ...(input.cron ? { cron: input.cron } : {}), - manifest: { - version: manifest.version, - storageAuthorization: manifest.storage.authorization, - functionNames: manifest.functions.map(({ name }) => name), - enabledCronNames: manifest.cron - .filter(({ enabled }) => enabled) - .map(({ name }) => name), - }, - }; -} - -export async function verifySessions( - rawApp: unknown, - options: { edgeUrl?: string } = {}, -): Promise> { - const app = runtimeAppSchema.parse(rawApp); - const transport = new EdgeTransport(options.edgeUrl); - const sessions = await establishSessions(transport, app); - return { - passed: true, - appId: app.id, - appUrl: app.appUrl, - authUrl: app.authUrl, - apiUrl: app.apiUrl, - privateRedirect: - app.visibility === "private" - ? { status: sessions.redirectStatus, target: app.authUrl } - : { status: sessions.redirectStatus, target: null }, - users: [ - { label: "A", userId: sessions.first.userId, session: "established" }, - { label: "B", userId: sessions.second.userId, session: "established" }, - ], - }; -} - -async function verifyData( - transport: EdgeTransport, - app: RuntimeApp, - configValue: AppConfig, - sessions: { first: UserSession; second: UserSession }, - spec: RuntimeVerificationSpec["data"], - marker: string, -): Promise> { - const firstMarker = `verify-a-${marker}`; - const secondMarker = `verify-b-${marker}`; - const baseInsert = { ...spec.insert }; - if (spec.ownerColumn) delete baseInsert[spec.ownerColumn]; - delete baseInsert[spec.markerColumn]; - - const insert = async (session: UserSession, value: string) => { - const response = await transport.request( - app.appUrl, - `/rest/v1/${spec.table}`, - { - method: "POST", - headers: userHeaders(configValue, session, { - "content-type": "application/json", - prefer: "return=representation", - }), - body: JSON.stringify({ - ...baseInsert, - [spec.markerColumn]: value, - }), - }, - ); - assert(response.ok, `RLS fixture insert returned HTTP ${response.status}`); - const rows = z.array(z.record(z.string(), z.unknown())).parse(response.value); - const row = rows[0]; - assert(row && typeof row.id === "string", "RLS insert returned no row ID"); - if (spec.ownerColumn) { - assert( - row[spec.ownerColumn] === session.userId, - "auth.uid() did not populate the expected owner", - ); - } - return row; - }; - - const firstRow = await insert(sessions.first, firstMarker); - const secondRow = await insert(sessions.second, secondMarker); - const select = ["id", spec.ownerColumn, spec.markerColumn] - .filter((value): value is string => Boolean(value)) - .join(","); - const read = async (session: UserSession) => { - const response = await transport.request( - app.appUrl, - `/rest/v1/${spec.table}?select=${encodeURIComponent(select)}`, - { headers: userHeaders(configValue, session) }, - ); - assert(response.ok, `RLS fixture read returned HTTP ${response.status}`); - return z.array(z.record(z.string(), z.unknown())).parse(response.value); - }; - const firstRows = await read(sessions.first); - const secondRows = await read(sessions.second); - if (spec.mode === "owner") { - assert( - firstRows.some((row) => row[spec.markerColumn] === firstMarker) && - !firstRows.some((row) => row[spec.markerColumn] === secondMarker), - "User A did not see exactly its own verification row", - ); - assert( - secondRows.some((row) => row[spec.markerColumn] === secondMarker) && - !secondRows.some((row) => row[spec.markerColumn] === firstMarker), - "User B did not see exactly its own verification row", - ); - assert(spec.ownerColumn, "Owner-mode verification needs ownerColumn"); - const forged = await transport.request( - app.appUrl, - `/rest/v1/${spec.table}`, - { - method: "POST", - headers: userHeaders(configValue, sessions.first, { - "content-type": "application/json", - prefer: "return=representation", - }), - body: JSON.stringify({ - ...baseInsert, - [spec.markerColumn]: `verify-forged-${marker}`, - [spec.ownerColumn]: sessions.second.userId, - }), - }, - ); - assert(!forged.ok, "RLS accepted an explicitly forged owner value"); - } else { - for (const [label, rows] of [ - ["A", firstRows], - ["B", secondRows], - ] as const) { - assert( - rows.some((row) => row[spec.markerColumn] === firstMarker) && - rows.some((row) => row[spec.markerColumn] === secondMarker), - `User ${label} did not see both shared verification rows`, - ); - } - } - - const remove = async (session: UserSession, id: unknown) => { - const response = await transport.request( - app.appUrl, - `/rest/v1/${spec.table}?id=eq.${encodeURIComponent(String(id))}`, - { - method: "DELETE", - headers: userHeaders(configValue, session), - }, - ); - assert(response.ok, `RLS fixture cleanup returned HTTP ${response.status}`); - }; - await remove(sessions.first, firstRow.id); - await remove( - spec.mode === "shared" ? sessions.first : sessions.second, - secondRow.id, - ); - return { - status: "passed", - mode: spec.mode, - detail: - spec.mode === "owner" - ? "Two users saw only their own rows and forged ownership was denied." - : "Two admitted users both saw the shared verification fixtures.", - }; -} - -export async function verifyStorage( - transport: EdgeTransport, - app: RuntimeApp, - configValue: AppConfig, - sessions: { first: UserSession; second: UserSession }, - spec: NonNullable, - marker: string, -): Promise> { - const objectName = - spec.authorization === "owner-prefix" - ? `${sessions.first.userId}/${spec.objectPrefix}/${marker}.txt` - : `${spec.objectPrefix}/${marker}.txt`; - const encodedName = objectName.split("/").map(encodeURIComponent).join("/"); - const path = `/storage/v1/object/${encodeURIComponent( - configValue.storageBucket, - )}/${encodedName}`; - const payload = `OpenCloud verification ${marker}`; - const upload = await transport.request(app.appUrl, path, { - method: "POST", - headers: userHeaders(configValue, sessions.first, { - "content-type": "text/plain", - "x-upsert": "true", - }), - body: payload, - }); - assert(upload.ok, `Storage upload returned HTTP ${upload.status}`); - - const ownerRead = await transport.request(app.appUrl, path, { - headers: userHeaders(configValue, sessions.first), - }); - assert( - ownerRead.ok && ownerRead.body === payload, - `Storage owner read returned HTTP ${ownerRead.status}`, - ); - - const crossUserRead = await transport.request(app.appUrl, path, { - headers: userHeaders(configValue, sessions.second), - }); - if (spec.authorization === "owner-prefix") { - assert( - !crossUserRead.ok, - "A second authenticated user read another user's Storage prefix", - ); - } else { - assert( - crossUserRead.ok && crossUserRead.body === payload, - `Storage app-scoped read returned HTTP ${crossUserRead.status}`, - ); - } - - const anonymous = await transport.request(app.appUrl, path, { - headers: { - apikey: configValue.supabaseAnonKey, - authorization: `Bearer ${configValue.supabaseAnonKey}`, - cookie: sessions.first.cookie, - }, - }); - assert(!anonymous.ok, "Anonymous JWT read a private Storage object"); - - if (spec.authorization === "owner-prefix") { - const forgedName = - `${sessions.second.userId}/${spec.objectPrefix}/${marker}-forged.txt` - .split("/") - .map(encodeURIComponent) - .join("/"); - const forged = await transport.request( - app.appUrl, - `/storage/v1/object/${encodeURIComponent( - configValue.storageBucket, - )}/${forgedName}`, - { - method: "POST", - headers: userHeaders(configValue, sessions.first, { - "content-type": "text/plain", - }), - body: "forbidden", - }, - ); - assert( - !forged.ok, - "A user wrote into another user's Storage prefix", - ); - } - - const foreignBucket = - "app-00000000-0000-0000-0000-000000000000"; - const foreign = await transport.request( - app.appUrl, - `/storage/v1/object/${foreignBucket}/opencloud-verify/forbidden.txt`, - { - method: "POST", - headers: userHeaders(configValue, sessions.first, { - "content-type": "text/plain", - }), - body: "forbidden", - }, - ); - assert(!foreign.ok, "Authenticated user wrote outside the app Storage bucket"); - - const remove = await transport.request(app.appUrl, path, { - method: "DELETE", - headers: userHeaders(configValue, sessions.first), - }); - assert(remove.ok, `Storage cleanup returned HTTP ${remove.status}`); - return { - status: "passed", - detail: - spec.authorization === "owner-prefix" - ? "Owner-prefixed upload/read/delete passed; cross-user read/write, anonymous read, and foreign-bucket write were denied." - : "App-scoped authenticated upload/read/delete passed; anonymous read and foreign-bucket write were denied.", - }; -} - -function joinRealtime( - transport: EdgeTransport, - app: RuntimeApp, - configValue: AppConfig, - session: UserSession, - channelName: string, -): Promise { - return new Promise((resolve, reject) => { - const query = new URLSearchParams({ - apikey: configValue.supabaseAnonKey, - vsn: "1.0.0", - }); - const socket = transport.webSocket( - app.appUrl, - `/realtime/v1/websocket?${query}`, - { cookie: session.cookie }, - ); - const ref = `${Date.now()}-${Math.random()}`; - const timeout = setTimeout(() => { - socket.close(); - reject(new Error(`Realtime join timed out for ${channelName}`)); - }, 15_000); - socket.once("open", () => { - socket.send( - JSON.stringify({ - topic: `realtime:${channelName}`, - event: "phx_join", - payload: { - config: { - private: true, - broadcast: { ack: true, self: true }, - presence: { enabled: false }, - postgres_changes: [], - }, - access_token: session.accessToken, - }, - ref, - join_ref: ref, - }), - ); - }); - socket.on("message", (data) => { - const message = JSON.parse(data.toString()) as { - event?: string; - ref?: string; - payload?: { status?: string }; - }; - if (message.event !== "phx_reply" || message.ref !== ref) return; - clearTimeout(timeout); - resolve({ - socket, - topic: `realtime:${channelName}`, - joinRef: ref, - status: message.payload?.status, - }); - }); - socket.once("error", () => { - clearTimeout(timeout); - reject(new Error(`Realtime socket failed for ${channelName}`)); - }); - }); -} - -async function verifyRealtime( - transport: EdgeTransport, - app: RuntimeApp, - configValue: AppConfig, - sessions: { first: UserSession; second: UserSession }, - spec: NonNullable, - marker: string, -): Promise> { - const channelName = `app:${app.id}:${spec.topic}`; - const first = await joinRealtime( - transport, - app, - configValue, - sessions.first, - channelName, - ); - const second = await joinRealtime( - transport, - app, - configValue, - sessions.second, - channelName, - ); - let foreign: JoinedChannel | undefined; - try { - assert( - first.status === "ok" && second.status === "ok", - "Granted users could not join the private app channel", - ); - const received = new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error("Realtime broadcast was not delivered")), - 15_000, - ); - second.socket.on("message", (data) => { - const message = JSON.parse(data.toString()) as { - event?: string; - payload?: { event?: string; payload?: { marker?: string } }; - }; - if ( - message.event === "broadcast" && - message.payload?.event === "opencloud-verify" && - message.payload.payload?.marker === marker - ) { - clearTimeout(timeout); - resolve(); - } - }); - }); - first.socket.send( - JSON.stringify({ - topic: first.topic, - event: "broadcast", - payload: { - type: "broadcast", - event: "opencloud-verify", - payload: { marker }, - }, - ref: `${Date.now()}-broadcast`, - join_ref: first.joinRef, - }), - ); - await received; - - foreign = await joinRealtime( - transport, - app, - configValue, - sessions.first, - `app:00000000-0000-0000-0000-000000000000:${spec.topic}`, - ); - assert(foreign.status !== "ok", "User joined a foreign private app topic"); - } finally { - first.socket.close(); - second.socket.close(); - foreign?.socket.close(); - } - return { - status: "passed", - detail: - "Two granted users exchanged a private broadcast and a foreign-app topic was denied.", - }; -} - -async function verifyFunction( - transport: EdgeTransport, - client: OpenCloudClient, - app: RuntimeApp, - configValue: AppConfig, - session: UserSession, - spec: NonNullable, -): Promise> { - const functionPath = `/functions/v1/${spec.name}`; - const anonymous = await transport.request(app.appUrl, functionPath, { - method: "POST", - headers: { - apikey: configValue.supabaseAnonKey, - authorization: `Bearer ${configValue.supabaseAnonKey}`, - cookie: session.cookie, - "content-type": "application/json", - }, - body: JSON.stringify({ verification: true }), - }); - assert( - anonymous.status === 401, - `JWT-protected function returned HTTP ${anonymous.status} for the anonymous JWT`, - ); - - const invoke = async (secret: string) => { - await client.put( - `/v1/apps/${app.id}/secrets/${encodeURIComponent(spec.secretName)}`, - { value: secret }, - ); - const response = await transport.request(app.appUrl, functionPath, { - method: "POST", - headers: userHeaders(configValue, session, { - "content-type": "application/json", - }), - body: JSON.stringify({ verification: true }), - }); - assert( - response.ok, - `Authenticated function returned HTTP ${response.status}`, - ); - const value = z.record(z.string(), z.unknown()).parse(response.value); - const digest = createHash("sha256").update(secret).digest("hex"); - assert( - value[spec.presentField] === true && - value[spec.digestField] === digest, - "Function did not return the expected one-way secret marker", - ); - assert( - !containsValue(value, secret), - "Function response exposed a plaintext verification secret", - ); - return digest; - }; - - const firstSecret = randomBytes(32).toString("base64url"); - const firstDigest = await invoke(firstSecret); - const secondSecret = randomBytes(32).toString("base64url"); - const secondDigest = await invoke(secondSecret); - assert(firstDigest !== secondDigest, "Secret rotation did not change the digest"); - - const listed = await client.get(`/v1/apps/${app.id}/secrets`); - assert( - JSON.stringify(listed).includes(spec.secretName) && - !containsValue(listed, firstSecret) && - !containsValue(listed, secondSecret), - "Secret metadata was missing or exposed plaintext", - ); - return { - status: "passed", - detail: - "Anonymous JWT was rejected; authenticated execution and secret rotation passed without plaintext disclosure.", - }; -} - -async function verifyCron( - client: OpenCloudClient, - app: RuntimeApp, - spec: NonNullable, -): Promise> { - const startedAt = new Date(); - const trigger = z - .object({ - accepted: z.literal(true), - jobId: z.string().min(1), - cronName: z.string(), - functionName: z.string(), - }) - .parse( - await client.post( - `/v1/apps/${app.id}/cron/${encodeURIComponent(spec.name)}/invoke`, - {}, - ), - ); - const deadline = Date.now() + spec.timeoutSeconds * 1_000; - while (Date.now() < deadline) { - const query = new URLSearchParams({ - name: spec.name, - state: "succeeded", - after: startedAt.toISOString(), - limit: "20", - }); - const raw = await client.get( - `/v1/apps/${app.id}/cron/invocations?${query}`, - ); - const invocations = z.array(cronInvocationSchema).parse(raw); - const invocation = invocations[0]; - if (invocation) { - return { - status: "passed", - detail: - "The active cron was triggered deterministically and a fresh successful invocation was read from structured history.", - jobId: trigger.jobId, - invocationId: invocation.id, - startedAt: invocation.startedAt, - }; - } - await new Promise((resolve) => setTimeout(resolve, 2_000)); - } - throw new Error( - `No successful ${spec.name} invocation appeared in structured cron history`, - ); -} - -export async function verifyRuntime( - client: OpenCloudClient, - rawApp: unknown, - spec: RuntimeVerificationSpec, - options: { edgeUrl?: string } = {}, -): Promise> { - const app = runtimeAppSchema.parse(rawApp); - assert(app.state === "active", `App state is ${app.state}, expected active`); - assert(app.activeDeploymentId, "App has no active deployment"); - const marker = randomUUID().slice(0, 12); - const transport = new EdgeTransport(options.edgeUrl); - const sessions = await establishSessions(transport, app); - const scopedSessions = { - first: sessions.first, - second: sessions.second, - }; - const applicability = runtimeVerificationApplicability(spec); - - const results: Record[] = [ - { - name: "canonical origins and sessions", - status: "passed", - detail: - "Canonical origins resolved; private redirect and two brokered user sessions passed.", - }, - await verificationCheck("two-user RLS", () => verifyData( - transport, - app, - sessions.config, - scopedSessions, - spec.data, - marker, - )), - spec.storage - ? await verificationCheck("authenticated Storage", () => - verifyStorage( - transport, - app, - sessions.config, - scopedSessions, - spec.storage!, - marker, - ), - ) - : dispositionResult("authenticated Storage", applicability.storage), - spec.realtime - ? await verificationCheck("private Realtime", () => - verifyRealtime( - transport, - app, - sessions.config, - scopedSessions, - spec.realtime!, - marker, - ), - ) - : dispositionResult("private Realtime", applicability.realtime), - spec.function - ? await verificationCheck( - "function authentication and secret rotation", - () => - verifyFunction( - transport, - client, - app, - sessions.config, - sessions.first, - spec.function!, - ), - ) - : dispositionResult( - "function authentication and secret rotation", - applicability.function, - ), - spec.cron - ? await verificationCheck("structured cron history", () => - verifyCron(client, app, spec.cron!), - ) - : dispositionResult("structured cron history", applicability.cron), - ]; - - results.push( - await verificationCheck("scoped observability", async () => { - const now = new Date(); - const logs = await client.post(`/v1/apps/${app.id}/logs/query`, { - from: new Date(now.getTime() - 15 * 60_000).toISOString(), - to: now.toISOString(), - limit: 50, - }); - const usage = await client.get(`/v1/apps/${app.id}/usage`); - return { - status: "passed", - detail: `Logs query succeeded; usage returned ${ - Array.isArray(usage) ? usage.length : 0 - } rollup row(s).`, - logsReturned: Array.isArray(logs) ? logs.length : undefined, - }; - }), - ); - - const passed = results.every(({ status }) => - status === "passed" || status === "not_applicable", - ); - - return { - passed, - appId: app.id, - deploymentId: app.activeDeploymentId, - contract: spec.manifest, - appUrl: app.appUrl, - authUrl: app.authUrl, - apiUrl: app.apiUrl, - adapterUsed: Boolean(options.edgeUrl), - results, - }; -} - -async function verificationCheck( - name: string, - check: () => Promise>, -): Promise> { - try { - return { name, ...(await check()) }; - } catch (error) { - return { - name, - status: "failed", - detail: error instanceof Error ? error.message.slice(0, 2_000) : "Verification failed", - }; - } -} - -function notApplicable(name: string, detail: string): Record { - return { name, status: "not_applicable", detail }; -} - -function notTested(name: string, detail: string): Record { - return { name, status: "not_tested", detail }; -} - -function dispositionResult( - name: string, - applicability: { disposition: RuntimeVerificationDisposition; detail: string }, -): Record { - if (applicability.disposition === "not_applicable") { - return notApplicable(name, applicability.detail); - } - if (applicability.disposition === "not_tested") { - return notTested(name, applicability.detail); - } - throw new Error(`Verification capability ${name} was not executed`); -} diff --git a/src/ui-verify.test.ts b/src/ui-verify.test.ts deleted file mode 100644 index 8ba8a0c..0000000 --- a/src/ui-verify.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { verifyAppUi } from "./ui-verify.js"; - -const app = { - id: "11111111-1111-4111-8111-111111111111", - visibility: "private", - state: "active", - activeDeploymentId: "22222222-2222-4222-8222-222222222222", - appUrl: "https://example.opencloud.ai", - authUrl: "https://auth.opencloud.ai", -}; - -function launcher(options: { - session?: "established" | "none"; - consoleError?: string; - publicSession401?: boolean; - interactionContract?: { - declared: boolean; - required: boolean; - checks: string[]; - coverage: Array<"view-transition" | "state-assertion">; - }; -} = {}) { - const listeners = new Map void>(); - const page = { - on: vi.fn((event: string, listener: (...args: any[]) => void) => { - listeners.set(event, listener); - }), - goto: vi.fn(async () => { - if (options.publicSession401) { - listeners.get("response")?.({ - status: () => 401, - url: () => `${app.appUrl}/_opencloud/session`, - }); - listeners.get("console")?.({ - type: () => "error", - text: () => - "Failed to load resource: the server responded with a status of 401 ()", - }); - } - if (options.consoleError) { - listeners.get("console")?.({ - type: () => "error", - text: () => options.consoleError, - }); - } - }), - waitForLoadState: vi.fn().mockResolvedValue(undefined), - waitForTimeout: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue({ - title: "Example", - visibleTextLength: 120, - landmarkCount: 2, - sdk: { - package: "@opencloud/js", - version: "0.2.1", - module: "/_opencloud/sdk/js/v0.2.1/index.js", - types: "/_opencloud/sdk/js/v0.2.1/index.d.ts", - exportedVersion: "0.2.1", - configMatched: true, - typesStatus: 200, - }, - session: options.session ?? "established", - interactionContract: options.interactionContract ?? { - declared: false, - required: false, - checks: [], - coverage: [], - }, - }), - url: vi.fn(() => app.appUrl), - }; - const login = { - ok: vi.fn(() => true), - status: vi.fn(() => 201), - }; - const browser = { - newContext: vi.fn().mockResolvedValue({ - request: { post: vi.fn().mockResolvedValue(login) }, - newPage: vi.fn().mockResolvedValue(page), - }), - close: vi.fn().mockResolvedValue(undefined), - }; - return { - launch: vi.fn().mockResolvedValue(browser), - browser, - page, - }; -} - -describe("verifyAppUi", () => { - it("authenticates and verifies the exact runtime SDK contract", async () => { - const fake = launcher(); - const result = await verifyAppUi(app, { - email: "agent@example.test", - password: "not-printed", - launcher: fake as never, - }); - - expect(result).toMatchObject({ - passed: true, - appId: app.id, - session: "established", - sdk: { version: "0.2.1", typesStatus: 200 }, - }); - expect(fake.browser.newContext).toHaveBeenCalledWith({ - ignoreHTTPSErrors: false, - }); - expect(fake.browser.close).toHaveBeenCalled(); - }); - - it("fails on browser console errors and still closes Chromium", async () => { - const fake = launcher({ consoleError: "broken frontend" }); - await expect( - verifyAppUi(app, { - email: "agent@example.test", - password: "not-printed", - launcher: fake as never, - }), - ).rejects.toThrow("console: broken frontend"); - expect(fake.browser.close).toHaveBeenCalled(); - }); - - it("requires provisioned verification credentials for a private app", async () => { - await expect( - verifyAppUi(app, { - email: "", - password: "", - launcher: launcher() as never, - }), - ).rejects.toThrow("OPENCLOUD_VERIFY_USER_A_EMAIL"); - }); - - it("accepts a public app without verifier credentials or a session", async () => { - const fake = launcher({ session: "none" }); - const result = await verifyAppUi( - { ...app, visibility: "public" }, - { launcher: fake as never }, - ); - - expect(result).toMatchObject({ - passed: true, - visibility: "public", - session: "none", - }); - expect(fake.browser.newContext).toHaveBeenCalled(); - }); - - it("rejects the legacy public signed-out 401 browser error", async () => { - const fake = launcher({ - session: "none", - publicSession401: true, - }); - - await expect( - verifyAppUi( - { ...app, visibility: "public" }, - { launcher: fake as never }, - ), - ).rejects.toThrow("status of 401"); - }); - - it("does not suppress an unrelated generic 401 console entry", async () => { - const fake = launcher({ - session: "none", - consoleError: - "Failed to load resource: the server responded with a status of 401 ()", - }); - - await expect( - verifyAppUi( - { ...app, visibility: "public" }, - { launcher: fake as never }, - ), - ).rejects.toThrow("status of 401"); - }); - - it("reports a passing app-supplied interaction contract", async () => { - const fake = launcher({ - interactionContract: { - declared: true, - required: true, - checks: ["opened workspace", "rendered empty state"], - coverage: ["view-transition", "state-assertion"], - }, - }); - const result = await verifyAppUi(app, { - email: "agent@example.test", - password: "not-printed", - launcher: fake as never, - }); - - expect(result).toMatchObject({ - passed: true, - interactionContract: { - declared: true, - required: true, - checks: ["opened workspace", "rendered empty state"], - coverage: ["view-transition", "state-assertion"], - }, - }); - }); - - it("fails when an app marks its interaction contract as required but omits it", async () => { - const fake = launcher({ - interactionContract: { - declared: false, - required: true, - checks: [], - coverage: [], - }, - }); - - await expect( - verifyAppUi(app, { - email: "agent@example.test", - password: "not-printed", - launcher: fake as never, - }), - ).rejects.toThrow("did not declare it"); - }); - - it("requires both interaction coverage categories", async () => { - const fake = launcher({ - interactionContract: { - declared: true, - required: false, - checks: ["opened workspace"], - coverage: ["view-transition"], - }, - }); - - await expect( - verifyAppUi(app, { - email: "agent@example.test", - password: "not-printed", - requireInteraction: true, - launcher: fake as never, - }), - ).rejects.toThrow("did not cover state-assertion"); - }); - - it("lets the command require a complete interaction contract", async () => { - const fake = launcher({ - interactionContract: { - declared: true, - required: false, - checks: ["opened workspace", "confirmed active view"], - coverage: ["view-transition", "state-assertion"], - }, - }); - - const result = await verifyAppUi(app, { - email: "agent@example.test", - password: "not-printed", - requireInteraction: true, - launcher: fake as never, - }); - - expect(result).toMatchObject({ - passed: true, - interactionContract: { - declared: true, - required: true, - }, - }); - }); -}); diff --git a/src/ui-verify.ts b/src/ui-verify.ts deleted file mode 100644 index 3db5d9d..0000000 --- a/src/ui-verify.ts +++ /dev/null @@ -1,469 +0,0 @@ -import { chromium } from "playwright"; -import { z } from "zod"; - -const uiAppSchema = z.object({ - id: z.uuid(), - visibility: z.enum(["public", "private"]), - state: z.string(), - activeDeploymentId: z.uuid().nullable(), - appUrl: z.url(), - authUrl: z.url(), -}); - -interface BrowserRequestResult { - ok(): boolean; - status(): number; -} - -interface UiPage { - on(event: string, listener: (...args: any[]) => void): void; - goto( - url: string, - options: { waitUntil: "domcontentloaded"; timeout: number }, - ): Promise; - waitForLoadState( - state: "networkidle", - options: { timeout: number }, - ): Promise; - waitForTimeout(milliseconds: number): Promise; - evaluate(callback: () => Promise): Promise; - url(): string; -} - -interface UiBrowserContext { - request: { - post( - url: string, - options: { - data: Record; - failOnStatusCode: boolean; - timeout: number; - }, - ): Promise; - }; - newPage(): Promise; -} - -interface UiBrowser { - newContext(options: { - ignoreHTTPSErrors: boolean; - }): Promise; - close(): Promise; -} - -interface UiBrowserLauncher { - launch(options: { - headless: boolean; - executablePath?: string; - }): Promise; -} - -export interface VerifyAppUiOptions { - chromiumPath?: string; - timeoutMs?: number; - email?: string; - password?: string; - requireInteraction?: boolean; - launcher?: UiBrowserLauncher; -} - -type InteractionCoverage = "view-transition" | "state-assertion"; - -interface BrowserEvaluation { - title: string; - visibleTextLength: number; - landmarkCount: number; - sdk: { - package: string; - version: string; - module: string; - types: string; - exportedVersion: string; - configMatched: boolean; - typesStatus: number; - }; - session: "established" | "none"; - interactionContract: { - declared: boolean; - required: boolean; - checks: string[]; - coverage: InteractionCoverage[]; - }; -} - -function diagnosticText(value: unknown): string { - const source = - value instanceof Error - ? `${value.name}: ${value.message}` - : typeof value === "string" - ? value - : String(value); - return source - .replace(/([?&](?:token|key|secret|password|code)=)[^&\s]+/gi, "$1[redacted]") - .replace(/\beyJ[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{8,}\b/g, "[redacted-jwt]") - .slice(0, 500); -} - -function requiredPrivateCredentials( - app: z.infer, - options: VerifyAppUiOptions, -): { email: string; password: string } | null { - if (app.visibility === "public") return null; - const email = - options.email ?? process.env.OPENCLOUD_VERIFY_USER_A_EMAIL; - const password = - options.password ?? process.env.OPENCLOUD_VERIFY_USER_A_PASSWORD; - if (!email || !password) { - throw new Error( - "Private UI verification requires OPENCLOUD_VERIFY_USER_A_EMAIL and OPENCLOUD_VERIFY_USER_A_PASSWORD", - ); - } - return { email, password }; -} - -export async function verifyAppUi( - rawApp: unknown, - options: VerifyAppUiOptions = {}, -): Promise> { - const app = uiAppSchema.parse(rawApp); - if (app.state !== "active" || !app.activeDeploymentId) { - throw new Error("UI verification requires an active deployment"); - } - const timeoutMs = options.timeoutMs ?? 30_000; - const credentials = requiredPrivateCredentials(app, options); - const launcher = options.launcher ?? (chromium as unknown as UiBrowserLauncher); - let browser: UiBrowser; - try { - browser = await launcher.launch({ - headless: true, - ...(options.chromiumPath - ? { executablePath: options.chromiumPath } - : {}), - }); - } catch (error) { - throw new Error( - `Could not launch Chromium for OpenCloud UI verification. Run "npx playwright install chromium" or set OPENCLOUD_CHROMIUM_PATH. ${diagnosticText(error)}`, - ); - } - - const consoleErrors: string[] = []; - const pageErrors: string[] = []; - const failedRequests: string[] = []; - const serverErrors: string[] = []; - try { - const context = await browser.newContext({ ignoreHTTPSErrors: false }); - if (credentials) { - const login = await context.request.post( - `${app.authUrl}/v1/auth/_internal/verifier-session`, - { - data: credentials, - failOnStatusCode: false, - timeout: timeoutMs, - }, - ); - if (!login.ok()) { - throw new Error( - `Central Auth login returned HTTP ${login.status()}`, - ); - } - } - - const page = await context.newPage(); - const appOrigin = new URL(app.appUrl).origin; - page.on("console", (message: { type(): string; text(): string }) => { - if (message.type() === "error") { - consoleErrors.push(diagnosticText(message.text())); - } - }); - page.on("pageerror", (error: Error) => { - pageErrors.push(diagnosticText(error)); - }); - page.on( - "requestfailed", - (request: { - url(): string; - failure(): { errorText?: string } | null; - }) => { - const url = new URL(request.url()); - if (url.origin === appOrigin) { - failedRequests.push( - `${url.pathname}: ${diagnosticText( - request.failure()?.errorText ?? "request failed", - )}`, - ); - } - }, - ); - page.on( - "response", - (response: { status(): number; url(): string }) => { - const url = new URL(response.url()); - if (url.origin === appOrigin && response.status() >= 500) { - serverErrors.push(`${url.pathname}: HTTP ${response.status()}`); - } - }, - ); - - await page.goto(app.appUrl, { - waitUntil: "domcontentloaded", - timeout: timeoutMs, - }); - await page - .waitForLoadState("networkidle", { - timeout: Math.min(timeoutMs, 5_000), - }) - .catch(() => undefined); - await page.waitForTimeout(750); - - const evaluation = await page.evaluate(async (): Promise => { - const response = await fetch("/_opencloud/config", { - credentials: "same-origin", - }); - if (!response.ok) { - throw new Error(`Runtime config returned HTTP ${response.status}`); - } - const runtime = await response.json() as { - appId: string; - visibility: "public" | "private"; - javascriptSdk: { - package: string; - version: string; - module: string; - types: string; - }; - }; - const sdk = await import(runtime.javascriptSdk.module) as { - OPEN_CLOUD_JS_VERSION?: string; - createOpenCloudClient?: () => { - config(): Promise<{ appId: string; javascriptSdk: { version: string } }>; - session(): Promise; - dispose(): void; - }; - }; - if (typeof sdk.createOpenCloudClient !== "function") { - throw new Error("SDK does not export createOpenCloudClient"); - } - const client = sdk.createOpenCloudClient(); - try { - const [config, session, typesResponse] = await Promise.all([ - client.config(), - client.session(), - fetch(runtime.javascriptSdk.types, { - credentials: "same-origin", - }), - ]); - const visibleTextLength = - document.body?.innerText.replace(/\s+/g, " ").trim().length ?? 0; - const requiredContract = - document - .querySelector('meta[name="opencloud-ui-contract"]') - ?.getAttribute("content") === "required"; - const candidate = ( - globalThis as typeof globalThis & { - __opencloudVerify?: (context: { - client: ReturnType>; - config: typeof config; - session: unknown; - }) => Promise | unknown; - } - ).__opencloudVerify; - let interactionContract: BrowserEvaluation["interactionContract"] = { - declared: false, - required: requiredContract, - checks: [], - coverage: [], - }; - if (candidate !== undefined) { - if (typeof candidate !== "function") { - throw new Error( - "window.__opencloudVerify must be a function", - ); - } - const timeout = new Promise((_, reject) => { - globalThis.setTimeout( - () => - reject( - new Error( - "App-supplied UI verification timed out after 10 seconds", - ), - ), - 10_000, - ); - }); - const rawResult = await Promise.race([ - Promise.resolve(candidate({ client, config, session })), - timeout, - ]); - if ( - !rawResult || - typeof rawResult !== "object" || - (rawResult as { passed?: unknown }).passed !== true - ) { - throw new Error( - "App-supplied UI verification did not return passed: true", - ); - } - const rawChecks = (rawResult as { checks?: unknown }).checks; - if ( - !Array.isArray(rawChecks) || - rawChecks.length < 1 || - rawChecks.length > 20 || - rawChecks.some( - (check) => - typeof check !== "string" || - check.length < 1 || - check.length > 160, - ) - ) { - throw new Error( - "App-supplied UI verification must return 1-20 short check names", - ); - } - const rawCoverage = ( - rawResult as { coverage?: unknown } - ).coverage; - if ( - rawCoverage !== undefined && - (!Array.isArray(rawCoverage) || - rawCoverage.some( - (item) => - item !== "view-transition" && - item !== "state-assertion", - )) - ) { - throw new Error( - "App-supplied UI verification coverage must contain only view-transition and state-assertion", - ); - } - interactionContract = { - declared: true, - required: requiredContract, - checks: rawChecks as string[], - coverage: [ - ...new Set( - (rawCoverage ?? []) as InteractionCoverage[], - ), - ], - }; - } - return { - title: document.title, - visibleTextLength, - landmarkCount: document.querySelectorAll( - "main, [role=main], header, nav", - ).length, - sdk: { - package: runtime.javascriptSdk.package, - version: runtime.javascriptSdk.version, - module: runtime.javascriptSdk.module, - types: runtime.javascriptSdk.types, - exportedVersion: sdk.OPEN_CLOUD_JS_VERSION ?? "", - configMatched: - config.appId === runtime.appId && - config.javascriptSdk.version === runtime.javascriptSdk.version, - typesStatus: typesResponse.status, - }, - session: session ? "established" : "none", - interactionContract, - }; - } finally { - client.dispose(); - } - }); - - const finalUrl = page.url(); - const failures: string[] = []; - if (new URL(finalUrl).origin !== appOrigin) { - failures.push(`navigation ended at unexpected origin ${new URL(finalUrl).origin}`); - } - if (evaluation.visibleTextLength < 20) { - failures.push("document rendered fewer than 20 visible text characters"); - } - if (evaluation.sdk.package !== "@opencloud/js") { - failures.push("runtime advertised an unexpected SDK package"); - } - if ( - !evaluation.sdk.exportedVersion || - evaluation.sdk.exportedVersion !== evaluation.sdk.version - ) { - failures.push("SDK export and runtime versions do not match"); - } - if (!evaluation.sdk.configMatched) { - failures.push("SDK config did not match runtime config"); - } - if (evaluation.sdk.typesStatus !== 200) { - failures.push( - `SDK declarations returned HTTP ${evaluation.sdk.typesStatus}`, - ); - } - if (app.visibility === "private" && evaluation.session !== "established") { - failures.push("private app did not establish an SDK session"); - } - const interactionRequired = - evaluation.interactionContract.required || - options.requireInteraction === true; - if ( - interactionRequired && - !evaluation.interactionContract.declared - ) { - failures.push( - "app requires window.__opencloudVerify but did not declare it", - ); - } - if (interactionRequired && evaluation.interactionContract.declared) { - for (const requiredCoverage of [ - "view-transition", - "state-assertion", - ] as const) { - if ( - !evaluation.interactionContract.coverage.includes( - requiredCoverage, - ) - ) { - failures.push( - `app interaction contract did not cover ${requiredCoverage}`, - ); - } - } - } - failures.push( - ...consoleErrors.map((message) => `console: ${message}`), - ...pageErrors.map((message) => `page: ${message}`), - ...failedRequests.map((message) => `request: ${message}`), - ...serverErrors.map((message) => `response: ${message}`), - ); - if (failures.length) { - throw new Error( - `OpenCloud UI verification failed: ${failures.join("; ")}`, - ); - } - - return { - passed: true, - appId: app.id, - deploymentId: app.activeDeploymentId, - appUrl: app.appUrl, - finalUrl, - visibility: app.visibility, - document: { - title: evaluation.title, - visibleTextLength: evaluation.visibleTextLength, - landmarkCount: evaluation.landmarkCount, - }, - sdk: evaluation.sdk, - session: evaluation.session, - interactionContract: { - ...evaluation.interactionContract, - required: interactionRequired, - }, - diagnostics: { - consoleErrors: 0, - pageErrors: 0, - failedSameOriginRequests: 0, - serverErrors: 0, - }, - }; - } finally { - await browser.close(); - } -} diff --git a/vendor/browser-client/package.json b/vendor/browser-client/package.json index 8e8a71a..79d3b0c 100644 --- a/vendor/browser-client/package.json +++ b/vendor/browser-client/package.json @@ -1,6 +1,6 @@ { "name": "@opencloud/js", - "version": "0.2.2", + "version": "1.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 13d1caa..45672dc 100644 --- a/vendor/browser-client/src/index.test.ts +++ b/vendor/browser-client/src/index.test.ts @@ -1,50 +1,95 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as sdk from "./index.js"; import { - createOpenCloudClient, - type OpenCloudRuntimeConfig, + OPEN_CLOUD_SDK_VERSION, + OpenCloudError, + isOpenCloudError, + opencloud, + type OpenCloudTelemetrySurface, } from "./index.js"; -const origin = "https://tasks.opencloud.ai"; -const initialNow = Date.parse("2026-01-01T00:00:00.000Z"); +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 runtimeConfig: OpenCloudRuntimeConfig = { - appId: "11111111-1111-4111-8111-111111111111", - deploymentVersion: "v1", +const runtimeConfig = { + appId, + deploymentVersion: "release-7", visibility: "private", - supabaseUrl: origin, - supabaseAnonKey: "anon-project-key", - storageBucket: "app-11111111-1111-4111-8111-111111111111", - functionsBasePath: "/functions/v1", - javascriptSdk: { + environment: "production", + sdk: { package: "@opencloud/js", - version: "0.2.2", - module: "/_opencloud/sdk/js/v0.2.2/index.js", - types: "/_opencloud/sdk/js/v0.2.2/index.d.ts", + version: "1.0.0", + module: "/_opencloud/sdk.js", + types: "/_opencloud/sdk.d.ts", docs: "https://docs.opencloud.ai/sdk/javascript/", }, - browserClient: "/_opencloud/sdk/js/v0.2.2/index.js", - environment: "prod", + capabilities: { + auth: true, + data: true, + files: true, + functions: true, + realtime: true, + telemetry: true, + }, + files: { access: "user", maxUploadBytes: 10_000 }, + functions: [ + { name: "private-probe", access: "user" }, + { name: "public-probe", access: "public" }, + { name: "stream-probe", access: "user" }, + { name: "cron-probe", access: "system" }, + ], }; -function wireSession( - accessToken: string, - refreshAfter = "2026-01-01T00:30:00.000Z", -) { +function wireSession(token = "private-access-token") { return { - appId: runtimeConfig.appId, - userId: "22222222-2222-4222-8222-222222222222", + appId, + userId, profile: { email: "person@example.test", displayName: "Test Person", avatarUrl: null, }, - accessToken, - accessTokenExpiresAt: "2026-01-01T00:31:00.000Z", - refreshAfter, - sessionExpiresAt: "2026-01-31T00:00:00.000Z", + accessToken: token, + accessTokenExpiresAt: "2099-01-01T00:31:00.000Z", + refreshAfter: "2099-01-01T00:30:00.000Z", + sessionExpiresAt: "2099-01-31T00:00:00.000Z", }; } +function json(value: unknown, init: ResponseInit = {}): Response { + return Response.json(value, init); +} + +function requestUrl(source: URL | RequestInfo): URL { + return new URL(String(source)); +} + +function installFetch( + handler: (url: URL, init: RequestInit) => Promise | Response, +) { + const mock = vi.fn((source: URL | RequestInfo, init: RequestInit = {}) => + handler(requestUrl(source), init), + ) as unknown as typeof fetch; + vi.stubGlobal("fetch", mock); + return mock; +} + +function standardFetch( + handler?: (url: URL, init: RequestInit) => Promise | Response | undefined, +) { + return installFetch(async (url, init) => { + if (url.pathname === "/_opencloud/config") return json(runtimeConfig); + if (url.pathname === "/_opencloud/session") { + return json({ session: wireSession() }); + } + const result = await handler?.(url, init); + if (result) return result; + throw new Error(`Unexpected request ${init.method ?? "GET"} ${url}`); + }); +} + class FakeWebSocket { static instances: FakeWebSocket[] = []; readonly sent: Record[] = []; @@ -83,6 +128,15 @@ class FakeWebSocket { } } + broadcast(event: string, payload: unknown): void { + this.emit("message", { + data: JSON.stringify({ + event: "broadcast", + payload: { event, payload }, + }), + }); + } + close(): void { if (this.readyState === 3) return; this.readyState = 3; @@ -96,181 +150,603 @@ class FakeWebSocket { } } -function fakeWebSocket(): typeof WebSocket { - return FakeWebSocket as unknown as typeof WebSocket; -} +beforeEach(() => { + opencloud.dispose(); + vi.stubGlobal("location", { origin }); + FakeWebSocket.instances = []; +}); + +afterEach(() => { + opencloud.dispose(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); -describe("@opencloud/js", () => { - it("keeps access tokens private while owning REST and Storage headers", async () => { - const runtimeRequests: Array<{ url: string; init?: RequestInit }> = []; - const fetchMock = vi.fn( - async (source: URL | RequestInfo, init?: RequestInit) => { - const url = String(source); - if (url.endsWith("/_opencloud/config")) { - return Response.json(runtimeConfig); - } - if (url.endsWith("/_opencloud/session/v2")) { - return Response.json({ - session: wireSession("private-access-token"), - }); - } - runtimeRequests.push({ url, init }); - return Response.json({ ok: true }); +describe("@opencloud/js v1", () => { + it("exports one stable singleton contract without legacy factories or raw namespaces", () => { + expect(OPEN_CLOUD_SDK_VERSION).toBe("1.0.0"); + expect("OPEN_CLOUD_JS_VERSION" in sdk).toBe(false); + expect(opencloud).toMatchObject({ + app: { info: expect.any(Function) }, + auth: { + currentUser: expect.any(Function), + requireUser: expect.any(Function), + signInUrl: expect.any(Function), + }, + data: { table: expect.any(Function) }, + files: { + upload: expect.any(Function), + download: expect.any(Function), + save: expect.any(Function), + replace: expect.any(Function), + remove: expect.any(Function), + attach: expect.any(Function), + }, + functions: { + call: expect.any(Function), + stream: expect.any(Function), }, - ) as unknown as typeof fetch; - const client = createOpenCloudClient({ - baseUrl: origin, - fetch: fetchMock, - WebSocket: fakeWebSocket(), - automaticSessionRefresh: false, - now: () => initialNow, - }); - - const session = await client.session(); - expect(session).toMatchObject({ - userId: "22222222-2222-4222-8222-222222222222", - profile: { displayName: "Test Person" }, - accessTokenExpiresAt: "2026-01-01T00:31:00.000Z", - }); - expect(session).not.toHaveProperty("accessToken"); - expect(JSON.stringify(session)).not.toContain("private-access-token"); - - await client.rest.request("todos?select=*", { - headers: { authorization: "Bearer attacker-controlled" }, - }); - await client.storage.request( - `object/${runtimeConfig.storageBucket}/person/file.txt`, - { method: "POST", body: "contents" }, + realtime: { + subscribe: expect.any(Function), + publish: expect.any(Function), + }, + telemetry: { + summary: expect.any(Function), + increment: expect.any(Function), + gauge: expect.any(Function), + }, + }); + expect("createOpenCloudClient" in sdk).toBe(false); + expect("OpenCloudBrowserClient" in sdk).toBe(false); + expect(opencloud).not.toHaveProperty("rest"); + expect(opencloud).not.toHaveProperty("storage"); + expect(opencloud).not.toHaveProperty("config"); + expect(opencloud).not.toHaveProperty("session"); + }); + + it("creates a safe same-origin sign-in URL that returns to the current page", () => { + vi.stubGlobal("location", { + origin, + pathname: "/cases/claim-7", + search: "?tab=files", + hash: "#latest", + }); + + const target = new URL(opencloud.auth.signInUrl()); + + expect(target.origin).toBe(origin); + expect(target.pathname).toBe("/_opencloud/sign-in"); + expect(target.searchParams.get("return_to")).toBe( + "/cases/claim-7?tab=files#latest", ); + }); - expect(runtimeRequests).toHaveLength(2); - for (const request of runtimeRequests) { - const headers = new Headers(request.init?.headers); - expect(headers.get("apikey")).toBe("anon-project-key"); - expect(headers.get("authorization")).toBe( + it("loads app info and current user without exposing token or requiring WebSocket", async () => { + standardFetch(); + vi.stubGlobal("WebSocket", undefined); + + await expect(opencloud.app.info()).resolves.toEqual({ + id: appId, + version: "release-7", + environment: "production", + visibility: "private", + capabilities: runtimeConfig.capabilities, + }); + const user = await opencloud.auth.requireUser(); + expect(user).toEqual({ + id: userId, + email: "person@example.test", + displayName: "Test Person", + avatarUrl: null, + }); + expect(JSON.stringify(user)).not.toContain("private-access-token"); + }); + + it("rejects non-v1 runtime config shapes instead of interpreting compatibility aliases", async () => { + const incompatibleConfigs = [ + { ...runtimeConfig, sdk: undefined, javascriptSdk: runtimeConfig.sdk }, + { ...runtimeConfig, environment: "prod" }, + { ...runtimeConfig, supabaseUrl: origin }, + { ...runtimeConfig, publicKey: "browser-key" }, + { ...runtimeConfig, supabaseAnonKey: "browser-key" }, + { ...runtimeConfig, functionsBasePath: "/custom/functions" }, + { ...runtimeConfig, storageAuthorization: "owner-prefix" }, + { ...runtimeConfig, storage: { authorization: "owner-prefix" } }, + { + ...runtimeConfig, + capabilities: { ...runtimeConfig.capabilities, database: true }, + }, + { + ...runtimeConfig, + functions: [{ name: "public-probe", verifyJwt: false }], + }, + ]; + + for (const config of incompatibleConfigs) { + opencloud.dispose(); + installFetch((url) => { + if (url.pathname === "/_opencloud/config") return json(config); + throw new Error(`Unexpected request ${url}`); + }); + await expect(opencloud.app.info()).rejects.toMatchObject({ + code: "INVALID_RESPONSE", + surface: "app", + }); + } + }); + + it("returns null for a signed-out visitor and throws a typed auth error when required", async () => { + installFetch((url) => { + if (url.pathname === "/_opencloud/config") return json(runtimeConfig); + if (url.pathname === "/_opencloud/session") return json({ session: null }); + throw new Error(`Unexpected request ${url}`); + }); + + await expect(opencloud.auth.currentUser()).resolves.toBeNull(); + await expect(opencloud.auth.requireUser()).rejects.toMatchObject({ + code: "AUTH_REQUIRED", + surface: "auth", + status: 401, + }); + }); + + it("builds safe data reads and owns auth, projection, filters, ordering and pagination", async () => { + const calls: Array<{ url: URL; init: RequestInit }> = []; + standardFetch((url, init) => { + calls.push({ url, init }); + return json([{ id: "task-1", title: "Ship", done: false }]); + }); + + const rows = await opencloud.data.table("tasks").list({ + select: ["id", "title", "done"], + where: { done: false, status: "open" }, + orderBy: { column: "created_at", direction: "desc" }, + limit: 25, + offset: 5, + }); + + expect(rows).toHaveLength(1); + expect(calls[0]?.url.pathname).toBe("/rest/v1/tasks"); + expect(calls[0]?.url.searchParams.get("select")).toBe("id,title,done"); + expect(calls[0]?.url.searchParams.get("done")).toBe("eq.false"); + expect(calls[0]?.url.searchParams.get("status")).toBe("eq.open"); + expect(calls[0]?.url.searchParams.get("order")).toBe("created_at.desc"); + expect(calls[0]?.url.searchParams.get("limit")).toBe("25"); + expect(calls[0]?.url.searchParams.get("offset")).toBe("5"); + expect(new Headers(calls[0]?.init.headers).get("authorization")).toBe( + "Bearer private-access-token", + ); + expect(new Headers(calls[0]?.init.headers).has("apikey")).toBe(false); + }); + + it("creates, updates and deletes rows with JSON handling and narrow id filters", async () => { + const calls: Array<{ url: URL; init: RequestInit }> = []; + standardFetch((url, init) => { + calls.push({ url, init }); + if (init.method === "POST") return json([{ id: "task-1", title: "Ship" }], { status: 201 }); + if (init.method === "PATCH") return json([{ id: "task-1", done: true }]); + if (init.method === "DELETE") return json([{ id: "task-1" }]); + return undefined; + }); + const tasks = opencloud.data.table<{ id: string; title?: string; done?: boolean }>("tasks"); + + await expect(tasks.create({ title: "Ship" })).resolves.toMatchObject({ id: "task-1" }); + await expect(tasks.updateById("task-1", { done: true })).resolves.toMatchObject({ done: true }); + await expect(tasks.deleteById("task-1")).resolves.toBe(true); + + expect(calls.map((call) => call.init.method)).toEqual(["POST", "PATCH", "DELETE"]); + expect(calls[1]?.url.searchParams.get("id")).toBe("eq.task-1"); + expect(calls[2]?.url.searchParams.get("id")).toBe("eq.task-1"); + for (const call of calls) { + expect(new Headers(call.init.headers).get("authorization")).toBe( "Bearer private-access-token", ); - expect(request.init?.credentials).toBe("same-origin"); + expect(new Headers(call.init.headers).get("prefer")).toBe("return=representation"); } }); - it("returns null from a successful public signed-out session envelope", async () => { - const fetchMock = vi.fn(async (source: URL | RequestInfo) => { - const url = String(source); - if (url.endsWith("/_opencloud/session/v2")) { - return Response.json({ session: null }); + it("rejects malformed identifiers and broad mutations before a runtime request", async () => { + const fetchMock = standardFetch(); + expect(() => opencloud.data.table("tasks?delete=all")).toThrow(/identifier/); + await expect(opencloud.data.table("tasks").updateById("", { title: "x" })).rejects.toMatchObject({ + code: "INVALID_ARGUMENT", + }); + await expect(opencloud.data.table("tasks").updateById("task-1", {})).rejects.toMatchObject({ + code: "INVALID_ARGUMENT", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("uses the managed opaque file gateway without buckets, object paths or bearer headers", async () => { + const calls: Array<{ url: URL; init: RequestInit }> = []; + standardFetch((url, init) => { + calls.push({ url, init }); + if (url.pathname === "/_opencloud/files" && init.method === "POST") { + return json({ + id: fileId, + name: "rĆ©sumĆ©.pdf", + contentType: "application/pdf", + size: 7, + }, { status: 201 }); } - throw new Error(`Unexpected URL ${url}`); - }) as unknown as typeof fetch; - const client = createOpenCloudClient({ - baseUrl: origin, - fetch: fetchMock, - WebSocket: fakeWebSocket(), - automaticSessionRefresh: false, + if (url.pathname === `/_opencloud/files/${fileId}` && !init.method) { + return new Response("content", { + headers: { + "content-type": "application/pdf", + "x-opencloud-file-name": encodeURIComponent("rĆ©sumĆ©.pdf"), + }, + }); + } + if (url.pathname === `/_opencloud/files/${fileId}` && init.method === "PUT") { + return json({ + id: fileId, + name: "new.pdf", + contentType: "application/pdf", + size: 3, + }); + } + if (url.pathname === `/_opencloud/files/${fileId}` && init.method === "DELETE") { + return new Response(null, { status: 204 }); + } + return undefined; }); - await expect(client.session()).resolves.toBeNull(); - expect(fetchMock).toHaveBeenCalledOnce(); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - `${origin}/_opencloud/session/v2`, + 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" }, + ); + await opencloud.files.remove(file.id); + + expect(file.id).toBe(fileId); + expect(await downloaded.blob.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}`, + `/_opencloud/files/${fileId}`, + `/_opencloud/files/${fileId}`, + ]); + expect(new Headers(calls[0]?.init.headers).get("x-opencloud-file-name")).toBe( + encodeURIComponent("rĆ©sumĆ©.pdf"), ); + 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( + /^[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) { + expect(call.url.pathname).not.toContain("storage"); + expect(new Headers(call.init.headers).has("authorization")).toBe(false); + expect(new Headers(call.init.headers).has("apikey")).toBe(false); + expect(call.init.credentials).toBe("same-origin"); + } }); - it("refreshes through the broker before using an expired cache entry", async () => { - let now = initialNow; - let sessionRequests = 0; - const runtimeAuthorizations: string[] = []; - const fetchMock = vi.fn( - async (source: URL | RequestInfo, init?: RequestInit) => { - const url = String(source); - if (url.endsWith("/_opencloud/config")) { - return Response.json(runtimeConfig); - } - if (url.endsWith("/_opencloud/session/v2")) { - sessionRequests += 1; - return Response.json({ - session: wireSession( - `access-token-${sessionRequests}`, - sessionRequests === 1 - ? "2026-01-01T00:00:10.000Z" - : "2026-01-01T00:30:00.000Z", - ), - }); - } - runtimeAuthorizations.push( - new Headers(init?.headers).get("authorization") ?? "", - ); - return new Response(null, { status: 204 }); - }, - ) as unknown as typeof fetch; - const client = createOpenCloudClient({ - baseUrl: origin, - fetch: fetchMock, - WebSocket: fakeWebSocket(), - automaticSessionRefresh: false, - now: () => now, + it("reports upload progress and safely retries once with the same private idempotency key", async () => { + const progress: number[] = []; + const idempotencyKeys: string[] = []; + let attempts = 0; + standardFetch((url, init) => { + if (url.pathname !== "/_opencloud/files") return undefined; + attempts += 1; + idempotencyKeys.push( + new Headers(init.headers).get("idempotency-key") ?? "", + ); + if (attempts === 1) throw new TypeError("connection reset after commit"); + return json({ + id: fileId, + name: "proof.txt", + contentType: "text/plain", + size: 5, + }); }); - await client.session(); - now += 11_000; - await client.rest.request("todos"); + await expect( + opencloud.files.upload(new Blob(["proof"], { type: "text/plain" }), { + name: "proof.txt", + onProgress: ({ percent }) => progress.push(percent), + }), + ).resolves.toMatchObject({ id: fileId, name: "proof.txt" }); - expect(sessionRequests).toBe(2); - expect(runtimeAuthorizations).toEqual(["Bearer access-token-2"]); + expect(attempts).toBe(2); + expect(idempotencyKeys[0]).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(idempotencyKeys[1]).toBe(idempotencyKeys[0]); + expect(progress.at(0)).toBe(0); + expect(progress.at(-1)).toBe(100); }); - it("uses anonymous identity for public functions and user identity for JWT functions", async () => { - const calls: Array<{ url: string; authorization: string | null }> = []; - const fetchMock = vi.fn( - async (source: URL | RequestInfo, init?: RequestInit) => { - const url = String(source); - if (url.endsWith("/_opencloud/config")) { - return Response.json(runtimeConfig); - } - if (url.endsWith("/_opencloud/session/v2")) { - return Response.json({ - session: wireSession("signed-in-user-token"), - }); - } - calls.push({ - url, - authorization: new Headers(init?.headers).get("authorization"), - }); - return Response.json({ ok: true }); + it("rejects oversized files before contacting the managed gateway", async () => { + const fetchMock = standardFetch(); + await expect( + opencloud.files.upload(new Blob(["123456"]), { maxBytes: 5 }), + ).rejects.toMatchObject({ code: "FILE_TOO_LARGE", surface: "files" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("rejects invented file option fields instead of silently ignoring them", async () => { + const fetchMock = standardFetch(); + + await expect( + opencloud.files.upload(new Blob(["proof"]), { + clientKey: "guessed-retry-key", + } as never), + ).rejects.toMatchObject({ + code: "INVALID_ARGUMENT", + surface: "files", + }); + await expect( + opencloud.files.attach(new Blob(["proof"]), { + table: "evidence", + row: { case_id: "claim-1" }, + } as never), + ).rejects.toMatchObject({ + code: "INVALID_ARGUMENT", + surface: "files", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("attaches a file and writes conventional metadata", async () => { + const writes: Record[] = []; + standardFetch((url, init) => { + if (url.pathname === "/_opencloud/files") { + return json({ id: fileId, name: "proof.txt", contentType: "text/plain", size: 5 }); + } + if (url.pathname === "/rest/v1/evidence" && init.method === "POST") { + const value = JSON.parse(String(init.body)) as Record; + writes.push(value); + return json([{ id: "evidence-1", ...value }], { status: 201 }); + } + 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" }, }, - ) as unknown as typeof fetch; - const client = createOpenCloudClient({ - baseUrl: origin, - fetch: fetchMock, - WebSocket: fakeWebSocket(), - automaticSessionRefresh: false, - now: () => initialNow, + ); + + expect(result.record.id).toBe("evidence-1"); + expect(writes).toEqual([{ + claim_id: "claim-1", + file_id: fileId, + file_name: "proof.txt", + file_type: "text/plain", + file_size: 5, + }]); + }); + + it("reconciles an ambiguous metadata write by stable file id", async () => { + let metadataWrites = 0; + let deletes = 0; + standardFetch((url, init) => { + if (url.pathname === "/_opencloud/files") { + return json({ id: fileId, name: "proof.txt", contentType: "text/plain", size: 5 }); + } + if (url.pathname === "/rest/v1/evidence" && init.method === "POST") { + metadataWrites += 1; + throw new TypeError("connection reset after commit"); + } + if (url.pathname === "/rest/v1/evidence" && !init.method) { + expect(url.searchParams.get("file_id")).toBe(`eq.${fileId}`); + return json([{ id: "evidence-1", file_id: fileId }]); + } + if (url.pathname === `/_opencloud/files/${fileId}` && init.method === "DELETE") { + deletes += 1; + return new Response(null, { status: 204 }); + } + return undefined; }); - await client.functions.invokePublic("status-probe", { method: "POST" }); - await client.functions.invoke("private-probe", { method: "POST" }); + await expect(opencloud.files.attach( + new Blob(["proof"], { type: "text/plain" }), + { table: "evidence", upload: { name: "proof.txt" } }, + )).resolves.toMatchObject({ record: { id: "evidence-1" } }); + expect(metadataWrites).toBe(1); + expect(deletes).toBe(0); + }); + + 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 }); + } + if (url.pathname === "/rest/v1/evidence") { + return json({ message: "claim does not exist" }, { status: 400 }); + } + if (url.pathname === `/_opencloud/files/${fileId}` && init.method === "DELETE") { + return json({ message: "storage unavailable" }, { status: 503 }); + } + return undefined; + }); + + const promise = opencloud.files.attach( + new Blob(["proof"], { type: "text/plain" }), + { table: "evidence", upload: { name: "proof.txt" } }, + ); + await expect(promise).rejects.toMatchObject({ + code: "FILE_ATTACHMENT_INCOMPLETE", + surface: "files", + retryable: true, + details: { file: { id: fileId }, cleanupRequired: true }, + }); + }); + + it("selects declared Function access and parses JSON, empty and streaming responses", async () => { + const calls: Array<{ + path: string; + authorization: string | null; + apiKey: string | null; + body: unknown; + }> = []; + const fetchMock = standardFetch((url, init) => { + if (!url.pathname.includes("probe")) return undefined; + calls.push({ + path: url.pathname, + authorization: new Headers(init.headers).get("authorization"), + apiKey: new Headers(init.headers).get("apikey"), + body: init.body ? JSON.parse(String(init.body)) : undefined, + }); + if (url.pathname.endsWith("stream-probe")) return new Response("streamed"); + if (url.pathname.endsWith("public-probe")) return new Response(null, { status: 204 }); + return json({ ok: true }); + }); + + await expect(opencloud.functions.call("cron-probe")).rejects.toMatchObject({ + code: "FUNCTION_SYSTEM_ONLY", + surface: "functions", + details: { name: "cron-probe" }, + }); + await expect(opencloud.functions.stream("cron-probe")).rejects.toMatchObject({ + code: "FUNCTION_SYSTEM_ONLY", + surface: "functions", + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + await expect(opencloud.functions.call("private-probe", { id: 1 })).resolves.toEqual({ ok: true }); + await expect(opencloud.functions.call("public-probe")).resolves.toBeUndefined(); + const stream = await opencloud.functions.stream("stream-probe", { id: 2 }); + expect(await new Response(stream).text()).toBe("streamed"); expect(calls).toEqual([ { - url: `${origin}/functions/v1/status-probe`, - authorization: "Bearer anon-project-key", + path: "/functions/v1/private-probe", + authorization: "Bearer private-access-token", + apiKey: null, + body: { id: 1 }, + }, + { + path: "/functions/v1/public-probe", + authorization: null, + apiKey: null, + body: undefined, }, { - url: `${origin}/functions/v1/private-probe`, - authorization: "Bearer signed-in-user-token", + path: "/functions/v1/stream-probe", + authorization: "Bearer private-access-token", + apiKey: null, + body: { id: 2 }, }, ]); + await expect(opencloud.functions.call("invented-method")).rejects.toMatchObject({ + code: "FUNCTION_NOT_DECLARED", + }); + expect(calls).toHaveLength(3); }); - it("reads the host-bound aggregate telemetry summary without runtime credentials", async () => { - const fetchMock = vi.fn( - async (source: URL | RequestInfo, init?: RequestInit) => { - expect(String(source)).toBe( - `${origin}/_opencloud/telemetry/summary`, - ); - expect(new Headers(init?.headers).has("authorization")).toBe(false); - return Response.json({ - appId: runtimeConfig.appId, + it("normalizes backend and network errors into bounded typed failures", async () => { + installFetch((url) => { + if (url.pathname === "/_opencloud/config") return json(runtimeConfig); + if (url.pathname === "/_opencloud/session") return json({ session: wireSession() }); + return json( + { code: "23505", message: "duplicate task", details: "private database detail" }, + { status: 409, headers: { "x-request-id": "request-7" } }, + ); + }); + const conflict = opencloud.data.table("tasks").create({ title: "Duplicate" }); + await expect(conflict).rejects.toMatchObject({ + code: "CONFLICT", + surface: "data", + status: 409, + requestId: "request-7", + retryable: false, + message: "duplicate task", + }); + await conflict.catch((error: unknown) => { + expect(isOpenCloudError(error)).toBe(true); + expect(error).toBeInstanceOf(OpenCloudError); + }); + + opencloud.dispose(); + installFetch(() => { + throw new TypeError("offline"); + }); + await expect(opencloud.app.info()).rejects.toMatchObject({ + code: "NETWORK_ERROR", + surface: "app", + retryable: true, + }); + }); + + it("subscribes and publishes on one managed private channel, then closes on unsubscribe", async () => { + standardFetch(); + vi.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket); + const messages: unknown[] = []; + + const unsubscribe = await opencloud.realtime.subscribe("tasks", (message) => { + messages.push(message); + }); + await opencloud.realtime.publish("tasks", "changed", { id: "task-1" }); + const socket = FakeWebSocket.instances[0]!; + socket.broadcast("changed", { id: "task-2" }); + + expect(FakeWebSocket.instances).toHaveLength(1); + expect(socket.url).toContain("wss://tasks.opencloud.test/realtime/v1/websocket"); + expect(new URL(socket.url).searchParams.has("apikey")).toBe(false); + expect(socket.sent[0]).toMatchObject({ + topic: `realtime:app:${appId}:tasks`, + event: "phx_join", + payload: { access_token: "private-access-token" }, + }); + expect(socket.sent[1]).toMatchObject({ + event: "broadcast", + payload: { event: "changed", payload: { id: "task-1" } }, + }); + expect(messages).toEqual([{ event: "changed", payload: { id: "task-2" } }]); + unsubscribe(); + expect(socket.readyState).toBe(3); + }); + + it("fails locally when a declared capability is unavailable", async () => { + const fetchMock = installFetch((url) => { + if (url.pathname === "/_opencloud/config") { + return json({ + ...runtimeConfig, + environment: "dev", + capabilities: { ...runtimeConfig.capabilities, files: false }, + files: undefined, + }); + } + throw new Error(`Unexpected request ${url}`); + }); + + await expect(opencloud.files.upload(new Blob(["x"]))).rejects.toMatchObject({ + code: "CAPABILITY_UNAVAILABLE", + surface: "files", + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("reads telemetry and writes declared metrics through cookie-bound host endpoints", async () => { + const surfaces = Object.fromEntries( + (["page", "rest", "storage", "realtime", "function", "cron"] as OpenCloudTelemetrySurface[]) + .map((surface) => [surface, { + lastActivityAt: null, + requests24h: 0, + errors24h: 0, + lastStatus: null, + }]), + ); + const calls: Array<{ path: string; init: RequestInit }> = []; + standardFetch((url, init) => { + calls.push({ path: url.pathname, init }); + if (url.pathname.endsWith("summary")) { + return json({ + appId, asOf: "2026-01-01T00:00:00.000Z", usage: null, activity: { @@ -282,160 +758,28 @@ describe("@opencloud/js", () => { telemetry: { status: "available", latestIngestedAt: "2025-12-31T23:59:59.000Z", - ingestionLagSeconds: 0.2, - sampledEntries: 12, + ingestionLagSeconds: 1, + sampledEntries: 2, truncated: false, }, - surfaces: Object.fromEntries( - [ - "page", - "rest", - "storage", - "realtime", - "function", - "cron", - ].map((surface) => [ - surface, - { - lastActivityAt: null, - requests24h: 0, - errors24h: 0, - lastStatus: null, - }, - ]), - ), - }, - }); - }, - ) as unknown as typeof fetch; - const client = createOpenCloudClient({ - baseUrl: origin, - fetch: fetchMock, - WebSocket: fakeWebSocket(), - automaticSessionRefresh: false, - }); - - await expect(client.telemetry.summary()).resolves.toMatchObject({ - appId: runtimeConfig.appId, - activity: { - telemetry: { - status: "available", - sampledEntries: 12, - }, - surfaces: { - rest: { - requests24h: 0, - errors24h: 0, + surfaces, }, - }, - }, - }); - }); - - it("emits declared counters and gauges through the same-origin telemetry endpoint", async () => { - const requests: Array<{ url: string; init?: RequestInit }> = []; - const fetchMock = vi.fn( - async (source: URL | RequestInfo, init?: RequestInit) => { - requests.push({ url: String(source), init }); - return Response.json({ - accepted: 1, - duplicates: 0, - recordedAt: "2026-01-01T00:00:00.000Z", - }); - }, - ) as unknown as typeof fetch; - const client = createOpenCloudClient({ - baseUrl: origin, - fetch: fetchMock, - WebSocket: fakeWebSocket(), - automaticSessionRefresh: false, - }); - - await client.telemetry.increment("tasks_created", 1, { - dimensions: { assignee_type: "child" }, - idempotencyKey: "task-created:123", - }); - await client.telemetry.gauge("overdue_tasks", 7); - - expect(requests).toHaveLength(2); - expect(requests[0]?.url).toBe( - `${origin}/_opencloud/telemetry/metrics`, - ); - expect(requests[0]?.init?.method).toBe("POST"); - expect(requests[0]?.init?.credentials).toBe("same-origin"); - expect(JSON.parse(String(requests[0]?.init?.body))).toEqual({ - measurements: [ - { - name: "tasks_created", - value: 1, - dimensions: { assignee_type: "child" }, - idempotencyKey: "task-created:123", - }, - ], - }); - expect(JSON.parse(String(requests[1]?.init?.body))).toEqual({ - measurements: [ - { - name: "overdue_tasks", - value: 7, - dimensions: {}, - }, - ], - }); - }); - - it("joins, broadcasts, and reconnects private app-prefixed channels with a fresh session", async () => { - FakeWebSocket.instances = []; - let sessionRequests = 0; - const fetchMock = vi.fn(async (source: URL | RequestInfo) => { - const url = String(source); - if (url.endsWith("/_opencloud/config")) { - return Response.json(runtimeConfig); - } - if (url.endsWith("/_opencloud/session/v2")) { - sessionRequests += 1; - return Response.json({ - session: wireSession(`socket-token-${sessionRequests}`), }); } - throw new Error(`Unexpected URL ${url}`); - }) as unknown as typeof fetch; - const client = createOpenCloudClient({ - baseUrl: origin, - fetch: fetchMock, - WebSocket: fakeWebSocket(), - automaticSessionRefresh: false, - now: () => initialNow, - }); - const channel = client.realtime.channel("updates", { - reconnect: { initialDelayMs: 10, maxDelayMs: 10 }, - }); - - await channel.connect(); - await channel.broadcast("changed", { id: 1 }); - const first = FakeWebSocket.instances[0]!; - expect(first.url).toContain( - "wss://tasks.opencloud.ai/realtime/v1/websocket?", - ); - expect(first.sent[0]).toMatchObject({ - topic: `realtime:app:${runtimeConfig.appId}:updates`, - event: "phx_join", - payload: { access_token: "socket-token-1" }, - }); - expect(first.sent[1]).toMatchObject({ - event: "broadcast", - payload: { event: "changed", payload: { id: 1 } }, + return json({ accepted: 1, duplicates: 0, recordedAt: "2026-01-01T00:00:00.000Z" }, { status: 202 }); }); - first.close(); - await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2)); - await vi.waitFor(() => - expect(FakeWebSocket.instances[1]?.sent[0]).toMatchObject({ - event: "phx_join", - payload: { access_token: "socket-token-2" }, - }), - ); - expect(channel.state).toBe("joined"); - channel.close(); + 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([ + "/_opencloud/telemetry/summary", + "/_opencloud/telemetry/metrics", + "/_opencloud/telemetry/metrics", + ]); + expect(calls.every((call) => call.init.credentials === "same-origin")).toBe(true); }); }); diff --git a/vendor/browser-client/src/index.ts b/vendor/browser-client/src/index.ts index bdc717c..f2f45e7 100644 --- a/vendor/browser-client/src/index.ts +++ b/vendor/browser-client/src/index.ts @@ -1,96 +1,144 @@ /** - * OpenCloud's same-origin browser runtime client. + * OpenCloud's same-origin browser SDK. * - * Access tokens are deliberately kept inside this module. The public session - * shape contains identity and expiry metadata only; token refresh is brokered - * through the HttpOnly OpenCloud session cookie. + * Applications import the deployment-pinned singleton from + * `/_opencloud/sdk.js`. Runtime credentials, buckets, protocol paths and raw + * HTTP responses deliberately stay behind this module. */ -/** Exact version of the self-hosted OpenCloud JavaScript SDK. */ -export const OPEN_CLOUD_JS_VERSION = "0.2.2"; +export const OPEN_CLOUD_SDK_VERSION = "1.0.0"; -/** @deprecated Use {@link OPEN_CLOUD_JS_VERSION}. */ -export const BROWSER_CLIENT_VERSION = OPEN_CLOUD_JS_VERSION; +export type OpenCloudEnvironment = "dev" | "production"; +export type OpenCloudVisibility = "public" | "private"; +export type OpenCloudErrorSurface = + | "app" + | "auth" + | "data" + | "files" + | "functions" + | "realtime" + | "telemetry"; + +export interface OpenCloudErrorOptions { + code: string; + surface: OpenCloudErrorSurface; + status?: number | null; + requestId?: string | null; + retryable?: boolean; + details?: unknown; + cause?: unknown; +} -export interface OpenCloudJavaScriptSdkConfig { - package: "@opencloud/js"; - version: string; - module: string; - types: string; - docs: string; +/** A bounded, structured failure from an OpenCloud capability. */ +export class OpenCloudError extends Error { + readonly code: string; + readonly surface: OpenCloudErrorSurface; + readonly status: number | null; + readonly requestId: string | null; + readonly retryable: boolean; + readonly details?: unknown; + override readonly cause?: unknown; + + constructor(message: string, options: OpenCloudErrorOptions) { + super(message); + this.name = "OpenCloudError"; + this.code = options.code; + this.surface = options.surface; + this.status = options.status ?? null; + this.requestId = options.requestId ?? null; + this.retryable = options.retryable ?? false; + if (options.details !== undefined) this.details = options.details; + if (options.cause !== undefined) this.cause = options.cause; + } } -export interface OpenCloudRuntimeConfig { - appId: string; - deploymentVersion: string | null; - visibility: "public" | "private"; - supabaseUrl: string; - supabaseAnonKey: string; - storageBucket: string; - functionsBasePath: string; - javascriptSdk: OpenCloudJavaScriptSdkConfig; - /** Exact module URL; retained as a compatibility alias. */ - browserClient: string; - environment: string; -} - -export interface OpenCloudSessionProfile { +export function isOpenCloudError(value: unknown): value is OpenCloudError { + return value instanceof OpenCloudError; +} + +export interface OpenCloudUser { + id: string; email: string | null; displayName: string | null; avatarUrl: string | null; } -export interface OpenCloudSession { - appId: string; - userId: string; - profile: OpenCloudSessionProfile; - accessTokenExpiresAt: string; - refreshAfter: string; - sessionExpiresAt: string; +export interface OpenCloudCapabilities { + auth: boolean; + data: boolean; + files: boolean; + functions: boolean; + realtime: boolean; + telemetry: boolean; } -interface WireSession extends OpenCloudSession { - accessToken: string; +export interface OpenCloudAppInfo { + id: string; + version: string | null; + environment: OpenCloudEnvironment; + visibility: OpenCloudVisibility; + capabilities: OpenCloudCapabilities; } -export type OpenCloudAuthMode = "authenticated" | "anonymous" | "optional"; +export type OpenCloudScalar = string | number | boolean | null; + +export interface OpenCloudListOptions { + select?: string[]; + where?: Record; + orderBy?: { + column: string; + direction?: "asc" | "desc"; + }; + limit?: number; + offset?: number; +} -export interface OpenCloudRequestInit extends RequestInit { - auth?: OpenCloudAuthMode; +export interface OpenCloudGetOptions { + select?: string[]; } -export interface OpenCloudClientOptions { - /** Defaults to window.location.origin. Must be an origin, not a path. */ - baseUrl?: string; - /** Primarily useful to deterministic browser tests. */ - fetch?: typeof fetch; - /** Primarily useful to deterministic browser tests. */ - WebSocket?: typeof WebSocket; - /** Disable only in deterministic tests; production defaults to true. */ - automaticSessionRefresh?: boolean; - /** Primarily useful to deterministic browser tests. */ - now?: () => number; +export interface OpenCloudFile { + id: string; + name: string; + contentType: string; + size: number; } -export type RealtimeState = - | "idle" - | "connecting" - | "joined" - | "reconnecting" - | "closed"; +export interface OpenCloudFileUploadOptions { + name?: string; + contentType?: string; + maxBytes?: number; + onProgress?: (progress: OpenCloudFileUploadProgress) => void; +} -export interface RealtimeChannelOptions { - broadcast?: { - ack?: boolean; - self?: boolean; - }; - reconnect?: { - initialDelayMs?: number; - maxDelayMs?: number; - }; +export interface OpenCloudFileUploadProgress { + loaded: number; + total: number; + percent: number; } -export interface RealtimeBroadcast { +export interface OpenCloudFileDownload { + blob: Blob; + name: string; + contentType: string; + size: number; +} + +export interface OpenCloudFileAttachmentColumns { + id?: string; + name?: string; + contentType?: string; + size?: string; +} + +export interface OpenCloudFileAttachmentOptions { + table: string; + values?: Record; + columns?: OpenCloudFileAttachmentColumns; + upload?: OpenCloudFileUploadOptions; +} + +export interface OpenCloudRealtimeMessage { event: string; payload: unknown; } @@ -120,11 +168,7 @@ export interface OpenCloudTelemetryRollup { } export interface OpenCloudTelemetryActivity { - window: { - from: string; - to: string; - seconds: number; - }; + window: { from: string; to: string; seconds: number }; telemetry: { status: "available" | "unavailable"; latestIngestedAt: string | null; @@ -138,7 +182,6 @@ export interface OpenCloudTelemetryActivity { >; } -/** Safe, host-bound 24-hour aggregate. It never contains raw logs or paths. */ export interface OpenCloudTelemetrySummary { appId: string; asOf: string; @@ -150,7 +193,6 @@ export type OpenCloudMetricDimensions = Record; export interface OpenCloudMetricWriteOptions { dimensions?: OpenCloudMetricDimensions; - /** Stable key used to make a retried measurement idempotent. */ idempotencyKey?: string; } @@ -160,538 +202,1494 @@ export interface OpenCloudMetricWriteResult { recordedAt: string; } -export class OpenCloudError extends Error {} +export interface OpenCloudAppClient { + info(): Promise; +} + +export interface OpenCloudAuthClient { + currentUser(): Promise; + requireUser(): Promise; + /** A safe same-origin URL that signs in and returns to the current page. */ + signInUrl(): string; +} -export class OpenCloudAuthError extends OpenCloudError {} +export interface OpenCloudDataTable { + list(options?: OpenCloudListOptions): Promise; + getById(id: string, options?: OpenCloudGetOptions): Promise; + create(values: Record): Promise; + createMany(values: Record[]): Promise; + updateById(id: string, patch: Record): Promise; + deleteById(id: string): Promise; +} -function requiredString( - value: unknown, - field: string, - allowEmpty = false, -): string { - if ( - typeof value !== "string" || - (!allowEmpty && value.length === 0) - ) { - throw new OpenCloudError(`Invalid OpenCloud response field: ${field}`); - } - return value; +export interface OpenCloudDataClient { + table>(name: string): OpenCloudDataTable; } -function nullableString(value: unknown, field: string): string | null { - if (value === null) return null; - return requiredString(value, field, true); +export interface OpenCloudFilesClient { + upload( + source: Blob, + options?: OpenCloudFileUploadOptions, + ): Promise; + download(value: OpenCloudFile | string): Promise; + save(value: OpenCloudFile | string): Promise; + replace( + value: OpenCloudFile | string, + source: Blob, + options?: OpenCloudFileUploadOptions, + ): Promise; + remove(value: OpenCloudFile | string): Promise; + attach>( + source: Blob, + options: OpenCloudFileAttachmentOptions, + ): Promise<{ file: OpenCloudFile; record: Row }>; } -function requiredNumber(value: unknown, field: string): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - throw new OpenCloudError(`Invalid OpenCloud response field: ${field}`); - } - return value; +export interface OpenCloudFunctionsClient { + call( + name: string, + input?: Input, + ): Promise; + stream( + name: string, + input?: Input, + ): Promise>; } -function requiredInteger(value: unknown, field: string): number { - const parsed = requiredNumber(value, field); - if (!Number.isInteger(parsed)) { - throw new OpenCloudError(`Invalid OpenCloud response field: ${field}`); - } - return parsed; +export interface OpenCloudRealtimeClient { + subscribe( + topic: string, + handler: (message: OpenCloudRealtimeMessage) => void, + ): Promise<() => void>; + publish(topic: string, event: string, payload: unknown): Promise; } -function requiredBoolean(value: unknown, field: string): boolean { - if (typeof value !== "boolean") { - throw new OpenCloudError(`Invalid OpenCloud response field: ${field}`); - } - return value; +export interface OpenCloudTelemetryClient { + summary(): Promise; + increment( + name: string, + value?: number, + options?: OpenCloudMetricWriteOptions, + ): Promise; + gauge( + name: string, + value: number, + options?: OpenCloudMetricWriteOptions, + ): Promise; } -function nullableNumber(value: unknown, field: string): number | null { - return value === null ? null : requiredNumber(value, field); +export interface OpenCloudClient { + readonly app: OpenCloudAppClient; + readonly auth: OpenCloudAuthClient; + readonly data: OpenCloudDataClient; + readonly files: OpenCloudFilesClient; + readonly functions: OpenCloudFunctionsClient; + readonly realtime: OpenCloudRealtimeClient; + readonly telemetry: OpenCloudTelemetryClient; + dispose(): void; } -function requiredObject( - value: unknown, - field: string, -): Record { +type AuthMode = "user" | "public" | "optional"; + +interface SdkConfig { + package: "@opencloud/js"; + version: string; + module: string; + types: string; + docs: string; +} + +interface RuntimeFunction { + name: string; + access: "user" | "public" | "system"; +} + +interface RuntimeConfig { + appId: string; + deploymentVersion: string | null; + visibility: OpenCloudVisibility; + environment: OpenCloudEnvironment; + runtimeOrigin: string; + sdk: SdkConfig; + capabilities: OpenCloudCapabilities; + files: { access: "app" | "user"; maxUploadBytes: number } | null; + functions: RuntimeFunction[]; +} + +interface WireSession { + appId: string; + userId: string; + profile: { + email: string | null; + displayName: string | null; + avatarUrl: string | null; + }; + accessToken: string; + accessTokenExpiresAt: string; + refreshAfter: string; + sessionExpiresAt: string; +} + +const IDENTIFIER = /^[a-z_][a-z0-9_]{0,62}$/; +const FUNCTION_NAME = /^[a-z][a-z0-9-]{0,62}$/; +const REALTIME_NAME = /^[a-z][a-z0-9-]{0,62}$/; +const EVENT_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,79}$/; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function object(value: unknown, field: string): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new OpenCloudError(`Invalid OpenCloud response field: ${field}`); + throw invalidResponse(`Invalid OpenCloud response field: ${field}`, "app"); } return value as Record; } -function parseConfig(value: unknown): OpenCloudRuntimeConfig { - const input = requiredObject(value, "config"); - const visibility = input.visibility; - if (visibility !== "public" && visibility !== "private") { - throw new OpenCloudError("Invalid OpenCloud response field: visibility"); - } - const functionsBasePath = requiredString( - input.functionsBasePath, - "functionsBasePath", - ); - const browserClient = requiredString(input.browserClient, "browserClient"); - const rawSdk = requiredObject(input.javascriptSdk, "javascriptSdk"); - const javascriptSdk: OpenCloudJavaScriptSdkConfig = { - package: requiredString( - rawSdk.package, - "javascriptSdk.package", - ) as "@opencloud/js", - version: requiredString(rawSdk.version, "javascriptSdk.version"), - module: requiredString(rawSdk.module, "javascriptSdk.module"), - types: requiredString(rawSdk.types, "javascriptSdk.types"), - docs: requiredString(rawSdk.docs, "javascriptSdk.docs"), - }; - if (javascriptSdk.package !== "@opencloud/js") { - throw new OpenCloudError( - "Invalid OpenCloud response field: javascriptSdk.package", - ); - } - if ( - !functionsBasePath.startsWith("/") || - functionsBasePath.startsWith("//") || - !browserClient.startsWith("/") || - browserClient.startsWith("//") || - !javascriptSdk.module.startsWith("/") || - javascriptSdk.module.startsWith("//") || - !javascriptSdk.types.startsWith("/") || - javascriptSdk.types.startsWith("//") - ) { - throw new OpenCloudError("OpenCloud runtime paths must be same-origin"); - } - if ( - javascriptSdk.module !== browserClient || - javascriptSdk.version !== OPEN_CLOUD_JS_VERSION - ) { - throw new OpenCloudError( - "OpenCloud runtime and JavaScript SDK versions do not match", - ); +function string(value: unknown, field: string, allowEmpty = false): string { + if (typeof value !== "string" || (!allowEmpty && value.length === 0)) { + throw invalidResponse(`Invalid OpenCloud response field: ${field}`, "app"); } - const docs = new URL(javascriptSdk.docs); - if (docs.protocol !== "https:") { - throw new OpenCloudError("OpenCloud SDK documentation must use HTTPS"); + return value; +} + +function nullableString(value: unknown, field: string): string | null { + return value === null ? null : string(value, field, true); +} + +function number(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw invalidResponse(`Invalid OpenCloud response field: ${field}`, "app"); } - return { - appId: requiredString(input.appId, "appId"), - deploymentVersion: - input.deploymentVersion === null - ? null - : requiredString(input.deploymentVersion, "deploymentVersion"), - visibility, - supabaseUrl: requiredString(input.supabaseUrl, "supabaseUrl"), - supabaseAnonKey: requiredString(input.supabaseAnonKey, "supabaseAnonKey"), - storageBucket: requiredString(input.storageBucket, "storageBucket"), - functionsBasePath, - javascriptSdk, - browserClient, - environment: requiredString(input.environment, "environment"), - }; + return value; } -function parseIso(value: unknown, field: string): string { - const parsed = requiredString(value, field); - if (!Number.isFinite(Date.parse(parsed))) { - throw new OpenCloudError(`Invalid OpenCloud response field: ${field}`); +function integer(value: unknown, field: string): number { + const parsed = number(value, field); + if (!Number.isInteger(parsed)) { + throw invalidResponse(`Invalid OpenCloud response field: ${field}`, "app"); } return parsed; } -function parseSurfaceActivity( - value: unknown, - field: string, -): OpenCloudTelemetrySurfaceActivity { - const input = requiredObject(value, field); - const lastStatus = nullableNumber(input.lastStatus, `${field}.lastStatus`); - if ( - lastStatus !== null && - (!Number.isInteger(lastStatus) || lastStatus < 100 || lastStatus > 599) - ) { - throw new OpenCloudError( - `Invalid OpenCloud response field: ${field}.lastStatus`, - ); +function bool(value: unknown, field: string): boolean { + if (typeof value !== "boolean") { + throw invalidResponse(`Invalid OpenCloud response field: ${field}`, "app"); } - return { - lastActivityAt: - input.lastActivityAt === null - ? null - : parseIso(input.lastActivityAt, `${field}.lastActivityAt`), - requests24h: requiredInteger(input.requests24h, `${field}.requests24h`), - errors24h: requiredInteger(input.errors24h, `${field}.errors24h`), - lastStatus, - }; + return value; } -function parseTelemetrySummary(value: unknown): OpenCloudTelemetrySummary { - const input = requiredObject(value, "telemetry"); - const rawActivity = requiredObject(input.activity, "activity"); - const rawWindow = requiredObject(rawActivity.window, "activity.window"); - const rawTelemetry = requiredObject( - rawActivity.telemetry, - "activity.telemetry", - ); - const telemetryStatus = rawTelemetry.status; - if (telemetryStatus !== "available" && telemetryStatus !== "unavailable") { - throw new OpenCloudError( - "Invalid OpenCloud response field: activity.telemetry.status", - ); +function iso(value: unknown, field: string): string { + const parsed = string(value, field); + if (!Number.isFinite(Date.parse(parsed))) { + throw invalidResponse(`Invalid OpenCloud response field: ${field}`, "app"); } - const rawSurfaces = requiredObject( - rawActivity.surfaces, - "activity.surfaces", - ); - const surfaces = Object.fromEntries( - ( - [ - "page", - "rest", - "storage", - "realtime", - "function", - "cron", - ] as const - ).map((surface) => [ - surface, - parseSurfaceActivity( - rawSurfaces[surface], - `activity.surfaces.${surface}`, - ), - ]), - ) as Record; + return parsed; +} - let usage: OpenCloudTelemetryRollup | null = null; - if (input.usage !== null) { - const rawUsage = requiredObject(input.usage, "usage"); - const completeness = rawUsage.completeness; - if ( - completeness !== "complete" && - completeness !== "partial" && - completeness !== "corrected" - ) { - throw new OpenCloudError( - "Invalid OpenCloud response field: usage.completeness", - ); - } - usage = { - windowStart: parseIso(rawUsage.windowStart, "usage.windowStart"), - windowEnd: parseIso(rawUsage.windowEnd, "usage.windowEnd"), - calculationVersion: requiredString( - rawUsage.calculationVersion, - "usage.calculationVersion", - ), - completeness, - metrics: requiredObject(rawUsage.metrics, "usage.metrics"), - createdAt: parseIso(rawUsage.createdAt, "usage.createdAt"), - }; - } +function invalidResponse( + message: string, + surface: OpenCloudErrorSurface, + cause?: unknown, +): OpenCloudError { + return new OpenCloudError(message, { + code: "INVALID_RESPONSE", + surface, + ...(cause === undefined ? {} : { cause }), + }); +} - return { - appId: requiredString(input.appId, "appId"), - asOf: parseIso(input.asOf, "asOf"), - usage, - activity: { - window: { - from: parseIso(rawWindow.from, "activity.window.from"), - to: parseIso(rawWindow.to, "activity.window.to"), - seconds: requiredInteger( - rawWindow.seconds, - "activity.window.seconds", - ), - }, - telemetry: { - status: telemetryStatus, - latestIngestedAt: - rawTelemetry.latestIngestedAt === null - ? null - : parseIso( - rawTelemetry.latestIngestedAt, - "activity.telemetry.latestIngestedAt", - ), - ingestionLagSeconds: nullableNumber( - rawTelemetry.ingestionLagSeconds, - "activity.telemetry.ingestionLagSeconds", - ), - sampledEntries: requiredInteger( - rawTelemetry.sampledEntries, - "activity.telemetry.sampledEntries", - ), - truncated: requiredBoolean( - rawTelemetry.truncated, - "activity.telemetry.truncated", - ), - }, - surfaces, - }, - }; +function invalidArgument( + message: string, + surface: OpenCloudErrorSurface, +): OpenCloudError { + return new OpenCloudError(message, { code: "INVALID_ARGUMENT", surface }); } -function parseWireSession(value: unknown): WireSession { - if (!value || typeof value !== "object") { - throw new OpenCloudError("Invalid OpenCloud session"); - } - const input = value as Record; - const rawProfile = input.profile; - if (!rawProfile || typeof rawProfile !== "object") { - throw new OpenCloudError("Invalid OpenCloud response field: profile"); - } - const profile = rawProfile as Record; - const session: WireSession = { - appId: requiredString(input.appId, "appId"), - userId: requiredString(input.userId, "userId"), - profile: { - email: nullableString(profile.email, "profile.email"), - displayName: nullableString( - profile.displayName, - "profile.displayName", - ), - avatarUrl: nullableString(profile.avatarUrl, "profile.avatarUrl"), - }, - accessToken: requiredString(input.accessToken, "accessToken"), - accessTokenExpiresAt: requiredString( - input.accessTokenExpiresAt, - "accessTokenExpiresAt", - ), - refreshAfter: requiredString(input.refreshAfter, "refreshAfter"), - sessionExpiresAt: requiredString( - input.sessionExpiresAt, - "sessionExpiresAt", - ), - }; - for (const [field, source] of [ - ["accessTokenExpiresAt", session.accessTokenExpiresAt], - ["refreshAfter", session.refreshAfter], - ["sessionExpiresAt", session.sessionExpiresAt], - ] as const) { - if (!Number.isFinite(Date.parse(source))) { - throw new OpenCloudError(`Invalid OpenCloud response field: ${field}`); - } - } - return session; +function capabilityUnavailable( + capability: keyof OpenCloudCapabilities, + surface: OpenCloudErrorSurface, +): OpenCloudError { + return new OpenCloudError( + `OpenCloud ${capability} is unavailable in this environment`, + { code: "CAPABILITY_UNAVAILABLE", surface }, + ); } -function parseWireSessionEnvelope(value: unknown): WireSession | null { - const input = requiredObject(value, "session envelope"); - if (input.session === null) return null; - return parseWireSession(input.session); +function assertIdentifier(value: string, field: string): string { + if (!IDENTIFIER.test(value)) { + throw invalidArgument(`${field} must be a lowercase SQL identifier`, "data"); + } + return value; } -function publicSession(session: WireSession): OpenCloudSession { - return { - appId: session.appId, - userId: session.userId, - profile: { ...session.profile }, - accessTokenExpiresAt: session.accessTokenExpiresAt, - refreshAfter: session.refreshAfter, - sessionExpiresAt: session.sessionExpiresAt, - }; +function assertFunctionName(value: string): string { + if (!FUNCTION_NAME.test(value)) { + throw invalidArgument("OpenCloud Function name is invalid", "functions"); + } + return value; } -function normalizedBaseUrl(explicit?: string): string { +function normalizedOrigin(explicit?: string): string { const source = explicit ?? (typeof globalThis.location === "object" ? globalThis.location.origin : undefined); if (!source) { - throw new OpenCloudError( - "OpenCloud browser client requires a browser origin or baseUrl", + throw invalidArgument( + "OpenCloud requires a browser origin", + "app", ); } - const url = new URL(source); - if (url.pathname !== "/" || url.search || url.hash) { - throw new OpenCloudError("OpenCloud baseUrl must be an origin"); + const parsed = new URL(source); + if (parsed.pathname !== "/" || parsed.search || parsed.hash) { + throw invalidArgument("OpenCloud origin must not contain a path", "app"); } - return url.origin; + return parsed.origin; +} + +function currentLocationPath(): string { + if (typeof globalThis.location !== "object") return "/"; + const pathname = + typeof globalThis.location.pathname === "string" && + globalThis.location.pathname.startsWith("/") && + !globalThis.location.pathname.startsWith("//") + ? globalThis.location.pathname + : "/"; + const search = + typeof globalThis.location.search === "string" && + (globalThis.location.search === "" || globalThis.location.search.startsWith("?")) + ? globalThis.location.search + : ""; + const hash = + typeof globalThis.location.hash === "string" && + (globalThis.location.hash === "" || globalThis.location.hash.startsWith("#")) + ? globalThis.location.hash + : ""; + return `${pathname}${search}${hash}`; } function sameOriginPath(basePath: string, relativePath: string): string { const cleanBase = basePath.replace(/\/+$/, ""); const cleanRelative = relativePath.replace(/^\/+/, ""); - const segments = cleanRelative.split("/"); if ( !cleanRelative || cleanRelative.includes("\\") || - segments.some((segment) => { - let decoded = segment; + cleanRelative.split("/").some((segment) => { try { - decoded = decodeURIComponent(segment); + const decoded = decodeURIComponent(segment); + return decoded === "." || decoded === ".."; } catch { return true; } - return decoded === ".." || decoded === "."; }) ) { - throw new OpenCloudError("OpenCloud runtime path is invalid"); + throw invalidArgument("OpenCloud runtime path is invalid", "app"); } return `${cleanBase}/${cleanRelative}`; } -function functionName(value: string): string { - if (!/^[a-z][a-z0-9-]{0,62}$/.test(value)) { - throw new OpenCloudError("OpenCloud function name is invalid"); +function exactFields( + source: Record, + fields: readonly string[], + scope: string, +): void { + const allowed = new Set(fields); + for (const field of Object.keys(source)) { + if (!allowed.has(field)) { + throw invalidResponse( + `Invalid OpenCloud response field: ${scope}.${field}`, + "app", + ); + } } - return value; } -class RuntimeResourceClient { - constructor( - private readonly client: OpenCloudBrowserClient, - private readonly basePath: "/rest/v1" | "/storage/v1", - private readonly defaultAuth: OpenCloudAuthMode, - ) {} - - /** - * Send a path relative to this resource namespace. - * - * Authentication defaults to the current user. Pass `auth: "anonymous"` - * only for a deliberately public REST or Storage operation. - */ - request( - path: string, - init: OpenCloudRequestInit = {}, - ): Promise { - return this.client.runtimeRequest( - sameOriginPath(this.basePath, path), - init, - init.auth ?? this.defaultAuth, - ); +function parseRuntimeConfig(value: unknown, expectedOrigin: string): RuntimeConfig { + const input = object(value, "config"); + exactFields( + input, + [ + "appId", + "deploymentVersion", + "visibility", + "environment", + "sdk", + "capabilities", + "files", + "functions", + "devSessionId", + "devRevisionId", + ], + "config", + ); + const appId = string(input.appId, "appId"); + const visibility = input.visibility; + if (visibility !== "public" && visibility !== "private") { + throw invalidResponse("Invalid OpenCloud response field: visibility", "app"); } -} - -export class OpenCloudFunctionsClient { - constructor(private readonly client: OpenCloudBrowserClient) {} - - /** Invoke a verifyJwt:true function with the current user's bearer token. */ - async invoke( - name: string, - init: Omit = {}, - ): Promise { - const config = await this.client.config(); - return this.client.runtimeRequest( - sameOriginPath(config.functionsBasePath, functionName(name)), - init, - "authenticated", + const environment = string(input.environment, "environment"); + if (environment !== "dev" && environment !== "production") { + throw invalidResponse("Invalid OpenCloud response field: environment", "app"); + } + const rawSdk = object(input.sdk, "sdk"); + exactFields(rawSdk, ["package", "version", "module", "types", "docs"], "sdk"); + const sdk: SdkConfig = { + package: string(rawSdk.package, "sdk.package") as "@opencloud/js", + version: string(rawSdk.version, "sdk.version"), + module: string(rawSdk.module, "sdk.module"), + types: string(rawSdk.types, "sdk.types"), + docs: string(rawSdk.docs, "sdk.docs"), + }; + if ( + sdk.package !== "@opencloud/js" || + sdk.version !== OPEN_CLOUD_SDK_VERSION + ) { + throw invalidResponse( + "OpenCloud runtime and JavaScript SDK versions do not match", + "app", ); } - - /** - * Invoke a verifyJwt:false function. - * - * OpenCloud still supplies the anonymous project identity required by the - * gateway, even when a signed-in user has an active brokered session. - */ - async invokePublic( - name: string, - init: Omit = {}, - ): Promise { - const config = await this.client.config(); - return this.client.runtimeRequest( - sameOriginPath(config.functionsBasePath, functionName(name)), - init, - "anonymous", + if (sdk.module !== "/_opencloud/sdk.js") { + throw invalidResponse("Invalid OpenCloud response field: sdk.module", "app"); + } + if (sdk.types !== "/_opencloud/sdk.d.ts") { + throw invalidResponse("Invalid OpenCloud response field: sdk.types", "app"); + } + try { + if (new URL(sdk.docs).protocol !== "https:") throw new Error(); + } catch { + throw invalidResponse( + "OpenCloud SDK documentation must use HTTPS", + "app", ); } -} -export class OpenCloudTelemetryClient { - constructor( - private readonly request: ( - path: string, - init?: RequestInit, - ) => Promise, - ) {} + const rawCapabilities = object(input.capabilities, "capabilities"); + exactFields( + rawCapabilities, + ["auth", "data", "files", "functions", "realtime", "telemetry"], + "capabilities", + ); + const capabilities: OpenCloudCapabilities = { + auth: bool(rawCapabilities.auth, "capabilities.auth"), + data: bool(rawCapabilities.data, "capabilities.data"), + files: bool(rawCapabilities.files, "capabilities.files"), + functions: bool(rawCapabilities.functions, "capabilities.functions"), + realtime: bool(rawCapabilities.realtime, "capabilities.realtime"), + telemetry: bool(rawCapabilities.telemetry, "capabilities.telemetry"), + }; - /** - * Read the exact, safe 24-hour activity and latest usage-rollup shape. - * - * Missing activity is quiet or unknown, never proof of health. Check - * `activity.telemetry.status`, `latestIngestedAt`, and `truncated`. - */ - async summary(): Promise { - const response = await this.request("/_opencloud/telemetry/summary"); - if (!response.ok) { - throw new OpenCloudError( - `OpenCloud telemetry returned HTTP ${response.status}`, - ); + let files: RuntimeConfig["files"] = null; + if (capabilities.files) { + const rawFiles = object(input.files, "files"); + exactFields(rawFiles, ["access", "maxUploadBytes"], "files"); + const access = rawFiles.access; + if (access !== "app" && access !== "user") { + throw invalidResponse("Invalid OpenCloud response field: files.access", "app"); } - return parseTelemetrySummary(await response.json()); - } - - /** Add a non-negative delta to a deployment-declared counter. */ - increment( - name: string, - value = 1, - options: OpenCloudMetricWriteOptions = {}, - ): Promise { - if (!Number.isFinite(value) || value < 0) { - throw new OpenCloudError( - "OpenCloud counter increments must be finite and non-negative", + const maxUploadBytes = integer(rawFiles.maxUploadBytes, "files.maxUploadBytes"); + if (maxUploadBytes < 1) { + throw invalidResponse( + "Invalid OpenCloud response field: files.maxUploadBytes", + "app", ); } - return this.write(name, value, options); + files = { access, maxUploadBytes }; + } else if (input.files !== undefined) { + throw invalidResponse( + "OpenCloud config declared Files settings without the Files capability", + "app", + ); } - /** Record the current value of a deployment-declared gauge. */ - gauge( - name: string, - value: number, - options: OpenCloudMetricWriteOptions = {}, - ): Promise { - if (!Number.isFinite(value)) { - throw new OpenCloudError("OpenCloud gauge values must be finite"); - } - return this.write(name, value, options); + if (!Array.isArray(input.functions)) { + throw invalidResponse("Invalid OpenCloud response field: functions", "app"); } - - private async write( - name: string, - value: number, - options: OpenCloudMetricWriteOptions, - ): Promise { - const response = await this.request("/_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 } - : {}), - }, - ], - }), - }); - if (!response.ok) { - throw new OpenCloudError( - `OpenCloud metric ingestion returned HTTP ${response.status}`, + const functions: RuntimeFunction[] = input.functions.map((entry, index) => { + const raw = object(entry, `functions[${index}]`); + exactFields(raw, ["name", "access"], `functions[${index}]`); + const name = assertFunctionName(string(raw.name, `functions[${index}].name`)); + const access = raw.access; + if (access !== "user" && access !== "public" && access !== "system") { + throw invalidResponse( + `Invalid OpenCloud response field: functions[${index}].access`, + "app", ); } - const result = requiredObject(await response.json(), "metric result"); - return { - accepted: requiredInteger(result.accepted, "accepted"), - duplicates: requiredInteger(result.duplicates, "duplicates"), - recordedAt: parseIso(result.recordedAt, "recordedAt"), - }; - } -} + return { name, access }; + }); -interface ChannelDependencies { - config: () => Promise; - session: (forceRefresh: boolean) => Promise; - WebSocket: typeof WebSocket; - setTimer: ( - handler: () => void, - timeoutMilliseconds: number, - ) => ReturnType; - clearTimer: (timer: ReturnType) => void; + return { + appId, + deploymentVersion: + input.deploymentVersion === null + ? null + : string(input.deploymentVersion, "deploymentVersion"), + visibility, + environment, + runtimeOrigin: expectedOrigin, + sdk, + capabilities, + files, + functions, + }; } -export class OpenCloudPrivateRealtimeChannel { - private stateValue: RealtimeState = "idle"; +function parseWireSession(value: unknown): WireSession | null { + const envelope = object(value, "session envelope"); + if (envelope.session === null) return null; + const input = object(envelope.session, "session"); + const profile = object(input.profile, "profile"); + return { + appId: string(input.appId, "appId"), + userId: string(input.userId, "userId"), + profile: { + email: nullableString(profile.email, "profile.email"), + displayName: nullableString(profile.displayName, "profile.displayName"), + avatarUrl: nullableString(profile.avatarUrl, "profile.avatarUrl"), + }, + accessToken: string(input.accessToken, "accessToken"), + accessTokenExpiresAt: iso(input.accessTokenExpiresAt, "accessTokenExpiresAt"), + refreshAfter: iso(input.refreshAfter, "refreshAfter"), + sessionExpiresAt: iso(input.sessionExpiresAt, "sessionExpiresAt"), + }; +} + +function publicUser(session: WireSession): OpenCloudUser { + return { + id: session.userId, + email: session.profile.email, + displayName: session.profile.displayName, + avatarUrl: session.profile.avatarUrl, + }; +} + +function statusCode(status: number): string { + if (status === 400 || status === 422) return "INVALID_ARGUMENT"; + if (status === 401) return "AUTH_REQUIRED"; + if (status === 403) return "PERMISSION_DENIED"; + if (status === 404) return "NOT_FOUND"; + if (status === 409) return "CONFLICT"; + if (status === 429) return "RATE_LIMITED"; + return status >= 500 ? "PLATFORM_ERROR" : "REQUEST_FAILED"; +} + +function retryableStatus(status: number): boolean { + return status === 408 || status === 429 || status === 502 || status === 503 || status === 504; +} + +function bounded(value: string, maximum = 500): string { + const compact = value.replace(/\s+/g, " ").trim(); + return compact.length > maximum ? `${compact.slice(0, maximum - 1)}…` : compact; +} + +function responseMessage(parsed: unknown, fallback: string): string { + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const value = parsed as Record; + if (typeof value.message === "string") return bounded(value.message); + if (typeof value.error === "string") return bounded(value.error); + if (value.error && typeof value.error === "object") { + const nested = value.error as Record; + if (typeof nested.message === "string") return bounded(nested.message); + } + } + if (typeof parsed === "string" && parsed.trim()) return bounded(parsed); + return fallback; +} + +async function responseError( + response: Response, + surface: OpenCloudErrorSurface, +): Promise { + let source = ""; + try { + source = await response.text(); + } catch { + source = ""; + } + let parsed: unknown = source; + if (source) { + try { + parsed = JSON.parse(source); + } catch { + parsed = source; + } + } + const requestId = + response.headers.get("x-request-id") ?? + response.headers.get("x-opencloud-request-id"); + const parsedObject = + parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : undefined; + const platformCode = + typeof parsedObject?.code === "string" && + /^[A-Z][A-Z0-9_]{1,79}$/.test(parsedObject.code) + ? parsedObject.code + : undefined; + const rawDetails = + parsedObject?.details && + typeof parsedObject.details === "object" && + !Array.isArray(parsedObject.details) + ? (parsedObject.details as Record) + : undefined; + return new OpenCloudError( + responseMessage(parsed, `OpenCloud ${surface} returned HTTP ${response.status}`), + { + code: platformCode ?? statusCode(response.status), + surface, + status: response.status, + requestId, + retryable: + retryableStatus(response.status) || rawDetails?.retryable === true, + ...(parsed && typeof parsed === "object" ? { details: parsed } : {}), + }, + ); +} + +async function requireOk( + response: Response, + surface: OpenCloudErrorSurface, +): Promise { + if (!response.ok) throw await responseError(response, surface); + return response; +} + +async function parsedBody( + response: Response, + surface: OpenCloudErrorSurface, +): Promise { + if (response.status === 204) return undefined; + const source = await response.text(); + if (!source) return undefined; + const type = response.headers.get("content-type") ?? ""; + if (!type.includes("json")) return source; + try { + return JSON.parse(source) as unknown; + } catch (cause) { + throw invalidResponse(`OpenCloud ${surface} returned invalid JSON`, surface, cause); + } +} + +class RuntimeCore { + private configValue: RuntimeConfig | undefined; + private configPromise: Promise | undefined; + private sessionValue: WireSession | null | undefined; + private sessionPromise: Promise | undefined; + private refreshTimer: ReturnType | undefined; + + async config(): Promise { + if (this.configValue) return this.configValue; + if (!this.configPromise) { + const origin = normalizedOrigin(); + this.configPromise = this.fetch(new URL("/_opencloud/config", origin), { + credentials: "same-origin", + headers: { accept: "application/json" }, + }, "app") + .then((response) => requireOk(response, "app")) + .then((response) => response.json()) + .then((value) => { + const config = parseRuntimeConfig(value, origin); + this.configValue = config; + return config; + }) + .finally(() => { + this.configPromise = undefined; + }); + } + return this.configPromise; + } + + async user(refresh = false): Promise { + const session = await this.session(refresh); + return session ? publicUser(session) : null; + } + + async requireUser(): Promise { + const session = await this.session(false); + if (!session) { + throw new OpenCloudError("An OpenCloud user session is required", { + code: "AUTH_REQUIRED", + surface: "auth", + status: 401, + }); + } + return publicUser(session); + } + + async session(forceRefresh: boolean): Promise { + const now = Date.now(); + if ( + !forceRefresh && + this.sessionValue && + now < Date.parse(this.sessionValue.refreshAfter) + ) { + return this.sessionValue; + } + if (!forceRefresh && this.sessionValue === null) return null; + if (!this.sessionPromise) { + const origin = normalizedOrigin(); + this.sessionPromise = this.fetch( + new URL("/_opencloud/session", origin), + { + cache: "no-store", + credentials: "same-origin", + headers: { accept: "application/json" }, + }, + "auth", + ) + .then(async (response) => { + if (response.status === 401) return null; + await requireOk(response, "auth"); + return parseWireSession(await response.json()); + }) + .then(async (session) => { + if (session) { + const config = await this.config(); + if (session.appId !== config.appId) { + throw invalidResponse( + "OpenCloud session belongs to a different app", + "auth", + ); + } + } + this.sessionValue = session; + this.scheduleRefresh(session); + return session; + }) + .finally(() => { + this.sessionPromise = undefined; + }); + } + return this.sessionPromise; + } + + async request( + path: string, + init: RequestInit, + auth: AuthMode, + surface: OpenCloudErrorSurface, + ): Promise { + const config = await this.config(); + const target = new URL(path, normalizedOrigin()); + if ( + !path.startsWith("/") || + path.startsWith("//") || + target.origin !== config.runtimeOrigin + ) { + throw invalidArgument( + "OpenCloud runtime requests must remain same-origin", + surface, + ); + } + const headers = new Headers(init.headers); + headers.delete("apikey"); + if (auth === "public") { + headers.delete("authorization"); + } else { + const session = await this.session(false); + if (!session && auth === "user") { + throw new OpenCloudError("An OpenCloud user session is required", { + code: "AUTH_REQUIRED", + surface, + status: 401, + }); + } + if (session?.accessToken) { + headers.set("authorization", `Bearer ${session.accessToken}`); + } else { + headers.delete("authorization"); + } + } + return this.fetch( + target, + { ...init, credentials: "same-origin", headers }, + surface, + ); + } + + async hostRequest( + path: string, + init: RequestInit, + surface: OpenCloudErrorSurface, + ): Promise { + const target = new URL(path, normalizedOrigin()); + return this.fetch( + target, + { ...init, credentials: "same-origin" }, + surface, + ); + } + + async hostUpload( + path: string, + method: "POST" | "PUT", + source: Blob, + headers: HeadersInit, + surface: OpenCloudErrorSurface, + onProgress?: (progress: OpenCloudFileUploadProgress) => void, + ): Promise { + if (!onProgress || typeof globalThis.XMLHttpRequest !== "function") { + onProgress?.({ loaded: 0, total: source.size, percent: 0 }); + const response = await this.hostRequest( + path, + { method, headers, body: source }, + surface, + ); + onProgress?.({ loaded: source.size, total: source.size, percent: 100 }); + return response; + } + const target = new URL(path, normalizedOrigin()); + return new Promise((resolve, reject) => { + const request = new globalThis.XMLHttpRequest(); + let lastLoaded = -1; + const report = (loaded: number, total = source.size) => { + const boundedTotal = Math.max(0, total || source.size); + const boundedLoaded = Math.max(0, Math.min(loaded, boundedTotal)); + if (boundedLoaded === lastLoaded) return; + lastLoaded = boundedLoaded; + onProgress({ + loaded: boundedLoaded, + total: boundedTotal, + percent: boundedTotal === 0 ? 100 : Math.round((boundedLoaded / boundedTotal) * 100), + }); + }; + const fail = (cause: unknown) => + reject( + new OpenCloudError("OpenCloud files request could not be completed", { + code: "NETWORK_ERROR", + surface, + retryable: true, + cause, + }), + ); + request.open(method, target.toString()); + request.withCredentials = true; + request.responseType = "arraybuffer"; + new Headers(headers).forEach((value, name) => { + request.setRequestHeader(name, value); + }); + request.upload.addEventListener("progress", (event) => { + report(event.loaded, event.lengthComputable ? event.total : source.size); + }); + request.addEventListener("load", () => { + report(source.size, source.size); + const responseHeaders = new Headers(); + for (const line of request.getAllResponseHeaders().trim().split(/\r?\n/)) { + if (!line) continue; + const separator = line.indexOf(":"); + if (separator > 0) { + responseHeaders.append( + line.slice(0, separator).trim(), + line.slice(separator + 1).trim(), + ); + } + } + resolve( + new Response(request.response, { + status: request.status, + statusText: request.statusText, + headers: responseHeaders, + }), + ); + }); + request.addEventListener("error", fail); + request.addEventListener("abort", fail); + request.addEventListener("timeout", fail); + report(0, source.size); + request.send(source); + }); + } + + reset(): void { + if (this.refreshTimer !== undefined) clearTimeout(this.refreshTimer); + this.refreshTimer = undefined; + this.configValue = undefined; + this.configPromise = undefined; + this.sessionValue = undefined; + this.sessionPromise = undefined; + } + + private async fetch( + input: URL, + init: RequestInit, + surface: OpenCloudErrorSurface, + ): Promise { + if (typeof globalThis.fetch !== "function") { + throw capabilityUnavailable("data", surface); + } + try { + return await globalThis.fetch(input, init); + } catch (cause) { + if (cause instanceof OpenCloudError) throw cause; + throw new OpenCloudError(`OpenCloud ${surface} request could not be completed`, { + code: "NETWORK_ERROR", + surface, + retryable: true, + cause, + }); + } + } + + private scheduleRefresh(session: WireSession | null): void { + if (this.refreshTimer !== undefined) clearTimeout(this.refreshTimer); + this.refreshTimer = undefined; + if (!session) return; + const delay = Math.max(0, Date.parse(session.refreshAfter) - Date.now()); + this.refreshTimer = setTimeout(() => { + this.refreshTimer = undefined; + void this.session(true).catch(() => undefined); + }, Math.min(delay, 2_147_483_647)); + } +} + +class AppClient { + constructor(private readonly runtime: RuntimeCore) {} + + async info(): Promise { + const config = await this.runtime.config(); + return { + id: config.appId, + version: config.deploymentVersion, + environment: config.environment, + visibility: config.visibility, + capabilities: { ...config.capabilities }, + }; + } +} + +class AuthClient { + constructor(private readonly runtime: RuntimeCore) {} + currentUser(): Promise { + return this.runtime.user(false); + } + requireUser(): Promise { + return this.runtime.requireUser(); + } + signInUrl(): string { + const target = new URL("/_opencloud/sign-in", normalizedOrigin()); + target.searchParams.set("return_to", currentLocationPath()); + return target.toString(); + } +} + +function selectValue(select: string[] | undefined): string { + if (!select?.length) return "*"; + return select.map((column) => assertIdentifier(column, "column")).join(","); +} + +function filterValue(value: OpenCloudScalar): string { + if (value === null) return "is.null"; + if (typeof value === "number" && !Number.isFinite(value)) { + throw invalidArgument("OpenCloud data filters must be finite", "data"); + } + return `eq.${String(value)}`; +} + +class DataTable { + constructor( + private readonly runtime: RuntimeCore, + private readonly tableName: string, + ) { + assertIdentifier(tableName, "table"); + } + + async list(options: OpenCloudListOptions = {}): Promise { + const config = await this.runtime.config(); + if (!config.capabilities.data) throw capabilityUnavailable("data", "data"); + const query = new URLSearchParams({ select: selectValue(options.select) }); + for (const [column, value] of Object.entries(options.where ?? {})) { + query.set(assertIdentifier(column, "filter column"), filterValue(value)); + } + if (options.orderBy) { + const direction = options.orderBy.direction ?? "asc"; + query.set( + "order", + `${assertIdentifier(options.orderBy.column, "order column")}.${direction}`, + ); + } + if (options.limit !== undefined) { + if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 1000) { + throw invalidArgument("OpenCloud data limit must be between 1 and 1000", "data"); + } + query.set("limit", String(options.limit)); + } + if (options.offset !== undefined) { + if (!Number.isInteger(options.offset) || options.offset < 0) { + throw invalidArgument("OpenCloud data offset must be a non-negative integer", "data"); + } + query.set("offset", String(options.offset)); + } + const response = await this.runtime.request( + `/rest/v1/${this.tableName}?${query.toString()}`, + { headers: { accept: "application/json" } }, + "optional", + "data", + ); + await requireOk(response, "data"); + const rows = await parsedBody(response, "data"); + if (!Array.isArray(rows)) throw invalidResponse("OpenCloud data did not return rows", "data"); + return rows as Row[]; + } + + async getById(id: string, options: OpenCloudGetOptions = {}): Promise { + if (!id) throw invalidArgument("OpenCloud row id is required", "data"); + const rows = await this.list({ + ...(options.select ? { select: options.select } : {}), + where: { id }, + limit: 1, + }); + return rows[0] ?? null; + } + + async create(values: Record): Promise { + const rows = await this.write("POST", "", values); + const row = rows[0]; + if (!row) throw invalidResponse("OpenCloud data create returned no row", "data"); + return row; + } + + async createMany(values: Record[]): Promise { + if (!values.length) throw invalidArgument("OpenCloud createMany needs at least one row", "data"); + return this.write("POST", "", values); + } + + async updateById(id: string, patch: Record): Promise { + if (!id) throw invalidArgument("OpenCloud row id is required", "data"); + if (!Object.keys(patch).length) throw invalidArgument("OpenCloud update patch is empty", "data"); + const query = new URLSearchParams({ id: `eq.${id}` }); + const rows = await this.write("PATCH", `?${query.toString()}`, patch); + return rows[0] ?? null; + } + + async deleteById(id: string): Promise { + if (!id) throw invalidArgument("OpenCloud row id is required", "data"); + const config = await this.runtime.config(); + if (!config.capabilities.data) throw capabilityUnavailable("data", "data"); + const query = new URLSearchParams({ id: `eq.${id}` }); + const response = await this.runtime.request( + `/rest/v1/${this.tableName}?${query.toString()}`, + { method: "DELETE", headers: { accept: "application/json", prefer: "return=representation" } }, + "user", + "data", + ); + await requireOk(response, "data"); + const result = await parsedBody(response, "data"); + return result === undefined || (Array.isArray(result) && result.length > 0); + } + + private async write( + method: "POST" | "PATCH", + suffix: string, + body: Record | Record[], + ): Promise { + const config = await this.runtime.config(); + if (!config.capabilities.data) throw capabilityUnavailable("data", "data"); + const response = await this.runtime.request( + `/rest/v1/${this.tableName}${suffix}`, + { + method, + headers: { + accept: "application/json", + "content-type": "application/json", + prefer: "return=representation", + }, + body: JSON.stringify(body), + }, + "user", + "data", + ); + await requireOk(response, "data"); + const result = await parsedBody(response, "data"); + if (!Array.isArray(result)) throw invalidResponse("OpenCloud data did not return rows", "data"); + return result as Row[]; + } +} + +class DataClient { + constructor(private readonly runtime: RuntimeCore) {} + table>(name: string): DataTable { + return new DataTable(this.runtime, name); + } +} + +function argumentObject( + value: unknown, + allowedFields: readonly string[], + scope: string, +): Record { + const input = plainArgumentObject(value, scope); + const allowed = new Set(allowedFields); + for (const field of Object.keys(input)) { + if (!allowed.has(field)) { + throw invalidArgument(`${scope}.${field} is not supported`, "files"); + } + } + return input; +} + +function plainArgumentObject( + value: unknown, + scope: string, +): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw invalidArgument(`${scope} must be an object`, "files"); + } + return value as Record; +} + +function fileUploadOptions(value: unknown): OpenCloudFileUploadOptions { + const input = argumentObject( + value, + ["name", "contentType", "maxBytes", "onProgress"], + "file upload options", + ); + if (input.name !== undefined && typeof input.name !== "string") { + throw invalidArgument("file upload options.name must be a string", "files"); + } + if ( + input.contentType !== undefined && + typeof input.contentType !== "string" + ) { + throw invalidArgument( + "file upload options.contentType must be a string", + "files", + ); + } + if ( + input.onProgress !== undefined && + typeof input.onProgress !== "function" + ) { + throw invalidArgument( + "file upload options.onProgress must be a function", + "files", + ); + } + return input as OpenCloudFileUploadOptions; +} + +function fileName(source: Blob, explicit?: string): string { + const candidate = explicit ?? + ("name" in source && typeof source.name === "string" ? source.name : "file"); + const safe = candidate + .replace(/[\/\\\u0000-\u001f\u007f]/g, "_") + .trim() + .slice(0, 220); + return safe || "file"; +} + +function operationUuid(surface: OpenCloudErrorSurface): string { + const value = globalThis.crypto?.randomUUID?.(); + if (!value || !UUID.test(value)) { + throw capabilityUnavailable(surface === "files" ? "files" : "data", surface); + } + return value; +} + +function fileReference(value: OpenCloudFile | string): OpenCloudFile { + const reference = typeof value === "string" + ? { id: value, name: value, contentType: "application/octet-stream", size: 0 } + : value; + if (!UUID.test(reference.id)) { + throw invalidArgument("OpenCloud file id is invalid", "files"); + } + return reference; +} + +function parseFile(value: unknown): OpenCloudFile { + const input = object(value, "file"); + const id = string(input.id, "file.id"); + if (!UUID.test(id)) { + throw invalidResponse("OpenCloud Files returned an invalid file id", "files"); + } + const size = integer(input.size, "file.size"); + if (size < 0) { + throw invalidResponse("OpenCloud Files returned an invalid file size", "files"); + } + return { + id, + name: string(input.name, "file.name", true), + contentType: string(input.contentType, "file.contentType"), + size, + }; +} + +class FilesClient { + constructor( + private readonly runtime: RuntimeCore, + private readonly data: DataClient, + ) {} + + async upload( + source: Blob, + options: OpenCloudFileUploadOptions = {}, + ): Promise { + const checkedOptions = fileUploadOptions(options); + const metadata = await this.uploadMetadata(source, checkedOptions); + await this.runtime.requireUser(); + const idempotencyKey = operationUuid("files"); + const response = await this.uploadRequest( + "/_opencloud/files", + "POST", + source, + { + accept: "application/json", + "content-type": metadata.contentType, + "x-opencloud-file-name": encodeURIComponent(metadata.name), + "idempotency-key": idempotencyKey, + }, + checkedOptions.onProgress, + ); + await requireOk(response, "files"); + return parseFile(await response.json()); + } + + async download(value: OpenCloudFile | string): Promise { + const file = fileReference(value); + await this.assertAvailable(); + const response = await this.runtime.hostRequest( + `/_opencloud/files/${encodeURIComponent(file.id)}`, + { headers: { accept: "*/*" } }, + "files", + ); + await requireOk(response, "files"); + const blob = await response.blob(); + const encodedName = response.headers.get("x-opencloud-file-name"); + let responseName: string | null = null; + if (encodedName) { + try { + responseName = decodeURIComponent(encodedName); + } catch { + throw invalidResponse("OpenCloud Files returned an invalid file name", "files"); + } + } + return { + blob, + name: responseName ?? file.name, + contentType: response.headers.get("content-type") || file.contentType || blob.type, + size: blob.size, + }; + } + + async save(value: OpenCloudFile | string): Promise { + if ( + typeof document !== "object" || + typeof URL.createObjectURL !== "function" || + typeof URL.revokeObjectURL !== "function" + ) { + throw capabilityUnavailable("files", "files"); + } + const result = await this.download(value); + const url = URL.createObjectURL(result.blob); + try { + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = result.name; + anchor.hidden = true; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + } finally { + setTimeout(() => URL.revokeObjectURL(url), 1_000); + } + } + + async replace( + value: OpenCloudFile | string, + source: Blob, + options: OpenCloudFileUploadOptions = {}, + ): 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, + }); + await this.runtime.requireUser(); + const idempotencyKey = operationUuid("files"); + const response = await this.uploadRequest( + `/_opencloud/files/${encodeURIComponent(current.id)}`, + "PUT", + source, + { + accept: "application/json", + "content-type": metadata.contentType, + "x-opencloud-file-name": encodeURIComponent(metadata.name), + "idempotency-key": idempotencyKey, + }, + checkedOptions.onProgress, + ); + await requireOk(response, "files"); + return parseFile(await response.json()); + } + + async remove(value: OpenCloudFile | string): Promise { + const file = fileReference(value); + await this.assertAvailable(); + await this.runtime.requireUser(); + const response = await this.runtime.hostRequest( + `/_opencloud/files/${encodeURIComponent(file.id)}`, + { method: "DELETE" }, + "files", + ); + if (response.status !== 404) await requireOk(response, "files"); + } + + async attach>( + source: Blob, + options: OpenCloudFileAttachmentOptions, + ): Promise<{ file: OpenCloudFile; record: Row }> { + argumentObject( + options, + ["table", "values", "columns", "upload"], + "file attachment options", + ); + if (typeof options.table !== "string") { + throw invalidArgument( + "file attachment options.table must be a string", + "files", + ); + } + if (options.values !== undefined) { + plainArgumentObject(options.values, "file attachment options.values"); + } + if (options.columns) { + argumentObject( + options.columns, + ["id", "name", "contentType", "size"], + "file attachment options.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", + }; + for (const column of Object.values(columns)) assertIdentifier(column, "attachment column"); + const file = await this.upload(source, options.upload); + const values = { + ...(options.values ?? {}), + [columns.id]: file.id, + [columns.name]: file.name, + [columns.contentType]: file.contentType, + [columns.size]: file.size, + }; + const table = this.data.table(options.table); + try { + const record = await table.create(values); + return { file, record }; + } catch (cause) { + const ambiguous = + cause instanceof OpenCloudError && + (cause.code === "NETWORK_ERROR" || cause.retryable); + if (ambiguous) { + try { + const rows = await table.list({ where: { [columns.id]: file.id }, limit: 1 }); + if (rows[0]) return { file, record: rows[0] }; + } catch (reconciliationCause) { + throw this.incomplete(file, cause, reconciliationCause); + } + } + try { + await this.remove(file); + } catch (cleanupCause) { + throw this.incomplete(file, cause, cleanupCause); + } + throw cause; + } + } + + private incomplete( + file: OpenCloudFile, + cause: unknown, + cleanupCause: unknown, + ): OpenCloudError { + return new OpenCloudError( + "OpenCloud could not confirm file attachment cleanup", + { + code: "FILE_ATTACHMENT_INCOMPLETE", + surface: "files", + retryable: true, + details: { file, cleanupRequired: true, cleanupError: String(cleanupCause) }, + cause, + }, + ); + } + + private async uploadRequest( + path: string, + method: "POST" | "PUT", + source: Blob, + headers: HeadersInit, + onProgress?: (progress: OpenCloudFileUploadProgress) => void, + ): Promise { + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const response = await this.runtime.hostUpload( + path, + method, + source, + headers, + "files", + onProgress, + ); + if (attempt === 0 && retryableStatus(response.status)) { + await response.arrayBuffer().catch(() => undefined); + continue; + } + return response; + } catch (error) { + if ( + attempt === 0 && + error instanceof OpenCloudError && + error.retryable + ) { + continue; + } + throw error; + } + } + throw new OpenCloudError("OpenCloud files request could not be completed", { + code: "NETWORK_ERROR", + surface: "files", + retryable: true, + }); + } + + private async uploadMetadata( + source: Blob, + options: OpenCloudFileUploadOptions, + ): Promise> { + const config = await this.runtime.config(); + if (!config.capabilities.files) throw capabilityUnavailable("files", "files"); + if ( + typeof source !== "object" || + source === null || + typeof source.size !== "number" || + typeof source.type !== "string" + ) { + 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; + if (!Number.isInteger(requestedLimit) || requestedLimit < 1) { + throw invalidArgument("OpenCloud file maxBytes must be a positive integer", "files"); + } + const maximum = Math.min(requestedLimit, config.files.maxUploadBytes); + if (source.size > maximum) { + throw new OpenCloudError(`File exceeds the ${maximum}-byte upload limit`, { + code: "FILE_TOO_LARGE", + surface: "files", + details: { size: source.size, maxBytes: maximum }, + }); + } + return { + name: fileName(source, options.name), + contentType: options.contentType || source.type || "application/octet-stream", + size: source.size, + }; + } + + private async assertAvailable(): Promise { + const config = await this.runtime.config(); + if (!config.capabilities.files) throw capabilityUnavailable("files", "files"); + return config; + } +} + +class FunctionsClient { + constructor(private readonly runtime: RuntimeCore) {} + + async call( + name: string, + input?: Input, + ): Promise { + const { config, auth } = await this.definition(name); + const headers = new Headers({ accept: "application/json" }); + const init: RequestInit = { method: "POST", headers }; + if (input !== undefined) { + headers.set("content-type", "application/json"); + init.body = JSON.stringify(input); + } + const response = await this.runtime.request( + sameOriginPath( + config.environment === "dev" + ? "/_opencloud/dev/functions" + : "/functions/v1", + name, + ), + init, + auth, + "functions", + ); + await requireOk(response, "functions"); + return (await parsedBody(response, "functions")) as Output; + } + + async stream( + name: string, + input?: Input, + ): Promise> { + const { config, auth } = await this.definition(name); + const headers = new Headers({ accept: "application/octet-stream" }); + const init: RequestInit = { method: "POST", headers }; + if (input !== undefined) { + headers.set("content-type", "application/json"); + init.body = JSON.stringify(input); + } + const response = await this.runtime.request( + sameOriginPath( + config.environment === "dev" + ? "/_opencloud/dev/functions" + : "/functions/v1", + name, + ), + init, + auth, + "functions", + ); + await requireOk(response, "functions"); + if (!response.body) throw invalidResponse("OpenCloud Function returned no stream", "functions"); + return response.body; + } + + private async definition( + name: string, + ): Promise<{ config: RuntimeConfig; auth: AuthMode }> { + assertFunctionName(name); + const config = await this.runtime.config(); + if (!config.capabilities.functions) { + throw capabilityUnavailable("functions", "functions"); + } + const definition = config.functions.find((item) => item.name === name); + if (!definition) { + throw new OpenCloudError(`OpenCloud Function ${name} is not declared`, { + code: "FUNCTION_NOT_DECLARED", + surface: "functions", + }); + } + if (definition?.access === "system") { + throw new OpenCloudError( + `OpenCloud Function ${name} is available only to platform invocations`, + { + code: "FUNCTION_SYSTEM_ONLY", + surface: "functions", + details: { name }, + }, + ); + } + return { + config, + auth: definition.access === "user" ? "user" : "public", + }; + } +} + +type RealtimeState = "idle" | "connecting" | "joined" | "reconnecting" | "closed"; + +class RealtimeChannel { + private state: RealtimeState = "idle"; private socket: WebSocket | undefined; private joinReference: string | undefined; private reference = 0; @@ -701,203 +1699,154 @@ export class OpenCloudPrivateRealtimeChannel { private connectPromise: Promise | undefined; private resolveConnect: (() => void) | undefined; private rejectConnect: ((error: unknown) => void) | undefined; - private deliberatelyClosed = false; - private readonly broadcastHandlers = new Set< - (message: RealtimeBroadcast) => void - >(); - private readonly stateHandlers = new Set<(state: RealtimeState) => void>(); - private readonly initialReconnectDelay: number; - private readonly maxReconnectDelay: number; + private closed = false; + readonly handlers = new Set<(message: OpenCloudRealtimeMessage) => void>(); constructor( - private readonly channelName: string, - private readonly dependencies: ChannelDependencies, - private readonly options: RealtimeChannelOptions = {}, + private readonly name: string, + private readonly runtime: RuntimeCore, ) { - if (!/^[a-z][a-z0-9-]{0,62}$/.test(channelName)) { - throw new OpenCloudError("OpenCloud Realtime channel name is invalid"); + if (!REALTIME_NAME.test(name)) { + throw invalidArgument("OpenCloud Realtime topic is invalid", "realtime"); } - this.initialReconnectDelay = Math.max( - 10, - options.reconnect?.initialDelayMs ?? 500, - ); - this.maxReconnectDelay = Math.max( - this.initialReconnectDelay, - options.reconnect?.maxDelayMs ?? 10_000, - ); - } - - /** Current connection lifecycle state. */ - get state(): RealtimeState { - return this.stateValue; - } - - /** Register a broadcast handler and return its unsubscribe function. */ - onBroadcast( - handler: (message: RealtimeBroadcast) => void, - ): () => void { - this.broadcastHandlers.add(handler); - return () => this.broadcastHandlers.delete(handler); - } - - /** Register a state handler and return its unsubscribe function. */ - onStateChange(handler: (state: RealtimeState) => void): () => void { - this.stateHandlers.add(handler); - return () => this.stateHandlers.delete(handler); } - /** Connect or resolve immediately when already joined. */ connect(): Promise { - if (this.deliberatelyClosed) { - return Promise.reject( - new OpenCloudError("OpenCloud Realtime channel is closed"), - ); + if (this.closed) { + return Promise.reject(new OpenCloudError("OpenCloud Realtime subscription is closed", { + code: "SUBSCRIPTION_CLOSED", + surface: "realtime", + })); + } + if (this.state === "joined") return Promise.resolve(); + if (!this.connectPromise) { + this.connectPromise = new Promise((resolve, reject) => { + this.resolveConnect = resolve; + this.rejectConnect = reject; + }); + void this.open(false); } - if (this.stateValue === "joined") return Promise.resolve(); - if (this.connectPromise) return this.connectPromise; - this.connectPromise = new Promise((resolve, reject) => { - this.resolveConnect = resolve; - this.rejectConnect = reject; - }); - void this.open(false); return this.connectPromise; } - /** Ensure the channel is joined and send a private broadcast. */ - async broadcast(event: string, payload: unknown): Promise { - if (!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,79}$/.test(event)) { - throw new OpenCloudError("OpenCloud Realtime event name is invalid"); + async publish(event: string, payload: unknown): Promise { + if (!EVENT_NAME.test(event)) { + throw invalidArgument("OpenCloud Realtime event is invalid", "realtime"); } await this.connect(); - const socket = this.socket; - if (!socket || socket.readyState !== 1 || !this.joinReference) { - throw new OpenCloudError("OpenCloud Realtime channel is not joined"); + if (!this.socket || this.socket.readyState !== 1 || !this.joinReference) { + throw invalidResponse("OpenCloud Realtime subscription is not joined", "realtime"); } - socket.send( - JSON.stringify({ - topic: await this.topic(), - event: "broadcast", - payload: { type: "broadcast", event, payload }, - ref: this.nextReference(), - join_ref: this.joinReference, - }), - ); + const config = await this.runtime.config(); + this.socket.send(JSON.stringify({ + topic: `realtime:app:${config.appId}:${this.name}`, + event: "broadcast", + payload: { type: "broadcast", event, payload }, + ref: this.nextReference(), + join_ref: this.joinReference, + })); } - /** Permanently stop reconnect/heartbeat behavior and close the socket. */ close(): void { - this.deliberatelyClosed = true; + if (this.closed) return; + this.closed = true; this.clearTimers(); this.socket?.close(1000, "client closed"); this.socket = undefined; - this.rejectPending( - new OpenCloudError("OpenCloud Realtime channel was closed"), - ); - this.setState("closed"); + this.rejectConnect?.(new OpenCloudError("OpenCloud Realtime subscription was closed", { + code: "SUBSCRIPTION_CLOSED", + surface: "realtime", + })); + this.clearPending(); + this.state = "closed"; } private async open(reconnecting: boolean): Promise { try { - this.setState(reconnecting ? "reconnecting" : "connecting"); - const [config, session] = await Promise.all([ - this.dependencies.config(), - // Every socket/reconnect asks the broker for current token metadata. - this.dependencies.session(reconnecting), - ]); - if (this.deliberatelyClosed) return; - const runtimeOrigin = new URL(config.supabaseUrl); - runtimeOrigin.protocol = - runtimeOrigin.protocol === "https:" ? "wss:" : "ws:"; - runtimeOrigin.pathname = "/realtime/v1/websocket"; - runtimeOrigin.search = new URLSearchParams({ - apikey: config.supabaseAnonKey, - vsn: "1.0.0", - }).toString(); - const socket = new this.dependencies.WebSocket(runtimeOrigin); + const config = await this.runtime.config(); + if (!config.capabilities.realtime) { + throw capabilityUnavailable("realtime", "realtime"); + } + const session = await this.runtime.session(reconnecting); + if (!session) { + throw new OpenCloudError("An OpenCloud user session is required", { + code: "AUTH_REQUIRED", + surface: "realtime", + status: 401, + }); + } + if (typeof globalThis.WebSocket !== "function") { + throw capabilityUnavailable("realtime", "realtime"); + } + if (this.closed) return; + this.state = reconnecting ? "reconnecting" : "connecting"; + const target = new URL(config.runtimeOrigin); + target.protocol = target.protocol === "https:" ? "wss:" : "ws:"; + target.pathname = "/realtime/v1/websocket"; + const realtimeQuery = new URLSearchParams({ vsn: "1.0.0" }); + target.search = realtimeQuery.toString(); + const socket = new globalThis.WebSocket(target); this.socket = socket; - socket.addEventListener("open", () => { - void this.join(socket, config.appId, session.accessToken); - }); - socket.addEventListener("message", (event) => { - this.handleMessage(socket, event.data); - }); + socket.addEventListener("open", () => this.join(socket, config, session)); + socket.addEventListener("message", (event) => this.message(socket, event.data)); socket.addEventListener("close", () => { if (socket !== this.socket) return; this.socket = undefined; this.stopHeartbeat(); - if (!this.deliberatelyClosed) this.scheduleReconnect(); + if (!this.closed) this.scheduleReconnect(); }); socket.addEventListener("error", () => { if (socket === this.socket) socket.close(); }); - } catch (error) { - if (error instanceof OpenCloudAuthError) { - this.rejectPending(error); - this.setState("idle"); + } catch (cause) { + if (cause instanceof OpenCloudError && + ["AUTH_REQUIRED", "CAPABILITY_UNAVAILABLE"].includes(cause.code)) { + this.rejectConnect?.(cause); + this.clearPending(); + this.state = "idle"; return; } - if (!this.deliberatelyClosed) this.scheduleReconnect(); + if (!this.closed) this.scheduleReconnect(); } } - private async join( - socket: WebSocket, - appId: string, - accessToken: string, - ): Promise { - const joinReference = this.nextReference(); - this.joinReference = joinReference; - socket.send( - JSON.stringify({ - topic: `realtime:app:${appId}:${this.channelName}`, - event: "phx_join", - payload: { - config: { - private: true, - broadcast: { - ack: this.options.broadcast?.ack ?? true, - self: this.options.broadcast?.self ?? true, - }, - presence: { enabled: false }, - postgres_changes: [], - }, - access_token: accessToken, + private join(socket: WebSocket, config: RuntimeConfig, session: WireSession): void { + const ref = this.nextReference(); + this.joinReference = ref; + socket.send(JSON.stringify({ + topic: `realtime:app:${config.appId}:${this.name}`, + event: "phx_join", + payload: { + config: { + private: true, + broadcast: { ack: true, self: true }, + presence: { enabled: false }, + postgres_changes: [], }, - ref: joinReference, - join_ref: joinReference, - }), - ); + access_token: session.accessToken, + }, + ref, + join_ref: ref, + })); } - private handleMessage(socket: WebSocket, source: unknown): void { + private message(socket: WebSocket, source: unknown): void { if (socket !== this.socket) return; - let message: { - event?: string; - ref?: string; - payload?: { - status?: string; - event?: string; - payload?: unknown; - }; - }; + let message: { event?: string; ref?: string; payload?: { status?: string; event?: string; payload?: unknown } }; try { - const text = - typeof source === "string" - ? source - : source instanceof ArrayBuffer - ? new TextDecoder().decode(source) - : String(source); + const text = typeof source === "string" + ? source + : source instanceof ArrayBuffer + ? new TextDecoder().decode(source) + : String(source); message = JSON.parse(text) as typeof message; } catch { return; } - if ( - message.event === "phx_reply" && - message.ref === this.joinReference - ) { + if (message.event === "phx_reply" && message.ref === this.joinReference) { if (message.payload?.status === "ok") { this.reconnectAttempt = 0; - this.setState("joined"); + this.state = "joined"; this.resolveConnect?.(); this.clearPending(); this.startHeartbeat(); @@ -906,32 +1855,18 @@ export class OpenCloudPrivateRealtimeChannel { } return; } - if ( - message.event === "broadcast" && - typeof message.payload?.event === "string" - ) { - const broadcast = { - event: message.payload.event, - payload: message.payload.payload, - }; - for (const handler of this.broadcastHandlers) handler(broadcast); + if (message.event === "broadcast" && typeof message.payload?.event === "string") { + const value = { event: message.payload.event, payload: message.payload.payload }; + for (const handler of this.handlers) handler(value); } } - private async topic(): Promise { - const config = await this.dependencies.config(); - return `realtime:app:${config.appId}:${this.channelName}`; - } - private scheduleReconnect(): void { - if (this.deliberatelyClosed || this.reconnectTimer) return; - this.setState("reconnecting"); - const delay = Math.min( - this.maxReconnectDelay, - this.initialReconnectDelay * 2 ** this.reconnectAttempt, - ); + if (this.closed || this.reconnectTimer) return; + this.state = "reconnecting"; + const delay = Math.min(10_000, 500 * 2 ** this.reconnectAttempt); this.reconnectAttempt += 1; - this.reconnectTimer = this.dependencies.setTimer(() => { + this.reconnectTimer = setTimeout(() => { this.reconnectTimer = undefined; void this.open(true); }, delay); @@ -940,42 +1875,28 @@ export class OpenCloudPrivateRealtimeChannel { private startHeartbeat(): void { this.stopHeartbeat(); const heartbeat = () => { - if ( - this.socket?.readyState === 1 && - this.stateValue === "joined" - ) { - this.socket.send( - JSON.stringify({ - topic: "phoenix", - event: "heartbeat", - payload: {}, - ref: this.nextReference(), - }), - ); - this.heartbeatTimer = this.dependencies.setTimer(heartbeat, 25_000); + if (this.socket?.readyState === 1 && this.state === "joined") { + this.socket.send(JSON.stringify({ + topic: "phoenix", + event: "heartbeat", + payload: {}, + ref: this.nextReference(), + })); + this.heartbeatTimer = setTimeout(heartbeat, 25_000); } }; - this.heartbeatTimer = this.dependencies.setTimer(heartbeat, 25_000); + this.heartbeatTimer = setTimeout(heartbeat, 25_000); } private stopHeartbeat(): void { - if (this.heartbeatTimer !== undefined) { - this.dependencies.clearTimer(this.heartbeatTimer); - this.heartbeatTimer = undefined; - } + if (this.heartbeatTimer !== undefined) clearTimeout(this.heartbeatTimer); + this.heartbeatTimer = undefined; } private clearTimers(): void { this.stopHeartbeat(); - if (this.reconnectTimer !== undefined) { - this.dependencies.clearTimer(this.reconnectTimer); - this.reconnectTimer = undefined; - } - } - - private rejectPending(error: unknown): void { - this.rejectConnect?.(error); - this.clearPending(); + if (this.reconnectTimer !== undefined) clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; } private clearPending(): void { @@ -984,288 +1905,235 @@ export class OpenCloudPrivateRealtimeChannel { this.rejectConnect = undefined; } - private setState(state: RealtimeState): void { - if (state === this.stateValue) return; - this.stateValue = state; - for (const handler of this.stateHandlers) handler(state); - } - private nextReference(): string { this.reference += 1; return String(this.reference); } } -export class OpenCloudRealtimeClient { - constructor(private readonly client: OpenCloudBrowserClient) {} - - /** Create a private app-scoped logical channel. */ - channel( - name: string, - options: RealtimeChannelOptions = {}, - ): OpenCloudPrivateRealtimeChannel { - return new OpenCloudPrivateRealtimeChannel( - name, - this.client.realtimeDependencies(), - options, - ); - } -} - -export class OpenCloudBrowserClient { - readonly rest: RuntimeResourceClient; - readonly storage: RuntimeResourceClient; - readonly functions: OpenCloudFunctionsClient; - readonly realtime: OpenCloudRealtimeClient; - readonly telemetry: OpenCloudTelemetryClient; +class RealtimeClient { + private readonly channels = new Map(); + private lifecycleInstalled = false; - private readonly baseUrl: string; - private readonly fetcher: typeof fetch; - private readonly WebSocketImplementation: typeof WebSocket; - private readonly automaticSessionRefresh: boolean; - private readonly now: () => number; - private configValue: OpenCloudRuntimeConfig | undefined; - private configPromise: Promise | undefined; - private wireSession: WireSession | null | undefined; - private sessionPromise: Promise | undefined; - private refreshTimer: ReturnType | undefined; + constructor(private readonly runtime: RuntimeCore) {} - /** Create a same-origin client. Prefer {@link createOpenCloudClient}. */ - constructor(options: OpenCloudClientOptions = {}) { - this.baseUrl = normalizedBaseUrl(options.baseUrl); - this.fetcher = - options.fetch ?? - (typeof globalThis.fetch === "function" - ? globalThis.fetch.bind(globalThis) - : (() => { - throw new OpenCloudError("fetch is not available"); - })); - const WebSocketImplementation = - options.WebSocket ?? - (typeof globalThis.WebSocket === "function" - ? globalThis.WebSocket - : undefined); - if (!WebSocketImplementation) { - throw new OpenCloudError("WebSocket is not available"); + async subscribe( + topic: string, + handler: (message: OpenCloudRealtimeMessage) => void, + ): Promise<() => void> { + this.installLifecycle(); + const channel = this.channel(topic); + channel.handlers.add(handler); + try { + await channel.connect(); + } catch (cause) { + channel.handlers.delete(handler); + if (!channel.handlers.size) { + channel.close(); + this.channels.delete(topic); + } + throw cause; } - this.WebSocketImplementation = WebSocketImplementation; - this.automaticSessionRefresh = - options.automaticSessionRefresh ?? true; - this.now = options.now ?? Date.now; - this.rest = new RuntimeResourceClient( - this, - "/rest/v1", - "authenticated", - ); - this.storage = new RuntimeResourceClient( - this, - "/storage/v1", - "authenticated", - ); - this.functions = new OpenCloudFunctionsClient(this); - this.realtime = new OpenCloudRealtimeClient(this); - this.telemetry = new OpenCloudTelemetryClient((path, init = {}) => { - const headers = new Headers(init.headers); - if (!headers.has("accept")) headers.set("accept", "application/json"); - return this.fetcher(new URL(path, this.baseUrl), { - ...init, - credentials: "same-origin", - headers, - }); - }); + let active = true; + return () => { + if (!active) return; + active = false; + channel.handlers.delete(handler); + if (!channel.handlers.size) { + channel.close(); + this.channels.delete(topic); + } + }; } - /** Read and cache host-bound runtime configuration. */ - async config(): Promise { - if (this.configValue) return { ...this.configValue }; - if (!this.configPromise) { - this.configPromise = this.fetcher( - new URL("/_opencloud/config", this.baseUrl), - { credentials: "same-origin" }, - ) - .then(async (response) => { - if (!response.ok) { - throw new OpenCloudError( - `OpenCloud config returned HTTP ${response.status}`, - ); - } - const config = parseConfig(await response.json()); - const runtimeOrigin = new URL(config.supabaseUrl).origin; - if (runtimeOrigin !== this.baseUrl) { - throw new OpenCloudError( - "OpenCloud config attempted a cross-origin runtime", - ); - } - this.configValue = config; - return config; - }) - .finally(() => { - this.configPromise = undefined; - }); + async publish(topic: string, event: string, payload: unknown): Promise { + this.installLifecycle(); + const existing = this.channels.get(topic); + const channel = existing ?? this.channel(topic); + try { + await channel.publish(event, payload); + } finally { + if (!existing && !channel.handlers.size) { + channel.close(); + this.channels.delete(topic); + } } - return { ...(await this.configPromise) }; } - /** - * Return the safe current-user profile and exact expiry metadata. - * Access and refresh tokens are never returned by this public API. - */ - async session(options: { refresh?: boolean } = {}): Promise< - OpenCloudSession | null - > { - const session = await this.loadSession(options.refresh ?? false); - return session ? publicSession(session) : null; + dispose(): void { + for (const channel of this.channels.values()) channel.close(); + this.channels.clear(); } - /** @internal Used by resource namespaces to preserve SDK auth behavior. */ - async runtimeRequest( - path: string, - init: OpenCloudRequestInit, - auth: OpenCloudAuthMode, - ): Promise { - const config = await this.config(); - const headers = new Headers(init.headers); - headers.set("apikey", config.supabaseAnonKey); - if (auth === "anonymous") { - headers.set("authorization", `Bearer ${config.supabaseAnonKey}`); - } else { - const session = await this.loadSession(false); - if (!session && auth === "authenticated") { - throw new OpenCloudAuthError("An OpenCloud user session is required"); - } - headers.set( - "authorization", - `Bearer ${session?.accessToken ?? config.supabaseAnonKey}`, - ); - } - const { auth: _ignored, ...requestInit } = init; - const target = new URL(path, this.baseUrl); - if ( - !path.startsWith("/") || - path.startsWith("//") || - target.origin !== this.baseUrl - ) { - throw new OpenCloudError( - "OpenCloud runtime requests must remain same-origin", - ); - } - return this.fetcher(target, { - ...requestInit, - headers, - credentials: "same-origin", - }); + private channel(topic: string): RealtimeChannel { + const existing = this.channels.get(topic); + if (existing) return existing; + const channel = new RealtimeChannel(topic, this.runtime); + this.channels.set(topic, channel); + return channel; } - /** Stop automatic session refresh and clear cached session state. */ - dispose(): void { - if (this.refreshTimer !== undefined) { - clearTimeout(this.refreshTimer); - this.refreshTimer = undefined; - } - this.wireSession = undefined; - this.sessionPromise = undefined; + private installLifecycle(): void { + if (this.lifecycleInstalled || typeof globalThis.addEventListener !== "function") return; + this.lifecycleInstalled = true; + globalThis.addEventListener("pagehide", () => this.dispose(), { once: true }); } +} - /** @internal Used by the first-party Realtime namespace. */ - realtimeDependencies(): ChannelDependencies { - return { - config: () => this.config(), - session: async (forceRefresh) => { - const session = await this.loadSession(forceRefresh); - if (!session) { - throw new OpenCloudAuthError( - "An OpenCloud user session is required", - ); - } - return session; - }, - WebSocket: this.WebSocketImplementation, - setTimer: (handler, timeoutMilliseconds) => - globalThis.setTimeout(handler, timeoutMilliseconds), - clearTimer: (timer) => globalThis.clearTimeout(timer), +function parseTelemetrySummary(value: unknown): OpenCloudTelemetrySummary { + const input = object(value, "telemetry"); + const activity = object(input.activity, "activity"); + const window = object(activity.window, "activity.window"); + const freshness = object(activity.telemetry, "activity.telemetry"); + const status = freshness.status; + if (status !== "available" && status !== "unavailable") { + throw invalidResponse("Invalid OpenCloud response field: activity.telemetry.status", "telemetry"); + } + const rawSurfaces = object(activity.surfaces, "activity.surfaces"); + const surfaces = {} as Record; + for (const surface of ["page", "rest", "storage", "realtime", "function", "cron"] as const) { + const raw = object(rawSurfaces[surface], `activity.surfaces.${surface}`); + surfaces[surface] = { + lastActivityAt: raw.lastActivityAt === null ? null : iso(raw.lastActivityAt, `${surface}.lastActivityAt`), + requests24h: integer(raw.requests24h, `${surface}.requests24h`), + errors24h: integer(raw.errors24h, `${surface}.errors24h`), + lastStatus: raw.lastStatus === null ? null : integer(raw.lastStatus, `${surface}.lastStatus`), + }; + } + let usage: OpenCloudTelemetryRollup | null = null; + if (input.usage !== null) { + const raw = object(input.usage, "usage"); + usage = { + windowStart: iso(raw.windowStart, "usage.windowStart"), + windowEnd: iso(raw.windowEnd, "usage.windowEnd"), + calculationVersion: string(raw.calculationVersion, "usage.calculationVersion"), + completeness: string(raw.completeness, "usage.completeness") as OpenCloudTelemetryRollup["completeness"], + metrics: object(raw.metrics, "usage.metrics"), + createdAt: iso(raw.createdAt, "usage.createdAt"), }; } + return { + appId: string(input.appId, "appId"), + asOf: iso(input.asOf, "asOf"), + usage, + activity: { + window: { + from: iso(window.from, "activity.window.from"), + to: iso(window.to, "activity.window.to"), + seconds: number(window.seconds, "activity.window.seconds"), + }, + telemetry: { + status, + latestIngestedAt: freshness.latestIngestedAt === null ? null : iso(freshness.latestIngestedAt, "activity.telemetry.latestIngestedAt"), + ingestionLagSeconds: freshness.ingestionLagSeconds === null ? null : number(freshness.ingestionLagSeconds, "activity.telemetry.ingestionLagSeconds"), + sampledEntries: integer(freshness.sampledEntries, "activity.telemetry.sampledEntries"), + truncated: bool(freshness.truncated, "activity.telemetry.truncated"), + }, + surfaces, + }, + }; +} - private async loadSession(forceRefresh: boolean): Promise { - if ( - !forceRefresh && - this.wireSession && - this.now() < Date.parse(this.wireSession.refreshAfter) - ) { - return this.wireSession; +class TelemetryClient { + constructor(private readonly runtime: RuntimeCore) {} + + async summary(): Promise { + const config = await this.runtime.config(); + if (!config.capabilities.telemetry) { + throw capabilityUnavailable("telemetry", "telemetry"); } - if (!forceRefresh && this.wireSession === null) return null; - if (!this.sessionPromise) { - this.sessionPromise = this.fetcher( - new URL("/_opencloud/session/v2", this.baseUrl), - { - cache: "no-store", - credentials: "same-origin", - headers: { accept: "application/json" }, - }, - ) - .then(async (response) => { - if (response.status === 401) { - this.wireSession = null; - this.cancelRefresh(); - return null; - } - if (!response.ok) { - throw new OpenCloudError( - `OpenCloud session returned HTTP ${response.status}`, - ); - } - const session = parseWireSessionEnvelope(await response.json()); - if (!session) { - this.wireSession = null; - this.cancelRefresh(); - return null; - } - const config = await this.config(); - if (session.appId !== config.appId) { - throw new OpenCloudError( - "OpenCloud session belongs to a different app", - ); - } - this.wireSession = session; - this.scheduleRefresh(session); - return session; - }) - .finally(() => { - this.sessionPromise = undefined; - }); + const response = await this.runtime.hostRequest( + "/_opencloud/telemetry/summary", + { headers: { accept: "application/json" } }, + "telemetry", + ); + await requireOk(response, "telemetry"); + return parseTelemetrySummary(await response.json()); + } + + increment( + name: string, + value = 1, + options: OpenCloudMetricWriteOptions = {}, + ): Promise { + if (!Number.isFinite(value) || value < 0) { + throw invalidArgument("OpenCloud counter increments must be finite and non-negative", "telemetry"); } - return this.sessionPromise; + return this.write(name, value, options); } - private scheduleRefresh(session: WireSession): void { - this.cancelRefresh(); - if (!this.automaticSessionRefresh) return; - const delay = Math.max(0, Date.parse(session.refreshAfter) - this.now()); - this.refreshTimer = setTimeout(() => { - this.refreshTimer = undefined; - void this.loadSession(true).catch(() => { - if (this.wireSession) { - this.refreshTimer = setTimeout(() => { - this.refreshTimer = undefined; - void this.loadSession(true); - }, 5_000); - } - }); - }, Math.min(delay, 2_147_483_647)); + gauge( + name: string, + value: number, + options: OpenCloudMetricWriteOptions = {}, + ): Promise { + if (!Number.isFinite(value)) { + throw invalidArgument("OpenCloud gauge values must be finite", "telemetry"); + } + return this.write(name, value, options); } - private cancelRefresh(): void { - if (this.refreshTimer !== undefined) { - clearTimeout(this.refreshTimer); - this.refreshTimer = undefined; + private async write( + name: string, + value: number, + options: OpenCloudMetricWriteOptions, + ): Promise { + const config = await this.runtime.config(); + 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"), + }; } } -/** Create one same-origin OpenCloud client for the current app. */ -export function createOpenCloudClient( - options: OpenCloudClientOptions = {}, -): OpenCloudBrowserClient { - return new OpenCloudBrowserClient(options); +class OpenCloudClientImplementation implements OpenCloudClient { + readonly app: AppClient; + readonly auth: AuthClient; + readonly data: DataClient; + readonly files: FilesClient; + readonly functions: FunctionsClient; + readonly realtime: RealtimeClient; + readonly telemetry: TelemetryClient; + private readonly runtime = new RuntimeCore(); + + constructor() { + this.app = new AppClient(this.runtime); + this.auth = new AuthClient(this.runtime); + this.data = new DataClient(this.runtime); + this.files = new FilesClient(this.runtime, this.data); + this.functions = new FunctionsClient(this.runtime); + this.realtime = new RealtimeClient(this.runtime); + this.telemetry = new TelemetryClient(this.runtime); + } + + /** Close subscriptions and clear cached runtime/session state. */ + dispose(): void { + this.realtime.dispose(); + this.runtime.reset(); + } } + +/** The same-origin, deployment-pinned OpenCloud client for this app. */ +export const opencloud: OpenCloudClient = new OpenCloudClientImplementation(); diff --git a/vendor/bundler/src/index.ts b/vendor/bundler/src/index.ts index f31489c..134f112 100644 --- a/vendor/bundler/src/index.ts +++ b/vendor/bundler/src/index.ts @@ -14,7 +14,7 @@ import { import os from "node:os"; import path from "node:path"; import { parseManifest, type OpenCloudManifest } from "@opencloud/contracts"; -import { OPEN_CLOUD_JS_VERSION } from "@opencloud/js"; +import { OPEN_CLOUD_SDK_VERSION } from "@opencloud/js"; import * as tar from "tar"; import YAML from "yaml"; @@ -27,11 +27,11 @@ interface AuthorManifest { spa?: boolean; }; runtime?: { - javascriptSdk?: { + sdk?: { version?: string; }; }; - storage?: unknown; + files?: unknown; migrations?: Array<{ id?: string; file?: string; @@ -98,10 +98,10 @@ export async function buildBundle( manifestFile.endsWith(".json") ? JSON.parse(source) : YAML.parse(source) ) as AuthorManifest; if (options.version) raw.version = options.version; - raw.schemaVersion ??= 1; + raw.schemaVersion ??= 2; raw.runtime ??= {}; - raw.runtime.javascriptSdk ??= {}; - raw.runtime.javascriptSdk.version ??= OPEN_CLOUD_JS_VERSION; + raw.runtime.sdk ??= {}; + raw.runtime.sdk.version ??= OPEN_CLOUD_SDK_VERSION; raw.migrations ??= []; for (const migration of raw.migrations) { if (!migration.file) throw new Error("Every migration needs a file"); @@ -203,8 +203,8 @@ async function findFrontendSdkWarnings( for (const [, sourceFile] of candidates) { const content = await readFile(sourceFile, "utf8"); if ( - content.includes("createOpenCloudClient") && - content.includes("javascriptSdk.module") + content.includes("/_opencloud/sdk.js") && + /\bopencloud\b/.test(content) ) { return []; } @@ -214,7 +214,7 @@ async function findFrontendSdkWarnings( code: "FRONTEND_SDK_NOT_REFERENCED", path: manifest.frontend.directory, message: - "Frontend source does not reference the runtime-discovered OpenCloud JavaScript SDK. Read /_opencloud/config, import runtime.javascriptSdk.module, and create the client with createOpenCloudClient.", + 'Frontend source does not import the deployment-pinned OpenCloud SDK. Import { opencloud } from "/_opencloud/sdk.js".', }, ]; } diff --git a/vendor/contracts/src/api.ts b/vendor/contracts/src/api.ts index a63db00..8d536c1 100644 --- a/vendor/contracts/src/api.ts +++ b/vendor/contracts/src/api.ts @@ -238,7 +238,7 @@ export interface DeploymentRecord { appId: string; version: string; artifactSha256: string; - javascriptSdkVersion: string; + sdkVersion: string; manifest: unknown; state: DeploymentState; rollbackOfDeploymentId: string | null; diff --git a/vendor/contracts/src/control-plane.test.ts b/vendor/contracts/src/control-plane.test.ts index 6d6ba18..a2a7a9e 100644 --- a/vendor/contracts/src/control-plane.test.ts +++ b/vendor/contracts/src/control-plane.test.ts @@ -189,7 +189,7 @@ describe("controlPlaneOperations", () => { }); }); - it("treats development capabilities as negotiated booleans", () => { + it("requires the exact development capability vector", () => { const session = { id: "11111111-1111-4111-8111-111111111111", appId: "22222222-2222-4222-8222-222222222222", @@ -203,10 +203,10 @@ describe("controlPlaneOperations", () => { frontend: true, database: true, functions: true, + files: true, productionSecrets: false, cron: false, - storageSandbox: false, - syntheticAuth: false, + syntheticAuth: true, }, createdAt: "2026-08-05T00:00:00.000Z", updatedAt: "2026-08-05T00:00:00.000Z", @@ -214,15 +214,14 @@ describe("controlPlaneOperations", () => { expiresAt: "2026-08-06T00:00:00.000Z", }; - for (const [name, enabled] of Object.entries(session.capabilities)) { - const parsed = controlPlaneOperations.getDevSession.output.parse({ + expect( + controlPlaneOperations.getDevSession.output.parse(session).capabilities, + ).toEqual(session.capabilities); + expect(() => + controlPlaneOperations.getDevSession.output.parse({ ...session, - capabilities: { - ...session.capabilities, - [name]: !enabled, - }, - }); - expect(parsed.capabilities).toMatchObject({ [name]: !enabled }); - } + capabilities: { ...session.capabilities, files: false }, + }), + ).toThrow(); }); }); diff --git a/vendor/contracts/src/control-plane.ts b/vendor/contracts/src/control-plane.ts index da0186b..88fdc1e 100644 --- a/vendor/contracts/src/control-plane.ts +++ b/vendor/contracts/src/control-plane.ts @@ -119,7 +119,7 @@ export const controlPlaneDeploymentSchema = z appId: uuid, version: z.string(), artifactSha256: sha256, - javascriptSdkVersion: z.string(), + sdkVersion: z.string(), manifest: z.unknown(), state: deploymentStateSchema, rollbackOfDeploymentId: uuid.nullable(), @@ -202,8 +202,6 @@ const draftValidationOutput = z sourceManifest: z.string().nullable(), sourceFiles: z.array(z.string()), artifactFiles: z.array(z.string()), - // Backward-compatible alias for artifactFiles. - files: z.array(z.string()), diagnostics: z.array( z .object({ @@ -251,13 +249,13 @@ export const devSessionOutput = z }) .nullable(), capabilities: z.object({ - frontend: z.boolean(), - database: z.boolean(), - functions: z.boolean(), - productionSecrets: z.boolean(), - cron: z.boolean(), - storageSandbox: z.boolean(), - syntheticAuth: z.boolean(), + frontend: z.literal(true), + database: z.literal(true), + functions: z.literal(true), + files: z.literal(true), + productionSecrets: z.literal(false), + cron: z.literal(false), + syntheticAuth: z.literal(true), }), createdAt: z.string(), updatedAt: z.string(), diff --git a/vendor/contracts/src/manifest.test.ts b/vendor/contracts/src/manifest.test.ts index 1b2f090..108ca7d 100644 --- a/vendor/contracts/src/manifest.test.ts +++ b/vendor/contracts/src/manifest.test.ts @@ -2,10 +2,11 @@ import { describe, expect, it } from "vitest"; import { parseManifest } from "./manifest.js"; const valid = { - schemaVersion: 1, + schemaVersion: 2, appId: "aeea1c71-72a3-4b1d-a32e-213900735091", version: "2026.07.27-1", frontend: { directory: "frontend", spa: true }, + runtime: { sdk: { version: "1.0.0" } }, migrations: [ { id: "0001_create_notes", @@ -21,62 +22,89 @@ const valid = { describe("OpenCloud manifest", () => { it("accepts a deterministic app bundle", () => { - expect(parseManifest(valid)).toMatchObject({ - ...valid, - storage: { authorization: "app" }, - }); + expect(parseManifest(valid)).toMatchObject(valid); + expect(parseManifest(valid)).not.toHaveProperty("files"); }); - it("accepts owner-prefixed Storage authorization as an opt-in", () => { + it("enables files only when declared and defaults to user isolation", () => { expect( parseManifest({ ...valid, - storage: { authorization: "owner-prefix" }, - }).storage, - ).toEqual({ authorization: "owner-prefix" }); + files: {}, + }).files, + ).toEqual({ access: "user", maxUploadBytes: 50 * 1024 * 1024 }); + expect(parseManifest({ ...valid, files: { access: "app" } }).files).toEqual( + { access: "app", maxUploadBytes: 50 * 1024 * 1024 }, + ); }); - it("accepts an exact deployment-pinned JavaScript SDK version", () => { + it("requires one exact deployment-pinned SDK version", () => { expect( parseManifest({ ...valid, runtime: { - javascriptSdk: { - version: "0.2.0", - }, + sdk: { version: "1.0.0" }, }, }).runtime, ).toEqual({ - javascriptSdk: { - version: "0.2.0", - }, + sdk: { version: "1.0.0" }, }); }); - it("rejects moving JavaScript SDK ranges and tags", () => { - for (const version of ["^0.2.0", "latest", "0.2"]) { + it("rejects moving SDK ranges and tags", () => { + for (const version of ["^1.0.0", "latest", "1.0"]) { expect(() => parseManifest({ ...valid, - runtime: { - javascriptSdk: { - version, - }, - }, + runtime: { sdk: { version } }, }), ).toThrow(/exact semantic version/); } }); - it("rejects unknown Storage authorization modes", () => { + it("rejects unknown file access modes", () => { expect(() => parseManifest({ ...valid, - storage: { authorization: "shared-prefix" }, + files: { access: "shared-prefix" }, }), ).toThrow(); }); + it("rejects stale schema-1 names with migration guidance", () => { + expect(() => + parseManifest({ ...valid, storage: { authorization: "app" } }), + ).toThrow(/replaces storage with files/); + expect(() => + parseManifest({ + ...valid, + runtime: { javascriptSdk: { version: "0.2.2" } }, + }), + ).toThrow(/runtime\.javascriptSdk with runtime\.sdk/); + expect(() => + parseManifest({ + ...valid, + functions: [ + { name: "old", entrypoint: "functions/old/index.ts", verifyJwt: true }, + ], + }), + ).toThrow(/verifyJwt with access/); + }); + + it("rejects unknown keys instead of silently stripping mistakes", () => { + expect(() => parseManifest({ ...valid, filez: {} })).toThrow( + /Unrecognized key/, + ); + expect(() => + parseManifest({ + ...valid, + functions: [ + { name: "tick", entrypoint: "functions/tick/index.ts", acess: "user" }, + ], + }), + ).toThrow(/Unrecognized key/); + }); + it("accepts a bounded deployment-pinned custom metric catalog", () => { expect( parseManifest({ @@ -185,6 +213,49 @@ describe("OpenCloud manifest", () => { ).toThrow(/unknown function/); }); + it("requires cron targets to be platform-only system Functions", () => { + expect(() => + parseManifest({ + ...valid, + functions: [ + { name: "tick", entrypoint: "functions/tick/index.ts", access: "user" }, + ], + cron: [ + { + name: "hourly", + schedule: "0 * * * *", + function: "tick", + enabled: true, + }, + ], + }), + ).toThrow(/must declare access: system/); + expect( + parseManifest({ + ...valid, + functions: [ + { name: "tick", entrypoint: "functions/tick/index.ts", access: "system" }, + ], + cron: [ + { + name: "hourly", + schedule: "0 * * * *", + function: "tick", + enabled: true, + }, + ], + }).functions[0]?.access, + ).toBe("system"); + }); + + it("rejects required 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/); + } + }); + it("rejects reordered migration history", () => { expect(() => parseManifest({ @@ -206,7 +277,7 @@ describe("OpenCloud manifest", () => { parseManifest({ ...valid, functions: [ - { name: "tick", entrypoint: "functions/tick/index.ts", verifyJwt: true }, + { name: "tick", entrypoint: "functions/tick/index.ts", access: "system" }, ], cron: [ { diff --git a/vendor/contracts/src/manifest.ts b/vendor/contracts/src/manifest.ts index a8270d6..6d6a238 100644 --- a/vendor/contracts/src/manifest.ts +++ b/vendor/contracts/src/manifest.ts @@ -14,30 +14,38 @@ const relativePath = z const digest = z.string().regex(/^[a-f0-9]{64}$/, "expected a SHA-256 digest"); -export const javascriptSdkVersionSchema = z +export const sdkVersionSchema = z .string() .regex(/^\d+\.\d+\.\d+$/, "expected an exact semantic version"); -export const migrationSchema = z.object({ - id: z.string().regex(/^[0-9]{4,14}_[a-z0-9][a-z0-9_-]*$/), - file: relativePath, - sha256: digest, -}); +export const migrationSchema = z + .object({ + id: z.string().regex(/^[0-9]{4,14}_[a-z0-9][a-z0-9_-]*$/), + file: relativePath, + sha256: digest, + }) + .strict(); -export const functionSchema = z.object({ - name: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/), - entrypoint: relativePath, - verifyJwt: z.boolean().default(true), -}); +export const functionAccessSchema = z.enum(["user", "public", "system"]); + +export const functionSchema = z + .object({ + name: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/), + entrypoint: relativePath, + access: functionAccessSchema.default("user"), + }) + .strict(); -export const cronSchema = z.object({ - name: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/), - schedule: z.string().min(5).max(100), - function: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/), - enabled: z.boolean().default(true), -}); +export const cronSchema = z + .object({ + name: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/), + schedule: z.string().min(5).max(100), + function: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/), + enabled: z.boolean().default(true), + }) + .strict(); -export const storageAuthorizationSchema = z.enum(["app", "owner-prefix"]); +export const filesAccessSchema = z.enum(["user", "app"]); export const customMetricNameSchema = z .string() @@ -63,61 +71,75 @@ export const customMetricDimensionValueSchema = z "metric dimension values must be bounded identifiers", ); -export const customMetricDefinitionSchema = z.object({ - name: customMetricNameSchema, - type: z.enum(["counter", "gauge"]), - unit: z - .string() - .min(1) - .max(32) - .regex(/^[A-Za-z0-9][A-Za-z0-9_./%*-]*$/) - .optional(), - description: z.string().min(1).max(240).optional(), - dimensions: z - .record( - customMetricDimensionNameSchema, - z.object({ - values: z.array(customMetricDimensionValueSchema).min(1).max(20), - }), - ) - .refine((dimensions) => Object.keys(dimensions).length <= 3, { - message: "custom metrics may declare at most three dimensions", - }) - .default({}), -}); +export const customMetricDefinitionSchema = z + .object({ + name: customMetricNameSchema, + type: z.enum(["counter", "gauge"]), + unit: z + .string() + .min(1) + .max(32) + .regex(/^[A-Za-z0-9][A-Za-z0-9_./%*-]*$/) + .optional(), + description: z.string().min(1).max(240).optional(), + dimensions: z + .record( + customMetricDimensionNameSchema, + z + .object({ + values: z.array(customMetricDimensionValueSchema).min(1).max(20), + }) + .strict(), + ) + .refine((dimensions) => Object.keys(dimensions).length <= 3, { + message: "custom metrics may declare at most three dimensions", + }) + .default({}), + }) + .strict(); export const openCloudManifestSchema = z .object({ - schemaVersion: z.literal(1), + schemaVersion: z.literal(2), appId: z.uuid(), version: z .string() .min(1) .max(80) .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/), - frontend: z.object({ - directory: relativePath, - spa: z.boolean().default(true), - }), + frontend: z + .object({ + directory: relativePath, + spa: z.boolean().default(true), + }) + .strict(), runtime: z .object({ - javascriptSdk: z.object({ - version: javascriptSdkVersionSchema, - }), + sdk: z + .object({ + version: sdkVersionSchema, + }) + .strict(), }) - .optional(), - storage: z + .strict(), + files: z .object({ - authorization: storageAuthorizationSchema.default("app"), + access: filesAccessSchema.default("user"), + maxUploadBytes: z + .number() + .int() + .min(1) + .max(100 * 1024 * 1024) + .default(50 * 1024 * 1024), }) - .default({ authorization: "app" }), + .strict() + .optional(), migrations: z.array(migrationSchema).max(500).default([]), functions: z.array(functionSchema).max(100).default([]), cron: z.array(cronSchema).max(100).default([]), health: z - .object({ - path: z.string().startsWith("/").max(200).default("/"), - }) + .object({ path: z.string().startsWith("/").max(200).default("/") }) + .strict() .default({ path: "/" }), requiredSecrets: z .array(z.string().regex(/^[A-Z][A-Z0-9_]{0,127}$/)) @@ -127,8 +149,10 @@ export const openCloudManifestSchema = z .object({ metrics: z.array(customMetricDefinitionSchema).max(20).default([]), }) + .strict() .optional(), }) + .strict() .superRefine((manifest, context) => { const assertUnique = ( values: string[], @@ -182,12 +206,22 @@ export const openCloudManifestSchema = z manifest.functions.map((definition) => definition.name), ); manifest.cron.forEach((cron, index) => { + const target = manifest.functions.find( + (definition) => definition.name === cron.function, + ); if (!functions.has(cron.function)) { context.addIssue({ code: "custom", path: ["cron", index, "function"], message: `cron references unknown function: ${cron.function}`, }); + } else if (target?.access !== "system") { + context.addIssue({ + code: "custom", + path: ["cron", index, "function"], + message: + `cron function ${cron.function} must declare access: system`, + }); } try { CronExpressionParser.parse(cron.schedule, { tz: "Etc/UTC" }); @@ -199,16 +233,61 @@ 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 StorageAuthorization = z.infer; -export type JavaScriptSdkVersion = z.infer; +export type FilesAccess = z.infer; +export type FunctionAccess = z.infer; +export type SdkVersion = z.infer; export type CustomMetricDefinition = z.infer< typeof customMetricDefinitionSchema >; export function parseManifest(value: unknown): OpenCloudManifest { + if (value && typeof value === "object" && !Array.isArray(value)) { + const manifest = value as Record; + if ("storage" in manifest) { + throw new Error( + "Manifest schema 2 replaces storage with files; use files.access: user or app", + ); + } + const runtime = manifest.runtime; + if ( + runtime && + typeof runtime === "object" && + !Array.isArray(runtime) && + "javascriptSdk" in runtime + ) { + throw new Error( + "Manifest schema 2 replaces runtime.javascriptSdk with runtime.sdk", + ); + } + const functions = manifest.functions; + if (Array.isArray(functions)) { + const legacyIndex = functions.findIndex( + (definition) => + definition && + typeof definition === "object" && + !Array.isArray(definition) && + "verifyJwt" in definition, + ); + if (legacyIndex >= 0) { + throw new Error( + `Manifest schema 2 replaces functions[${legacyIndex}].verifyJwt with access: user, public, or system`, + ); + } + } + } return openCloudManifestSchema.parse(value); } diff --git a/vendor/control-plane-client/src/index.test.ts b/vendor/control-plane-client/src/index.test.ts index b997230..f0b4db8 100644 --- a/vendor/control-plane-client/src/index.test.ts +++ b/vendor/control-plane-client/src/index.test.ts @@ -1,12 +1,8 @@ import { describe, expect, it } from "vitest"; -import { - OPEN_CLOUD_CLIENT_CAPABILITIES_HEADER, - OPEN_CLOUD_DEV_SESSION_CAPABILITIES_V2, - OpenCloudClient, -} from "./index.js"; +import { OpenCloudClient } from "./index.js"; describe("OpenCloudClient", () => { - it("advertises support for negotiated development capabilities", async () => { + it("sends only request headers required by the operation", async () => { const requests: RequestInit[] = []; const fetcher = async ( _input: string | URL | Request, @@ -27,7 +23,7 @@ describe("OpenCloudClient", () => { await client.get("/v1/apps"); const init = requests[0]; - expect(new Headers(init?.headers).get(OPEN_CLOUD_CLIENT_CAPABILITIES_HEADER)) - .toBe(OPEN_CLOUD_DEV_SESSION_CAPABILITIES_V2); + expect(new Headers(init?.headers).has("x-opencloud-client-capabilities")) + .toBe(false); }); }); diff --git a/vendor/control-plane-client/src/index.ts b/vendor/control-plane-client/src/index.ts index a1d131e..d66641c 100644 --- a/vendor/control-plane-client/src/index.ts +++ b/vendor/control-plane-client/src/index.ts @@ -6,14 +6,6 @@ import { type ControlPlaneOperationOutput, } from "@opencloud/contracts"; -export const OPEN_CLOUD_CLIENT_CAPABILITIES_HEADER = - "x-opencloud-client-capabilities"; -export const OPEN_CLOUD_DEV_SESSION_CAPABILITIES_V2 = - "dev-session-capabilities-v2"; - -const OPEN_CLOUD_CLIENT_CAPABILITIES = - OPEN_CLOUD_DEV_SESSION_CAPABILITIES_V2; - export interface ClientOptions { apiUrl: string; token?: string | undefined; @@ -152,8 +144,6 @@ export class OpenCloudClient { headers: { authorization: `Bearer ${token}`, "idempotency-key": idempotencyKey, - [OPEN_CLOUD_CLIENT_CAPABILITIES_HEADER]: - OPEN_CLOUD_CLIENT_CAPABILITIES, }, body: form, signal: AbortSignal.timeout(120_000), @@ -185,8 +175,6 @@ export class OpenCloudClient { accept: "application/json", ...(body === undefined ? {} : { "content-type": "application/json" }), ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}), - [OPEN_CLOUD_CLIENT_CAPABILITIES_HEADER]: - OPEN_CLOUD_CLIENT_CAPABILITIES, }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(timeoutMs),