diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9467e70..8dc9b8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,4 +21,5 @@ jobs: - run: npm test - run: npm run typecheck - run: npm run build + - run: npm run verify:version - run: node dist/index.cjs --cli-version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 235d2aa..38f9d98 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,7 @@ jobs: - run: npm ci - run: npm test - run: npm run typecheck + - run: npm run verify:version -- "$GITHUB_REF_NAME" - run: npm pack - run: sha256sum opencloud-cli-*.tgz > checksums.txt - name: Verify packed CLI diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..618d0e1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# Repository guidance for coding agents + +## Source ownership + +- This repository is the sole editable source for the public, versioned + `opencloud` application CLI. +- `opencloud-ai/platform` owns the control-plane API and server implementation, + but must consume published CLI releases rather than carrying a second CLI + source tree. +- The private `opencloud-platform` deployment operator is a separate program + and remains in the platform repository. + +## Contract snapshots + +- `vendor/` contains the exact public contract, JavaScript SDK, bundler, and + typed-client snapshots required by this CLI release. Update the relevant + snapshot, CLI source, and tests together when a platform contract changes. +- Do not import or copy unrelated platform internals into this public package. + +## Releases + +- Keep `package.json`, `src/index.ts`, `CHANGELOG.md`, and the Git tag on the + same semantic version. Run `npm run verify:version` before release. +- Run tests, typechecking, the standalone build, and a packed-package smoke + test before tagging. Publish this repository first; only then update the + platform repository's exact release pin. +- Never commit credentials, tokens, browser cookies, or generated credential + stores. diff --git a/CHANGELOG.md b/CHANGELOG.md index a9dc1c9..c8f5a08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 1.0.0 + +- Add reusable account login through an explicit browser approval flow with + `login`, `auth status`, and `logout`; no localhost callback, pasted code, or + copied credential is required. +- Store rotating account credentials in the operating-system keyring with a + protected per-user file fallback. +- Add `app create` and `app connect` so one account login can select and work + across multiple app workspaces using renewable app-scoped credentials. +- Keep non-secret workspace bindings separate from protected credentials and + retain legacy email onboarding as a compatibility path. +- Add source/artifact checkpoint evidence, runtime-aware SDK warnings, richer + verification coverage, owner launch URLs, and refreshable typed API tokens. +- Move canonical CLI ownership entirely to this public repository. + ## 0.6.1 - Negotiate development-session capabilities with the control plane instead diff --git a/README.md b/README.md index eb135aa..bd3a540 100644 --- a/README.md +++ b/README.md @@ -3,18 +3,21 @@ The public, versioned command-line client for building, validating, deploying, and verifying applications on [OpenCloud](https://opencloud.ai). +This repository is the sole editable source for the application CLI. The +platform repository consumes exact published releases. + The CLI is intended for coding agents and humans with a terminal. A browser-only chat that cannot run Node.js and shell commands can prepare an 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 `v0.6.1` in +OpenCloud application skills pin an exact CLI release. To install `v1.0.0` in an isolated task directory: ```bash -OPENCLOUD_CLI_VERSION="v0.6.1" -OPENCLOUD_CLI_PACKAGE="opencloud-cli-0.6.1.tgz" +OPENCLOUD_CLI_VERSION="v1.0.0" +OPENCLOUD_CLI_PACKAGE="opencloud-cli-1.0.0.tgz" OPENCLOUD_CLI_DIR="$(mktemp -d)" curl -fsSLo "$OPENCLOUD_CLI_DIR/$OPENCLOUD_CLI_PACKAGE" \ @@ -33,7 +36,53 @@ OPENCLOUD_CLI="$OPENCLOUD_CLI_DIR/node_modules/.bin/opencloud" "$OPENCLOUD_CLI" --cli-version ``` -## Passwordless project onboarding +## Account login and workspace connection + +Sign in to an existing account through an explicit browser approval, then +select an app and connect its source directory: + +```bash +"$OPENCLOUD_CLI" auth status +"$OPENCLOUD_CLI" login +"$OPENCLOUD_CLI" app list +cd /absolute/path/to/app +"$OPENCLOUD_CLI" app connect "$APP_ID" +"$OPENCLOUD_CLI" doctor +``` + +`login` prints and opens a short-lived HTTPS approval page. The user signs in +with a one-time email link or configured password and explicitly allows the +CLI. It does not start a localhost callback or ask anyone to paste a code, +email link, cookie, password, or token. Use `login --no-browser` when the +terminal cannot open a browser, or `login --force` to replace an unusable +stored login. + +The 15-minute account access token and rotating 30-day refresh token are stored +in the operating-system credential service under `ai.opencloud.cli`. A +headless environment without a usable keyring falls back to a mode-`0600` +per-user credential file under the normal OpenCloud configuration directory. +Never inspect, print, copy, upload, or commit either credential backend. + +The account credential can list, inspect, and create apps, but it cannot build +or deploy them. `app connect` writes only a non-secret `.opencloud/app.json` +binding and stores a separate renewable 24-hour app credential in the protected +backend. This lets later terminal sessions reuse the account login and lets one +user work safely across multiple app directories. + +```bash +# Only when the requested app does not already exist: +"$OPENCLOUD_CLI" app create \ + --name "Family tasks" \ + --visibility private + +# Revoke the login family and derived workspace credentials: +"$OPENCLOUD_CLI" logout +``` + +## Legacy passwordless project onboarding + +The pre-1.0 email onboarding flow remains available for compatibility. New +terminal workflows should use `login` and `app connect`. Give the CLI the user's email and agreed project title: diff --git a/package-lock.json b/package-lock.json index f86e04f..8cccce3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "@opencloud/cli", - "version": "0.6.1", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opencloud/cli", - "version": "0.6.1", + "version": "1.0.0", "dependencies": { + "@napi-rs/keyring": "1.3.0", "playwright": "1.62.0" }, "bin": { @@ -530,6 +531,225 @@ "dev": true, "license": "MIT" }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz", diff --git a/package.json b/package.json index 706bfb3..a19213c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opencloud/cli", - "version": "0.6.1", + "version": "1.0.0", "description": "Versioned command-line client for building, deploying, and verifying OpenCloud applications", "type": "module", "bin": { @@ -18,11 +18,13 @@ "scripts": { "build": "node scripts/build.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run src vendor/contracts/src vendor/control-plane-client/src", + "test": "vitest run src vendor/contracts/src vendor/browser-client/src vendor/control-plane-client/src", "lint": "npm run typecheck", + "verify:version": "node scripts/verify-version.mjs", "prepack": "npm run build" }, "dependencies": { + "@napi-rs/keyring": "1.3.0", "playwright": "1.62.0" }, "devDependencies": { diff --git a/scripts/build.mjs b/scripts/build.mjs index 641e8fe..97cb753 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -17,7 +17,7 @@ await build({ target: "node22", sourcemap: false, legalComments: "none", - external: ["playwright"], + external: ["@napi-rs/keyring", "playwright"], alias: { "@opencloud/contracts": path.join( root, diff --git a/scripts/verify-version.mjs b/scripts/verify-version.mjs new file mode 100644 index 0000000..327a7ae --- /dev/null +++ b/scripts/verify-version.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, ".."); +const packageJson = JSON.parse( + await readFile(path.join(root, "package.json"), "utf8"), +); +const version = packageJson.version; +assert.match(version, /^\d+\.\d+\.\d+$/); + +const [source, changelog, readme] = await Promise.all([ + readFile(path.join(root, "src", "index.ts"), "utf8"), + readFile(path.join(root, "CHANGELOG.md"), "utf8"), + readFile(path.join(root, "README.md"), "utf8"), +]); + +assert.ok( + source.includes(`const CLI_VERSION = "${version}";`), + `src/index.ts does not declare CLI_VERSION ${version}`, +); +assert.ok( + changelog.includes(`## ${version}`), + `CHANGELOG.md has no ${version} section`, +); +assert.ok( + readme.includes(`v${version}`), + `README.md does not reference v${version}`, +); + +const expected = process.argv[2]?.replace(/^v/, ""); +if (expected) { + assert.equal(version, expected, `package version does not match tag v${expected}`); +} + +process.stdout.write(`OpenCloud CLI version ${version} is aligned.\n`); diff --git a/src/account-auth.test.ts b/src/account-auth.test.ts new file mode 100644 index 0000000..a96dd1e --- /dev/null +++ b/src/account-auth.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from "vitest"; +import { + beginDeviceAuthorization, + completeDeviceAuthorization, + DEVICE_AUTHORIZATION_GRANT, +} from "./account-auth.js"; + +describe("OpenCloud CLI device authorization", () => { + it("discovers the authorization server from the selected API", async () => { + const request = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + resource: "https://api.opencloud.ai", + authorization_servers: ["https://auth.opencloud.ai"], + }), + ) + .mockResolvedValueOnce( + Response.json({ + issuer: "https://auth.opencloud.ai", + token_endpoint: "https://auth.opencloud.ai/oauth/token", + device_authorization_endpoint: + "https://auth.opencloud.ai/oauth/device/authorize", + revocation_endpoint: "https://auth.opencloud.ai/oauth/revoke", + }), + ) + .mockResolvedValueOnce( + Response.json({ + device_code: `oc_device_${"d".repeat(43)}`, + user_code: "browser-request-code", + verification_uri: "https://auth.opencloud.ai/cli/authorize", + verification_uri_complete: + "https://auth.opencloud.ai/cli/authorize?request=browser-request-code", + expires_in: 600, + interval: 2, + }), + ); + + const authorization = await beginDeviceAuthorization( + "https://api.opencloud.ai/", + request as typeof fetch, + ); + + expect(authorization).toMatchObject({ + apiUrl: "https://api.opencloud.ai", + issuer: "https://auth.opencloud.ai", + tokenEndpoint: "https://auth.opencloud.ai/oauth/token", + intervalSeconds: 2, + }); + expect(request.mock.calls.map((call) => call[0])).toEqual([ + "https://api.opencloud.ai/.well-known/oauth-protected-resource/api", + "https://auth.opencloud.ai/.well-known/oauth-authorization-server", + "https://auth.opencloud.ai/oauth/device/authorize", + ]); + const startBody = request.mock.calls[2]?.[1]?.body; + expect(String(startBody)).toContain("client_id=opencloud-cli"); + expect(String(startBody)).toContain("cli%3Aaccount"); + }); + + it("polls through authorization_pending and returns redacted credential data", async () => { + const request = vi + .fn() + .mockResolvedValueOnce( + Response.json( + { code: "authorization_pending", message: "Waiting" }, + { status: 400 }, + ), + ) + .mockResolvedValueOnce( + Response.json({ + access_token: "oc_cli_private-access", + token_type: "Bearer", + expires_in: 900, + refresh_token: "oc_refresh_private-refresh", + refresh_expires_in: 2_592_000, + scope: "openid profile cli:account", + resource: "https://api.opencloud.ai", + }), + ); + const wait = vi.fn().mockResolvedValue(undefined); + + const credential = await completeDeviceAuthorization( + { + apiUrl: "https://api.opencloud.ai", + issuer: "https://auth.opencloud.ai", + tokenEndpoint: "https://auth.opencloud.ai/oauth/token", + revocationEndpoint: "https://auth.opencloud.ai/oauth/revoke", + deviceCode: `oc_device_${"d".repeat(43)}`, + verificationUri: "https://auth.opencloud.ai/cli/authorize", + verificationUriComplete: + "https://auth.opencloud.ai/cli/authorize?request=browser-request-code", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + intervalSeconds: 2, + }, + request as typeof fetch, + wait, + ); + + expect(wait).toHaveBeenCalledWith(2_000); + expect(credential).toMatchObject({ + kind: "account", + accessToken: "oc_cli_private-access", + refreshToken: "oc_refresh_private-refresh", + }); + const tokenBody = request.mock.calls[1]?.[1]?.body; + expect(String(tokenBody)).toContain( + `grant_type=${encodeURIComponent(DEVICE_AUTHORIZATION_GRANT)}`, + ); + }); +}); diff --git a/src/account-auth.ts b/src/account-auth.ts new file mode 100644 index 0000000..8790550 --- /dev/null +++ b/src/account-auth.ts @@ -0,0 +1,349 @@ +import { spawn } from "node:child_process"; +import { + CredentialStore, + type AccountCredential, + type StoredCredential, +} from "./credential-store.js"; + +export const OPEN_CLOUD_CLI_CLIENT_ID = "opencloud-cli"; +export const DEVICE_AUTHORIZATION_GRANT = + "urn:ietf:params:oauth:grant-type:device_code"; +export const DEFAULT_API_URL = "https://api.opencloud.ai"; + +interface OAuthResourceMetadata { + resource: string; + authorization_servers: string[]; +} + +interface OAuthAuthorizationMetadata { + issuer: string; + token_endpoint: string; + device_authorization_endpoint: string; + revocation_endpoint: string; +} + +export interface DeviceAuthorization { + apiUrl: string; + issuer: string; + tokenEndpoint: string; + revocationEndpoint: string; + deviceCode: string; + verificationUri: string; + verificationUriComplete: string; + expiresAt: string; + intervalSeconds: number; +} + +interface TokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + refresh_expires_in: number; + token_type: string; + scope: string; + resource: string; +} + +export function normalizeApiUrl(value: string): string { + const url = new URL(value); + if (!['https:', 'http:'].includes(url.protocol)) { + throw new Error("OpenCloud API URL must use HTTPS or HTTP for local testing"); + } + if (url.username || url.password || url.search || url.hash) { + throw new Error("OpenCloud API URL cannot contain credentials, query, or fragment"); + } + return url.href.replace(/\/+$/, ""); +} + +export async function beginDeviceAuthorization( + apiUrl: string, + request: typeof fetch = fetch, +): Promise { + const normalizedApiUrl = normalizeApiUrl(apiUrl); + const resource = await requestJson( + `${normalizedApiUrl}/.well-known/oauth-protected-resource/api`, + request, + ); + const issuer = resource.authorization_servers?.[0]; + if (!issuer || resource.resource !== normalizedApiUrl) { + throw new Error("OpenCloud returned invalid API authorization metadata"); + } + const authorization = await requestJson( + `${issuer.replace(/\/+$/, "")}/.well-known/oauth-authorization-server`, + request, + ); + if ( + authorization.issuer !== issuer || + !authorization.token_endpoint || + !authorization.device_authorization_endpoint || + !authorization.revocation_endpoint + ) { + throw new Error("OpenCloud returned incomplete authorization metadata"); + } + const response = await postForm( + authorization.device_authorization_endpoint, + { + client_id: OPEN_CLOUD_CLI_CLIENT_ID, + scope: "openid profile cli:account", + resource: normalizedApiUrl, + }, + request, + ); + if (!response.ok) throw await responseError(response, "start CLI login"); + const value = (await response.json()) as Record; + if ( + typeof value.device_code !== "string" || + typeof value.verification_uri !== "string" || + typeof value.verification_uri_complete !== "string" || + typeof value.expires_in !== "number" || + typeof value.interval !== "number" + ) { + throw new Error("OpenCloud returned an invalid device authorization"); + } + return { + apiUrl: normalizedApiUrl, + issuer, + tokenEndpoint: authorization.token_endpoint, + revocationEndpoint: authorization.revocation_endpoint, + deviceCode: value.device_code, + verificationUri: value.verification_uri, + verificationUriComplete: value.verification_uri_complete, + expiresAt: new Date(Date.now() + value.expires_in * 1_000).toISOString(), + intervalSeconds: Math.max(1, value.interval), + }; +} + +export async function completeDeviceAuthorization( + authorization: DeviceAuthorization, + request: typeof fetch = fetch, + wait: (milliseconds: number) => Promise = (milliseconds) => + new Promise((resolve) => setTimeout(resolve, milliseconds)), +): Promise { + let intervalSeconds = authorization.intervalSeconds; + while (Date.now() < Date.parse(authorization.expiresAt)) { + const response = await postForm( + authorization.tokenEndpoint, + { + grant_type: DEVICE_AUTHORIZATION_GRANT, + device_code: authorization.deviceCode, + client_id: OPEN_CLOUD_CLI_CLIENT_ID, + }, + request, + ); + if (response.ok) { + return accountCredential( + authorization, + parseTokenResponse(await response.json()), + ); + } + const error = await oauthError(response); + if (error.code === "authorization_pending") { + await wait(intervalSeconds * 1_000); + continue; + } + if (error.code === "slow_down") { + intervalSeconds += 5; + await wait(intervalSeconds * 1_000); + continue; + } + if (error.code === "access_denied") { + throw new Error("OpenCloud CLI login was cancelled in the browser"); + } + if (error.code === "expired_token") { + throw new Error("OpenCloud CLI login expired; run opencloud login again"); + } + throw new Error(`OpenCloud CLI login failed: ${error.message}`); + } + throw new Error("OpenCloud CLI login expired; run opencloud login again"); +} + +export async function freshAccountCredential( + store: CredentialStore, + apiUrl: string, + request: typeof fetch = fetch, +): Promise> { + const normalizedApiUrl = normalizeApiUrl(apiUrl); + const stored = await store.loadAccount(normalizedApiUrl); + if (!stored) { + throw new Error("No OpenCloud account login was found. Run opencloud login."); + } + if (Date.parse(stored.credential.refreshExpiresAt) <= Date.now()) { + throw new Error("OpenCloud account login expired. Run opencloud login again."); + } + if (Date.parse(stored.credential.accessExpiresAt) > Date.now() + 60_000) { + return stored; + } + const response = await postForm( + stored.credential.tokenEndpoint, + { + grant_type: "refresh_token", + refresh_token: stored.credential.refreshToken, + client_id: OPEN_CLOUD_CLI_CLIENT_ID, + }, + request, + ); + if (!response.ok) { + const error = await oauthError(response); + throw new Error( + error.code === "invalid_grant" + ? "OpenCloud account login expired or was revoked. Run opencloud login again." + : `Could not refresh OpenCloud login: ${error.message}`, + ); + } + const refreshed = accountCredential( + { + apiUrl: stored.credential.apiUrl, + issuer: stored.credential.issuer, + tokenEndpoint: stored.credential.tokenEndpoint, + revocationEndpoint: stored.credential.revocationEndpoint, + }, + parseTokenResponse(await response.json()), + ); + return store.saveAccount(refreshed); +} + +export async function revokeAccountCredential( + credential: AccountCredential, + request: typeof fetch = fetch, +): Promise { + const response = await postForm( + credential.revocationEndpoint, + { + token: credential.refreshToken, + client_id: OPEN_CLOUD_CLI_CLIENT_ID, + }, + request, + ); + if (!response.ok) throw await responseError(response, "revoke CLI login"); +} + +export function openBrowser(url: string): boolean { + const parsed = new URL(url); + if (!["https:", "http:"].includes(parsed.protocol)) return false; + const command = + process.platform === "darwin" + ? { executable: "open", arguments: [parsed.href] } + : process.platform === "win32" + ? { + executable: "rundll32.exe", + arguments: ["url.dll,FileProtocolHandler", parsed.href], + } + : { executable: "xdg-open", arguments: [parsed.href] }; + try { + const child = spawn(command.executable, command.arguments, { + detached: true, + stdio: "ignore", + }); + child.on("error", () => undefined); + child.unref(); + return true; + } catch { + return false; + } +} + +function accountCredential( + authorization: Pick< + DeviceAuthorization, + "apiUrl" | "issuer" | "tokenEndpoint" | "revocationEndpoint" + >, + tokens: TokenResponse, +): AccountCredential { + if (tokens.resource !== authorization.apiUrl) { + throw new Error("OpenCloud returned a token for the wrong API resource"); + } + const issuedAt = Date.now(); + return { + schemaVersion: 1, + kind: "account", + apiUrl: authorization.apiUrl, + issuer: authorization.issuer, + tokenEndpoint: authorization.tokenEndpoint, + revocationEndpoint: authorization.revocationEndpoint, + accessToken: tokens.access_token, + accessExpiresAt: new Date(issuedAt + tokens.expires_in * 1_000).toISOString(), + refreshToken: tokens.refresh_token, + refreshExpiresAt: new Date( + issuedAt + tokens.refresh_expires_in * 1_000, + ).toISOString(), + }; +} + +function parseTokenResponse(value: unknown): TokenResponse { + const token = value as Partial; + if ( + !token || + typeof token.access_token !== "string" || + typeof token.refresh_token !== "string" || + typeof token.expires_in !== "number" || + typeof token.refresh_expires_in !== "number" || + token.token_type !== "Bearer" || + typeof token.scope !== "string" || + typeof token.resource !== "string" + ) { + throw new Error("OpenCloud returned an invalid CLI token response"); + } + return token as TokenResponse; +} + +async function requestJson( + url: string, + request: typeof fetch, +): Promise { + const response = await request(url, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) throw await responseError(response, "discover login"); + return (await response.json()) as T; +} + +function postForm( + url: string, + values: Record, + request: typeof fetch, +): Promise { + return request(url, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(values), + signal: AbortSignal.timeout(15_000), + }); +} + +async function oauthError( + response: Response, +): Promise<{ code: string; message: string }> { + let body: unknown; + try { + body = await response.json(); + } catch { + return { code: "http_error", message: `HTTP ${response.status}` }; + } + const value = body as Record; + return { + code: + typeof value.code === "string" + ? value.code + : typeof value.error === "string" + ? value.error + : "http_error", + message: + typeof value.message === "string" + ? value.message + : typeof value.error_description === "string" + ? value.error_description + : `HTTP ${response.status}`, + }; +} + +async function responseError( + response: Response, + action: string, +): Promise { + const error = await oauthError(response); + return new Error(`Could not ${action}: ${error.message}`); +} diff --git a/src/bundle.test.ts b/src/bundle.test.ts index 36aac4e..abad967 100644 --- a/src/bundle.test.ts +++ b/src/bundle.test.ts @@ -1,10 +1,4 @@ -import { - mkdtemp, - mkdir, - rm, - symlink, - writeFile, -} from "node:fs/promises"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -15,9 +9,9 @@ const temporary: string[] = []; afterEach(async () => { await Promise.all( - temporary.splice(0).map((directory) => - rm(directory, { recursive: true, force: true }), - ), + temporary + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), ); }); @@ -49,7 +43,10 @@ describe("bundle builder", () => { recursive: true, }); await writeFile(path.join(root, "frontend", "index.html"), "hello"); - await writeFile(path.join(root, "frontend", "assets", "app.js"), "app"); + await writeFile( + path.join(root, "frontend", "assets", "app.js"), + "const sdk = runtime.javascriptSdk.module; createOpenCloudClient(sdk);", + ); await writeFile( path.join(root, "migrations", "0001_notes.sql"), "create table notes(id uuid primary key);", @@ -68,7 +65,10 @@ describe("bundle builder", () => { ); await writeFile(path.join(root, "BRIEF.md"), "author instructions"); await writeFile(path.join(root, "AGENT_REPORT.md"), "first report"); - await writeFile(path.join(root, "smoke.mjs"), "throw new Error('test only')"); + await writeFile( + path.join(root, "smoke.mjs"), + "throw new Error('test only')", + ); await writeFile(path.join(root, "unreachable.txt"), "not runtime input"); await writeManifest( root, @@ -101,6 +101,17 @@ functions: "migrations/0001_notes.sql", "opencloud.json", ]); + expect(first.sourceManifest).toBe("opencloud.yaml"); + expect(first.sourceFiles).toEqual([ + "frontend/assets/app.js", + "frontend/index.html", + "functions/process/index.ts", + "functions/process/lib/index.ts", + "functions/process/shared.ts", + "migrations/0001_notes.sql", + "opencloud.yaml", + ]); + expect(first.sourceFiles).not.toContain("opencloud.json"); expect(first.warnings).toEqual([]); await writeFile(path.join(root, "AGENT_REPORT.md"), "updated report"); @@ -138,7 +149,9 @@ frontend: const bundle = await buildBundle(root); expect(bundle.files).toContain("index.html"); - expect(bundle.files.some((file) => file.startsWith(".opencloud/"))).toBe(false); + expect(bundle.files.some((file) => file.startsWith(".opencloud/"))).toBe( + false, + ); }); it("warns about conventional migrations and Functions omitted from the manifest", async () => { @@ -169,6 +182,10 @@ functions: [] const bundle = await buildBundle(root); expect(bundle.warnings).toEqual([ + expect.objectContaining({ + code: "FRONTEND_SDK_NOT_REFERENCED", + path: "frontend", + }), expect.objectContaining({ code: "UNDECLARED_FUNCTION_ENTRYPOINT", path: "functions/forgotten/index.ts", diff --git a/src/credential-store.test.ts b/src/credential-store.test.ts new file mode 100644 index 0000000..b06eecc --- /dev/null +++ b/src/credential-store.test.ts @@ -0,0 +1,97 @@ +import { chmod, mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + CredentialStore, + type AccountCredential, + type WorkspaceCredential, +} from "./credential-store.js"; + +const directories: string[] = []; +const noKeyring = async () => null; + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }), + ), + ); +}); + +function account(): AccountCredential { + return { + schemaVersion: 1, + kind: "account", + apiUrl: "https://api.opencloud.ai", + issuer: "https://auth.opencloud.ai", + tokenEndpoint: "https://auth.opencloud.ai/oauth/token", + revocationEndpoint: "https://auth.opencloud.ai/oauth/revoke", + accessToken: "oc_cli_private-access", + accessExpiresAt: "2026-08-08T12:15:00.000Z", + refreshToken: "oc_refresh_private-refresh", + refreshExpiresAt: "2026-09-07T12:00:00.000Z", + }; +} + +describe("CredentialStore protected-file fallback", () => { + it("uses a per-user mode-0600 file when no OS keyring is available", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "opencloud-auth-")); + directories.push(directory); + const store = new CredentialStore(directory, noKeyring); + + const saved = await store.saveAccount(account()); + + expect(saved.backend).toBe("protected-file"); + expect(saved.location.startsWith(directory)).toBe(true); + expect((await stat(saved.location)).mode & 0o777).toBe(0o600); + expect(await store.loadAccount(account().apiUrl)).toEqual(saved); + expect(await readFile(saved.location, "utf8")).toContain( + "oc_refresh_private-refresh", + ); + + await store.deleteAccount(account().apiUrl); + await expect(stat(saved.location)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("stores workspace credentials under a different derived key per app", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "opencloud-auth-")); + directories.push(directory); + const store = new CredentialStore(directory, noKeyring); + const first: WorkspaceCredential = { + schemaVersion: 1, + kind: "workspace", + apiUrl: "https://api.opencloud.ai", + appId: "248c0b0d-4a85-46de-af54-e3afb145dc2b", + token: "oc_agent_first", + expiresAt: "2026-08-09T00:00:00.000Z", + }; + const second = { + ...first, + appId: "9fcde397-8d0a-4b6c-9f69-3a4ca1abb635", + token: "oc_agent_second", + }; + + const firstSaved = await store.saveWorkspace(first); + const secondSaved = await store.saveWorkspace(second); + + expect(firstSaved.location).not.toBe(secondSaved.location); + expect((await store.loadWorkspace(first.apiUrl, first.appId))?.credential) + .toEqual(first); + expect((await store.loadWorkspace(second.apiUrl, second.appId))?.credential) + .toEqual(second); + }); + + it("refuses to read a fallback credential with broad Unix permissions", async () => { + if (process.platform === "win32") return; + const directory = await mkdtemp(path.join(os.tmpdir(), "opencloud-auth-")); + directories.push(directory); + const store = new CredentialStore(directory, noKeyring); + const saved = await store.saveAccount(account()); + await chmod(saved.location, 0o644); + + await expect(store.loadAccount(account().apiUrl)).rejects.toThrow( + "permissions are too broad", + ); + }); +}); diff --git a/src/credential-store.ts b/src/credential-store.ts new file mode 100644 index 0000000..41ef3c5 --- /dev/null +++ b/src/credential-store.ts @@ -0,0 +1,275 @@ +import { createHash, randomUUID } from "node:crypto"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; +import { chmod, mkdir, rename, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const KEYRING_SERVICE = "ai.opencloud.cli"; + +export interface AccountCredential { + schemaVersion: 1; + kind: "account"; + apiUrl: string; + issuer: string; + tokenEndpoint: string; + revocationEndpoint: string; + accessToken: string; + accessExpiresAt: string; + refreshToken: string; + refreshExpiresAt: string; +} + +export interface WorkspaceCredential { + schemaVersion: 1; + kind: "workspace"; + apiUrl: string; + appId: string; + token: string; + expiresAt: string; +} + +export interface StoredCredential { + credential: T; + backend: "keyring" | "protected-file"; + location: string; +} + +interface KeyringEntry { + getPassword(): string | null; + setPassword(password: string): void; + deletePassword(): void; +} + +type KeyringEntryConstructor = new ( + service: string, + account: string, +) => KeyringEntry; +type KeyringLoader = () => Promise; + +let keyringConstructor: Promise | null = null; + +async function loadKeyring(): Promise { + keyringConstructor ??= import("@napi-rs/keyring") + .then( + (module) => + (module as unknown as { Entry: KeyringEntryConstructor }).Entry, + ) + .catch(() => null); + return keyringConstructor; +} + +function defaultConfigDirectory(): string { + if (process.platform === "win32") { + const appData = process.env.APPDATA; + return appData + ? path.join(appData, "OpenCloud") + : path.join(os.homedir(), "AppData", "Roaming", "OpenCloud"); + } + if (process.platform === "darwin") { + return path.join(os.homedir(), "Library", "Application Support", "OpenCloud"); + } + return path.join( + process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), + "opencloud", + ); +} + +function credentialName(parts: string[]): string { + return createHash("sha256").update(parts.join("\0")).digest("hex"); +} + +function accountName(apiUrl: string): string { + return `account-${credentialName([apiUrl])}`; +} + +function workspaceName(apiUrl: string, appId: string): string { + return `workspace-${credentialName([apiUrl, appId])}`; +} + +function parseAccount(raw: string): AccountCredential { + const value = JSON.parse(raw) as Partial; + if ( + value.schemaVersion !== 1 || + value.kind !== "account" || + !value.apiUrl || + !value.issuer || + !value.tokenEndpoint || + !value.revocationEndpoint || + !value.accessToken || + !value.accessExpiresAt || + !value.refreshToken || + !value.refreshExpiresAt + ) { + throw new Error("Stored OpenCloud account credential is invalid"); + } + return value as AccountCredential; +} + +function parseWorkspace(raw: string): WorkspaceCredential { + const value = JSON.parse(raw) as Partial; + if ( + value.schemaVersion !== 1 || + value.kind !== "workspace" || + !value.apiUrl || + !value.appId || + !value.token || + !value.expiresAt + ) { + throw new Error("Stored OpenCloud workspace credential is invalid"); + } + return value as WorkspaceCredential; +} + +export class CredentialStore { + readonly configDirectory: string; + + constructor( + configDirectory = defaultConfigDirectory(), + private readonly keyringLoader: KeyringLoader = loadKeyring, + ) { + this.configDirectory = path.resolve(configDirectory); + } + + accountLocation(apiUrl: string): string { + return this.fallbackFile(accountName(apiUrl)); + } + + workspaceLocation(apiUrl: string, appId: string): string { + return this.fallbackFile(workspaceName(apiUrl, appId)); + } + + loadAccount(apiUrl: string): Promise | null> { + return this.load(accountName(apiUrl), parseAccount); + } + + saveAccount( + value: AccountCredential, + ): Promise> { + return this.save(accountName(value.apiUrl), value); + } + + deleteAccount(apiUrl: string): Promise { + return this.delete(accountName(apiUrl)); + } + + loadWorkspace( + apiUrl: string, + appId: string, + ): Promise | null> { + return this.load(workspaceName(apiUrl, appId), parseWorkspace); + } + + saveWorkspace( + value: WorkspaceCredential, + ): Promise> { + return this.save(workspaceName(value.apiUrl, value.appId), value); + } + + deleteWorkspace(apiUrl: string, appId: string): Promise { + return this.delete(workspaceName(apiUrl, appId)); + } + + private async load( + name: string, + parse: (raw: string) => T, + ): Promise | null> { + const Entry = await this.keyringLoader(); + if (Entry) { + try { + const raw = new Entry(KEYRING_SERVICE, name).getPassword(); + if (raw) { + return { + credential: parse(raw), + backend: "keyring", + location: `OS credential store (${KEYRING_SERVICE})`, + }; + } + } catch { + // Headless Linux environments commonly have no usable secret service. + } + } + const file = this.fallbackFile(name); + if (!existsSync(file)) return null; + const metadata = lstatSync(file); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new Error(`Unsafe OpenCloud credential path: ${file}`); + } + if (process.platform !== "win32" && (metadata.mode & 0o077) !== 0) { + throw new Error( + `OpenCloud credential permissions are too broad; run chmod 600 ${file}`, + ); + } + return { + credential: parse(readFileSync(file, "utf8")), + backend: "protected-file", + location: file, + }; + } + + private async save( + name: string, + value: T, + ): Promise> { + const serialized = JSON.stringify(value); + const Entry = await this.keyringLoader(); + if (Entry) { + try { + new Entry(KEYRING_SERVICE, name).setPassword(serialized); + await this.deleteFallback(name); + return { + credential: value, + backend: "keyring", + location: `OS credential store (${KEYRING_SERVICE})`, + }; + } catch { + // Fall through to a mode-0600 per-user file. + } + } + const directory = path.join(this.configDirectory, "credentials"); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const directoryMetadata = lstatSync(directory); + if (!directoryMetadata.isDirectory() || directoryMetadata.isSymbolicLink()) { + throw new Error(`Unsafe OpenCloud credential directory: ${directory}`); + } + if (process.platform !== "win32") await chmod(directory, 0o700); + const file = this.fallbackFile(name); + const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`; + await writeFile(temporary, `${serialized}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + await rename(temporary, file); + if (process.platform !== "win32") await chmod(file, 0o600); + return { credential: value, backend: "protected-file", location: file }; + } + + private async delete(name: string): Promise { + const Entry = await this.keyringLoader(); + if (Entry) { + try { + new Entry(KEYRING_SERVICE, name).deletePassword(); + } catch { + // Missing entries and unavailable keyrings are both safe to ignore. + } + } + await this.deleteFallback(name); + } + + private async deleteFallback(name: string): Promise { + const file = this.fallbackFile(name); + if (!existsSync(file)) return; + const metadata = lstatSync(file); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new Error(`Refusing to remove unsafe credential path: ${file}`); + } + await rm(file, { force: true }); + } + + private fallbackFile(name: string): string { + if (!/^(account|workspace)-[a-f0-9]{64}$/.test(name)) { + throw new Error("Invalid OpenCloud credential key"); + } + return path.join(this.configDirectory, "credentials", `${name}.json`); + } +} diff --git a/src/doctor.test.ts b/src/doctor.test.ts new file mode 100644 index 0000000..0505072 --- /dev/null +++ b/src/doctor.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; +import { doctorDiagnostics } from "./doctor.js"; + +const base = { + apiUrl: "http://agent-service:3010/_internal/opencloud/user/app/run", + token: "oc_run_private-test-token", + cliVersion: "1.0.0", + currentDirectory: "/workspace", + sessionFile: "/run/opencloud/authority/session.json", + identitySource: "session-file" as const, + sessionState: "ready", + appId: "22222222-2222-4222-8222-222222222222", + credentialExpiresAt: "2026-08-04T21:30:00.000Z", +}; + +describe("doctor diagnostics", () => { + it("authenticates the version probe for a brokered run session", async () => { + const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + expect(new Headers(init?.headers).get("authorization")).toBe( + `Bearer ${base.token}`, + ); + return new Response(JSON.stringify({ version: "staging-test" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + + const result = await doctorDiagnostics(base, request as typeof fetch); + expect(result).toMatchObject({ + passed: true, + api: { reachable: true, platform: { version: "staging-test" } }, + identity: { tokenPresent: true, appId: base.appId }, + }); + expect(JSON.stringify(result)).not.toContain(base.token); + expect(request).toHaveBeenCalledOnce(); + }); + + it("never includes a missing credential in the probe or diagnostics", async () => { + const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + expect(new Headers(init?.headers).has("authorization")).toBe(false); + return new Response(JSON.stringify({ version: "public-test" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + + const result = await doctorDiagnostics( + { ...base, token: null, identitySource: "none", sessionState: null }, + request as typeof fetch, + ); + expect(result).toMatchObject({ + passed: false, + identity: { tokenPresent: false }, + }); + expect(JSON.stringify(result)).not.toContain(base.token); + }); +}); diff --git a/src/doctor.ts b/src/doctor.ts new file mode 100644 index 0000000..25c514a --- /dev/null +++ b/src/doctor.ts @@ -0,0 +1,68 @@ +export interface DoctorDiagnosticsInput { + apiUrl: string | null; + token: string | null; + cliVersion: string; + currentDirectory: string; + sessionFile: string; + identitySource: + | "environment-or-flag" + | "workspace-credential" + | "account-login" + | "session-file" + | "none"; + sessionState: string | null; + appId: string | null; + credentialExpiresAt: string | null; + workspaceBindingFile?: string | null; + accountLogin?: { + backend: string; + storedIn: string; + accessExpiresAt: string; + refreshExpiresAt: string; + } | null; +} + +export async function doctorDiagnostics( + input: DoctorDiagnosticsInput, + request: typeof fetch = fetch, +): Promise> { + let platform: unknown = null; + let reachable = false; + if (input.apiUrl) { + try { + const response = await request( + `${input.apiUrl.replace(/\/+$/, "")}/version`, + { + ...(input.token + ? { headers: { authorization: `Bearer ${input.token}` } } + : {}), + signal: AbortSignal.timeout(10_000), + }, + ); + reachable = response.ok; + platform = response.ok + ? await response.json() + : { status: response.status }; + } catch (error) { + platform = { + error: error instanceof Error ? error.message : String(error), + }; + } + } + return { + passed: Boolean(input.apiUrl && reachable && input.token), + cliVersion: input.cliVersion, + currentDirectory: input.currentDirectory, + api: { url: input.apiUrl, reachable, platform }, + identity: { + source: input.identitySource, + sessionFile: input.sessionFile, + workspaceBindingFile: input.workspaceBindingFile ?? null, + state: input.sessionState, + appId: input.appId, + credentialExpiresAt: input.credentialExpiresAt, + tokenPresent: Boolean(input.token), + accountLogin: input.accountLogin ?? null, + }, + }; +} diff --git a/src/index.ts b/src/index.ts index ecebdd0..4cc9ac8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,10 +5,25 @@ import path from "node:path"; import { Command, Option } from "commander"; import YAML from "yaml"; import { OPEN_CLOUD_JS_VERSION } from "@opencloud/js"; -import type { AgentOnboardingResponse } from "@opencloud/contracts"; +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 { + beginDeviceAuthorization, + completeDeviceAuthorization, + DEFAULT_API_URL, + freshAccountCredential, + normalizeApiUrl, + openBrowser, + revokeAccountCredential, +} from "./account-auth.js"; import { buildBundle } from "./bundle.js"; +import { CredentialStore } from "./credential-store.js"; +import { doctorDiagnostics } from "./doctor.js"; import { parseRuntimeVerificationSpec, verifyRuntime, @@ -16,13 +31,22 @@ import { } from "./runtime-verify.js"; import { verifyAppUi } from "./ui-verify.js"; import { + deleteSession, loadSession, resolveSessionFile, saveSession, type OpenCloudSession, } from "./session-store.js"; +import { + connectWorkspace, + freshWorkspaceCredential, +} from "./workspace-auth.js"; +import { + loadWorkspaceBinding, + resolveWorkspaceFile, +} from "./workspace-store.js"; -const CLI_VERSION = "0.6.1"; +const CLI_VERSION = "1.0.0"; const program = new Command() .name("opencloud") @@ -43,40 +67,120 @@ const program = new Command() "--session-file ", "Secure CLI session file created by onboarding", ).env("OPENCLOUD_SESSION_FILE"), + ) + .addOption( + new Option( + "--workspace-file ", + "Non-secret app binding file (defaults to .opencloud/app.json)", + ).env("OPENCLOUD_WORKSPACE_FILE"), ); +const credentialStore = new CredentialStore(); + function sessionFile(): string { return resolveSessionFile( program.opts<{ sessionFile?: string }>().sessionFile, ); } +function workspaceFile(): string { + return resolveWorkspaceFile( + program.opts<{ workspaceFile?: string }>().workspaceFile, + ); +} + function availableSession(): OpenCloudSession | null { return loadSession(sessionFile()); } +function availableWorkspace() { + return loadWorkspaceBinding(workspaceFile()); +} + +function targetApiUrl(): string { + return ( + program.opts<{ apiUrl?: string }>().apiUrl ?? + availableWorkspace()?.apiUrl ?? + availableSession()?.apiUrl ?? + DEFAULT_API_URL + ); +} + function client(): OpenCloudClient { - const options = program.opts<{ - apiUrl?: string; - token?: string; - }>(); - const stored = options.apiUrl && options.token ? null : availableSession(); - const apiUrl = options.apiUrl ?? stored?.apiUrl; - const token = - options.token ?? (stored?.state === "ready" ? stored.token : undefined); - if (!apiUrl || !token) { - throw new Error( - stored?.state === "awaiting_email_verification" - ? "Email verification is still pending. Run opencloud onboard-complete after confirming the email." - : stored?.state === "starting" - ? "Onboarding has not completed. Re-run the same opencloud onboard command." - : "Run opencloud onboard, set OPENCLOUD_API_URL and OPENCLOUD_TOKEN, or pass --api-url and --token.", - ); + const options = program.opts<{ apiUrl?: string; token?: string }>(); + const binding = availableWorkspace(); + const legacy = availableSession(); + const apiUrl = options.apiUrl ?? binding?.apiUrl ?? legacy?.apiUrl; + if (options.token) { + if (!apiUrl) { + throw new Error("Pass --api-url with --token outside a connected workspace."); + } + return new OpenCloudClient({ apiUrl, token: options.token }); } - return new OpenCloudClient({ - apiUrl, - token, - }); + if (binding) { + if ( + options.apiUrl && + normalizeApiUrl(options.apiUrl) !== normalizeApiUrl(binding.apiUrl) + ) { + throw new Error( + `This workspace is connected to ${binding.apiUrl}; remove --api-url or reconnect it.`, + ); + } + let tokenPromise: Promise | null = null; + return new OpenCloudClient({ + apiUrl: binding.apiUrl, + tokenProvider: () => { + tokenPromise ??= freshWorkspaceCredential({ + store: credentialStore, + bindingFile: workspaceFile(), + }).then((stored) => stored.credential.token); + return tokenPromise; + }, + }); + } + if (legacy?.state === "ready") { + if ( + options.apiUrl && + normalizeApiUrl(options.apiUrl) !== normalizeApiUrl(legacy.apiUrl) + ) { + throw new Error("The legacy session belongs to a different API URL."); + } + return new OpenCloudClient({ apiUrl: legacy.apiUrl, token: legacy.token }); + } + throw new Error( + legacy?.state === "awaiting_email_verification" + ? "Email verification is still pending. Run opencloud onboard-complete after confirming the email." + : legacy?.state === "starting" + ? "Onboarding has not completed. Re-run the same opencloud onboard command." + : "Run opencloud login, then opencloud app connect in this directory.", + ); +} + +async function managementClient(): Promise { + const options = program.opts<{ token?: string }>(); + if (options.token) return client(); + const apiUrl = normalizeApiUrl(targetApiUrl()); + const account = await credentialStore.loadAccount(apiUrl); + if (account) { + const fresh = await freshAccountCredential(credentialStore, apiUrl); + return new OpenCloudClient({ + apiUrl, + token: fresh.credential.accessToken, + }); + } + if (availableWorkspace()) return client(); + const legacy = availableSession(); + if (legacy?.state === "ready") { + return new OpenCloudClient({ apiUrl: legacy.apiUrl, token: legacy.token }); + } + throw new Error("No OpenCloud account login was found. Run opencloud login."); +} + +function requiredAccountCredential() { + return freshAccountCredential( + credentialStore, + normalizeApiUrl(targetApiUrl()), + ); } function output(value: unknown): void { @@ -343,6 +447,7 @@ function parseOnboardingResponse(value: unknown): AgentOnboardingResponse { !value || typeof value !== "object" || typeof (value as { onboardingId?: unknown }).onboardingId !== "string" || + typeof (value as { launchUrl?: unknown }).launchUrl !== "string" || typeof (value as { state?: unknown }).state !== "string" ) { throw new Error("OpenCloud returned an invalid onboarding response"); @@ -372,7 +477,7 @@ async function persistOnboardingResponse( expiresAt: credential.expiresAt, storedIn: file, }, - next: "The app-scoped credential is stored locally. Continue with app list, app get, init, validate, and deploy; do not print the session file.", + next: "The app-scoped credential is stored locally. Continue building, give the owner launchUrl as their primary link while confirmation is pending, and do not print the session file.", }; } if (completionToken) { @@ -388,21 +493,178 @@ async function persistOnboardingResponse( ...safe, credential: null, sessionFile: file, - next: "Ask the user to confirm the email, then run opencloud onboard-complete. Do not print the session file.", + next: "Give the user launchUrl, ask them to confirm the project email, then run opencloud onboard-complete. Do not print the session file.", }; } return { ...safe, credential: null, sessionFile: file, - next: "Email verification is still pending. Run opencloud onboard-complete after the user confirms.", + next: "Give the owner launchUrl. Email verification is still pending; run opencloud onboard-complete after they confirm.", }; } +program + .command("login") + .description("Sign in to an existing OpenCloud account in your browser") + .option("--force", "replace and revoke the currently stored account login") + .option("--no-browser", "print the approval URL without opening a browser") + .action(async (options) => { + const apiUrl = normalizeApiUrl(targetApiUrl()); + const existing = await credentialStore.loadAccount(apiUrl); + if (existing && !options.force) { + try { + const fresh = await freshAccountCredential(credentialStore, apiUrl); + output({ + state: "authenticated", + apiUrl, + credential: { + backend: fresh.backend, + storedIn: fresh.location, + accessExpiresAt: fresh.credential.accessExpiresAt, + refreshExpiresAt: fresh.credential.refreshExpiresAt, + }, + next: "Run opencloud app list, then opencloud app connect in the app directory.", + }); + return; + } catch { + throw new Error( + "The stored login could not be refreshed. Run opencloud login --force to replace it.", + ); + } + } + if (existing) { + await revokeAccountCredential(existing.credential); + await credentialStore.deleteAccount(apiUrl); + const binding = availableWorkspace(); + if (binding) { + await credentialStore.deleteWorkspace(binding.apiUrl, binding.appId); + } + } + const authorization = await beginDeviceAuthorization(apiUrl); + process.stderr.write( + `Open this URL to approve the CLI:\n${authorization.verificationUriComplete}\n`, + ); + const browserOpened = options.browser !== false + ? openBrowser(authorization.verificationUriComplete) + : false; + const account = await completeDeviceAuthorization(authorization); + const stored = await credentialStore.saveAccount(account); + output({ + state: "authenticated", + apiUrl, + browserOpened, + credential: { + backend: stored.backend, + storedIn: stored.location, + accessExpiresAt: account.accessExpiresAt, + refreshExpiresAt: account.refreshExpiresAt, + }, + next: "Run opencloud app list, then opencloud app connect in the app directory.", + }); + }); + +const auth = program + .command("auth") + .description("Inspect or clear the stored OpenCloud account login"); + +auth + .command("status") + .description("Show redacted account and workspace authentication status") + .action(async () => { + const apiUrl = normalizeApiUrl(targetApiUrl()); + const existing = await credentialStore.loadAccount(apiUrl); + let account: + | { + state: "authenticated"; + backend: string; + storedIn: string; + accessExpiresAt: string; + refreshExpiresAt: string; + } + | { state: "needs_login"; reason: string } + | null = null; + if (existing) { + try { + const fresh = await freshAccountCredential(credentialStore, apiUrl); + account = { + state: "authenticated", + backend: fresh.backend, + storedIn: fresh.location, + accessExpiresAt: fresh.credential.accessExpiresAt, + refreshExpiresAt: fresh.credential.refreshExpiresAt, + }; + } catch (error) { + account = { + state: "needs_login", + reason: error instanceof Error ? error.message : String(error), + }; + } + } + const binding = availableWorkspace(); + const workspaceCredential = binding + ? await credentialStore.loadWorkspace(binding.apiUrl, binding.appId) + : null; + output({ + authenticated: account?.state === "authenticated", + apiUrl, + account, + workspace: binding + ? { + bindingFile: workspaceFile(), + appId: binding.appId, + appName: binding.appName, + appUrl: binding.appUrl, + credential: workspaceCredential + ? { + present: true, + active: + Date.parse(workspaceCredential.credential.expiresAt) > + Date.now(), + expiresAt: workspaceCredential.credential.expiresAt, + backend: workspaceCredential.backend, + storedIn: workspaceCredential.location, + } + : { present: false }, + } + : null, + legacyOnboardingSession: availableSession()?.state ?? null, + }); + }); + +async function logout(): Promise { + const apiUrl = normalizeApiUrl(targetApiUrl()); + const existing = await credentialStore.loadAccount(apiUrl); + if (existing) await revokeAccountCredential(existing.credential); + const binding = availableWorkspace(); + if (binding) { + await credentialStore.deleteWorkspace(binding.apiUrl, binding.appId); + } + await credentialStore.deleteAccount(apiUrl); + const legacyOnboardingSession = availableSession(); + const legacyOnboardingSessionRemoved = await deleteSession(sessionFile()); + output({ + state: "logged_out", + apiUrl, + remoteLoginRevoked: Boolean(existing), + currentWorkspaceCredentialRemoved: Boolean(binding), + workspaceBindingRetained: binding ? workspaceFile() : null, + legacyOnboardingSessionRemoved: legacyOnboardingSessionRemoved + ? legacyOnboardingSession?.state ?? true + : false, + next: binding + ? "The non-secret app binding remains. Run opencloud login to reconnect it later." + : "Run opencloud login to sign in again.", + }); +} + +auth.command("logout").description("Revoke and clear the CLI login").action(logout); +program.command("logout").description("Revoke and clear the CLI login").action(logout); + program .command("onboard") .description( - "Create a passwordless OpenCloud identity and automatically addressed project", + "Create an email-based OpenCloud identity and automatically addressed project", ) .requiredOption("--email ", "user email address") .requiredOption("--name ", "project title") @@ -513,49 +775,59 @@ program const options = program.opts<{ apiUrl?: string; token?: string }>(); const file = sessionFile(); const stored = availableSession(); - const apiUrl = options.apiUrl ?? stored?.apiUrl ?? null; - let platform: unknown = null; - let reachable = false; - if (apiUrl) { - try { - const response = await fetch(`${apiUrl.replace(/\/+$/, "")}/version`, { - signal: AbortSignal.timeout(10_000), - }); - reachable = response.ok; - platform = response.ok - ? await response.json() - : { status: response.status }; - } catch (error) { - platform = { - error: error instanceof Error ? error.message : String(error), - }; - } - } - output({ - passed: Boolean( - apiUrl && - reachable && - (options.token || stored?.state === "ready"), + const binding = availableWorkspace(); + const account = await credentialStore.loadAccount( + normalizeApiUrl( + options.apiUrl ?? binding?.apiUrl ?? stored?.apiUrl ?? DEFAULT_API_URL, ), - cliVersion: CLI_VERSION, - currentDirectory: process.cwd(), - api: { url: apiUrl, reachable, platform }, - identity: { - source: options.token - ? "environment-or-flag" - : stored - ? "session-file" - : "none", + ); + const workspaceCredential = binding + ? await credentialStore.loadWorkspace(binding.apiUrl, binding.appId) + : null; + const apiUrl = + options.apiUrl ?? + binding?.apiUrl ?? + account?.credential.apiUrl ?? + stored?.apiUrl ?? + null; + const token = + options.token ?? + workspaceCredential?.credential.token ?? + account?.credential.accessToken ?? + (stored?.state === "ready" ? stored.token : null); + output( + await doctorDiagnostics({ + apiUrl, + token, + cliVersion: CLI_VERSION, + currentDirectory: process.cwd(), sessionFile: file, - state: stored?.state ?? null, - appId: stored?.state === "ready" ? stored.appId : null, + identitySource: options.token + ? "environment-or-flag" + : workspaceCredential + ? "workspace-credential" + : account + ? "account-login" + : stored + ? "session-file" + : "none", + sessionState: binding ? "connected" : stored?.state ?? null, + appId: + binding?.appId ?? (stored?.state === "ready" ? stored.appId : null), credentialExpiresAt: - stored?.state === "ready" ? stored.credentialExpiresAt : null, - tokenPresent: Boolean( - options.token || (stored?.state === "ready" && stored.token), - ), - }, - }); + workspaceCredential?.credential.expiresAt ?? + (stored?.state === "ready" ? stored.credentialExpiresAt : null), + workspaceBindingFile: binding ? workspaceFile() : null, + accountLogin: account + ? { + backend: account.backend, + storedIn: account.location, + accessExpiresAt: account.credential.accessExpiresAt, + refreshExpiresAt: account.credential.refreshExpiresAt, + } + : null, + }), + ); }); const app = program.command("app").description("Manage OpenCloud apps"); @@ -571,7 +843,7 @@ app .option("--idempotency-key ") .action(async (options) => { output( - await client().call( + await (await managementClient()).call( "createApp", { body: { @@ -589,12 +861,47 @@ app ); }); -app.command("list").action(async () => output(await client().get("/v1/apps"))); +app + .command("list") + .description("List apps available to the signed-in account") + .action(async () => + output(await (await managementClient()).get("/v1/apps")), + ); app .command("get") .argument("") - .action(async (appId) => output(await client().get(`/v1/apps/${appId}`))); + .action(async (appId) => + output( + await (await managementClient()).get(`/v1/apps/${appId}`), + ), + ); + +app + .command("connect") + .description("Connect this directory to an app with an expiring credential") + .argument("") + .action(async (appId) => { + const account = await requiredAccountCredential(); + const connected = await connectWorkspace({ + store: credentialStore, + bindingFile: workspaceFile(), + apiUrl: account.credential.apiUrl, + accessToken: account.credential.accessToken, + appId, + }); + output({ + state: "connected", + app: connected.binding, + workspaceFile: workspaceFile(), + credential: { + backend: connected.stored.backend, + storedIn: connected.stored.location, + expiresAt: connected.stored.credential.expiresAt, + }, + next: "Codex can now build, validate, preview, and deploy this app with the OpenCloud CLI.", + }); + }); app .command("sdk-inspect") @@ -1194,16 +1501,21 @@ program .command("init") .description("Create a minimal app bundle") .argument("") - .option("--app-id ", "defaults to the onboarded project") + .option("--app-id ", "defaults to the connected workspace app") .option("--version ", "initial deployment version", "v1") .action(async (directory, options) => { + const root = callerPath(directory); + const binding = loadWorkspaceBinding(resolveWorkspaceFile(undefined, root)); const stored = availableSession(); const appId = - options.appId ?? (stored?.state === "ready" ? stored.appId : undefined); + options.appId ?? + binding?.appId ?? + (stored?.state === "ready" ? stored.appId : undefined); if (!appId) { - throw new Error("Pass --app-id or complete opencloud onboard first"); + throw new Error( + "Pass --app-id or run opencloud app connect in the target directory.", + ); } - const root = callerPath(directory); await mkdir(path.join(root, "frontend"), { recursive: true }); await mkdir(path.join(root, "migrations"), { recursive: true }); await writeFile( @@ -1213,7 +1525,16 @@ program OpenCloud app + +
+

Your OpenCloud app is running

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

@@ -1523,9 +1844,14 @@ program } const spec = parseRuntimeVerificationSpec(source, bundle.manifest); const edgeUrl = process.env.OPENCLOUD_EDGE_URL; - output( - await verifyRuntime(control, appValue, spec, edgeUrl ? { edgeUrl } : {}), + const result = await verifyRuntime( + control, + appValue, + spec, + edgeUrl ? { edgeUrl } : {}, ); + output(result); + if (result.passed !== true) process.exitCode = 1; }); const secret = program diff --git a/src/runtime-verify.test.ts b/src/runtime-verify.test.ts index df993a9..58e17fe 100644 --- a/src/runtime-verify.test.ts +++ b/src/runtime-verify.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { parseRuntimeVerificationSpec, + runtimeVerificationApplicability, verifyStorage, } from "./runtime-verify.js"; @@ -172,6 +173,48 @@ cron: `, 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", () => { diff --git a/src/runtime-verify.ts b/src/runtime-verify.ts index b9b4454..bb0420b 100644 --- a/src/runtime-verify.ts +++ b/src/runtime-verify.ts @@ -36,14 +36,13 @@ export const runtimeVerificationSchema = z .default("opencloud-verify"), }) .strict() - .default({ - objectPrefix: "opencloud-verify", - }), + .optional(), realtime: z .object({ topic: logicalName, }) - .strict(), + .strict() + .optional(), function: z .object({ name: logicalName, @@ -51,13 +50,15 @@ export const runtimeVerificationSchema = z digestField: responseField.default("secretDigest"), presentField: responseField.default("secretPresent"), }) - .strict(), + .strict() + .optional(), cron: z .object({ name: logicalName, timeoutSeconds: z.coerce.number().int().min(10).max(300).default(120), }) - .strict(), + .strict() + .optional(), }) .strict(); @@ -68,13 +69,68 @@ export interface RuntimeVerificationSpec data: RuntimeVerificationInput["data"] & { ownerColumn?: string; }; - storage: { + 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.", + }, }; } @@ -199,11 +255,15 @@ async function login( app: RuntimeApp, user: VerificationUser, ): Promise { - const response = await transport.request(app.authUrl, "/v1/auth/login", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ email: user.email, password: user.password }), - }); + 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; @@ -298,7 +358,7 @@ export function parseRuntimeVerificationSpec( const ownerColumn = input.data.ownerColumn ?? (input.data.mode === "owner" ? "owner_id" : undefined); - const requestedAuthorization = input.storage.authorization; + const requestedAuthorization = input.storage?.authorization; if ( requestedAuthorization && requestedAuthorization !== manifest.storage.authorization @@ -307,49 +367,64 @@ export function parseRuntimeVerificationSpec( `Verification storage.authorization is ${requestedAuthorization}, but the app manifest declares ${manifest.storage.authorization}`, ); } - 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.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`, + ); + } } - 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 (input.cron) { + const verifiedCron = manifest.cron.find( + (definition) => definition.name === input.cron?.name, ); - } - if (!verifiedCron.enabled) { - throw new Error(`Verification cron ${input.cron.name} is disabled`); + 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 { - ...input, + schemaVersion: input.schemaVersion, data: { ...data, ...(ownerColumn ? { ownerColumn } : {}), }, - storage: { - authorization: manifest.storage.authorization, - objectPrefix: input.storage.objectPrefix, - }, + ...(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), }, }; } @@ -510,7 +585,7 @@ export async function verifyStorage( app: RuntimeApp, configValue: AppConfig, sessions: { first: UserSession; second: UserSession }, - spec: RuntimeVerificationSpec["storage"], + spec: NonNullable, marker: string, ): Promise> { const objectName = @@ -686,7 +761,7 @@ async function verifyRealtime( app: RuntimeApp, configValue: AppConfig, sessions: { first: UserSession; second: UserSession }, - spec: RuntimeVerificationSpec["realtime"], + spec: NonNullable, marker: string, ): Promise> { const channelName = `app:${app.id}:${spec.topic}`; @@ -771,7 +846,7 @@ async function verifyFunction( app: RuntimeApp, configValue: AppConfig, session: UserSession, - spec: RuntimeVerificationSpec["function"], + spec: NonNullable, ): Promise> { const functionPath = `/functions/v1/${spec.name}`; const anonymous = await transport.request(app.appUrl, functionPath, { @@ -842,7 +917,7 @@ async function verifyFunction( async function verifyCron( client: OpenCloudClient, app: RuntimeApp, - spec: RuntimeVerificationSpec["cron"], + spec: NonNullable, ): Promise> { const startedAt = new Date(); const trigger = z @@ -904,6 +979,7 @@ export async function verifyRuntime( first: sessions.first, second: sessions.second, }; + const applicability = runtimeVerificationApplicability(spec); const results: Record[] = [ { @@ -912,9 +988,7 @@ export async function verifyRuntime( detail: "Canonical origins resolved; private redirect and two brokered user sessions passed.", }, - { - name: "two-user RLS", - ...(await verifyData( + await verificationCheck("two-user RLS", () => verifyData( transport, app, sessions.config, @@ -922,64 +996,79 @@ export async function verifyRuntime( spec.data, marker, )), - }, - { - name: "authenticated Storage", - ...(await verifyStorage( - transport, - app, - sessions.config, - scopedSessions, - spec.storage, - marker, - )), - }, - { - name: "private Realtime", - ...(await verifyRealtime( - transport, - app, - sessions.config, - scopedSessions, - spec.realtime, - marker, - )), - }, - { - name: "function authentication and secret rotation", - ...(await verifyFunction( - transport, - client, - app, - sessions.config, - sessions.first, - spec.function, - )), - }, - { - name: "structured cron history", - ...(await verifyCron(client, app, spec.cron)), - }, + 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), ]; - 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`); - results.push({ - name: "scoped observability", - status: "passed", - detail: `Logs query succeeded; usage returned ${ - Array.isArray(usage) ? usage.length : 0 - } rollup row(s).`, - logsReturned: Array.isArray(logs) ? logs.length : undefined, - }); + 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: true, + passed, appId: app.id, deploymentId: app.activeDeploymentId, contract: spec.manifest, @@ -990,3 +1079,39 @@ export async function verifyRuntime( 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/session-store.test.ts b/src/session-store.test.ts index a4bbe3e..a524490 100644 --- a/src/session-store.test.ts +++ b/src/session-store.test.ts @@ -1,8 +1,16 @@ -import { mkdir, mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readFile, + rm, + stat, + symlink, +} from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + deleteSession, loadSession, resolveSessionFile, saveSession, @@ -82,4 +90,45 @@ describe("OpenCloud CLI session store", () => { expect(resolveSessionFile(undefined, nested)).toBe(file); }); + + it("removes only a regular legacy session file", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "opencloud-cli-")); + directories.push(directory); + const file = path.join(directory, ".opencloud", "session.json"); + await saveSession(file, { + schemaVersion: 1, + state: "ready", + apiUrl: "https://api.opencloud.ai", + appId: "248c0b0d-4a85-46de-af54-e3afb145dc2b", + appUrl: "https://family-tasks-a1b2c3.opencloud.ai", + token: "oc_agent_secret", + credentialExpiresAt: "2026-07-30T12:00:00.000Z", + }); + + await expect(deleteSession(file)).resolves.toBe(true); + expect(loadSession(file)).toBeNull(); + await expect(deleteSession(file)).resolves.toBe(false); + }); + + it("refuses to follow a symlink when clearing a legacy session", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "opencloud-cli-")); + directories.push(directory); + const target = path.join(directory, "target.json"); + const file = path.join(directory, "session.json"); + await saveSession(target, { + schemaVersion: 1, + state: "ready", + apiUrl: "https://api.opencloud.ai", + appId: "248c0b0d-4a85-46de-af54-e3afb145dc2b", + appUrl: "https://family-tasks-a1b2c3.opencloud.ai", + token: "oc_agent_secret", + credentialExpiresAt: "2026-07-30T12:00:00.000Z", + }); + await symlink(target, file); + + await expect(deleteSession(file)).rejects.toThrow( + "unsafe OpenCloud session", + ); + expect(loadSession(target)).not.toBeNull(); + }); }); diff --git a/src/session-store.ts b/src/session-store.ts index 8bc8f7a..b434bd3 100644 --- a/src/session-store.ts +++ b/src/session-store.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; import { promises as fs } from "node:fs"; import path from "node:path"; @@ -142,3 +142,13 @@ export async function saveSession( await fs.rename(temporary, file); await fs.chmod(file, 0o600); } + +export async function deleteSession(file: string): Promise { + if (!existsSync(file)) return false; + const metadata = lstatSync(file); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new Error(`Refusing to remove unsafe OpenCloud session path: ${file}`); + } + await fs.rm(file); + return true; +} diff --git a/src/ui-verify.ts b/src/ui-verify.ts index 847af99..3db5d9d 100644 --- a/src/ui-verify.ts +++ b/src/ui-verify.ts @@ -154,7 +154,7 @@ export async function verifyAppUi( const context = await browser.newContext({ ignoreHTTPSErrors: false }); if (credentials) { const login = await context.request.post( - `${app.authUrl}/v1/auth/login`, + `${app.authUrl}/v1/auth/_internal/verifier-session`, { data: credentials, failOnStatusCode: false, diff --git a/src/workspace-auth.test.ts b/src/workspace-auth.test.ts new file mode 100644 index 0000000..656821e --- /dev/null +++ b/src/workspace-auth.test.ts @@ -0,0 +1,71 @@ +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CredentialStore } from "./credential-store.js"; +import { + connectWorkspace, + freshWorkspaceCredential, +} from "./workspace-auth.js"; +import { loadWorkspaceBinding } from "./workspace-store.js"; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }), + ), + ); +}); + +describe("CLI workspace connection", () => { + it("keeps app binding metadata in the workspace and its token outside it", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "opencloud-workspace-")); + directories.push(directory); + const bindingFile = path.join(directory, "app", ".opencloud", "app.json"); + const store = new CredentialStore( + path.join(directory, "user-config"), + async () => null, + ); + const request = vi.fn().mockResolvedValue( + Response.json({ + app: { + id: "248c0b0d-4a85-46de-af54-e3afb145dc2b", + name: "Family tasks", + appUrl: "https://family-tasks.opencloud.ai", + }, + credential: { + token: "oc_agent_private-workspace", + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1_000).toISOString(), + }, + }), + ); + + const connected = await connectWorkspace( + { + store, + bindingFile, + apiUrl: "https://api.opencloud.ai", + accessToken: "oc_cli_private-account", + appId: "248c0b0d-4a85-46de-af54-e3afb145dc2b", + }, + request as typeof fetch, + ); + + expect(loadWorkspaceBinding(bindingFile)).toEqual(connected.binding); + expect(await readFile(bindingFile, "utf8")).not.toContain( + "oc_agent_private-workspace", + ); + expect(connected.stored.location.startsWith(directory)).toBe(true); + expect((await stat(bindingFile)).mode & 0o777).toBe(0o600); + expect(new Headers(request.mock.calls[0]?.[1]?.headers).get("authorization")) + .toBe("Bearer oc_cli_private-account"); + + const fresh = await freshWorkspaceCredential( + { store, bindingFile }, + vi.fn() as unknown as typeof fetch, + ); + expect(fresh.credential.token).toBe("oc_agent_private-workspace"); + }); +}); diff --git a/src/workspace-auth.ts b/src/workspace-auth.ts new file mode 100644 index 0000000..f61b5f0 --- /dev/null +++ b/src/workspace-auth.ts @@ -0,0 +1,114 @@ +import { OpenCloudClient } from "./api-client.js"; +import { + freshAccountCredential, + normalizeApiUrl, +} from "./account-auth.js"; +import { + CredentialStore, + type StoredCredential, + type WorkspaceCredential, +} from "./credential-store.js"; +import { + loadWorkspaceBinding, + saveWorkspaceBinding, + type WorkspaceBinding, +} from "./workspace-store.js"; + +export interface ConnectedWorkspace { + binding: WorkspaceBinding; + stored: StoredCredential; +} + +export async function connectWorkspace( + input: { + store: CredentialStore; + bindingFile: string; + apiUrl: string; + accessToken: string; + appId: string; + }, + request: typeof fetch = fetch, +): Promise { + const apiUrl = normalizeApiUrl(input.apiUrl); + const previous = loadWorkspaceBinding(input.bindingFile); + const response = await new OpenCloudClient({ + apiUrl, + token: input.accessToken, + fetch: request, + }).call("connectCliWorkspace", { appId: input.appId }); + if ( + !response.app.id || + !response.app.name || + !response.app.appUrl || + !response.credential.token || + !response.credential.expiresAt + ) { + throw new Error("OpenCloud returned an invalid workspace connection"); + } + const credential: WorkspaceCredential = { + schemaVersion: 1, + kind: "workspace", + apiUrl, + appId: response.app.id, + token: response.credential.token, + expiresAt: response.credential.expiresAt, + }; + const stored = await input.store.saveWorkspace(credential); + const binding: WorkspaceBinding = { + schemaVersion: 1, + apiUrl, + appId: response.app.id, + appName: response.app.name, + appUrl: response.app.appUrl, + connectedAt: new Date().toISOString(), + credentialExpiresAt: response.credential.expiresAt, + }; + try { + await saveWorkspaceBinding(input.bindingFile, binding); + } catch (error) { + await input.store.deleteWorkspace(apiUrl, response.app.id); + throw error; + } + if ( + previous && + (previous.apiUrl !== binding.apiUrl || previous.appId !== binding.appId) + ) { + await input.store.deleteWorkspace(previous.apiUrl, previous.appId); + } + return { binding, stored }; +} + +export async function freshWorkspaceCredential( + input: { + store: CredentialStore; + bindingFile: string; + }, + request: typeof fetch = fetch, +): Promise> { + const binding = loadWorkspaceBinding(input.bindingFile); + if (!binding) { + throw new Error( + "This directory is not connected to an OpenCloud app. Run opencloud app connect .", + ); + } + const stored = await input.store.loadWorkspace(binding.apiUrl, binding.appId); + if (stored && Date.parse(stored.credential.expiresAt) > Date.now() + 60_000) { + return stored; + } + const account = await freshAccountCredential( + input.store, + binding.apiUrl, + request, + ); + const connected = await connectWorkspace( + { + store: input.store, + bindingFile: input.bindingFile, + apiUrl: binding.apiUrl, + accessToken: account.credential.accessToken, + appId: binding.appId, + }, + request, + ); + return connected.stored; +} diff --git a/src/workspace-store.ts b/src/workspace-store.ts new file mode 100644 index 0000000..cf003fd --- /dev/null +++ b/src/workspace-store.ts @@ -0,0 +1,83 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { chmod, mkdir, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; + +export interface WorkspaceBinding { + schemaVersion: 1; + apiUrl: string; + appId: string; + appName: string; + appUrl: string; + connectedAt: string; + credentialExpiresAt: string; +} + +export function defaultWorkspaceFile(cwd = process.cwd()): string { + return path.join(cwd, ".opencloud", "app.json"); +} + +export function resolveWorkspaceFile( + value?: string, + cwd = process.env.INIT_CWD ?? process.cwd(), +): string { + const start = path.resolve(cwd); + if (value) return path.resolve(start, value); + let current = start; + while (true) { + const candidate = defaultWorkspaceFile(current); + if (existsSync(candidate)) return candidate; + const parent = path.dirname(current); + if (parent === current) return defaultWorkspaceFile(start); + current = parent; + } +} + +export function loadWorkspaceBinding(file: string): WorkspaceBinding | null { + let raw: string; + try { + raw = readFileSync(file, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + const value = JSON.parse(raw) as Partial; + if ( + value.schemaVersion !== 1 || + !value.apiUrl || + !value.appId || + !value.appName || + !value.appUrl || + !value.connectedAt || + !value.credentialExpiresAt + ) { + throw new Error(`Invalid OpenCloud workspace binding: ${file}`); + } + return value as WorkspaceBinding; +} + +export async function saveWorkspaceBinding( + file: string, + binding: WorkspaceBinding, +): Promise { + const directory = path.dirname(file); + await mkdir(directory, { recursive: true, mode: 0o700 }); + if (process.platform !== "win32") await chmod(directory, 0o700); + try { + await writeFile(path.join(directory, ".gitignore"), "*\n!.gitignore\n", { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`; + await writeFile(temporary, `${JSON.stringify(binding, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + await rename(temporary, file); + if (process.platform !== "win32") await chmod(file, 0o600); +} diff --git a/vendor/bundler/src/index.ts b/vendor/bundler/src/index.ts index dfbea28..f31489c 100644 --- a/vendor/bundler/src/index.ts +++ b/vendor/bundler/src/index.ts @@ -13,10 +13,7 @@ import { } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { - parseManifest, - type OpenCloudManifest, -} from "@opencloud/contracts"; +import { parseManifest, type OpenCloudManifest } from "@opencloud/contracts"; import { OPEN_CLOUD_JS_VERSION } from "@opencloud/js"; import * as tar from "tar"; import YAML from "yaml"; @@ -51,11 +48,16 @@ export interface BuiltBundle { archive: Buffer; sha256: string; files: string[]; + sourceManifest: string; + sourceFiles: string[]; warnings: BundleWarning[]; } export interface BundleWarning { - code: "UNDECLARED_MIGRATION_FILE" | "UNDECLARED_FUNCTION_ENTRYPOINT"; + code: + | "UNDECLARED_MIGRATION_FILE" + | "UNDECLARED_FUNCTION_ENTRYPOINT" + | "FRONTEND_SDK_NOT_REFERENCED"; path: string; message: string; } @@ -90,10 +92,11 @@ export async function buildBundle( const root = path.resolve(directory); await assertDirectory(root, "App bundle root"); const manifestFile = await findManifest(root); + const sourceManifest = bundleRelativePath(root, manifestFile); const source = await readFile(manifestFile, "utf8"); - const raw = (manifestFile.endsWith(".json") - ? JSON.parse(source) - : YAML.parse(source)) as AuthorManifest; + const raw = ( + manifestFile.endsWith(".json") ? JSON.parse(source) : YAML.parse(source) + ) as AuthorManifest; if (options.version) raw.version = options.version; raw.schemaVersion ??= 1; raw.runtime ??= {}; @@ -122,7 +125,13 @@ export async function buildBundle( } const manifest = parseManifest(raw); const selection = await selectBundleFiles(root, manifest, manifestFile); - const warnings = await findUndeclaredConventionalFiles(root, manifest); + const warnings = [ + ...(await findUndeclaredConventionalFiles(root, manifest)), + ...(await findFrontendSdkWarnings(manifest, selection)), + ].sort((left, right) => comparePaths(left.path, right.path)); + const sourceFiles = [sourceManifest, ...selection.files.keys()].sort( + comparePaths, + ); const temporary = await mkdtemp(path.join(os.tmpdir(), "opencloud-bundle-")); const staging = path.join(temporary, "root"); @@ -151,10 +160,9 @@ export async function buildBundle( const files = ["opencloud.json", ...selection.files.keys()].sort( comparePaths, ); - const archiveEntries = [ - ...selection.directories, - ...files, - ].sort(comparePaths); + const archiveEntries = [...selection.directories, ...files].sort( + comparePaths, + ); await tar.create( { cwd: staging, @@ -173,6 +181,8 @@ export async function buildBundle( archive, sha256: createHash("sha256").update(archive).digest("hex"), files, + sourceManifest, + sourceFiles, warnings, }; } finally { @@ -180,6 +190,34 @@ export async function buildBundle( } } +async function findFrontendSdkWarnings( + manifest: OpenCloudManifest, + selection: BundleSelection, +): Promise { + const frontendPrefix = manifest.frontend.directory + "/"; + const candidates = [...selection.files.entries()].filter( + ([relative]) => + relative.startsWith(frontendPrefix) && + /[.](?:html|js|mjs|cjs|ts|tsx|jsx)$/.test(relative), + ); + for (const [, sourceFile] of candidates) { + const content = await readFile(sourceFile, "utf8"); + if ( + content.includes("createOpenCloudClient") && + content.includes("javascriptSdk.module") + ) { + return []; + } + } + return [ + { + 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.", + }, + ]; +} async function findUndeclaredConventionalFiles( root: string, manifest: OpenCloudManifest, @@ -273,11 +311,7 @@ async function selectBundleFiles( relativeDirectory: string, label: string, ): Promise => { - const absoluteDirectory = resolveBundlePath( - root, - relativeDirectory, - label, - ); + const absoluteDirectory = resolveBundlePath(root, relativeDirectory, label); assertNotLocalMetadataPath(root, absoluteDirectory, label); await assertNoSymlinkComponents(root, absoluteDirectory, label); await assertDirectory(absoluteDirectory, label); @@ -393,7 +427,9 @@ function assertNotLocalMetadataPath( ): void { const relative = bundleRelativePath(root, target); if (relative === ".opencloud" || relative.startsWith(".opencloud/")) { - throw new Error(`${label} cannot use the reserved .opencloud metadata directory`); + throw new Error( + `${label} cannot use the reserved .opencloud metadata directory`, + ); } } @@ -407,7 +443,8 @@ function addSelectedFile( if ( file === manifestFile || (path.posix.dirname(relative) === "." && isManifestName(relative)) - ) return; + ) + return; selection.files.set(relative, file); addParentDirectories(relative, selection.directories); } @@ -470,10 +507,7 @@ function assertNotAuthorManifestInput( label: string, ): void { const relative = bundleRelativePath(root, target); - if ( - path.posix.dirname(relative) === "." && - isManifestName(relative) - ) { + if (path.posix.dirname(relative) === "." && isManifestName(relative)) { throw new Error(`${label} conflicts with the canonical opencloud.json`); } } diff --git a/vendor/contracts/src/api.ts b/vendor/contracts/src/api.ts index 7c62f57..a63db00 100644 --- a/vendor/contracts/src/api.ts +++ b/vendor/contracts/src/api.ts @@ -183,6 +183,8 @@ export type AgentOnboardingState = export interface AgentOnboardingResponse { onboardingId: string; + /** Non-secret owner page that waits for confirmation and deployment. */ + launchUrl: string; state: AgentOnboardingState; existingUser: boolean; verification: { @@ -209,6 +211,7 @@ export interface AppRecord { apiUrl: string; visibility: AppVisibility; state: AppState; + backupSchedule?: "none" | "daily" | "weekly"; ownerUserId: string; desiredDeploymentId: string | null; activeDeploymentId: string | null; diff --git a/vendor/contracts/src/brand.generated.ts b/vendor/contracts/src/brand.generated.ts new file mode 100644 index 0000000..217b529 --- /dev/null +++ b/vendor/contracts/src/brand.generated.ts @@ -0,0 +1,4 @@ +// Generated by scripts/generate-brand-assets.mjs. Do not edit by hand. +export const OPEN_CLOUD_LOGO_DATA_URI = "data:image/png;base64,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"; + +export const OPEN_CLOUD_FAVICON_DATA_URI = "data:image/png;base64,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"; diff --git a/vendor/contracts/src/brand.test.ts b/vendor/contracts/src/brand.test.ts new file mode 100644 index 0000000..fe35e29 --- /dev/null +++ b/vendor/contracts/src/brand.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { + OPEN_CLOUD_FAVICON_DATA_URI, + OPEN_CLOUD_LOGO_DATA_URI, +} from "./brand.generated.js"; + +function pngDimensions(dataUri: string): [number, number] { + expect(dataUri).toMatch(/^data:image\/png;base64,/); + const image = Buffer.from(dataUri.split(",", 2)[1] ?? "", "base64"); + expect(image.subarray(0, 8).toString("hex")).toBe("89504e470d0a1a0a"); + return [image.readUInt32BE(16), image.readUInt32BE(20)]; +} + +describe("OpenCloud brand assets", () => { + it("exports a two-density interface logo", () => { + expect(pngDimensions(OPEN_CLOUD_LOGO_DATA_URI)).toEqual([96, 96]); + }); + + it("exports a purpose-sized browser icon", () => { + expect(pngDimensions(OPEN_CLOUD_FAVICON_DATA_URI)).toEqual([32, 32]); + }); +}); diff --git a/vendor/contracts/src/control-plane.test.ts b/vendor/contracts/src/control-plane.test.ts index 2ad252c..6d6ba18 100644 --- a/vendor/contracts/src/control-plane.test.ts +++ b/vendor/contracts/src/control-plane.test.ts @@ -34,6 +34,33 @@ describe("controlPlaneOperations", () => { ); }); + it("keeps CLI workspace connection typed and outside the MCP tool surface", () => { + const operation = controlPlaneOperations.connectCliWorkspace; + const appId = "22222222-2222-4222-8222-222222222222"; + + expect(operation).toMatchObject({ + method: "POST", + path: "/v1/apps/{appId}/cli-connection", + scopes: ["app:read"], + idempotency: "none", + }); + expect("mcp" in operation).toBe(false); + expect(operation.input.parse({ appId })).toEqual({ appId }); + expect( + operation.output.parse({ + app: { + id: appId, + name: "Family tasks", + appUrl: "https://family-tasks.opencloud.ai", + }, + credential: { + token: "oc_agent_private-workspace", + expiresAt: "2026-08-09T00:00:00.000Z", + }, + }), + ).toMatchObject({ app: { id: appId } }); + }); + it("does not advertise owner-destructive legacy lifecycle actions to MCP", () => { const tools = Object.values(controlPlaneOperations).flatMap((operation) => operation.mcp ? [operation.mcp.toolName] : [], @@ -56,6 +83,112 @@ describe("controlPlaneOperations", () => { expect(controlPlaneOperations.verifyApp.idempotency).toBe("required"); }); + it("keeps MCP approval hints aligned with high-risk behavior", () => { + const tools = new Map( + Object.values(controlPlaneOperations).flatMap((operation) => + operation.mcp ? [[operation.mcp.toolName, operation.mcp] as const] : [], + ), + ); + const expected = { + start_onboarding: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + create_app: { idempotentHint: false }, + configure_app: { destructiveHint: true }, + apply_file_changes: { destructiveHint: true }, + validate_draft: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + deploy_draft: { destructiveHint: true }, + apply_dev_revision: { destructiveHint: true }, + mutate_dev_data: { destructiveHint: true }, + invoke_dev_function: { destructiveHint: true }, + promote_dev_revision: { destructiveHint: true }, + verify_app: { idempotentHint: false }, + rollback_app: { idempotentHint: false }, + generate_secret: { destructiveHint: true }, + create_backup: { idempotentHint: false }, + configure_backup_schedule: { destructiveHint: true }, + restore_backup: { idempotentHint: false }, + invoke_cron: { destructiveHint: true }, + put_alert_rule: { destructiveHint: true }, + } as const; + + for (const [name, annotations] of Object.entries(expected)) { + expect(tools.get(name), `${name} annotations`).toMatchObject(annotations); + } + for (const name of ["request_dev_app", "mutate_dev_data"]) { + expect(tools.get(name)?.description, `${name} API reference`).toContain( + "https://docs.opencloud.ai/openapi.yaml", + ); + } + for (const operation of Object.values(controlPlaneOperations)) { + if (operation.method === "DELETE" && operation.mcp) { + expect( + operation.mcp.destructiveHint, + `${operation.mcp.toolName} destructiveHint`, + ).toBe(true); + } + } + }); + + it("keeps MCP directory metadata complete and bounded", () => { + for (const operation of Object.values(controlPlaneOperations)) { + if (!operation.mcp) continue; + + const { mcp } = operation; + expect( + mcp.toolName.length, + `${mcp.toolName} name length`, + ).toBeLessThanOrEqual(64); + expect(mcp.title.trim().length, `${mcp.toolName} title`).toBeGreaterThan(0); + expect( + mcp.description.trim().length, + `${mcp.toolName} description`, + ).toBeGreaterThan(0); + expect(mcp.readOnlyHint, `${mcp.toolName} readOnlyHint`).toEqual( + expect.any(Boolean), + ); + expect(mcp.destructiveHint, `${mcp.toolName} destructiveHint`).toEqual( + expect.any(Boolean), + ); + expect(mcp.openWorldHint, `${mcp.toolName} openWorldHint`).toEqual( + expect.any(Boolean), + ); + } + }); + + it("keeps interaction verification optional for legacy clients", () => { + const path = { + appId: "22222222-2222-4222-8222-222222222222", + sessionId: "11111111-1111-4111-8111-111111111111", + }; + + expect(controlPlaneOperations.verifyDevSession.input.parse(path)).toEqual( + path, + ); + expect( + controlPlaneOperations.verifyDevSession.input.parse({ + ...path, + body: {}, + }), + ).toEqual({ ...path, body: {} }); + expect( + controlPlaneOperations.verifyDevSession.input.parse({ + ...path, + body: { requireInteractionContract: true }, + }), + ).toEqual({ + ...path, + body: { requireInteractionContract: true }, + }); + }); + it("treats development capabilities as negotiated booleans", () => { const session = { id: "11111111-1111-4111-8111-111111111111", diff --git a/vendor/contracts/src/control-plane.ts b/vendor/contracts/src/control-plane.ts index ed5357b..da0186b 100644 --- a/vendor/contracts/src/control-plane.ts +++ b/vendor/contracts/src/control-plane.ts @@ -16,9 +16,52 @@ const uuid = z.uuid(); const sha256 = z.string().regex(/^[a-f0-9]{64}$/); const secretName = z.string().regex(/^[A-Z][A-Z0-9_]{0,127}$/); const jsonObject = z.record(z.string(), z.unknown()); -const unknownOutput = z.unknown(); const emptyBody = z.object({}); +const backupOutput = z.object({ + id: uuid, + appId: uuid, + deploymentId: uuid.nullable(), + kind: z.enum(["pre_deployment", "scheduled", "manual"]), + sha256: sha256.nullable(), + state: z.string(), + metadata: jsonObject, + immutableUntil: z.string().nullable(), + createdAt: z.string(), + completedAt: z.string().nullable(), +}); + +const usageRollupOutput = z.object({ + windowStart: z.string(), + windowEnd: z.string(), + calculationVersion: z.string(), + completeness: z.enum(["complete", "partial", "corrected"]), + metrics: jsonObject, + createdAt: z.string(), +}); + +const usageOutput = z.object({ + asOf: z.string(), + latestRollup: usageRollupOutput.nullable(), + rollups: z.array(usageRollupOutput), + freshness: z.object({ + latestRollupCreatedAt: z.string().nullable(), + latestRollupWindowEnd: z.string().nullable(), + rollupAgeSeconds: z.number().nullable(), + latestIngestedAt: z.string().nullable(), + ingestionLagSeconds: z.number().nullable(), + telemetryStatus: z.enum(["available", "unavailable"]), + }), + lastActivity: z.object({ + page: z.string().nullable(), + rest: z.string().nullable(), + storage: z.string().nullable(), + realtime: z.string().nullable(), + function: z.string().nullable(), + cron: z.string().nullable(), + }), +}); + export const controlPlaneAppSchema = z .object({ id: uuid, @@ -29,6 +72,7 @@ export const controlPlaneAppSchema = z apiUrl: z.url(), visibility: appVisibilitySchema, state: appStateSchema, + backupSchedule: z.enum(["none", "daily", "weekly"]).optional(), ownerUserId: uuid, desiredDeploymentId: uuid.nullable(), activeDeploymentId: uuid.nullable(), @@ -88,6 +132,11 @@ export const controlPlaneDeploymentSchema = z const onboardingOutput = z .object({ onboardingId: uuid, + launchUrl: z + .url() + .describe( + "Non-secret owner URL that waits for email confirmation and deployment, then opens the project.", + ), state: z.enum([ "awaiting_email_verification", "provisional_ready", @@ -120,13 +169,7 @@ const draftOutput = z appId: uuid, baseDeploymentId: uuid.nullable(), name: z.string(), - status: z.enum([ - "open", - "validated", - "deploying", - "deployed", - "discarded", - ]), + status: z.enum(["open", "validated", "deploying", "deployed", "discarded"]), revision: z.number().int().positive(), createdAt: z.string(), updatedAt: z.string(), @@ -155,14 +198,24 @@ const draftValidationOutput = z passed: z.boolean(), artifactSha256: sha256.nullable(), manifest: z.unknown().nullable(), + canonicalSourceManifest: z.literal("opencloud.yaml"), + 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({ - level: z.enum(["error", "warning"]), - code: z.string().optional(), - message: z.string(), - }), + z + .object({ + level: z.enum(["error", "warning"]), + code: z.string().optional(), + path: z.string().optional(), + message: z.string(), + suggestedFix: z.string().optional(), + }) + .passthrough(), ), + nextAction: z.string(), createdAt: z.string(), }) .passthrough(); @@ -379,10 +432,7 @@ export interface ControlPlaneOperation< mcp?: McpOperationMetadata; } -function operation< - TInput extends z.ZodType, - TOutput extends z.ZodType, ->( +function operation( value: ControlPlaneOperation, ): ControlPlaneOperation { return value; @@ -397,9 +447,9 @@ export const controlPlaneOperations = { startAgentOnboarding: operation({ method: "POST", path: "/v1/onboarding/agent", - summary: "Start passwordless agent onboarding", + summary: "Start email-based agent onboarding", description: - "Creates a provisional user and first app for a new email, or requests verification for an existing identity.", + "Creates a provisional user and first app for a new email, or requests verification for an existing identity. Returns a non-secret owner launch URL for the pending flow.", auth: "none", scopes: [], input: z.object({ body: startAgentOnboardingRequestSchema }), @@ -410,9 +460,9 @@ export const controlPlaneOperations = { toolName: "start_onboarding", title: "Start OpenCloud onboarding", description: - "Start zero-blocking onboarding for a new email or request ownership verification for an existing email.", + "Start onboarding, send one verification email, and return the non-secret owner launch URL for confirmation and deployment.", readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, idempotentHint: true, openWorldHint: true, }, @@ -455,7 +505,7 @@ export const controlPlaneOperations = { "Create another OpenCloud app with an automatically allocated HTTPS address.", readOnlyHint: false, destructiveHint: false, - idempotentHint: true, + idempotentHint: false, openWorldHint: true, }, }), @@ -499,6 +549,32 @@ export const controlPlaneOperations = { openWorldHint: false, }, }), + connectCliWorkspace: operation({ + method: "POST", + path: "/v1/apps/{appId}/cli-connection", + summary: "Connect a CLI workspace", + description: + "Issues an expiring app-scoped credential linked to the authenticated CLI account login.", + auth: "bearer", + scopes: ["app:read"], + input: appPath, + output: z.object({ + app: z + .object({ + id: uuid, + name: z.string(), + appUrl: z.url(), + }) + .passthrough(), + credential: z + .object({ + token: z.string(), + expiresAt: z.string(), + }) + .passthrough(), + }), + idempotency: "none", + }), configureApp: operation({ method: "PATCH", path: "/v1/apps/{appId}", @@ -524,9 +600,10 @@ export const controlPlaneOperations = { mcp: { toolName: "configure_app", title: "Configure app", - description: "Change the app title or public/private visibility.", + description: + "Replace the app title or public/private visibility, which can publish or revoke public access.", readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, idempotentHint: true, openWorldHint: true, }, @@ -681,7 +758,7 @@ export const controlPlaneOperations = { description: "Create, update, or delete draft files with stale-write protection.", readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, idempotentHint: false, openWorldHint: false, }, @@ -731,10 +808,10 @@ export const controlPlaneOperations = { toolName: "validate_draft", title: "Validate source draft", description: - "Run OpenCloud's authoritative validation and deterministic bundling.", - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, + "Run authoritative validation, record its result, and replace the status for this draft revision.", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, openWorldHint: false, }, }), @@ -756,9 +833,10 @@ export const controlPlaneOperations = { mcp: { toolName: "deploy_draft", title: "Deploy source draft", - description: "Deploy the exact source revision that passed validation.", + description: + "Make the validated source revision active; its migrations and release replace current production state.", readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, idempotentHint: true, openWorldHint: true, }, @@ -812,7 +890,8 @@ export const controlPlaneOperations = { method: "GET", path: "/v1/apps/{appId}/dev-sessions/{sessionId}", summary: "Get a development session", - description: "Returns preview state, capabilities, and verification status.", + description: + "Returns preview state, capabilities, and verification status.", auth: "bearer", scopes: ["app:read"], input: devSessionPath, @@ -843,9 +922,9 @@ export const controlPlaneOperations = { toolName: "apply_dev_revision", title: "Apply dev revision", description: - "Sync the exact validated draft to its stable development preview.", + "Sync the validated draft to its development preview; migration changes reset isolated dev data.", readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, idempotentHint: true, openWorldHint: true, }, @@ -875,7 +954,8 @@ export const controlPlaneOperations = { mcp: { toolName: "request_dev_app", title: "Request dev app", - description: "Inspect a page or REST read from the development preview.", + description: + "Inspect a page or perform a GET or HEAD against the OpenCloud app runtime REST API in the isolated development preview; see https://docs.opencloud.ai/openapi.yaml.", readOnlyHint: true, destructiveHint: false, idempotentHint: true, @@ -908,9 +988,10 @@ export const controlPlaneOperations = { mcp: { toolName: "mutate_dev_data", title: "Write dev fixture data", - description: "Write bounded fixture data only to a dev schema.", + description: + "Create, replace, update, or delete bounded fixture data through the OpenCloud app runtime REST API only in an isolated dev schema; see https://docs.opencloud.ai/openapi.yaml.", readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, idempotentHint: false, openWorldHint: true, }, @@ -938,9 +1019,9 @@ export const controlPlaneOperations = { toolName: "invoke_dev_function", title: "Invoke dev Function", description: - "Explicitly test one development Function and capture correlated diagnostics.", + "Run app-defined development code that may change isolated data or external systems, and capture diagnostics.", readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, idempotentHint: false, openWorldHint: true, }, @@ -953,8 +1034,15 @@ export const controlPlaneOperations = { "Runs Chromium, console, HTTP, and optional primary-flow checks and issues a receipt bound to the exact revision.", auth: "bearer", scopes: ["app:deploy"], - input: devSessionPath, + input: devSessionPath.extend({ + body: z + .object({ + requireInteractionContract: z.boolean().optional(), + }) + .optional(), + }), output: devVerificationOutput, + bodyKey: "body", idempotency: "none", mcp: { toolName: "verify_dev_session", @@ -1034,9 +1122,9 @@ export const controlPlaneOperations = { toolName: "promote_dev_revision", title: "Promote dev revision", description: - "Deploy the exact verified development revision to production.", + "Make the verified development revision active in production, replacing current release state.", readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, idempotentHint: true, openWorldHint: true, }, @@ -1045,7 +1133,8 @@ export const controlPlaneOperations = { method: "DELETE", path: "/v1/apps/{appId}/dev-sessions/{sessionId}", summary: "Stop a development session", - description: "Removes its preview artifacts, Function links, and dev schema.", + description: + "Removes its preview artifacts, Function links, and dev schema.", auth: "bearer", scopes: ["app:deploy"], input: devSessionPath, @@ -1078,11 +1167,10 @@ export const controlPlaneOperations = { mcp: { toolName: "verify_app", title: "Verify app", - description: - "Run the complete OpenCloud release verification gate.", + description: "Run the complete OpenCloud release verification gate.", readOnlyHint: false, destructiveHint: false, - idempotentHint: true, + idempotentHint: false, openWorldHint: true, }, }), @@ -1162,7 +1250,7 @@ export const controlPlaneOperations = { description: "Restore a previously active immutable deployment.", readOnlyHint: false, destructiveHint: true, - idempotentHint: true, + idempotentHint: false, openWorldHint: true, }, }), @@ -1190,7 +1278,8 @@ export const controlPlaneOperations = { method: "GET", path: "/v1/apps/{appId}/secrets", summary: "List secret metadata", - description: "Lists secret names and timestamps; values are never returned.", + description: + "Lists secret names and timestamps; values are never returned.", auth: "bearer", scopes: ["app:configure"], input: appPath, @@ -1232,9 +1321,9 @@ export const controlPlaneOperations = { toolName: "generate_secret", title: "Generate secret", description: - "Generate a strong app secret without exposing its value to the agent.", + "Generate a strong app secret without exposing it, replacing any value already stored under the name.", readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, idempotentHint: false, openWorldHint: false, }, @@ -1296,7 +1385,7 @@ export const controlPlaneOperations = { auth: "bearer", scopes: ["app:read"], input: appPath, - output: z.array(unknownOutput), + output: z.array(backupOutput), idempotency: "none", mcp: { toolName: "list_backups", @@ -1324,7 +1413,7 @@ export const controlPlaneOperations = { description: "Create an immutable backup before a risky change.", readOnlyHint: false, destructiveHint: false, - idempotentHint: true, + idempotentHint: false, openWorldHint: false, }, }), @@ -1346,9 +1435,10 @@ export const controlPlaneOperations = { mcp: { toolName: "configure_backup_schedule", title: "Configure backups", - description: "Configure automatic app backups.", + description: + "Replace the automatic backup schedule, including disabling future scheduled backups.", readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, idempotentHint: true, openWorldHint: false, }, @@ -1369,7 +1459,7 @@ export const controlPlaneOperations = { description: "Restore an app database to a selected backup.", readOnlyHint: false, destructiveHint: true, - idempotentHint: true, + idempotentHint: false, openWorldHint: false, }, }), @@ -1386,6 +1476,7 @@ export const controlPlaneOperations = { name: z.string().optional(), state: z.enum(["running", "succeeded", "failed"]).optional(), after: z.iso.datetime({ offset: true }).optional(), + cursor: z.string().max(512).optional(), limit: z.number().int().min(1).max(200).default(50), }) .optional(), @@ -1425,9 +1516,10 @@ export const controlPlaneOperations = { mcp: { toolName: "invoke_cron", title: "Invoke cron", - description: "Manually invoke an enabled app cron job.", + description: + "Run an enabled production cron Function that may change app data or external systems.", readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, idempotentHint: false, openWorldHint: true, }, @@ -1499,9 +1591,10 @@ export const controlPlaneOperations = { mcp: { toolName: "put_alert_rule", title: "Put alert rule", - description: "Create or replace one bounded app metric alert rule.", + description: + "Create an app metric alert rule or replace the complete existing rule with the same ID.", readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, idempotentHint: true, openWorldHint: false, }, @@ -1534,7 +1627,7 @@ export const controlPlaneOperations = { auth: "bearer", scopes: ["app:observe"], input: appPath.extend({ body: jsonObject }), - output: unknownOutput, + output: jsonObject, bodyKey: "body", idempotency: "none", mcp: { @@ -1555,7 +1648,7 @@ export const controlPlaneOperations = { auth: "bearer", scopes: ["app:observe"], input: appPath.extend({ body: jsonObject }), - output: unknownOutput, + output: jsonObject, bodyKey: "body", idempotency: "none", mcp: { @@ -1575,8 +1668,16 @@ export const controlPlaneOperations = { description: "Returns app-scoped usage rollups.", auth: "bearer", scopes: ["app:observe"], - input: appPath, - output: unknownOutput, + input: appPath.extend({ + query: z + .object({ + from: z.iso.datetime({ offset: true }).optional(), + to: z.iso.datetime({ offset: true }).optional(), + }) + .optional(), + }), + output: usageOutput, + queryKey: "query", idempotency: "none", mcp: { toolName: "get_usage", @@ -1613,13 +1714,10 @@ export const controlPlaneOperations = { }), } as const; -export type ControlPlaneOperationId = - keyof typeof controlPlaneOperations; +export type ControlPlaneOperationId = keyof typeof controlPlaneOperations; -export type ControlPlaneOperationInput< - T extends ControlPlaneOperationId, -> = z.infer<(typeof controlPlaneOperations)[T]["input"]>; +export type ControlPlaneOperationInput = + z.infer<(typeof controlPlaneOperations)[T]["input"]>; -export type ControlPlaneOperationOutput< - T extends ControlPlaneOperationId, -> = z.infer<(typeof controlPlaneOperations)[T]["output"]>; +export type ControlPlaneOperationOutput = + z.infer<(typeof controlPlaneOperations)[T]["output"]>; diff --git a/vendor/contracts/src/index.ts b/vendor/contracts/src/index.ts index e84b0fb..8c45c91 100644 --- a/vendor/contracts/src/index.ts +++ b/vendor/contracts/src/index.ts @@ -1,3 +1,4 @@ export * from "./api.js"; +export * from "./brand.generated.js"; export * from "./control-plane.js"; export * from "./manifest.js"; diff --git a/vendor/control-plane-client/src/index.ts b/vendor/control-plane-client/src/index.ts index 54573fc..a1d131e 100644 --- a/vendor/control-plane-client/src/index.ts +++ b/vendor/control-plane-client/src/index.ts @@ -17,6 +17,7 @@ const OPEN_CLOUD_CLIENT_CAPABILITIES = export interface ClientOptions { apiUrl: string; token?: string | undefined; + tokenProvider?: (() => Promise) | undefined; internalMcpSecret?: string | undefined; fetch?: typeof fetch | undefined; } @@ -131,7 +132,8 @@ export class OpenCloudClient { archive: Buffer, idempotencyKey = randomUUID(), ): Promise { - if (!this.options.token) { + const token = await this.resolveToken(); + if (!token) { throw new Error("An OpenCloud credential is required"); } const form = new FormData(); @@ -148,7 +150,7 @@ export class OpenCloudClient { { method: "POST", headers: { - authorization: `Bearer ${this.options.token}`, + authorization: `Bearer ${token}`, "idempotency-key": idempotencyKey, [OPEN_CLOUD_CLIENT_CAPABILITIES_HEADER]: OPEN_CLOUD_CLIENT_CAPABILITIES, @@ -167,11 +169,12 @@ export class OpenCloudClient { idempotencyKey?: string, timeoutMs = 30_000, ): Promise { + const token = await this.resolveToken(); const response = await this.fetcher(`${this.apiUrl}${requestPath}`, { method, headers: { - ...(this.options.token - ? { authorization: `Bearer ${this.options.token}` } + ...(token + ? { authorization: `Bearer ${token}` } : {}), ...(this.options.internalMcpSecret ? { @@ -191,6 +194,12 @@ export class OpenCloudClient { return this.parse(response); } + private resolveToken(): Promise { + return this.options.tokenProvider + ? this.options.tokenProvider() + : Promise.resolve(this.options.token); + } + private async parse(response: Response): Promise { const text = await response.text(); let body: unknown = text; diff --git a/vendor/control-plane-client/src/token-provider.test.ts b/vendor/control-plane-client/src/token-provider.test.ts new file mode 100644 index 0000000..b316049 --- /dev/null +++ b/vendor/control-plane-client/src/token-provider.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; +import { OpenCloudClient } from "./index.js"; + +describe("OpenCloudClient tokenProvider", () => { + it("resolves a refreshed credential immediately before each request", async () => { + const tokenProvider = vi + .fn() + .mockResolvedValueOnce("oc_agent_first") + .mockResolvedValueOnce("oc_agent_second"); + const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + return Response.json({ + authorization: new Headers(init?.headers).get("authorization"), + }); + }); + const client = new OpenCloudClient({ + apiUrl: "https://api.opencloud.ai", + tokenProvider, + fetch: request as typeof fetch, + }); + + await expect(client.get("/first")).resolves.toEqual({ + authorization: "Bearer oc_agent_first", + }); + await expect(client.get("/second")).resolves.toEqual({ + authorization: "Bearer oc_agent_second", + }); + expect(tokenProvider).toHaveBeenCalledTimes(2); + }); +});