From 2eb4c44336da1598800187cc1f4c305e7f401e03 Mon Sep 17 00:00:00 2001 From: Peter Koomen Date: Tue, 14 Jul 2026 00:55:58 -0700 Subject: [PATCH 01/15] Phase 4: delegate shared utils to Effect and extract the Promise boundary Migrate shared/src to Effect-native per invariant 1 (maximal delegation), update every cli/server call site, and shrink the async/Promise surface to six small documented boundary files. - Delete hand-rolled base64/base64url/hex codecs; call sites use effect/Encoding. shared/src/encoding/base64.ts retains only decodedBase64ByteLength (size limits without allocating the decode; no Effect equivalent), with a conformance test pinning agreement with Encoding.decodeBase64. toArrayBuffer and errorMessage stay with documented rationale. - Delete util/json.ts (parseJson -> Schema.parseJson decoding, isRecord -> Predicate.isRecord) and util/strings.ts (nonEmpty chains are plain ||). - publish/bundle.ts is now the single Schema definition of the bundle wire format: decodePublishBundle deleted, path uniqueness enforced in the schema, api.ts imports the schema instead of redefining it. - Sweep: auth.json validation (cli/src/auth.ts), JWKS env parsing (server config.ts), and API error-body sniffing (cli/src/api.ts) become Schema decodes. - Extract the Web Crypto boundary so auth.ts and login.ts contain no async/await/Promise: HMAC + AES-GCM -> server/core/src/auth-crypto.ts, SHA-256 digests -> shared/src/crypto/digest.ts (shared by cli PKCE and server), Google token-endpoint POST -> google-jwt.ts, and the login loopback Bun.serve handler -> cli/src/commands/login-callback-server.ts. - Signed payloads already carried version + kind through the shared codec (landed with Phase 3); verified and checked off. bun run ci passes in all 13 workspaces; net -228 lines. Co-Authored-By: Claude Fable 5 --- cli/src/api.ts | 16 +- cli/src/auth.ts | 83 +++++----- cli/src/commands/login-callback-server.ts | 74 +++++++++ cli/src/commands/login.ts | 57 +------ cli/src/commands/projects.ts | 21 +-- cli/src/commands/publish.ts | 12 +- cli/src/project-config.ts | 18 +-- notes/improve-harness-plan.md | 16 +- server/core/src/app.ts | 8 +- server/core/src/auth-crypto.ts | 62 ++++++++ server/core/src/auth.ts | 158 ++++++------------- server/core/src/config.ts | 44 +++--- server/core/src/google-jwt.ts | 45 +++++- server/core/src/jwt-rs256.ts | 17 +- server/core/src/publish-request.ts | 15 +- server/core/src/share-request.ts | 8 +- server/core/src/site-records.ts | 3 +- server/core/src/site-store.ts | 12 +- server/core/src/storage.ts | 5 +- server/core/src/tokens.ts | 4 +- server/core/test/app.test.ts | 4 +- server/core/test/auth.test.ts | 4 +- server/core/test/cloudflare-jwt.test.ts | 4 +- server/core/test/google-jwt.test.ts | 4 +- server/core/test/helpers.ts | 7 +- server/core/test/jwt-helpers.ts | 8 +- server/core/test/publish-request.test.ts | 20 +-- server/deploy-cloudflare/src/local-worker.ts | 6 +- server/scripts/server-settings.ts | 1 - shared/src/crypto/digest.ts | 22 +++ shared/src/encoding/base64.ts | 116 +++----------- shared/src/encoding/bytes.ts | 4 +- shared/src/encoding/hex.ts | 18 --- shared/src/publish/api.ts | 21 +-- shared/src/publish/bundle.ts | 81 ++++------ shared/src/util/errors.ts | 5 +- shared/src/util/json.ts | 18 --- shared/src/util/strings.ts | 8 - shared/test/encoding.test.ts | 105 ++++++------ 39 files changed, 532 insertions(+), 602 deletions(-) create mode 100644 cli/src/commands/login-callback-server.ts create mode 100644 server/core/src/auth-crypto.ts create mode 100644 shared/src/crypto/digest.ts delete mode 100644 shared/src/encoding/hex.ts delete mode 100644 shared/src/util/json.ts delete mode 100644 shared/src/util/strings.ts diff --git a/cli/src/api.ts b/cli/src/api.ts index 686f77a..f3fdbc7 100644 --- a/cli/src/api.ts +++ b/cli/src/api.ts @@ -12,13 +12,20 @@ import * as HttpClient from "@effect/platform/HttpClient"; import * as HttpClientRequest from "@effect/platform/HttpClientRequest"; import type * as Path from "@effect/platform/Path"; import * as Effect from "effect/Effect"; +import * as Either from "effect/Either"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { CLI_TOKEN_EXCHANGE_PATH, ProjectResponseSchema } from "../../shared/src/publish/api"; -import { isRecord, parseJson } from "../../shared/src/util/json"; import { readAuthToken, readCfToken, serverApiUrl } from "./auth"; import { CliError, errorMessage } from "./errors"; +/** Decodes a response body as JSON, or null when it is not JSON. */ +const parseBodyJson = (text: string): unknown => + Either.getOrNull(Schema.decodeUnknownEither(Schema.parseJson())(text)); + +/** Matches an `{"error": "..."}` body, tolerating extra fields. */ +const decodeErrorBody = Schema.decodeUnknownOption(Schema.Struct({ error: Schema.String })); + /** A completed API exchange: HTTP status plus the raw and JSON-decoded body. */ export interface ApiResponse { readonly status: number; @@ -68,7 +75,7 @@ export function apiRequest( status: response.status, ok: response.status >= 200 && response.status < 300, text, - json: parseJson(text), + json: parseBodyJson(text), }; }).pipe( Effect.catchTags({ @@ -153,8 +160,9 @@ function edgeBlocked( * proxy HTML error pages, stack dumps) are never echoed wholesale into the terminal — * an HTML page is reduced to its title and anything long is truncated. */ export function apiErrorText(response: ApiResponse): string { - if (isRecord(response.json) && typeof response.json.error === "string" && response.json.error !== "") { - return response.json.error; + const errorBody = Option.getOrNull(decodeErrorBody(response.json)); + if (errorBody != null && errorBody.error !== "") { + return errorBody.error; } const text = response.text.trim(); if (text === "") return `server returned ${response.status}`; diff --git a/cli/src/auth.ts b/cli/src/auth.ts index 2c3d333..dc386b5 100644 --- a/cli/src/auth.ts +++ b/cli/src/auth.ts @@ -9,11 +9,11 @@ import type { PlatformError } from "@effect/platform/Error"; import * as FileSystem from "@effect/platform/FileSystem"; import * as Path from "@effect/platform/Path"; import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Schema from "effect/Schema"; import { homedir } from "node:os"; -import { bytesToBase64Url } from "../../shared/src/encoding/base64"; -import { nonEmpty } from "../../shared/src/util/strings"; +import { sha256Base64Url } from "../../shared/src/crypto/digest"; import { isLoopbackHost } from "../../shared/src/util/url"; -import { isRecord, parseJson } from "../../shared/src/util/json"; import { CliError, errorMessage } from "./errors"; const AUTH_FILE = "auth.json"; @@ -22,18 +22,29 @@ const DEFAULT_APP_SUBDOMAIN = "app"; /** One stored login: the bearer token for a server plus who/when it was issued. * `cfToken` is the Cloudflare Access JWT relayed by a cloudflare-access server at * login; the CLI presents it on API requests so they pass Cloudflare's edge. */ -export interface AuthRecord { - readonly token: string; - readonly email?: string; - readonly cfToken?: string; - readonly updatedAt: string; -} +const AuthRecordSchema = Schema.Struct({ + token: Schema.String, + email: Schema.optionalWith(Schema.String, { nullable: true }), + cfToken: Schema.optionalWith(Schema.String, { nullable: true }), + updatedAt: Schema.String, +}); -/** On-disk shape of auth.json: tokens keyed by normalized server origin. */ -interface AuthFile { - readonly version: 1; - readonly servers: Record; -} +/** One decoded stored login. */ +export type AuthRecord = typeof AuthRecordSchema.Type; + +/** On-disk shape of auth.json: tokens keyed by normalized server origin. The file + * is user-editable, so it is decoded tolerantly (unknown fields survive a rewrite + * of the known ones only in spirit — writes serialize the decoded shape). */ +const AuthFileSchema = Schema.Struct({ + version: Schema.Literal(1), + servers: Schema.Record({ key: Schema.String, value: AuthRecordSchema }), +}); + +/** The decoded auth.json contents. */ +type AuthFile = typeof AuthFileSchema.Type; + +/** Decodes the raw auth.json text, tolerating unknown fields. */ +const decodeAuthFile = Schema.decodeUnknownEither(Schema.parseJson(AuthFileSchema)); /** The transaction material a login generates before opening the browser: the * PKCE verifier (kept local), its S256 challenge (sent to the server), and the @@ -153,25 +164,22 @@ export function loginUrl(server: string, callbackUrl: string, proof: LoginProof) /** Generates the per-login PKCE verifier/challenge pair and callback state. */ export function generateLoginProof(): Effect.Effect { + const state = randomUrlSafe(16); + const codeVerifier = randomUrlSafe(32); return Effect.tryPromise({ - try: async () => { - const state = randomUrlSafe(16); - const codeVerifier = randomUrlSafe(32); - const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier)); - return { state, codeVerifier, codeChallenge: bytesToBase64Url(new Uint8Array(digest)) }; - }, + try: () => sha256Base64Url(codeVerifier), catch: (cause) => new CliError({ code: 1, message: `scratchwork login: ${errorMessage(cause)}` }), - }); + }).pipe(Effect.map((codeChallenge) => ({ state, codeVerifier, codeChallenge }))); } /** Decodes a loopback callback request. Anything without this login's exact state — * a competing local process, a stray request, a mismatched transaction — is null. */ export function decodeLoginCallback(url: URL, expectedState: string): LoginCallback | null { if (url.searchParams.get("state") !== expectedState) return null; - const error = nonEmpty(url.searchParams.get("error") ?? undefined); - if (error != null) return { error: sanitizeLoginError(error) }; - const code = nonEmpty(url.searchParams.get("code") ?? undefined); - return code == null ? null : { code }; + const error = url.searchParams.get("error"); + if (error) return { error: sanitizeLoginError(error) }; + const code = url.searchParams.get("code"); + return code ? { code } : null; } /** Keeps a hostile server from injecting terminal controls through the browser @@ -184,7 +192,7 @@ function sanitizeLoginError(error: string): string { function randomUrlSafe(bytes: number): string { const buffer = new Uint8Array(bytes); crypto.getRandomValues(buffer); - return bytesToBase64Url(buffer); + return Encoding.encodeBase64Url(buffer); } /** @@ -205,14 +213,12 @@ function readAuthFile(): Effect.Effect new CliError({ code: 1, message: `scratchwork: auth file ${path} is corrupt; fix or remove it` }), - ); - } - return parsed; + ), + ); }); } @@ -250,17 +256,6 @@ function candidateServers(server: string): ReadonlyArray { return [...new Set(candidates)]; } -/** Validates the parsed shape of auth.json. */ -function isAuthFile(value: unknown): value is AuthFile { - if (!isRecord(value) || value.version !== 1 || !isRecord(value.servers)) return false; - for (const record of Object.values(value.servers)) { - if (!isRecord(record) || typeof record.token !== "string" || typeof record.updatedAt !== "string") return false; - if (record.email != null && typeof record.email !== "string") return false; - if (record.cfToken != null && typeof record.cfToken !== "string") return false; - } - return true; -} - /** Checks whether a server string already carries an explicit URL scheme. */ function hasScheme(value: string): boolean { return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value); diff --git a/cli/src/commands/login-callback-server.ts b/cli/src/commands/login-callback-server.ts new file mode 100644 index 0000000..e9b4271 --- /dev/null +++ b/cli/src/commands/login-callback-server.ts @@ -0,0 +1,74 @@ +/* + * The one-shot loopback HTTP server behind `scratchwork login`. + * + * This module is a deliberate Promise boundary under invariant 1: Bun.serve's + * fetch handler is inherently async, and the browser's callback response is + * held on a Promise until the back-channel exchange settles so the page + * reports what actually happened rather than assuming success. Everything + * else about the login flow stays Effect-only in login.ts. + */ +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import { decodeLoginCallback, type LoginCallback } from "../auth"; + +/** A running loopback callback server plus its coordination handles. */ +export interface LoginCallbackServer { + /** The ephemeral 127.0.0.1 port the server bound. */ + readonly port: number; + /** Resolves the browser's held callback response with the exchange outcome. */ + readonly settleExchange: (ok: boolean) => void; + /** Stops the server without waiting for in-flight requests. */ + readonly stop: () => void; +} + +/** Starts the Bun loopback server that completes `done` on the first callback + * carrying this login's state, answering with the exchange's real outcome. + * The server binds 127.0.0.1 only, on an ephemeral port, and accepts only the + * exact /callback path; stray or competing requests get an error and the + * listener keeps waiting. */ +export function serveLoginCallback( + done: Deferred.Deferred, + expectedState: string, +): LoginCallbackServer { + let settleExchange: (ok: boolean) => void = () => {}; + const exchangeSettled = new Promise((resolve) => { + settleExchange = resolve; + }); + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(request) { + const url = new URL(request.url); + if (url.pathname !== "/callback") { + return new Response("Not found", { status: 404 }); + } + const decoded = decodeLoginCallback(url, expectedState); + if (decoded == null) { + return new Response("Scratchwork login failed. Return to the terminal.\n", { + status: 400, + headers: { "content-type": "text/plain; charset=utf-8" }, + }); + } + Deferred.unsafeDone(done, Effect.succeed(decoded)); + const ok = "code" in decoded && (await exchangeSettled); + return ok + ? new Response("Scratchwork login complete. You can close this tab.\n", { + headers: { "content-type": "text/plain; charset=utf-8" }, + }) + : new Response("Scratchwork login failed. Return to the terminal.\n", { + status: 400, + headers: { "content-type": "text/plain; charset=utf-8" }, + }); + }, + }); + const port = server.port; + if (port == null) { + server.stop(true); + throw new Error("loopback callback server did not bind a port"); + } + return { + port, + settleExchange, + stop: () => server.stop(false), + }; +} diff --git a/cli/src/commands/login.ts b/cli/src/commands/login.ts index 89c0504..78458ee 100644 --- a/cli/src/commands/login.ts +++ b/cli/src/commands/login.ts @@ -25,7 +25,6 @@ import { } from "../../../shared/src/publish/api"; import { apiJson } from "../api"; import { - decodeLoginCallback, generateLoginProof, loginUrl, normalizeServerUrl, @@ -37,6 +36,7 @@ import { openBrowser } from "../browser"; import { CliError, errorMessage } from "../errors"; import { resolveServerFromCwd } from "../project-config"; import type { LoginConfig } from "../types"; +import { serveLoginCallback } from "./login-callback-server"; /** Everything runLogin and its callers need from the platform. */ type LoginServices = CommandExecutor | FileSystem.FileSystem | Path.Path | HttpClient.HttpClient; @@ -74,21 +74,15 @@ function awaitBrowserLogin( Effect.gen(function* () { const proof = yield* generateLoginProof(); const done = yield* Deferred.make(); - // The browser's callback response is held until the exchange settles, so - // the page reports what actually happened rather than assuming success. - let settleExchange: (ok: boolean) => void = () => {}; - const exchangeSettled = new Promise((resolve) => { - settleExchange = resolve; - }); const callback = yield* Effect.acquireRelease( Effect.try({ - try: () => serveLoginCallback(done, proof.state, exchangeSettled), + try: () => serveLoginCallback(done, proof.state), catch: (cause) => new CliError({ code: 1, message: `scratchwork login: ${errorMessage(cause)}` }), }), - (bunServer) => Effect.sync(() => { + (server) => Effect.sync(() => { // Interruption/timeout must release the browser's pending callback too. - settleExchange(false); - bunServer.stop(false); + server.settleExchange(false); + server.stop(); }), ); const redirectUri = `http://127.0.0.1:${callback.port}/callback`; @@ -100,15 +94,15 @@ function awaitBrowserLogin( const outcome = yield* Deferred.await(done); if ("error" in outcome) { - settleExchange(false); + callback.settleExchange(false); return yield* Effect.fail( new CliError({ code: 1, message: `scratchwork login: the server reported a failed login (${outcome.error})` }), ); } const result = yield* exchangeLoginCode(server, outcome.code, proof.codeVerifier, redirectUri).pipe( - Effect.tapError(() => Effect.sync(() => settleExchange(false))), + Effect.tapError(() => Effect.sync(() => callback.settleExchange(false))), ); - settleExchange(true); + callback.settleExchange(true); // Give the browser a beat to receive the confirmation page before the // scope closes and stops the server. yield* Effect.sleep("250 millis"); @@ -145,38 +139,3 @@ function exchangeLoginCode( }); } -/** Starts the Bun loopback server that completes `done` on the first callback - * carrying this login's state, answering with the exchange's real outcome. */ -function serveLoginCallback( - done: Deferred.Deferred, - expectedState: string, - exchangeSettled: Promise, -) { - return Bun.serve({ - port: 0, - hostname: "127.0.0.1", - async fetch(request) { - const url = new URL(request.url); - if (url.pathname !== "/callback") { - return new Response("Not found", { status: 404 }); - } - const decoded = decodeLoginCallback(url, expectedState); - if (decoded == null) { - return new Response("Scratchwork login failed. Return to the terminal.\n", { - status: 400, - headers: { "content-type": "text/plain; charset=utf-8" }, - }); - } - Deferred.unsafeDone(done, Effect.succeed(decoded)); - const ok = "code" in decoded && (await exchangeSettled); - return ok - ? new Response("Scratchwork login complete. You can close this tab.\n", { - headers: { "content-type": "text/plain; charset=utf-8" }, - }) - : new Response("Scratchwork login failed. Return to the terminal.\n", { - status: 400, - headers: { "content-type": "text/plain; charset=utf-8" }, - }); - }, - }); -} diff --git a/cli/src/commands/projects.ts b/cli/src/commands/projects.ts index 23d6cf5..f64f64e 100644 --- a/cli/src/commands/projects.ts +++ b/cli/src/commands/projects.ts @@ -13,11 +13,11 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { base64ToBytes } from "../../../shared/src/encoding/base64"; +import * as Either from "effect/Either"; +import * as Encoding from "effect/Encoding"; import { ProjectsListResponseSchema } from "../../../shared/src/publish/api"; -import { decodePublishBundle } from "../../../shared/src/publish/bundle"; +import { PublishBundleSchema } from "../../../shared/src/publish/bundle"; import { isSafeProjectIdentifier } from "../../../shared/src/site/identifiers"; -import { isRecord } from "../../../shared/src/util/json"; import { apiErrorText, apiJson, apiRequest, projectApiUrl } from "../api"; import { readAuthToken, serverApiUrl } from "../auth"; import { SKIPPED_DIRECTORIES } from "../dev/target"; @@ -173,8 +173,10 @@ export function runClone( } const token = yield* readAuthToken(ref.server); const body = yield* apiJson("scratchwork clone", projectApiUrl(ref, "/bundle"), { token }); - const bundle = isRecord(body) ? decodePublishBundle(body.bundle) : null; - if (bundle == null) { + const envelope = Option.getOrNull( + Schema.decodeUnknownOption(Schema.Struct({ bundle: PublishBundleSchema }))(body), + ); + if (envelope == null) { return yield* Effect.fail(new CliError({ code: 1, message: "scratchwork clone: invalid server response" })); } @@ -182,11 +184,10 @@ export function runClone( const paths = yield* Path.Path; const destination = paths.resolve(process.cwd(), ref.project); yield* fs.makeDirectory(destination, { recursive: true }); - for (const file of bundle.files) { - const bytes = base64ToBytes(file.contentBase64); - if (bytes == null) { - return yield* Effect.fail(new CliError({ code: 1, message: `scratchwork clone: invalid file content ${file.path}` })); - } + for (const file of envelope.bundle.files) { + const bytes = yield* Encoding.decodeBase64(file.contentBase64).pipe( + Either.mapLeft(() => new CliError({ code: 1, message: `scratchwork clone: invalid file content ${file.path}` })), + ); const outputPath = paths.join(destination, ...file.path.split("/")); yield* fs.makeDirectory(paths.dirname(outputPath), { recursive: true }); yield* fs.writeFile(outputPath, bytes); diff --git a/cli/src/commands/publish.ts b/cli/src/commands/publish.ts index 9901a6b..93871c5 100644 --- a/cli/src/commands/publish.ts +++ b/cli/src/commands/publish.ts @@ -12,7 +12,8 @@ import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { bytesToBase64, decodedBase64ByteLength } from "../../../shared/src/encoding/base64"; +import * as Encoding from "effect/Encoding"; +import { decodedBase64ByteLength } from "../../../shared/src/encoding/base64"; import { PublishResponseSchema, type PublishRequestBody, @@ -21,7 +22,6 @@ import { import { PUBLISH_BUNDLE_VERSION, type PublishBundle } from "../../../shared/src/publish/bundle"; import { isSafeProjectIdentifier, slugifyIdentifier } from "../../../shared/src/site/identifiers"; import { isSafeSitePath, type SitePath } from "../../../shared/src/site/paths"; -import { nonEmpty } from "../../../shared/src/util/strings"; import { apiErrorText, apiRequest } from "../api"; import { readAuthToken, serverApiUrl } from "../auth"; import { openBrowser } from "../browser"; @@ -115,7 +115,7 @@ function createBundle( fs.readFile(paths.join(root, ...sitePath.split("/"))).pipe( Effect.map((bytes) => ({ path: sitePath, - contentBase64: bytesToBase64(bytes), + contentBase64: Encoding.encodeBase64(bytes), })), ), ); @@ -279,8 +279,8 @@ function resolveProjectName( target: { readonly root: string; readonly file?: string }, ): Effect.Effect { return Effect.gen(function* () { - const explicit = nonEmpty(config.project) ?? nonEmpty(projectConfig?.project); - if (explicit != null) { + const explicit = config.project || projectConfig?.project; + if (explicit) { if (!isSafeProjectIdentifier(explicit)) { return yield* Effect.fail(new CliError({ code: 1, @@ -291,7 +291,7 @@ function resolveProjectName( } const derived = target.file != null ? fileStem(target.file) : yield* basename(target.root); - return nonEmpty(slugifyIdentifier(derived, "")); + return slugifyIdentifier(derived, "") || undefined; }); } diff --git a/cli/src/project-config.ts b/cli/src/project-config.ts index cadff7a..f95b1a6 100644 --- a/cli/src/project-config.ts +++ b/cli/src/project-config.ts @@ -13,8 +13,8 @@ import * as Path from "@effect/platform/Path"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { isRecord, parseJson } from "../../shared/src/util/json"; -import { nonEmpty } from "../../shared/src/util/strings"; +import * as Either from "effect/Either"; +import * as Predicate from "effect/Predicate"; import { resolveProjectByPath, type ResolvedProjectRef } from "./api"; import { normalizeServerUrl } from "./auth"; import { CliError } from "./errors"; @@ -60,7 +60,7 @@ export function readProjectConfig( const path = paths.join(current, PROJECT_CONFIG_FILE); if (yield* fs.exists(path).pipe(Effect.catchAll(() => Effect.succeed(false)))) { const text = yield* fs.readFileString(path).pipe(Effect.catchAll(() => Effect.succeed(""))); - const parsed = parseJson(text); + const parsed = Either.getOrNull(Schema.decodeUnknownEither(Schema.parseJson())(text)); yield* rejectLegacyConfig(parsed, path); const config = decodeProjectConfig(parsed); return config == null ? null : { directory: current, config }; @@ -74,7 +74,7 @@ export function readProjectConfig( /** Fails with an explicit error when a config still carries workspace-era fields. */ function rejectLegacyConfig(value: unknown, path: string): Effect.Effect { - if (!isRecord(value)) return Effect.void; + if (!Predicate.isRecord(value)) return Effect.void; const legacy = ["workspace", "routePath"].filter((key) => key in value); if (legacy.length === 0) return Effect.void; return Effect.fail(new CliError({ @@ -103,7 +103,7 @@ export function resolveServer( command: string, ): Effect.Effect { return Effect.gen(function* () { - const server = nonEmpty(explicit) ?? nonEmpty(config?.server); + const server = explicit || config?.server || undefined; if (server == null) { return yield* Effect.fail(new CliError({ code: 1, message: `scratchwork ${command}: server is required` })); } @@ -145,11 +145,11 @@ export function resolveProjectRef(input: { const projectUrl = parseProjectUrl(input.pathOrUrl); if (projectUrl != null) { const server = yield* Effect.try({ - try: () => normalizeServerUrl(nonEmpty(input.server) ?? projectUrl.server), + try: () => normalizeServerUrl(input.server || projectUrl.server), catch: () => new CliError({ code: 1, message: `scratchwork ${input.command}: invalid server` }), }); - const project = nonEmpty(input.project); - if (project != null) return { server, project }; + const project = input.project; + if (project) return { server, project }; const resolved = yield* resolveProjectByPath(server, projectUrl.pathname, input.command); return { server, project: resolved.project }; } @@ -160,7 +160,7 @@ export function resolveProjectRef(input: { const lookup = yield* readProjectConfig(statPath); const config = lookup?.config ?? null; const server = yield* resolveServer(input.server, config, input.command); - const project = nonEmpty(input.project) ?? nonEmpty(config?.project); + const project = input.project || config?.project || undefined; if (project == null) { return yield* Effect.fail(new CliError({ code: 1, diff --git a/notes/improve-harness-plan.md b/notes/improve-harness-plan.md index ad178a4..373f642 100644 --- a/notes/improve-harness-plan.md +++ b/notes/improve-harness-plan.md @@ -77,18 +77,18 @@ Status legend: `[ ]` not started · `[~]` in progress · `[x]` done The invariant starts out violated by existing code, not just guarding new code: much of `shared/src` is hand-rolled utility with a direct Effect stdlib equivalent. Pre-launch churn is accepted here: the goal is to delegate effects, errors, validation, encoding, resource lifetime, concurrency, and test services to Effect wherever it has a maintained equivalent, reducing repository-owned semantics and maximizing the guarantees available to callers. -`[ ]` Migrate `shared/src` to Effect-native and update call sites in cli and server as each util is replaced: +`[x]` Migrate `shared/src` to Effect-native and update call sites in cli and server as each util is replaced: -- `util/json.ts` (`isRecord`, `parseJson`) → `Schema` decoding -- `encoding/{base64,hex,bytes}.ts` → `effect/Encoding` -- `util/strings.ts`, `util/errors.ts` → Effect stdlib / `Data.TaggedError` where they're error-shaped -- `site/*` contract and serving helpers → Effect types at the exported surface +- `util/json.ts` (`isRecord`, `parseJson`) → `Schema` decoding *(deleted; call sites use `Schema.parseJson(...)` decoding and `Predicate.isRecord`)* +- `encoding/{base64,hex,bytes}.ts` → `effect/Encoding` *(hand-rolled codecs deleted; `base64.ts` retains only `decodedBase64ByteLength` — size-without-decode has no Effect equivalent — with a conformance test pinning agreement with `Encoding.decodeBase64`; `bytes.ts`'s `toArrayBuffer` retained with documented rationale, Encoding covers codecs not BufferSource conversion)* +- `util/strings.ts`, `util/errors.ts` → Effect stdlib / `Data.TaggedError` where they're error-shaped *(`strings.ts` deleted — `nonEmpty` collapsed into plain `||` fallbacks; `errors.ts` retained with documented rationale: no Effect stdlib equivalent for unknown-thrown-value → message)* +- `site/*` contract and serving helpers → Effect types at the exported surface *(routing/serve/html/renderer/files were already Effect; `publish/bundle.ts` is now the single Schema definition of the bundle wire format — `decodePublishBundle` deleted, path uniqueness enforced in the schema; remaining `site/*` helpers are pure total functions, which is the Effect-idiomatic shape for them)* -`[ ]` Sweep cli and server for remaining hand-rolled equivalents once shared is clean. Before replacing a helper, preserve its intended behavior with characterization/adversarial tests; after migration, delete the old implementation rather than retaining parallel paths. +`[x]` Sweep cli and server for remaining hand-rolled equivalents once shared is clean. Before replacing a helper, preserve its intended behavior with characterization/adversarial tests; after migration, delete the old implementation rather than retaining parallel paths. *(auth.json validation → Schema in `cli/src/auth.ts`; JWKS env parsing → Schema in `server/core/src/config.ts`; API error-body sniffing → Schema in `cli/src/api.ts`; `db.ts` JSON codecs already Effect-native with domain validation and stay.)* -`[ ]` Extract the async Web Crypto helpers (`hmac` in `auth.ts`, and any similar inline `crypto.subtle` use) into a small dedicated boundary module so `auth.ts` itself stays subject to the Effect-boundary lint. The allowlist stays file-level; boundary files stay tiny. +`[x]` Extract the async Web Crypto helpers (`hmac` in `auth.ts`, and any similar inline `crypto.subtle` use) into a small dedicated boundary module so `auth.ts` itself stays subject to the Effect-boundary lint. The allowlist stays file-level; boundary files stay tiny. *(Boundary set is now six small documented files: `server/core/src/auth-crypto.ts` (HMAC + AES-GCM), `shared/src/crypto/digest.ts` (SHA-256 for PKCE/content hashing, shared by cli and server), `cli/src/commands/login-callback-server.ts` (Bun.serve loopback), plus the pre-existing `jwt-rs256.ts`/`google-jwt.ts`/`cloudflare-jwt.ts` provider boundary — the Google token-endpoint POST moved from `auth.ts` into `google-jwt.ts`. `auth.ts` and `login.ts` contain no async/await/Promise.)* -`[ ]` Bring existing signed payloads into invariant 3 compliance: add a discriminating `kind` to session and OAuth-state payloads, add the CLI authorization-code payload, version the format change deliberately, and verify every kind only through the shared signed-value codec. +`[x]` Bring existing signed payloads into invariant 3 compliance: add a discriminating `kind` to session and OAuth-state payloads, add the CLI authorization-code payload, version the format change deliberately, and verify every kind only through the shared signed-value codec. *(Landed with Phase 3: all four payload schemas carry `version` + `kind` literals and every kind signs/verifies only through `signValue`/`verifySignedValue`; the exhaustive adversarial corpus remains Phase 5.)* - Exempt: `shared/src/site/default-renderer.generated.js` (build artifact written by `renderer/build.js`, not source). diff --git a/server/core/src/app.ts b/server/core/src/app.ts index 0f3c780..e2ff819 100644 --- a/server/core/src/app.ts +++ b/server/core/src/app.ts @@ -23,7 +23,6 @@ import { type ShareResponse, } from "../../../shared/src/publish/api"; import { SiteFiles } from "../../../shared/src/site/files"; -import { parseJson } from "../../../shared/src/util/json"; import { servePath } from "../../../shared/src/site/serve"; import { isLoopbackHost } from "../../../shared/src/util/url"; import { defaultRendererHtml } from "../../../shared/src/site/default-renderer.generated.js"; @@ -245,10 +244,9 @@ function readCliTokenRequest( HttpServerRequest.withMaxBodySize(Option.some(MAX_CLI_TOKEN_BODY_BYTES)), Effect.mapError((cause) => new HttpError({ status: 413, message: "Request body is too large", cause })), ); - const parsed = parseJson(text); - if (parsed == null) { - return yield* Effect.fail(new HttpError({ status: 400, message: "Invalid JSON body" })); - } + const parsed = yield* Schema.decodeUnknown(Schema.parseJson())(text).pipe( + Effect.mapError(() => new HttpError({ status: 400, message: "Invalid JSON body" })), + ); return yield* Schema.decodeUnknown(CliTokenRequestSchema)(parsed, { errors: "all", onExcessProperty: "error", diff --git a/server/core/src/auth-crypto.ts b/server/core/src/auth-crypto.ts new file mode 100644 index 0000000..a0d3f73 --- /dev/null +++ b/server/core/src/auth-crypto.ts @@ -0,0 +1,62 @@ +/** + * Web Crypto primitives for the auth service: HMAC-SHA256 signing of auth + * tokens and AES-GCM protection of relayed credentials. + * + * This module is a deliberate Promise boundary under invariant 1: Web Crypto + * inherently returns Promises, so the async lives here — tiny and auditable — + * and auth.ts (which stays subject to the Effect-only lint) wraps each helper + * exactly once with Effect.tryPromise. Signing stays a chokepoint (invariant + * 3): only auth.ts's signValue/verifySignedValue may call hmacSha256Base64Url. + */ +import * as Either from "effect/Either"; +import * as Encoding from "effect/Encoding"; + +/** Computes a base64url HMAC-SHA256 signature for signed auth values. */ +export async function hmacSha256Base64Url(value: string, secret: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value)); + return Encoding.encodeBase64Url(new Uint8Array(signature)); +} + +/** Encrypts one credential with a key derived from the session secret. The random + * 96-bit IV is prefixed to the AES-GCM ciphertext and encoded as base64url. */ +export async function encryptCredential(value: string, secret: string): Promise { + const iv = new Uint8Array(12); + crypto.getRandomValues(iv); + const key = await credentialEncryptionKey(secret, ["encrypt"]); + const encrypted = new Uint8Array(await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + new TextEncoder().encode(value), + )); + const encoded = new Uint8Array(iv.length + encrypted.length); + encoded.set(iv); + encoded.set(encrypted, iv.length); + return Encoding.encodeBase64Url(encoded); +} + +/** Decrypts a credential produced by encryptCredential. */ +export async function decryptCredential(value: string, secret: string): Promise { + const encoded = Either.getOrNull(Encoding.decodeBase64Url(value)); + if (encoded == null || encoded.length <= 12) throw new Error("invalid encrypted credential"); + const iv = encoded.slice(0, 12); + const ciphertext = encoded.slice(12); + const key = await credentialEncryptionKey(secret, ["decrypt"]); + const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); + return new TextDecoder().decode(decrypted); +} + +/** Derives the fixed-width AES key without using the session secret as raw AES input. */ +async function credentialEncryptionKey( + secret: string, + usages: ReadonlyArray<"encrypt" | "decrypt">, +): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret)); + return crypto.subtle.importKey("raw", digest, "AES-GCM", false, [...usages]); +} diff --git a/server/core/src/auth.ts b/server/core/src/auth.ts index a307928..cc7e7df 100644 --- a/server/core/src/auth.ts +++ b/server/core/src/auth.ts @@ -14,12 +14,14 @@ import * as HttpServerResponse from "@effect/platform/HttpServerResponse"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Either from "effect/Either"; +import * as Encoding from "effect/Encoding"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { base64UrlToBytes, bytesToBase64Url } from "../../../shared/src/encoding/base64"; +import { sha256Base64Url } from "../../../shared/src/crypto/digest"; import { errorMessage } from "../../../shared/src/util/errors"; -import { parseJson } from "../../../shared/src/util/json"; import { accessGroupMatches } from "./access"; +import * as AuthCrypto from "./auth-crypto"; import { verifyCloudflareAccessToken } from "./cloudflare-jwt"; import { ServerConfig, type AuthConfig, type CloudflareAccessAuthConfig, type OAuthAuthConfig } from "./config"; import { @@ -31,7 +33,11 @@ import { sessionCookie, STATE_TTL_SECONDS, } from "./cookies"; -import { verifyGoogleIdToken, type GoogleIdTokenClaims } from "./google-jwt"; +import { + postAuthorizationCodeGrant, + verifyGoogleIdToken, + type GoogleIdTokenClaims, +} from "./google-jwt"; import { timingSafeEqual } from "./tokens"; const GOOGLE_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth"; @@ -151,13 +157,6 @@ const ProjectAccessPayloadSchema = Schema.Struct({ }); type ProjectAccessPayload = typeof ProjectAccessPayloadSchema.Type; -/** Google's token-endpoint response shape, as much of it as the exchange needs. */ -interface GoogleTokenResponse { - readonly id_token?: string; - readonly error?: string; - readonly error_description?: string; -} - /** Auth failure; `status` becomes the HTTP response status. */ export class AuthError extends Data.TaggedError("AuthError")<{ readonly status: number; @@ -244,7 +243,7 @@ function makeGoogleAuth(config: OAuthAuthConfig): AuthShape { const state = randomNonce(); const nonce = randomNonce(); const codeVerifier = randomVerifier(); - const codeChallenge = yield* sha256Base64Url(codeVerifier); + const codeChallenge = yield* sha256Challenge(codeVerifier); const stateToken = yield* signValue( { version: SESSION_VERSION, @@ -665,7 +664,7 @@ export function verifyCliCodeExchange( if (!PKCE_PATTERN.test(codeVerifier)) { return yield* Effect.fail(new AuthError({ status: 400, message: "Invalid code verifier" })); } - const challenge = yield* sha256Base64Url(codeVerifier); + const challenge = yield* sha256Challenge(codeVerifier); if (!timingSafeEqual(challenge, payload.codeChallenge) || redirectUri !== payload.redirectUri) { return yield* Effect.fail(new AuthError({ status: 400, message: "Authorization code does not match this login" })); } @@ -689,24 +688,11 @@ function verifySessionToken( }); } -/** Encrypts one credential with a key derived from the session secret. The random - * 96-bit IV is prefixed to the AES-GCM ciphertext and encoded as base64url. */ +/** Encrypts one credential with a key derived from the session secret (AES-GCM in + * the auth-crypto boundary module). */ function encryptCredential(value: string, secret: string): Effect.Effect { return Effect.tryPromise({ - try: async () => { - const iv = new Uint8Array(12); - crypto.getRandomValues(iv); - const key = await credentialEncryptionKey(secret, ["encrypt"]); - const encrypted = new Uint8Array(await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, - key, - new TextEncoder().encode(value), - )); - const encoded = new Uint8Array(iv.length + encrypted.length); - encoded.set(iv); - encoded.set(encrypted, iv.length); - return bytesToBase64Url(encoded); - }, + try: () => AuthCrypto.encryptCredential(value, secret), catch: (cause) => new AuthError({ status: 500, message: "Could not protect Cloudflare Access credential", cause }), }); } @@ -714,28 +700,11 @@ function encryptCredential(value: string, secret: string): Effect.Effect { return Effect.tryPromise({ - try: async () => { - const encoded = base64UrlToBytes(value); - if (encoded == null || encoded.length <= 12) throw new Error("invalid encrypted credential"); - const iv = encoded.slice(0, 12); - const ciphertext = encoded.slice(12); - const key = await credentialEncryptionKey(secret, ["decrypt"]); - const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); - return new TextDecoder().decode(decrypted); - }, + try: () => AuthCrypto.decryptCredential(value, secret), catch: (cause) => new AuthError({ status: 400, message: "Invalid authorization code", cause }), }); } -/** Derives the fixed-width AES key without using the session secret as raw AES input. */ -async function credentialEncryptionKey( - secret: string, - usages: ReadonlyArray<"encrypt" | "decrypt">, -): Promise { - const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret)); - return crypto.subtle.importKey("raw", digest, "AES-GCM", false, [...usages]); -} - /** Exchanges a Google OAuth code (with the transaction's PKCE verifier) and verifies * the returned ID token. */ function exchangeGoogleCode( @@ -748,23 +717,13 @@ function exchangeGoogleCode( return Effect.gen(function* () { const endpoints = providerEndpoints(config); const { ok, json } = yield* Effect.tryPromise({ - try: async () => { - const body = new URLSearchParams({ - client_id: config.clientId, - client_secret: config.clientSecret, - code, - code_verifier: codeVerifier, - grant_type: "authorization_code", - redirect_uri: redirectUri, - }); - const response = await fetch(endpoints.tokenUrl, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body, - }); - const json = (await response.json().catch(() => null)) as GoogleTokenResponse | null; - return { ok: response.ok, json }; - }, + try: () => postAuthorizationCodeGrant(endpoints.tokenUrl, { + clientId: config.clientId, + clientSecret: config.clientSecret, + code, + codeVerifier, + redirectUri, + }), catch: (cause) => new AuthError({ status: 500, message: errorMessage(cause) }), }); if (!ok || json?.id_token == null) { @@ -818,13 +777,16 @@ function allowedUser(user: AuthUser, config: AuthConfig): boolean { /** Signs arbitrary JSON as a compact HMAC token. */ function signValue(value: unknown, secret: string): Effect.Effect { - return Effect.tryPromise({ - try: async () => { - const payload = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(value))); - const signature = await hmac(payload, secret); - return `${payload}.${signature}`; - }, - catch: (cause) => new AuthError({ status: 500, message: `Could not sign auth token: ${errorMessage(cause)}` }), + return Effect.gen(function* () { + const payload = yield* Effect.try({ + try: () => Encoding.encodeBase64Url(new TextEncoder().encode(JSON.stringify(value))), + catch: (cause) => new AuthError({ status: 500, message: `Could not sign auth token: ${errorMessage(cause)}` }), + }); + const signature = yield* Effect.tryPromise({ + try: () => AuthCrypto.hmacSha256Base64Url(payload, secret), + catch: (cause) => new AuthError({ status: 500, message: `Could not sign auth token: ${errorMessage(cause)}` }), + }); + return `${payload}.${signature}`; }); } @@ -834,37 +796,20 @@ function verifySignedValue( secret: string, schema: Schema.Schema, ): Effect.Effect { - return Effect.tryPromise({ - try: async () => { - const [payload, signature] = token.split("."); - if (!payload || !signature) throw new Error("invalid token"); - const expected = await hmac(payload, secret); - if (!timingSafeEqual(signature, expected)) throw new Error("invalid token signature"); - const bytes = base64UrlToBytes(payload); - if (bytes == null) throw new Error("invalid token payload"); - return parseJson(new TextDecoder().decode(bytes)); - }, - catch: () => new AuthError({ status: 401, message: "Invalid auth token" }), - }).pipe( - Effect.flatMap((value) => - Schema.decodeUnknown(schema)(value).pipe( - Effect.mapError(() => new AuthError({ status: 401, message: "Invalid auth token" })), - ), - ), - ); -} - -/** Computes a base64url HMAC signature for signed auth values. */ -async function hmac(value: string, secret: string): Promise { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value)); - return bytesToBase64Url(new Uint8Array(signature)); + const invalid = () => new AuthError({ status: 401, message: "Invalid auth token" }); + return Effect.gen(function* () { + const [payload, signature] = token.split("."); + if (!payload || !signature) return yield* Effect.fail(invalid()); + const expected = yield* Effect.tryPromise({ + try: () => AuthCrypto.hmacSha256Base64Url(payload, secret), + catch: invalid, + }); + if (!timingSafeEqual(signature, expected)) return yield* Effect.fail(invalid()); + const bytes = yield* Encoding.decodeBase64Url(payload).pipe(Either.mapLeft(invalid)); + return yield* Schema.decodeUnknown(Schema.parseJson(schema))(new TextDecoder().decode(bytes)).pipe( + Effect.mapError(invalid), + ); + }); } /** Extracts a bearer token from the Authorization header. */ @@ -920,23 +865,20 @@ function epochSeconds(): number { function randomNonce(): string { const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); - return bytesToBase64Url(bytes); + return Encoding.encodeBase64Url(bytes); } /** Generates a PKCE code verifier: 32 random bytes as 43 base64url characters. */ function randomVerifier(): string { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); - return bytesToBase64Url(bytes); + return Encoding.encodeBase64Url(bytes); } /** Computes the base64url SHA-256 digest used for PKCE S256 challenges. */ -function sha256Base64Url(value: string): Effect.Effect { +function sha256Challenge(value: string): Effect.Effect { return Effect.tryPromise({ - try: async () => { - const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); - return bytesToBase64Url(new Uint8Array(digest)); - }, + try: () => sha256Base64Url(value), catch: (cause) => new AuthError({ status: 500, message: `Could not compute code challenge: ${errorMessage(cause)}` }), }); } diff --git a/server/core/src/config.ts b/server/core/src/config.ts index b505611..0de08ef 100644 --- a/server/core/src/config.ts +++ b/server/core/src/config.ts @@ -2,7 +2,7 @@ import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { nonEmpty } from "../../../shared/src/util/strings"; +import * as Schema from "effect/Schema"; import { isLoopbackHost } from "../../../shared/src/util/url"; import { isReservedSlug, isSafeProjectIdentifier, normalizeAccessGroup, safeDomain, type AccessGroup } from "./access"; @@ -106,11 +106,11 @@ export function readServerConfig( } const appUrl = yield* readPublicUrl( - nonEmpty(env.SCRATCHWORK_APP_URL) ?? urlFromDomain(env.SCRATCHWORK_APP_DOMAIN), + env.SCRATCHWORK_APP_URL || urlFromDomain(env.SCRATCHWORK_APP_DOMAIN), "SCRATCHWORK_APP_URL", ); const contentUrl = yield* readPublicUrl( - nonEmpty(env.SCRATCHWORK_CONTENT_URL) ?? urlFromDomain(env.SCRATCHWORK_CONTENT_DOMAIN), + env.SCRATCHWORK_CONTENT_URL || urlFromDomain(env.SCRATCHWORK_CONTENT_DOMAIN), "SCRATCHWORK_CONTENT_URL", ); @@ -157,8 +157,8 @@ function readHomepage( contentUrl: string | undefined, ): Effect.Effect, ServerConfigError> { return Effect.gen(function* () { - const domains = nonEmpty(env.SCRATCHWORK_HOMEPAGE_DOMAINS?.trim()); - const project = nonEmpty(env.SCRATCHWORK_HOMEPAGE_PROJECT?.trim()); + const domains = env.SCRATCHWORK_HOMEPAGE_DOMAINS?.trim() || undefined; + const project = env.SCRATCHWORK_HOMEPAGE_PROJECT?.trim() || undefined; if (domains == null && project == null) return { homepageUrls: [] }; if (domains == null || project == null) { return yield* Effect.fail( @@ -255,7 +255,7 @@ function readLocalOAuthEndpoints( "SCRATCHWORK_LOCAL_OAUTH_TOKEN_URL", "SCRATCHWORK_LOCAL_OAUTH_JWKS_URL", ] as const; - const values = names.map((name) => nonEmpty(env[name])); + const values = names.map((name) => env[name] || undefined); if (values.every((value) => value == null)) return Effect.succeed({}); if (appUrl == null || !isLiteralLoopbackHost(new URL(appUrl).hostname)) { return Effect.fail( @@ -299,8 +299,8 @@ function isLiteralLoopbackHost(hostname: string): boolean { /** Parses required Cloudflare Access settings from environment variables. */ function readCloudflareAccessConfig(env: EnvVars, appUrl: string | undefined): Effect.Effect { return Effect.gen(function* () { - const teamDomainValue = nonEmpty(env.SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN?.trim()); - const audience = nonEmpty(env.SCRATCHWORK_CF_ACCESS_AUD?.trim()); + const teamDomainValue = env.SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN?.trim() || undefined; + const audience = env.SCRATCHWORK_CF_ACCESS_AUD?.trim() || undefined; const sessionSecret = env.SCRATCHWORK_SESSION_SECRET; if (teamDomainValue == null || audience == null || !sessionSecret) { @@ -333,6 +333,13 @@ function readCloudflareAccessConfig(env: EnvVars, appUrl: string | undefined): E }); } +/** A JWKS document: JSON with a non-empty array of object-shaped keys. */ +const LocalJwksSchema = Schema.parseJson(Schema.Struct({ + keys: Schema.Array(Schema.Object).pipe( + Schema.filter((keys) => keys.length > 0 || "keys must not be empty"), + ), +})); + /** Parses the generated public JWKS used by the offline Access simulator. Keeping the * override behind a LOCAL-prefixed variable prevents it from becoming part of normal * Cloudflare deployment configuration. */ @@ -348,21 +355,12 @@ function readLocalCloudflareJwks( }), ); } - try { - const parsed = JSON.parse(value) as unknown; - if (typeof parsed !== "object" || parsed == null || !Array.isArray((parsed as { readonly keys?: unknown }).keys)) { - throw new Error("keys is not an array"); - } - const keys = (parsed as { readonly keys: ReadonlyArray }).keys; - if (keys.length === 0 || keys.some((key) => typeof key !== "object" || key == null)) { - throw new Error("keys is empty or invalid"); - } - return Effect.succeed({ localJwks: keys as ReadonlyArray }); - } catch { - return Effect.fail( + return Schema.decodeUnknown(LocalJwksSchema)(value).pipe( + Effect.map((parsed) => ({ localJwks: parsed.keys as ReadonlyArray })), + Effect.mapError(() => invalidValue("SCRATCHWORK_LOCAL_CF_ACCESS_JWKS", value, "a generated JWKS JSON document with at least one public key"), - ); - } + ), + ); } /** Parses the auth settings every mode shares: the session-signing secret (validated @@ -413,7 +411,7 @@ const RETIRED_ENV_VARS: ReadonlyArray = [ /** Fails when a retired environment variable is still set. */ function rejectRetiredEnvVars(env: EnvVars): Effect.Effect { for (const [name, replacement] of RETIRED_ENV_VARS) { - if (nonEmpty(env[name]) != null) { + if (env[name]) { return Effect.fail(new ServerConfigError({ message: `${name} is no longer supported: use ${replacement} instead` })); } } diff --git a/server/core/src/google-jwt.ts b/server/core/src/google-jwt.ts index 96d8bc8..a9ea5a6 100644 --- a/server/core/src/google-jwt.ts +++ b/server/core/src/google-jwt.ts @@ -1,6 +1,10 @@ /** - * Verifies Google OAuth ID tokens: RS256 signature against Google's JWKS via the shared - * machinery in jwt-rs256.ts, plus issuer/audience/expiry/email/nonce claim checks. + * The Google identity-provider back-channel: verifies Google OAuth ID tokens (RS256 + * signature against Google's JWKS via the shared machinery in jwt-rs256.ts, plus + * issuer/audience/expiry/email/nonce claim checks) and POSTs the authorization-code + * grant to the token endpoint. Together with jwt-rs256.ts this is a deliberate + * Promise boundary under invariant 1 (raw fetch + Web Crypto); auth.ts wraps each + * entry point exactly once with Effect.tryPromise. */ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -51,6 +55,43 @@ export function verifyGoogleIdToken( }); } +/** Google's token-endpoint response shape, as much of it as the exchange needs. */ +export interface GoogleTokenResponse { + readonly id_token?: string; + readonly error?: string; + readonly error_description?: string; +} + +/** POSTs an authorization-code grant (with its PKCE verifier) to the token endpoint — + * Google's, or the loopback-gated local test provider's. Network and JSON failures + * surface as a thrown error or a null body for the caller to translate. */ +export async function postAuthorizationCodeGrant( + tokenUrl: string, + params: { + readonly clientId: string; + readonly clientSecret: string; + readonly code: string; + readonly codeVerifier: string; + readonly redirectUri: string; + }, +): Promise<{ readonly ok: boolean; readonly json: GoogleTokenResponse | null }> { + const body = new URLSearchParams({ + client_id: params.clientId, + client_secret: params.clientSecret, + code: params.code, + code_verifier: params.codeVerifier, + grant_type: "authorization_code", + redirect_uri: params.redirectUri, + }); + const response = await fetch(tokenUrl, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + const json = (await response.json().catch(() => null)) as GoogleTokenResponse | null; + return { ok: response.ok, json }; +} + /** Validates issuer, audience, time, email, and nonce claims. */ function validateClaims( claims: GoogleIdTokenClaims, diff --git a/server/core/src/jwt-rs256.ts b/server/core/src/jwt-rs256.ts index 26ddc0c..785e364 100644 --- a/server/core/src/jwt-rs256.ts +++ b/server/core/src/jwt-rs256.ts @@ -9,9 +9,11 @@ * process-global: JWKS refreshes are idempotent, so concurrent cold-start misses at * worst duplicate one fetch. */ -import { base64UrlToBytes } from "../../../shared/src/encoding/base64"; +import * as Either from "effect/Either"; +import * as Encoding from "effect/Encoding"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; import { toArrayBuffer } from "../../../shared/src/encoding/bytes"; -import { isRecord, parseJson } from "../../../shared/src/util/json"; /** Tolerated clock difference for exp/nbf/iat claim checks. */ export const CLOCK_SKEW_SECONDS = 300; @@ -92,7 +94,7 @@ function decodeRs256Jwt(token: string): DecodedRs256Jwt { if (header.crit != null) throw new Error("Token uses unsupported critical headers"); const payload = decodeJwtJson>(encodedPayload); - const signature = base64UrlToBytes(encodedSignature); + const signature = Either.getOrNull(Encoding.decodeBase64Url(encodedSignature)); if (signature == null) throw new Error("Invalid token signature encoding"); return { header, @@ -113,12 +115,15 @@ async function verifySignature(decoded: DecodedRs256Jwt, key: CryptoKey): Promis if (!ok) throw new Error("Invalid token signature"); } +/** Decodes a JSON string (or fails) without running an Effect. */ +const parseJsonEither = Schema.decodeUnknownEither(Schema.parseJson()); + /** Decodes one base64url JWT part as JSON. */ function decodeJwtJson(value: string): A { - const bytes = base64UrlToBytes(value); + const bytes = Either.getOrNull(Encoding.decodeBase64Url(value)); if (bytes == null) throw new Error("Invalid JWT base64url"); - const parsed = parseJson(new TextDecoder().decode(bytes)); - if (!isRecord(parsed)) throw new Error("Invalid JWT JSON"); + const parsed = Either.getOrNull(parseJsonEither(new TextDecoder().decode(bytes))); + if (!Predicate.isRecord(parsed)) throw new Error("Invalid JWT JSON"); return parsed as A; } diff --git a/server/core/src/publish-request.ts b/server/core/src/publish-request.ts index 6dbebd1..c01ea5f 100644 --- a/server/core/src/publish-request.ts +++ b/server/core/src/publish-request.ts @@ -5,7 +5,6 @@ import * as ParseResult from "effect/ParseResult"; import * as Schema from "effect/Schema"; import { decodedBase64ByteLength } from "../../../shared/src/encoding/base64"; import { PublishRequestBodySchema, type PublishRequestBody } from "../../../shared/src/publish/api"; -import { parseJson } from "../../../shared/src/util/json"; import { HttpError } from "./http"; /** Maximum accepted request body size (base64-encoded JSON, larger than the content caps). */ @@ -40,10 +39,9 @@ export function readPublishRequest( if (new TextEncoder().encode(text).byteLength > MAX_PUBLISH_BODY_BYTES) { return yield* Effect.fail(new HttpError({ status: 413, message: "Publish body is too large" })); } - const parsed = parseJson(text); - if (parsed == null) { - return yield* Effect.fail(new HttpError({ status: 400, message: "Invalid JSON body" })); - } + const parsed = yield* Schema.decodeUnknown(Schema.parseJson())(text).pipe( + Effect.mapError(() => new HttpError({ status: 400, message: "Invalid JSON body" })), + ); return yield* decodePublishRequest(parsed); }); } @@ -75,13 +73,10 @@ function normalizePublishRequest(raw: PublishRequestBody): Effect.Effect(); + // Path uniqueness and base64 validity are already guaranteed by the shared + // bundle schema; the size math still needs each file's decoded byte length. let totalBytes = 0; for (const file of raw.bundle.files) { - if (seen.has(file.path)) { - return yield* Effect.fail(new HttpError({ status: 400, message: `Duplicate file path: ${file.path}` })); - } - seen.add(file.path); const bytes = decodedBase64ByteLength(file.contentBase64); if (bytes == null) { return yield* Effect.fail(new HttpError({ status: 400, message: `Invalid base64 content: ${file.path}` })); diff --git a/server/core/src/share-request.ts b/server/core/src/share-request.ts index 639230f..8b08299 100644 --- a/server/core/src/share-request.ts +++ b/server/core/src/share-request.ts @@ -8,7 +8,6 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as ParseResult from "effect/ParseResult"; import * as Schema from "effect/Schema"; -import { parseJson } from "../../../shared/src/util/json"; import { HttpError } from "./http"; import type { ShareChanges } from "./site-store"; @@ -36,10 +35,9 @@ export function readShareRequest( HttpServerRequest.withMaxBodySize(Option.some(MAX_SHARE_BODY_BYTES)), Effect.mapError((cause) => new HttpError({ status: 413, message: "Share body is too large", cause })), ); - const parsed = parseJson(text); - if (parsed == null) { - return yield* Effect.fail(new HttpError({ status: 400, message: "Invalid JSON body" })); - } + const parsed = yield* Schema.decodeUnknown(Schema.parseJson())(text).pipe( + Effect.mapError(() => new HttpError({ status: 400, message: "Invalid JSON body" })), + ); const raw = yield* Schema.decodeUnknown(RawShareRequestSchema)(parsed, { errors: "all", onExcessProperty: "error", diff --git a/server/core/src/site-records.ts b/server/core/src/site-records.ts index 27140af..2a638d3 100644 --- a/server/core/src/site-records.ts +++ b/server/core/src/site-records.ts @@ -8,7 +8,6 @@ * pointer is the single server-wide claim on the project name. */ import * as Schema from "effect/Schema"; -import { isHex } from "../../../shared/src/encoding/hex"; import { isSafeSitePath } from "../../../shared/src/site/paths"; import { isSafeProjectIdentifier } from "./access"; @@ -27,7 +26,7 @@ const SiteOwnerSchema = Schema.Struct({ const SiteFileObjectSchema = Schema.Struct({ path: Schema.String.pipe(Schema.filter((path) => isSafeSitePath(path) || "Invalid site path")), objectKey: Schema.String, - sha256: Schema.String.pipe(Schema.filter((hash) => hash.length === 64 && isHex(hash) || "Invalid SHA-256")), + sha256: Schema.String.pipe(Schema.filter((hash) => /^[0-9a-f]{64}$/.test(hash) || "Invalid SHA-256")), size: Schema.Number.pipe(Schema.filter((size) => Number.isInteger(size) && size >= 0 || "Invalid file size")), contentType: Schema.String, }); diff --git a/server/core/src/site-store.ts b/server/core/src/site-store.ts index be66f2c..9d2b13c 100644 --- a/server/core/src/site-store.ts +++ b/server/core/src/site-store.ts @@ -7,10 +7,11 @@ import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Either from "effect/Either"; +import * as Encoding from "effect/Encoding"; import * as Layer from "effect/Layer"; import * as ParseResult from "effect/ParseResult"; import * as Schema from "effect/Schema"; -import { base64ToBytes, bytesToBase64 } from "../../../shared/src/encoding/base64"; import type { PublishResponse } from "../../../shared/src/publish/api"; import { contentType } from "../../../shared/src/site/content"; import { @@ -487,7 +488,7 @@ function projectBundle( Effect.flatMap((object) => object == null ? Effect.fail(new SiteStoreError({ status: 500, message: `Missing published object: ${file.path}` })) - : Effect.succeed({ path: file.path, contentBase64: bytesToBase64(object.body) }), + : Effect.succeed({ path: file.path, contentBase64: Encoding.encodeBase64(object.body) }), ), ), ); @@ -505,10 +506,9 @@ function buildRevision( return Effect.gen(function* () { const files: Array = []; for (const file of request.bundle.files) { - const bytes = base64ToBytes(file.contentBase64); - if (bytes == null) { - return yield* Effect.fail(new SiteStoreError({ status: 400, message: `Invalid base64 content: ${file.path}` })); - } + const bytes = yield* Encoding.decodeBase64(file.contentBase64).pipe( + Either.mapLeft(() => new SiteStoreError({ status: 400, message: `Invalid base64 content: ${file.path}` })), + ); const hash = yield* sha256Hex(bytes); const objectKey = blobObjectKey(hash); yield* storage.putObject(objectKey, bytes, { diff --git a/server/core/src/storage.ts b/server/core/src/storage.ts index 69f9b96..2346486 100644 --- a/server/core/src/storage.ts +++ b/server/core/src/storage.ts @@ -4,8 +4,7 @@ import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { toArrayBuffer } from "../../../shared/src/encoding/bytes"; -import { bytesToHex } from "../../../shared/src/encoding/hex"; +import { sha256Hex as sha256HexDigest } from "../../../shared/src/crypto/digest"; import { isWithinRoot } from "../../../shared/src/util/fs"; /** Raised when a storage backend cannot complete a read or write. */ @@ -191,7 +190,7 @@ export function safeObjectKey(key: string): boolean { /** Computes a SHA-256 digest as lowercase hex, used for ETags and content addressing. */ export function sha256Hex(bytes: Uint8Array): Effect.Effect { return Effect.tryPromise({ - try: async () => bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", toArrayBuffer(bytes)))), + try: () => sha256HexDigest(bytes), catch: (cause) => new StorageError({ message: "Could not hash bytes", cause }), }); } diff --git a/server/core/src/tokens.ts b/server/core/src/tokens.ts index 833251a..ff6ef2f 100644 --- a/server/core/src/tokens.ts +++ b/server/core/src/tokens.ts @@ -1,4 +1,4 @@ -import { bytesToBase64Url } from "../../../shared/src/encoding/base64"; +import * as Encoding from "effect/Encoding"; /** Slug alphabet without ambiguous characters (no 0/1/i/l/o), safe to read aloud or retype. */ const SLUG_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; @@ -12,7 +12,7 @@ export function randomSlug(): string { /** Generates a random revision identifier for immutable site revisions. */ export function randomRevisionId(): string { - return bytesToBase64Url(randomBytes(REVISION_BYTES)); + return Encoding.encodeBase64Url(randomBytes(REVISION_BYTES)); } /** Compares two strings in constant time per character; unequal lengths return false immediately. */ diff --git a/server/core/test/app.test.ts b/server/core/test/app.test.ts index f7a2ae6..784bea4 100644 --- a/server/core/test/app.test.ts +++ b/server/core/test/app.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import * as Effect from "effect/Effect"; -import { bytesToBase64Url } from "../../../shared/src/encoding/base64"; +import * as Encoding from "effect/Encoding"; import { createSessionToken, issueCliAuthorizationCode, type AuthUser } from "../src/auth"; import type { AuthConfig } from "../src/config"; import { MemoryPrimitiveDbLive } from "../src/db"; @@ -1189,7 +1189,7 @@ describe("server app", () => { async function s256(value: string): Promise { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); - return bytesToBase64Url(new Uint8Array(digest)); + return Encoding.encodeBase64Url(new Uint8Array(digest)); } async function issueCode(): Promise { diff --git a/server/core/test/auth.test.ts b/server/core/test/auth.test.ts index ab6c209..8935f66 100644 --- a/server/core/test/auth.test.ts +++ b/server/core/test/auth.test.ts @@ -2,7 +2,7 @@ import * as HttpServerRequest from "@effect/platform/HttpServerRequest"; import * as HttpServerResponse from "@effect/platform/HttpServerResponse"; import { afterEach, describe, expect, test } from "bun:test"; import * as Effect from "effect/Effect"; -import { bytesToBase64Url } from "../../../shared/src/encoding/base64"; +import * as Encoding from "effect/Encoding"; import { createSessionToken, decodeCliAuthorizationCode, @@ -35,7 +35,7 @@ const CLI_VERIFIER = "test-code-verifier-test-code-verifier-test1"; /** Computes the S256 challenge for a PKCE verifier. */ async function s256(verifier: string): Promise { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); - return bytesToBase64Url(new Uint8Array(digest)); + return Encoding.encodeBase64Url(new Uint8Array(digest)); } /** Builds the /auth/login URL a CLI sends: loopback redirect, state echo, and challenge. */ diff --git a/server/core/test/cloudflare-jwt.test.ts b/server/core/test/cloudflare-jwt.test.ts index c044c89..8949514 100644 --- a/server/core/test/cloudflare-jwt.test.ts +++ b/server/core/test/cloudflare-jwt.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import * as Effect from "effect/Effect"; -import { bytesToBase64Url } from "../../../shared/src/encoding/base64"; +import * as Encoding from "effect/Encoding"; import { verifyCloudflareAccessToken } from "../src/cloudflare-jwt"; import { jwksFetch, makeKeyPair, nowSeconds as now, signJwt } from "./jwt-helpers"; @@ -96,7 +96,7 @@ describe("verifyCloudflareAccessToken", () => { const token = await signJwt(keyPair.privateKey, validClaims()); const [header, , signature] = token.split("."); - const tamperedPayload = bytesToBase64Url(new TextEncoder().encode(JSON.stringify({ + const tamperedPayload = Encoding.encodeBase64Url(new TextEncoder().encode(JSON.stringify({ ...validClaims(), email: "attacker@example.com", }))); diff --git a/server/core/test/google-jwt.test.ts b/server/core/test/google-jwt.test.ts index 0dcf66d..a378127 100644 --- a/server/core/test/google-jwt.test.ts +++ b/server/core/test/google-jwt.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import * as Effect from "effect/Effect"; -import { bytesToBase64Url } from "../../../shared/src/encoding/base64"; +import * as Encoding from "effect/Encoding"; import { verifyGoogleIdToken } from "../src/google-jwt"; import { jwksFetch, makeKeyPair, nowSeconds as now, signJwt } from "./jwt-helpers"; @@ -48,7 +48,7 @@ describe("verifyGoogleIdToken", () => { }); const [header, payload, signature] = token.split("."); - const tamperedPayload = bytesToBase64Url(new TextEncoder().encode(JSON.stringify({ + const tamperedPayload = Encoding.encodeBase64Url(new TextEncoder().encode(JSON.stringify({ iss: "https://accounts.google.com", aud: "client-id", sub: "google-user-1", diff --git a/server/core/test/helpers.ts b/server/core/test/helpers.ts index a29cd3f..572e4e4 100644 --- a/server/core/test/helpers.ts +++ b/server/core/test/helpers.ts @@ -2,8 +2,7 @@ import * as HttpApp from "@effect/platform/HttpApp"; import * as HttpServerResponse from "@effect/platform/HttpServerResponse"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { bytesToBase64 } from "../../../shared/src/encoding/base64"; -import { bytesToHex } from "../../../shared/src/encoding/hex"; +import * as Encoding from "effect/Encoding"; import { app } from "../src/app"; import { Auth, AuthError, AuthLive, type AuthShape, type AuthUser } from "../src/auth"; import { ServerConfig, type ServerConfigShape } from "../src/config"; @@ -24,7 +23,7 @@ export function bundle(files: Record) { version: 1, files: Object.entries(files).map(([path, value]) => ({ path, - contentBase64: bytesToBase64(typeof value === "string" ? new TextEncoder().encode(value) : value), + contentBase64: Encoding.encodeBase64(typeof value === "string" ? new TextEncoder().encode(value) : value), })), }; } @@ -123,7 +122,7 @@ function memoryStorage(map: Map): ObjectStorageShape throw new StorageConflict({ key, message: `Object ETag mismatch: ${key}` }); } const body = value.slice(); - const etag = bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) as ArrayBuffer))); + const etag = Encoding.encodeHex(new Uint8Array(await crypto.subtle.digest("SHA-256", body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) as ArrayBuffer))); map.set(key, { body, contentType: options?.contentType, etag }); return { etag }; }, diff --git a/server/core/test/jwt-helpers.ts b/server/core/test/jwt-helpers.ts index e6d0adb..8ac53ba 100644 --- a/server/core/test/jwt-helpers.ts +++ b/server/core/test/jwt-helpers.ts @@ -4,7 +4,7 @@ * and auth tests. The production JWKS cache is keyed by URL, so tests that generate their * own key pair must also use a JWKS URL (or team domain) unique to that key pair. */ -import { bytesToBase64Url } from "../../../shared/src/encoding/base64"; +import * as Encoding from "effect/Encoding"; import { toArrayBuffer } from "../../../shared/src/encoding/bytes"; /** A generated RSA signing key with its public JWKS entry. */ @@ -31,15 +31,15 @@ export async function makeKeyPair(): Promise { /** Signs a test JWT with the generated RSA private key. */ export async function signJwt(privateKey: CryptoKey, payload: Record): Promise { - const header = bytesToBase64Url(new TextEncoder().encode(JSON.stringify({ alg: "RS256", kid: "kid-1", typ: "JWT" }))); - const body = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(payload))); + const header = Encoding.encodeBase64Url(new TextEncoder().encode(JSON.stringify({ alg: "RS256", kid: "kid-1", typ: "JWT" }))); + const body = Encoding.encodeBase64Url(new TextEncoder().encode(JSON.stringify(payload))); const data = `${header}.${body}`; const signature = await crypto.subtle.sign( "RSASSA-PKCS1-v1_5", privateKey, toArrayBuffer(new TextEncoder().encode(data)), ); - return `${data}.${bytesToBase64Url(new Uint8Array(signature))}`; + return `${data}.${Encoding.encodeBase64Url(new Uint8Array(signature))}`; } /** Returns a fetch mock that serves one JWKS document. */ diff --git a/server/core/test/publish-request.test.ts b/server/core/test/publish-request.test.ts index e8520d0..e001c28 100644 --- a/server/core/test/publish-request.test.ts +++ b/server/core/test/publish-request.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import * as Effect from "effect/Effect"; -import { bytesToBase64 } from "../../../shared/src/encoding/base64"; +import * as Encoding from "effect/Encoding"; import { decodePublishRequest, MAX_PUBLISH_FILE_BYTES, MAX_PUBLISH_FILES, MAX_PUBLISH_TOTAL_BYTES } from "../src/publish-request"; import { bundle } from "./helpers"; @@ -24,8 +24,8 @@ describe("decodePublishRequest", () => { bundle: { version: 1, files: [ - { path: "index.html", contentBase64: bytesToBase64(new TextEncoder().encode("a")) }, - { path: "index.html", contentBase64: bytesToBase64(new TextEncoder().encode("b")) }, + { path: "index.html", contentBase64: Encoding.encodeBase64(new TextEncoder().encode("a")) }, + { path: "index.html", contentBase64: Encoding.encodeBase64(new TextEncoder().encode("b")) }, ], }, }))).rejects.toThrow("Duplicate file path"); @@ -33,7 +33,7 @@ describe("decodePublishRequest", () => { await expect(Effect.runPromise(decodePublishRequest({ bundle: { version: 1, - files: [{ path: "../secret", contentBase64: bytesToBase64(new TextEncoder().encode("x")) }], + files: [{ path: "../secret", contentBase64: Encoding.encodeBase64(new TextEncoder().encode("x")) }], }, }))).rejects.toThrow("Invalid site path"); }); @@ -88,7 +88,7 @@ describe("decodePublishRequest", () => { version: 1, files: Array.from({ length: MAX_PUBLISH_FILES + 1 }, (_, index) => ({ path: `file-${index}.txt`, - contentBase64: bytesToBase64(new Uint8Array([index % 255])), + contentBase64: Encoding.encodeBase64(new Uint8Array([index % 255])), })), }, }))).rejects.toThrow("too many files"); @@ -96,7 +96,7 @@ describe("decodePublishRequest", () => { await expect(Effect.runPromise(decodePublishRequest({ bundle: { version: 1, - files: [{ path: "big.bin", contentBase64: bytesToBase64(new Uint8Array(MAX_PUBLISH_FILE_BYTES + 1)) }], + files: [{ path: "big.bin", contentBase64: Encoding.encodeBase64(new Uint8Array(MAX_PUBLISH_FILE_BYTES + 1)) }], }, }))).rejects.toThrow("too large"); @@ -105,10 +105,10 @@ describe("decodePublishRequest", () => { bundle: { version: 1, files: [ - { path: "a.bin", contentBase64: bytesToBase64(third) }, - { path: "b.bin", contentBase64: bytesToBase64(third) }, - { path: "c.bin", contentBase64: bytesToBase64(third) }, - { path: "d.bin", contentBase64: bytesToBase64(third) }, + { path: "a.bin", contentBase64: Encoding.encodeBase64(third) }, + { path: "b.bin", contentBase64: Encoding.encodeBase64(third) }, + { path: "c.bin", contentBase64: Encoding.encodeBase64(third) }, + { path: "d.bin", contentBase64: Encoding.encodeBase64(third) }, ], }, }))).rejects.toThrow("Publish bundle is too large"); diff --git a/server/deploy-cloudflare/src/local-worker.ts b/server/deploy-cloudflare/src/local-worker.ts index 3fe8048..cfd1747 100644 --- a/server/deploy-cloudflare/src/local-worker.ts +++ b/server/deploy-cloudflare/src/local-worker.ts @@ -3,7 +3,7 @@ * adds at the edge, then delegates to the production Worker. Wrangler supplies local R2 * and D1 bindings; this wrapper supplies the one edge feature Miniflare cannot emulate. */ -import { bytesToBase64Url } from "../../../shared/src/encoding/base64"; +import * as Encoding from "effect/Encoding"; import { toArrayBuffer } from "../../../shared/src/encoding/bytes"; import worker from "./worker"; @@ -80,7 +80,7 @@ export async function issueLocalAccessAssertion(env: LocalAccessEnv, now = epoch key, toArrayBuffer(new TextEncoder().encode(signed)), ); - return `${signed}.${bytesToBase64Url(new Uint8Array(signature))}`; + return `${signed}.${Encoding.encodeBase64Url(new Uint8Array(signature))}`; } /** Imports and caches the generated private key for the life of the local isolate. */ @@ -107,7 +107,7 @@ function normalizedTeamDomain(value: string): string { } function encodeJson(value: unknown): string { - return bytesToBase64Url(new TextEncoder().encode(JSON.stringify(value))); + return Encoding.encodeBase64Url(new TextEncoder().encode(JSON.stringify(value))); } function epochSeconds(): number { diff --git a/server/scripts/server-settings.ts b/server/scripts/server-settings.ts index d7fcabe..c3d2a3d 100644 --- a/server/scripts/server-settings.ts +++ b/server/scripts/server-settings.ts @@ -6,7 +6,6 @@ import * as Effect from "effect/Effect"; import * as Either from "effect/Either"; import { readServerConfig } from "../core/src/config"; -import { nonEmpty } from "../../shared/src/util/strings"; import type { DeployEnv } from "./env"; /** The `server` section of a deploy project's config, mapped onto SCRATCHWORK_* env vars. */ diff --git a/shared/src/crypto/digest.ts b/shared/src/crypto/digest.ts new file mode 100644 index 0000000..e48032e --- /dev/null +++ b/shared/src/crypto/digest.ts @@ -0,0 +1,22 @@ +/* + * SHA-256 digest helpers over Web Crypto, shared by the CLI (PKCE challenge + * generation) and server (PKCE challenge verification, content hashing). + * + * This module is a deliberate Promise boundary under invariant 1: Web Crypto + * inherently returns Promises, so the async lives here — small and auditable — + * and callers wrap these helpers exactly once with Effect.tryPromise. + */ +import * as Encoding from "effect/Encoding"; +import { toArrayBuffer } from "../encoding/bytes"; + +/** Computes the base64url SHA-256 digest of a UTF-8 string (PKCE S256). */ +export async function sha256Base64Url(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return Encoding.encodeBase64Url(new Uint8Array(digest)); +} + +/** Computes the lowercase-hex SHA-256 digest of raw bytes (content hashing). */ +export async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest("SHA-256", toArrayBuffer(bytes)); + return Encoding.encodeHex(new Uint8Array(digest)); +} diff --git a/shared/src/encoding/base64.ts b/shared/src/encoding/base64.ts index 1a20a3e..5e32ab2 100644 --- a/shared/src/encoding/base64.ts +++ b/shared/src/encoding/base64.ts @@ -1,104 +1,24 @@ /* - * Dependency-free base64 codecs (standard padded and unpadded URL-safe). - * Hand-rolled so the same code runs in Bun, Lambda, and Cloudflare Workers - * without Buffer or other platform APIs. Decoders return null on invalid - * input rather than throwing. + * Base64 size arithmetic. The base64 codecs themselves are `effect/Encoding` + * (encodeBase64/decodeBase64/encodeBase64Url/decodeBase64Url); this module + * keeps only what Effect has no equivalent for: computing the decoded byte + * length of standard base64 without allocating the bytes, so size limits can + * be enforced before (and without) decoding multi-megabyte file contents. + * + * Acceptance must stay in agreement with Encoding.decodeBase64 — a value this + * function sizes is a value the decoder will accept, and vice versa. The + * conformance test in shared/test/encoding.test.ts pins that property. */ -const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; -const BASE64_URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; +/** Standard base64 with optional trailing padding; `=` only at the end. */ +const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/; -/** Encodes bytes as standard padded base64. */ -export function bytesToBase64(bytes: Uint8Array): string { - return encodeBase64(bytes, BASE64_ALPHABET, true); -} - -/** Decodes standard base64, returning null for invalid input. */ -export function base64ToBytes(value: string): Uint8Array | null { - const normalized = value.replace(/\s/g, ""); - if (!isBase64(normalized)) return null; - return decodeBase64(normalized, BASE64_ALPHABET); -} - -/** Encodes bytes as unpadded URL-safe base64. */ -export function bytesToBase64Url(bytes: Uint8Array): string { - return encodeBase64(bytes, BASE64_URL_ALPHABET, false); -} - -/** Decodes URL-safe base64, returning null for invalid input. */ -export function base64UrlToBytes(value: string): Uint8Array | null { - if (!/^[A-Za-z0-9_-]*={0,2}$/.test(value) || value.length % 4 === 1) return null; - const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); - const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); - if (!isBase64(padded)) return null; - return decodeBase64(padded, BASE64_ALPHABET); -} - -/** Computes decoded standard-base64 byte length without allocating the bytes. */ +/** Computes decoded standard-base64 byte length without allocating the bytes. + * Returns null for input Encoding.decodeBase64 would reject. */ export function decodedBase64ByteLength(value: string): number | null { - const normalized = value.replace(/\s/g, ""); - if (!isBase64(normalized)) return null; - const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0; - return Math.floor((normalized.length * 3) / 4) - padding; -} - -/** Encodes bytes with the requested base64 alphabet and padding mode. */ -function encodeBase64(bytes: Uint8Array, alphabet: string, padding: boolean): string { - let output = ""; - for (let index = 0; index < bytes.length; index += 3) { - const a = bytes[index]; - const b = bytes[index + 1] ?? 0; - const c = bytes[index + 2] ?? 0; - const triplet = (a << 16) | (b << 8) | c; - output += alphabet[(triplet >> 18) & 63]; - output += alphabet[(triplet >> 12) & 63]; - if (index + 1 < bytes.length) { - output += alphabet[(triplet >> 6) & 63]; - } else if (padding) { - output += "="; - } - if (index + 2 < bytes.length) { - output += alphabet[triplet & 63]; - } else if (padding) { - output += "="; - } - } - return output; -} - -/** Decodes already-validated base64 using the requested alphabet. */ -function decodeBase64(value: string, alphabet: string): Uint8Array { - const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; - const bytes = new Uint8Array(Math.floor((value.length * 3) / 4) - padding); - let byteIndex = 0; - - for (let index = 0; index < value.length; index += 4) { - const a = alphabet.indexOf(value[index]); - const b = alphabet.indexOf(value[index + 1]); - const c = value[index + 2] === "=" ? 0 : alphabet.indexOf(value[index + 2]); - const d = value[index + 3] === "=" ? 0 : alphabet.indexOf(value[index + 3]); - const triplet = (a << 18) | (b << 12) | (c << 6) | d; - - if (byteIndex < bytes.length) bytes[byteIndex++] = (triplet >> 16) & 255; - if (byteIndex < bytes.length) bytes[byteIndex++] = (triplet >> 8) & 255; - if (byteIndex < bytes.length) bytes[byteIndex++] = triplet & 255; - } - - return bytes; -} - -/** Validates standard padded base64 syntax. */ -function isBase64(value: string): boolean { - if (value.length % 4 !== 0) return false; - const firstPadding = value.indexOf("="); - const contentEnd = firstPadding === -1 ? value.length : firstPadding; - const padding = firstPadding === -1 ? 0 : value.length - firstPadding; - if (padding > 2) return false; - for (let index = 0; index < contentEnd; index++) { - if (BASE64_ALPHABET.indexOf(value[index]) === -1) return false; - } - for (let index = contentEnd; index < value.length; index++) { - if (value[index] !== "=") return false; - } - return true; + // Encoding.decodeBase64 strips CR/LF before validating; mirror that. + const stripped = value.replace(/[\r\n]/g, ""); + if (stripped.length % 4 !== 0 || !BASE64_PATTERN.test(stripped)) return null; + const padding = stripped.endsWith("==") ? 2 : stripped.endsWith("=") ? 1 : 0; + return (stripped.length / 4) * 3 - padding; } diff --git a/shared/src/encoding/bytes.ts b/shared/src/encoding/bytes.ts index f98dd61..dc74bd2 100644 --- a/shared/src/encoding/bytes.ts +++ b/shared/src/encoding/bytes.ts @@ -1,6 +1,8 @@ /* * Byte-buffer conversion helpers shared by any code that hands bytes to Web - * APIs (crypto, Response bodies) that reject Uint8Array views. + * APIs (crypto, Response bodies) that reject Uint8Array views. Deliberately + * retained under invariant 1: effect/Encoding covers codecs, not BufferSource + * conversion, so there is no Effect equivalent to delegate to. */ /** Copies a Uint8Array view into an ArrayBuffer accepted by Web Crypto. */ diff --git a/shared/src/encoding/hex.ts b/shared/src/encoding/hex.ts deleted file mode 100644 index fd5c4c1..0000000 --- a/shared/src/encoding/hex.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Lowercase hex encoding and validation, used for content hashes and tokens - * that travel between the CLI and server. - */ - -/** Encodes bytes as lowercase hex. */ -export function bytesToHex(bytes: Uint8Array): string { - let output = ""; - for (const byte of bytes) { - output += byte.toString(16).padStart(2, "0"); - } - return output; -} - -/** Checks whether a string is non-empty even-length lowercase hex. */ -export function isHex(value: string): boolean { - return value.length > 0 && value.length % 2 === 0 && /^[0-9a-f]+$/.test(value); -} diff --git a/shared/src/publish/api.ts b/shared/src/publish/api.ts index fbb2783..2de9a72 100644 --- a/shared/src/publish/api.ts +++ b/shared/src/publish/api.ts @@ -7,27 +7,10 @@ * so a newer server adding fields never breaks an older CLI. */ import * as Schema from "effect/Schema"; -import { decodedBase64ByteLength } from "../encoding/base64"; import { isSafeProjectIdentifier } from "../site/identifiers"; -import { isSafeSitePath } from "../site/paths"; -import { PUBLISH_BUNDLE_VERSION } from "./bundle"; - -/** One file in a publish bundle: a safe site-relative path plus base64 content. */ -const PublishBundleFileSchema = Schema.Struct({ - path: Schema.String.pipe( - Schema.filter((path) => isSafeSitePath(path) || "Invalid site path"), - ), - contentBase64: Schema.String.pipe( - Schema.filter((content) => decodedBase64ByteLength(content) != null || "Invalid base64 content"), - ), -}); +import { PublishBundleSchema } from "./bundle"; -/** A complete publish upload: format version plus every file in the site. - * The runtime shape matches PublishBundle in bundle.ts. */ -export const PublishBundleSchema = Schema.Struct({ - version: Schema.Literal(PUBLISH_BUNDLE_VERSION), - files: Schema.Array(PublishBundleFileSchema), -}); +export { PublishBundleSchema }; /** The JSON body of `POST /api/publish`. `project` is optional at the protocol * level — a random-naming server mints a name when none is sent; `isPublic` is diff --git a/shared/src/publish/bundle.ts b/shared/src/publish/bundle.ts index a2de9eb..be861b0 100644 --- a/shared/src/publish/bundle.ts +++ b/shared/src/publish/bundle.ts @@ -1,57 +1,44 @@ /* * The publish-bundle wire format: the JSON body the CLI sends to the server's - * publish endpoint. Both sides use decodePublishBundle as the single - * definition of what a valid bundle is. + * publish endpoint. PublishBundleSchema is the single definition of what a + * valid bundle is — the server decodes uploads through it (via the publish + * request schema in api.ts) and the CLI decodes clone downloads through it. */ -import { base64ToBytes } from "../encoding/base64"; -import { isRecord } from "../util/json"; -import { isSafeSitePath, type SitePath } from "../site/paths"; +import * as Schema from "effect/Schema"; +import { decodedBase64ByteLength } from "../encoding/base64"; +import { isSafeSitePath } from "../site/paths"; /** Version number both sides must agree on before reading a bundle. */ export const PUBLISH_BUNDLE_VERSION = 1; -/** One file in a bundle: its site-relative path and base64-encoded content. */ -export interface PublishBundleFile { - readonly path: SitePath; - readonly contentBase64: string; -} +/** One file in a bundle: a safe site-relative path plus base64 content. */ +export const PublishBundleFileSchema = Schema.Struct({ + path: Schema.String.pipe( + Schema.filter((path) => isSafeSitePath(path) || "Invalid site path"), + ), + contentBase64: Schema.String.pipe( + Schema.filter((content) => decodedBase64ByteLength(content) != null || "Invalid base64 content"), + ), +}); -/** A complete publish upload: format version plus every file in the site. */ -export interface PublishBundle { - readonly version: typeof PUBLISH_BUNDLE_VERSION; - readonly files: ReadonlyArray; -} +/** A complete publish upload: format version plus every file in the site, + * each under a unique path. */ +export const PublishBundleSchema = Schema.Struct({ + version: Schema.Literal(PUBLISH_BUNDLE_VERSION), + files: Schema.Array(PublishBundleFileSchema).pipe( + Schema.filter((files) => { + const seen = new Set(); + for (const file of files) { + if (seen.has(file.path)) return `Duplicate file path: ${file.path}`; + seen.add(file.path); + } + return true; + }), + ), +}); -/** - * Validates an untrusted JSON value as a publish bundle. Returns null unless - * the version matches and every file has a safe, unique path and valid - * base64 content. - */ -export function decodePublishBundle(value: unknown): PublishBundle | null { - if (!isRecord(value) || value.version !== PUBLISH_BUNDLE_VERSION) { - return null; - } - if (!Array.isArray(value.files)) return null; - - const seen = new Set(); - const files: Array = []; - for (const file of value.files) { - if (!isRecord(file)) return null; - if (typeof file.path !== "string" || !isSafeSitePath(file.path)) { - return null; - } - if (seen.has(file.path)) return null; - if (typeof file.contentBase64 !== "string" || base64ToBytes(file.contentBase64) == null) return null; - - seen.add(file.path); - files.push({ - path: file.path, - contentBase64: file.contentBase64, - }); - } +/** One decoded bundle file. */ +export type PublishBundleFile = typeof PublishBundleFileSchema.Type; - return { - version: PUBLISH_BUNDLE_VERSION, - files, - }; -} +/** A decoded publish bundle. */ +export type PublishBundle = typeof PublishBundleSchema.Type; diff --git a/shared/src/util/errors.ts b/shared/src/util/errors.ts index a6ea3ec..01acc7b 100644 --- a/shared/src/util/errors.ts +++ b/shared/src/util/errors.ts @@ -1,6 +1,9 @@ /* * Error formatting for catch blocks and failure logs, shared so every - * surface renders thrown values the same way. + * surface renders thrown values the same way. Deliberately retained under + * invariant 1: the error types themselves are Data.TaggedError, but Effect + * has no stdlib equivalent for reducing an unknown thrown value to its + * human-readable message. */ /** Converts arbitrary thrown values into readable error messages. */ diff --git a/shared/src/util/json.ts b/shared/src/util/json.ts deleted file mode 100644 index 7e985e8..0000000 --- a/shared/src/util/json.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Small helpers for handling untrusted JSON at the edges of the system - * (request bodies, API responses). - */ - -/** Parses JSON and returns null instead of throwing on invalid input. */ -export function parseJson(text: string): unknown { - try { - return JSON.parse(text); - } catch { - return null; - } -} - -/** Checks whether an unknown value is a non-array object. */ -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/shared/src/util/strings.ts b/shared/src/util/strings.ts deleted file mode 100644 index 7d86fc3..0000000 --- a/shared/src/util/strings.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * String normalization helpers shared by CLI and server configuration code. - */ - -/** Collapses empty strings to undefined so config values can use `??` chains. */ -export function nonEmpty(value: string | undefined): string | undefined { - return value == null || value === "" ? undefined : value; -} diff --git a/shared/test/encoding.test.ts b/shared/test/encoding.test.ts index 3fd82f4..0486a05 100644 --- a/shared/test/encoding.test.ts +++ b/shared/test/encoding.test.ts @@ -1,73 +1,58 @@ /* - * First tests owned by the shared workspace itself. shared code is also - * exercised transitively through cli and server suites; these cover the - * dependency-free codecs directly. + * Tests for the one retained base64 helper. The codecs themselves are + * effect/Encoding (exercised transitively through cli and server suites); + * what must hold here is that decodedBase64ByteLength accepts exactly the + * strings Encoding.decodeBase64 accepts and sizes them exactly. */ import { describe, expect, test } from "bun:test"; -import { - base64ToBytes, - base64UrlToBytes, - bytesToBase64, - bytesToBase64Url, - decodedBase64ByteLength, -} from "../src/encoding/base64"; -import { isRecord, parseJson } from "../src/util/json"; +import * as Either from "effect/Either"; +import * as Encoding from "effect/Encoding"; +import { decodedBase64ByteLength } from "../src/encoding/base64"; const bytes = (...values: number[]) => Uint8Array.from(values); -describe("base64", () => { - test("round-trips every 0–2 padding length", () => { - for (const input of [bytes(), bytes(1), bytes(1, 2), bytes(1, 2, 3), bytes(255, 0, 128, 7)]) { - expect(base64ToBytes(bytesToBase64(input))).toEqual(input); +describe("decodedBase64ByteLength", () => { + test("matches actual decoded length for every 0–2 padding length", () => { + for (const input of [bytes(), bytes(9), bytes(9, 8), bytes(9, 8, 7), bytes(255, 0, 128, 7)]) { + expect(decodedBase64ByteLength(Encoding.encodeBase64(input))).toBe(input.length); } }); - test("matches the standard alphabet and padding", () => { - expect(bytesToBase64(new TextEncoder().encode("Ma"))).toBe("TWE="); - expect(base64ToBytes("TWFu")).toEqual(new TextEncoder().encode("Man")); - }); - test("returns null for invalid input instead of throwing", () => { - expect(base64ToBytes("not base64!")).toBeNull(); - expect(base64ToBytes("TWE")).toBeNull(); - expect(base64ToBytes("TW==E===")).toBeNull(); - }); - - test("decodedBase64ByteLength matches actual decoded length", () => { - for (const input of [bytes(), bytes(9), bytes(9, 8), bytes(9, 8, 7)]) { - expect(decodedBase64ByteLength(bytesToBase64(input))).toBe(input.length); - } expect(decodedBase64ByteLength("###")).toBeNull(); - }); -}); - -describe("base64url", () => { - test("round-trips without padding using the URL-safe alphabet", () => { - const input = bytes(251, 239, 190); - const encoded = bytesToBase64Url(input); - expect(encoded).not.toContain("="); - expect(encoded).not.toContain("+"); - expect(encoded).not.toContain("/"); - expect(base64UrlToBytes(encoded)).toEqual(input); - }); - - test("returns null for standard-alphabet or truncated input", () => { - expect(base64UrlToBytes("++++")).toBeNull(); - expect(base64UrlToBytes("TWFué")).toBeNull(); - expect(base64UrlToBytes("A")).toBeNull(); - }); -}); - -describe("json", () => { - test("parseJson returns the value or null, never throws", () => { - expect(parseJson('{"a":1}')).toEqual({ a: 1 }); - expect(parseJson("nope")).toBeNull(); - }); - - test("isRecord accepts plain objects only", () => { - expect(isRecord({})).toBe(true); - expect(isRecord([])).toBe(false); - expect(isRecord(null)).toBe(false); - expect(isRecord("x")).toBe(false); + expect(decodedBase64ByteLength("TWE")).toBeNull(); + expect(decodedBase64ByteLength("TW==E===")).toBeNull(); + expect(decodedBase64ByteLength("TW E=")).toBeNull(); + }); + + test("agrees with Encoding.decodeBase64 on acceptance and size", () => { + const corpus = [ + "", + "TWE=", + "TWFu", + "TWFu\n", + "TWFu\r\n", + "AA==", + "====", + "A===", + "=AAA", + "TW=u", + "TWE", + "A", + "TW E=", + "TWFué", + "+/+/", + "-_-_", + Encoding.encodeBase64(bytes(1, 2, 3, 4, 5)), + ]; + for (const value of corpus) { + const decoded = Encoding.decodeBase64(value); + const length = decodedBase64ByteLength(value); + if (Either.isRight(decoded)) { + expect(length).toBe(decoded.right.length); + } else { + expect(length).toBeNull(); + } + } }); }); From e2baab408e584caa70f25619a7b2c78d7055317a Mon Sep 17 00:00:00 2001 From: Peter Koomen Date: Tue, 14 Jul 2026 08:38:45 -0700 Subject: [PATCH 02/15] Fix Phase 4 review findings - generateLoginProof: run randomness inside the Effect (Effect.sync) so re-running yields fresh state/verifier instead of reusing values captured at construction - google-jwt: decode the token-endpoint response with a tolerant Schema instead of an unvalidated cast; malformed bodies become null - decodedBase64ByteLength: skip the CR/LF-strip copy when the input has no CR/LF (the common multi-megabyte publish case) - auth.json schema comment: state plainly that unknown fields survive reads but are dropped by the next write - LoginCallbackServer.stop doc: stop(false) waits for in-flight requests, which is why settleExchange must be called first Co-Authored-By: Claude Fable 5 --- cli/src/auth.ts | 19 +++++++++++-------- cli/src/commands/login-callback-server.ts | 4 +++- server/core/src/google-jwt.ts | 23 +++++++++++++++-------- shared/src/encoding/base64.ts | 5 +++-- 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/cli/src/auth.ts b/cli/src/auth.ts index dc386b5..fb1f1f4 100644 --- a/cli/src/auth.ts +++ b/cli/src/auth.ts @@ -33,8 +33,8 @@ const AuthRecordSchema = Schema.Struct({ export type AuthRecord = typeof AuthRecordSchema.Type; /** On-disk shape of auth.json: tokens keyed by normalized server origin. The file - * is user-editable, so it is decoded tolerantly (unknown fields survive a rewrite - * of the known ones only in spirit — writes serialize the decoded shape). */ + * is user-editable, so unknown fields are tolerated on read — but the decode drops + * them, so the next write rewrites the file with only the fields declared here. */ const AuthFileSchema = Schema.Struct({ version: Schema.Literal(1), servers: Schema.Record({ key: Schema.String, value: AuthRecordSchema }), @@ -164,12 +164,15 @@ export function loginUrl(server: string, callbackUrl: string, proof: LoginProof) /** Generates the per-login PKCE verifier/challenge pair and callback state. */ export function generateLoginProof(): Effect.Effect { - const state = randomUrlSafe(16); - const codeVerifier = randomUrlSafe(32); - return Effect.tryPromise({ - try: () => sha256Base64Url(codeVerifier), - catch: (cause) => new CliError({ code: 1, message: `scratchwork login: ${errorMessage(cause)}` }), - }).pipe(Effect.map((codeChallenge) => ({ state, codeVerifier, codeChallenge }))); + return Effect.gen(function* () { + const state = yield* Effect.sync(() => randomUrlSafe(16)); + const codeVerifier = yield* Effect.sync(() => randomUrlSafe(32)); + const codeChallenge = yield* Effect.tryPromise({ + try: () => sha256Base64Url(codeVerifier), + catch: (cause) => new CliError({ code: 1, message: `scratchwork login: ${errorMessage(cause)}` }), + }); + return { state, codeVerifier, codeChallenge }; + }); } /** Decodes a loopback callback request. Anything without this login's exact state — diff --git a/cli/src/commands/login-callback-server.ts b/cli/src/commands/login-callback-server.ts index e9b4271..c92782f 100644 --- a/cli/src/commands/login-callback-server.ts +++ b/cli/src/commands/login-callback-server.ts @@ -17,7 +17,9 @@ export interface LoginCallbackServer { readonly port: number; /** Resolves the browser's held callback response with the exchange outcome. */ readonly settleExchange: (ok: boolean) => void; - /** Stops the server without waiting for in-flight requests. */ + /** Stops accepting new connections; in-flight requests (including the held + * callback response) are allowed to finish, so call settleExchange first to + * unblock them. */ readonly stop: () => void; } diff --git a/server/core/src/google-jwt.ts b/server/core/src/google-jwt.ts index a9ea5a6..c02bbf0 100644 --- a/server/core/src/google-jwt.ts +++ b/server/core/src/google-jwt.ts @@ -8,6 +8,8 @@ */ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { errorMessage } from "../../../shared/src/util/errors"; import { CLOCK_SKEW_SECONDS, verifyRs256Jwt } from "./jwt-rs256"; @@ -55,12 +57,17 @@ export function verifyGoogleIdToken( }); } -/** Google's token-endpoint response shape, as much of it as the exchange needs. */ -export interface GoogleTokenResponse { - readonly id_token?: string; - readonly error?: string; - readonly error_description?: string; -} +/** Google's token-endpoint response shape, as much of it as the exchange needs — + * decoded tolerantly, so extra fields are ignored and a malformed body is null. */ +const GoogleTokenResponseSchema = Schema.Struct({ + id_token: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), + error_description: Schema.optional(Schema.String), +}); + +export type GoogleTokenResponse = typeof GoogleTokenResponseSchema.Type; + +const decodeTokenResponse = Schema.decodeUnknownOption(GoogleTokenResponseSchema); /** POSTs an authorization-code grant (with its PKCE verifier) to the token endpoint — * Google's, or the loopback-gated local test provider's. Network and JSON failures @@ -88,8 +95,8 @@ export async function postAuthorizationCodeGrant( headers: { "content-type": "application/x-www-form-urlencoded" }, body, }); - const json = (await response.json().catch(() => null)) as GoogleTokenResponse | null; - return { ok: response.ok, json }; + const responseBody: unknown = await response.json().catch(() => null); + return { ok: response.ok, json: Option.getOrNull(decodeTokenResponse(responseBody)) }; } /** Validates issuer, audience, time, email, and nonce claims. */ diff --git a/shared/src/encoding/base64.ts b/shared/src/encoding/base64.ts index 5e32ab2..a9c1150 100644 --- a/shared/src/encoding/base64.ts +++ b/shared/src/encoding/base64.ts @@ -16,8 +16,9 @@ const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/; /** Computes decoded standard-base64 byte length without allocating the bytes. * Returns null for input Encoding.decodeBase64 would reject. */ export function decodedBase64ByteLength(value: string): number | null { - // Encoding.decodeBase64 strips CR/LF before validating; mirror that. - const stripped = value.replace(/[\r\n]/g, ""); + // Encoding.decodeBase64 strips CR/LF before validating; mirror that, without + // copying multi-megabyte file contents in the common no-CR/LF case. + const stripped = /[\r\n]/.test(value) ? value.replace(/[\r\n]/g, "") : value; if (stripped.length % 4 !== 0 || !BASE64_PATTERN.test(stripped)) return null; const padding = stripped.endsWith("==") ? 2 : stripped.endsWith("=") ? 1 : 0; return (stripped.length / 4) * 3 - padding; From 2ac56eebcbc049f1bd41d47a76d3939c69f63425 Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Fri, 17 Jul 2026 14:37:14 -0700 Subject: [PATCH 03/15] Phase 5: root AGENTS.md with the six invariants, CLAUDE.md symlink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md is now the sole normative copy of the invariants registry — workspace map, the one gate, the standing verify-before-commit rule, and all six invariants in full. The plan links here instead of retaining a second editable copy, and the examples//notes/ gate exemption is documented (Phase 6 item). Co-Authored-By: Claude Fable 5 --- AGENTS.md | 172 ++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + notes/improve-harness-plan.md | 70 +++----------- 3 files changed, 187 insertions(+), 56 deletions(-) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bddfe2b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,172 @@ +# Working in this repository + +Scratchwork is a local development tool for static HTML and Markdown artifacts: a CLI +(`scratchwork dev` / `publish` / `share`), a single-file Markdown renderer, and a +publishing server that runs locally, on AWS, or on Cloudflare. `README.md` covers usage; +this file covers how to change the code safely. + +## Workspace map + +| Workspace | Package | What it is | +|---|---|---| +| `renderer/` | `scratchwork-renderer` | Build-once pipeline producing the single-file universal renderer (`index.html`). **Deliberately plain browser JS — the one exception to invariant 1.** | +| `shared/` | `@scratchwork/shared` | Code shared between CLI and server: the publish API contract (`src/publish/api.ts`), site serving helpers, crypto digest boundary. The only code importable by both cli and server. | +| `cli/` | `scratchwork-cli` | The `scratchwork` CLI, built with Effect: dev server with hot reload, publishing, login. | +| `server/` | `scratchwork-server` | Deploy tooling scripts only; the server itself lives in the sub-workspaces below. | +| `server/core/` | `@scratchwork/server-core` | Platform-neutral publishing server core: auth, routing, storage contracts. | +| `server/deploy-aws/` | `@scratchwork/server-deploy-aws` | AWS Lambda + S3/DynamoDB adapters. | +| `server/deploy-cloudflare/` | `@scratchwork/server-deploy-cloudflare` | Cloudflare Worker + R2/D1 adapters. | +| `server/deploy-local/` | `@scratchwork/server-deploy-local` | Local Bun deploy target. | +| `deploy/*` | `@scratchwork/deploy-*` | One deployment project per domain (generic-aws, cloudflare-vanilla, cloudflare-access, local-dev), each deployable with one command. | +| `e2e/` | `@scratchwork/e2e` | Full-loop publish e2e: real server (local-dev, miniflare, LocalStack) driven by the real CLI, hermetic OAuth provider standing in for Google. | + +`examples/` and `notes/` are deliberately outside the gate: examples are user-facing +sample content, notes are working documents. Nothing in them is imported by shipped code. + +## Build and test + +- **The one gate:** `bun run ci` at the root. It runs `typecheck` + `test` + builds in + every workspace (the list is derived from root `package.json` `workspaces`, so a new + workspace joins automatically and a missing script fails the run), then checks that + every generated artifact is fresh (`git diff` stays clean). +- Every workspace owns exactly three scripts: `typecheck` (static only), `test` (suites + only), `ci` (the gate). There is no `check` script anywhere; the name is retired. +- Bun is pinned in `.bun-version`. Acceptance for any harness change: fresh clone + + pinned Bun + `bun install --frozen-lockfile` + `bun run ci` passes and leaves + `git diff --exit-code` clean. +- CI (`.github/workflows/ci.yml`) runs exactly `bun run ci` on every PR and push to + main. Anything the gate should cover belongs in `bun run ci`, not in extra workflow + steps. +- `shared/src/site/default-renderer.generated.js` is a build artifact written by + `renderer/build.js` — never edit it by hand; it is exempt from source-code invariants. + +## Standing rule + +**Verify every diff against the six invariants below before committing.** Each invariant +is enforced at up to three layers, strongest first: *structural* (the architecture makes +violation impossible) → *mechanized* (a test in `bun run ci` fails) → *agent-pass* (the +judgment calls listed under each invariant — that's you). When you find a violation, +fix it or flag it; never commit around it silently. + +## The six invariants + +### 1. Effect-native everywhere + +All cli, server, and shared functionality is written against Effect: Effect types for +errors and async, the Effect runtime, and the Effect standard library (Schema, platform, +Encoding, stdlib data structures) instead of hand-rolled equivalents. The deliberate +objective is maximal delegation: if Effect already owns a capability with equal or +better semantics, use it rather than retaining a repository implementation. Promise/async +appears only at documented edges whose APIs inherently return Promises (Web Crypto, +provider SDKs, platform entrypoints); the allowlist in the boundary lint names every +such file and why it is a boundary. + +- Scope: `cli/src/**`, `server/**/src/**`, `shared/src/**`. (`renderer/` is the sole + exception — deliberately plain browser JS.) +- Mechanized: the Effect-boundary lint in `bun run ci` fails on `async` / `await` / + `new Promise` / `.then(` in scope outside the exact reviewed allowlist of boundary + files. New boundary = add its rationale to the allowlist in the same PR; the baseline + may shrink but not grow silently. The allowlist is file-level, so boundary files must + stay tiny: extract async helpers into a dedicated module rather than allowlisting a + large file. +- Agent-pass: Schema over hand-rolled validation, Effect stdlib over reimplemented + utilities, services/layers over ambient dependencies, scoped resources over manual + cleanup, error channels over thrown exceptions, and no parallel legacy implementation + left behind after a migration. + +### 2. CLI↔server contracts live in `shared` + +Anything the CLI and server both use — types, schemas, helpers — is exposed from +`shared/`, never duplicated. The CLI-consumed JSON API contract is defined once, as +Effect Schemas in `shared/src/publish/api.ts` (extend there, never inline). Auth +callbacks, published-content routing, health checks, deployment hooks, and other +server-only endpoints stay in server. + +- Structural (planned): migrate the shared CLI API contract to `@effect/platform` + `HttpApi`, so the server implements it and the CLI derives its client from the same + object — duplication becomes impossible rather than forbidden. +- Mechanized: the import-boundary test in `bun run ci` — `cli/**` never imports from + `server/**` and vice versa; the only code importable by both is `shared/**`. Every + CLI-consumed JSON route's request/response schema is imported from shared against an + explicit route inventory. +- Agent-pass: near-duplicate logic between cli and server that should be hoisted into + shared (imports can't catch a reimplementation). +- **Sanctioned exception:** `renderer/src/components.js` deliberately duplicates the + component-scan logic in `shared/src/site/components.ts` — the renderer is plain + browser JS and must not depend on shared, and the CLI dev diagnostics must predict + what the renderer's loader will do. This is the *only* permitted duplication, and only + because a ci conformance test asserts the two implementations agree. Don't "fix" it by + hoisting, and don't cite it as precedent for new duplication. + +### 3. Auth goes through the chokepoints + +The auth code stays reviewable because its security-critical operations are singular. +Every MAC/tag comparison goes through `timingSafeEqual` (`server/core/src/tokens.ts`); +every HMAC token is minted by `signValue` and verified by `verifySignedValue` +(`server/core/src/auth.ts`); every RS256 JWT is verified via `jwt-rs256.ts`. Every token +payload, including session and OAuth state, carries a `version` and discriminating +`kind` claim so cross-kind confusion fails Schema decoding. No new comparison, signing, +verification, or credential-transport path is introduced outside the chokepoints. + +- Scope: `server/core/src/{auth,tokens,cookies,jwt-rs256,google-jwt,cloudflare-jwt,access}.ts`, + `cli/src/{auth,api}.ts`, `cli/src/commands/login.ts`, and anything new that touches + credentials. +- Mechanized: the adversarial token corpus and OAuth full-loop tests in `bun run ci` — + integrity, typed meaning, parser hardening, lifecycle, PKCE/callback binding, and + secret-length checks. +- Agent-pass: no `===` on cryptographic secrets/tags, no inline `crypto.subtle` + signing/verifying outside the chokepoint modules, and every new token kind gets + `version` + `kind` claims and corpus coverage in the same PR. +- **Accepted trade-off** (decided; don't "fix" without Pete): sessions are stateless + HMAC — no single-token revocation (levers: allow-list removal, `SESSION_VERSION` + bump). + +### 4. API routes declare security policy and deny by default + +Every API route is registered once with its handler and explicit authentication mode, +minimum project role, mutation/origin policy, and response-visibility policy. Dispatch +and the authorization matrix derive from that production registry, so adding an +unclassified route is impossible and the default for an unspecified credential/role +combination is denial. + +- Scope: the server API router/registry, route handlers, and shared CLI API contract. +- Structural: the router dispatches only registered route definitions; handlers receive + the authenticated principal/authorized project capability produced by policy + middleware rather than independently reconstructing it. +- Mechanized: enumerate the registry and exercise credential kind × role × endpoint × + public/private status; fail on missing policy metadata and deny every unspecified cell. +- Agent-pass: sensitive response fields are gated by the declared visibility policy, + and no handler bypasses the registry or performs a weaker inline substitute. + +### 5. Browser origins and credentials stay isolated + +The app, content, and homepage origins are separate security principals. HTTPS accepts +only the intended secure-prefixed cookie names; cookies remain host/path scoped; +published content cannot plant or override an app session; mutations reject untrusted +origins; redirects remain on their intended origin/path; and production proxy/origin +trust is explicit. + +- Scope: `server/core/src/{app,auth,cookies,http,config}.ts`, deployment proxy + configuration, and published-content headers. +- Structural: cookie readers require the resolved origin mode instead of accepting both + secure and local names; production public origins/trusted proxies are explicit + configuration. +- Mechanized: the cross-host headless-browser security suite plus handler-level + adversarial origin/redirect tests. +- Agent-pass: any new cookie, redirect, forwarded header, CORS rule, or cross-origin + flow receives an explicit threat-boundary review. + +### 6. Storage adapters have identical observable semantics + +Every `PrimitiveDb` and `ObjectStorage` implementation honors the same contract for +conditional writes, versions/ETags, pagination, missing values, key validation, +concurrency, binary data, and typed failures. A deploy target cannot weaken ownership +or consistency guarantees through adapter drift. + +- Scope: in-memory/file implementations and D1/R2, DynamoDB/S3 deploy adapters. +- Structural: all deploy implementations satisfy the same Effect service interfaces and + use shared contract fixtures. +- Mechanized: the reusable adapter conformance suite runs unchanged against every + implementation, using miniflare and LocalStack where required. +- Agent-pass: provider-specific retries/eventual-consistency behavior is documented and + mapped without changing the core contract. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/notes/improve-harness-plan.md b/notes/improve-harness-plan.md index 373f642..8564698 100644 --- a/notes/improve-harness-plan.md +++ b/notes/improve-harness-plan.md @@ -96,7 +96,7 @@ The invariant starts out violated by existing code, not just guarding new code: Six invariants (registry below). They live **directly in root `AGENTS.md`** — the one file Claude (via `CLAUDE.md` symlink), Codex, and OpenCode all read natively — rather than behind a pointer agents might skip. Once moved, AGENTS.md is the sole normative prose copy and this plan links to it rather than retaining a second editable copy; anything a script can check graduates into `bun run ci` (agents drift; CI doesn't). -`[ ]` Root `AGENTS.md` (with `CLAUDE.md` symlinked to it): workspace map, `bun run ci`, the six invariants stated in full, and the standing rule "verify every diff against the invariants before committing." +`[x]` Root `AGENTS.md` (with `CLAUDE.md` symlinked to it): workspace map, `bun run ci`, the six invariants stated in full, and the standing rule "verify every diff against the invariants before committing." `[ ]` Mechanize invariants 1 and 2's checkable cores in `bun run ci`: the Effect-boundary lint test (no async/await/Promise outside an exact, reviewed initial allowlist) and the import-boundary test (cli ⇄ server only via shared). The remaining invariants' checks are described below and in their registry entries. @@ -123,64 +123,22 @@ Six invariants (registry below). They live **directly in root `AGENTS.md`** — ### Phase 6 — Housekeeping -`[ ]` Confirm `examples/` and `notes/` are exempt from the gate on purpose, and document the exemption in `AGENTS.md`. +`[x]` Confirm `examples/` and `notes/` are exempt from the gate on purpose, and document the exemption in `AGENTS.md`. ## Invariants registry -Six invariants, each enforced at three layers, strongest first: **structural** (the architecture makes violation impossible) → **mechanized** (a test in `bun run ci` fails) → **agent-pass** (judgment calls, per the standing rule in AGENTS.md). - -### 1. Effect-native everywhere - -All cli, server, and shared functionality is written against Effect: Effect types for errors and async, the Effect runtime, and the Effect standard library (Schema, platform, Encoding, stdlib data structures) instead of hand-rolled equivalents. The deliberate objective is maximal delegation: if Effect already owns a capability with equal or better semantics, use it rather than retaining a repository implementation. Pre-launch migration churn is accepted in exchange for fewer locally maintained semantics, stronger types at boundaries, service substitution in tests, controlled resource lifetime/concurrency, and a smaller custom surface after launch. Promise/async appears only at documented edges whose APIs inherently return Promises (Web Crypto/provider SDK/platform entrypoints); the initial allowlist names every such file and why it is a boundary. - -- Scope: `cli/src/**`, `server/**/src/**`, `shared/src/**`. (`renderer/` is the sole exception — deliberately plain browser JS.) -- Mechanized: a ci test that fails on `async function` / `await` / `new Promise` / `.then(` in scope outside the exact reviewed allowlist of boundary files. New boundary = add its rationale to the allowlist in the same PR, which makes the exception reviewable; the baseline may shrink but not grow silently. The allowlist is file-level, so boundary files must stay tiny: extract async helpers into a dedicated module rather than allowlisting a large file (Phase 4 does this for `auth.ts`'s Web Crypto helpers). -- Agent-pass: the parts a grep can't judge — Schema over hand-rolled validation, Effect stdlib over reimplemented utilities, services/layers over ambient dependencies, scoped resources over manual cleanup, error channels over thrown exceptions, and no parallel legacy implementation left behind after a migration. - -### 2. CLI↔server contracts live in `shared` - -Anything the CLI and server both use — types, schemas, helpers — is exposed from `shared/`, never duplicated. The CLI-consumed JSON API contract is defined once, as Effect Schemas in `shared/src/publish/api.ts` (already the case today; extend there, never inline). Auth callbacks, published-content routing, health checks, deployment hooks, and other server-only endpoints stay in server. - -- Structural (Phase 5 stretch): migrate the shared CLI API contract to `@effect/platform` `HttpApi`, so the server implements it and the CLI derives its client from the same object — duplication becomes impossible rather than forbidden. -- Mechanized: boundary test in ci — `cli/**` never imports from `server/**` and vice versa; the only code importable by both is `shared/**`. Plus: every CLI-consumed JSON route's request/response schema is imported from shared, not defined locally. Maintain an explicit inventory of those routes so a newly added CLI call cannot escape the check. -- Agent-pass: near-duplicate logic between cli and server that should be hoisted into shared (imports can't catch a reimplementation). -- **Sanctioned exception:** `renderer/src/components.js` deliberately duplicates the component-scan logic in `shared/src/site/components.ts` — the renderer is plain browser JS and must not depend on shared, and the CLI dev diagnostics must predict what the renderer's loader will do. This is the *only* permitted duplication, and only because a ci conformance test (Phase 5) asserts the two implementations agree; don't "fix" it by hoisting, and don't cite it as precedent for new duplication. - -### 3. Auth goes through the chokepoints - -The auth code stays reviewable because its security-critical operations are singular. Every MAC/tag comparison goes through `timingSafeEqual` (`server/core/src/tokens.ts`); every HMAC token is minted by `signValue` and verified by `verifySignedValue` (`server/core/src/auth.ts`); every RS256 JWT is verified via `jwt-rs256.ts`. Every token payload, including session and OAuth state, carries a `version` and discriminating `kind` claim so cross-kind confusion fails Schema decoding. No new comparison, signing, verification, or credential-transport path is introduced outside the chokepoints. - -- Scope: `server/core/src/{auth,tokens,cookies,jwt-rs256,google-jwt,cloudflare-jwt,access}.ts`, `cli/src/{auth,api}.ts`, `cli/src/commands/login.ts`, and anything new that touches credentials. -- Mechanized: the adversarial token corpus (Phase 5) and OAuth full-loop tests (Phase 3) — integrity, typed meaning, parser hardening, lifecycle, PKCE/callback binding, and secret-length checks. -- Agent-pass: no `===` on cryptographic secrets/tags, no inline `crypto.subtle` signing/verifying outside the chokepoint modules, and every token kind gets `version` + `kind` claims and corpus coverage in the same PR. -- **Accepted trade-off** (decided, don't "fix" without Pete): sessions are stateless HMAC — no single-token revocation (levers: allow-list removal, `SESSION_VERSION` bump). The old bearer-token-in-loopback-query flow is not accepted: replace it with the Phase 3 short-lived code + PKCE exchange before launch. - -### 4. API routes declare security policy and deny by default - -Every API route is registered once with its handler and explicit authentication mode, minimum project role, mutation/origin policy, and response-visibility policy. Dispatch and the authorization matrix derive from that production registry, so adding an unclassified route is impossible and the default for an unspecified credential/role combination is denial. - -- Scope: the server API router/registry, route handlers, and shared CLI API contract. -- Structural: the router dispatches only registered route definitions; handlers receive the authenticated principal/authorized project capability produced by policy middleware rather than independently reconstructing it. -- Mechanized: enumerate the registry and exercise credential kind × role × endpoint × public/private status; fail on missing policy metadata and deny every unspecified cell. -- Agent-pass: sensitive response fields are gated by the declared visibility policy, and no handler bypasses the registry or performs a weaker inline substitute. - -### 5. Browser origins and credentials stay isolated - -The app, content, and homepage origins are separate security principals. HTTPS accepts only the intended secure-prefixed cookie names; cookies remain host/path scoped; published content cannot plant or override an app session; mutations reject untrusted origins; redirects remain on their intended origin/path; and production proxy/origin trust is explicit. - -- Scope: `server/core/src/{app,auth,cookies,http,config}.ts`, deployment proxy configuration, and published-content headers. -- Structural: cookie readers require the resolved origin mode instead of accepting both secure and local names; production public origins/trusted proxies are explicit configuration. -- Mechanized: the cross-host headless-browser security suite plus handler-level adversarial origin/redirect tests. -- Agent-pass: any new cookie, redirect, forwarded header, CORS rule, or cross-origin flow receives an explicit threat-boundary review. - -### 6. Storage adapters have identical observable semantics - -Every `PrimitiveDb` and `ObjectStorage` implementation honors the same contract for conditional writes, versions/ETags, pagination, missing values, key validation, concurrency, binary data, and typed failures. A deploy target cannot weaken ownership or consistency guarantees through adapter drift. - -- Scope: in-memory/file implementations and D1/R2, DynamoDB/S3 deploy adapters. -- Structural: all deploy implementations satisfy the same Effect service interfaces and use shared contract fixtures. -- Mechanized: run the reusable adapter conformance suite unchanged against every implementation, using miniflare and LocalStack where required. -- Agent-pass: provider-specific retries/eventual-consistency behavior is documented and mapped without changing the core contract. +The six invariants now live in full in the root [`AGENTS.md`](../AGENTS.md) (with +`CLAUDE.md` symlinked to it) — the sole normative copy. Each is enforced at up to three +layers, strongest first: **structural** (the architecture makes violation impossible) → +**mechanized** (a test in `bun run ci` fails) → **agent-pass** (judgment calls, per the +standing rule in AGENTS.md). In brief: + +1. **Effect-native everywhere** — cli/server/shared code is written against Effect; async/Promise only in the reviewed boundary-file allowlist. +2. **CLI↔server contracts live in `shared`** — never duplicated; the renderer component-scan is the one sanctioned, conformance-tested exception. +3. **Auth goes through the chokepoints** — `timingSafeEqual`, `signValue`/`verifySignedValue`, `jwt-rs256.ts`; every payload carries `version` + `kind`. +4. **API routes declare security policy and deny by default** — one registry with auth mode, role, origin, and visibility policy per route. +5. **Browser origins and credentials stay isolated** — app/content/homepage are separate principals; cookies, redirects, and origin trust are explicit. +6. **Storage adapters have identical observable semantics** — one conformance suite across in-memory/file, D1/R2, DynamoDB/S3. ## Non-goals (for this branch) From b84ae2fa94966fc4711f8469ca0c9709fccf8958 Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Fri, 17 Jul 2026 14:46:25 -0700 Subject: [PATCH 04/15] Phase 5: mechanize the Effect-boundary, import-boundary, and route-inventory checks scripts/check-boundaries.ts runs first in the root ci gate. It lexes sources (comments and string prose can't fool it) and enforces: - Effect-boundary lint: async/await/new Promise/.then(/Promise.* appear only in the 14 reviewed boundary files, each with its rationale; stale or unused allowlist entries fail so the baseline shrinks but never grows silently. - Import boundary: cli never imports server and vice versa, shared imports neither, renderer/src imports none of them. - CLI route inventory: every route literal or helper call in cli/src must match the explicit inventory, whose schemas must be exported by shared/src/publish/api.ts. The {error} envelope is now ApiErrorBodySchema in the shared contract. Co-Authored-By: Claude Fable 5 --- cli/src/api.ts | 6 +- notes/improve-harness-plan.md | 2 +- package.json | 2 +- scripts/check-boundaries.ts | 376 ++++++++++++++++++++++++++++++++++ shared/src/publish/api.ts | 7 + 5 files changed, 388 insertions(+), 5 deletions(-) create mode 100644 scripts/check-boundaries.ts diff --git a/cli/src/api.ts b/cli/src/api.ts index f3fdbc7..7512adc 100644 --- a/cli/src/api.ts +++ b/cli/src/api.ts @@ -15,7 +15,7 @@ import * as Effect from "effect/Effect"; import * as Either from "effect/Either"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { CLI_TOKEN_EXCHANGE_PATH, ProjectResponseSchema } from "../../shared/src/publish/api"; +import { ApiErrorBodySchema, CLI_TOKEN_EXCHANGE_PATH, ProjectResponseSchema } from "../../shared/src/publish/api"; import { readAuthToken, readCfToken, serverApiUrl } from "./auth"; import { CliError, errorMessage } from "./errors"; @@ -23,8 +23,8 @@ import { CliError, errorMessage } from "./errors"; const parseBodyJson = (text: string): unknown => Either.getOrNull(Schema.decodeUnknownEither(Schema.parseJson())(text)); -/** Matches an `{"error": "..."}` body, tolerating extra fields. */ -const decodeErrorBody = Schema.decodeUnknownOption(Schema.Struct({ error: Schema.String })); +/** Matches the shared `{"error": "..."}` envelope, tolerating extra fields. */ +const decodeErrorBody = Schema.decodeUnknownOption(ApiErrorBodySchema); /** A completed API exchange: HTTP status plus the raw and JSON-decoded body. */ export interface ApiResponse { diff --git a/notes/improve-harness-plan.md b/notes/improve-harness-plan.md index 8564698..dad1176 100644 --- a/notes/improve-harness-plan.md +++ b/notes/improve-harness-plan.md @@ -98,7 +98,7 @@ Six invariants (registry below). They live **directly in root `AGENTS.md`** — `[x]` Root `AGENTS.md` (with `CLAUDE.md` symlinked to it): workspace map, `bun run ci`, the six invariants stated in full, and the standing rule "verify every diff against the invariants before committing." -`[ ]` Mechanize invariants 1 and 2's checkable cores in `bun run ci`: the Effect-boundary lint test (no async/await/Promise outside an exact, reviewed initial allowlist) and the import-boundary test (cli ⇄ server only via shared). The remaining invariants' checks are described below and in their registry entries. +`[x]` Mechanize invariants 1 and 2's checkable cores in `bun run ci`: the Effect-boundary lint test (no async/await/Promise outside an exact, reviewed initial allowlist) and the import-boundary test (cli ⇄ server only via shared). *(Landed as `scripts/check-boundaries.ts`, first step of the root ci gate: Effect-boundary lint with a 14-file rationale-carrying allowlist, import-boundary check including shared/renderer layering, and the explicit CLI route inventory checked against shared schema exports; the `{error}` envelope moved into `shared/src/publish/api.ts` as `ApiErrorBodySchema`.)* `[ ]` Component-scan conformance test in `bun run ci`: import both `collectComponentNames` implementations (`renderer/src/components.js` and `shared/src/site/components.ts`), run them over a shared table of adversarial markdown samples (nested backtick runs, tags in comments, fences), assert identical output. Guards the one sanctioned duplication (see invariant 2). diff --git a/package.json b/package.json index 9e60ade..604e5e0 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "scripts": { "build": "cd cli && bun run build", "build:renderer": "cd renderer && bun run build", - "ci": "bun scripts/each-workspace.ts ci && bun scripts/check-generated-fresh.ts", + "ci": "bun scripts/check-boundaries.ts && bun scripts/each-workspace.ts ci && bun scripts/check-generated-fresh.ts", "deploy:generic-aws": "cd deploy/generic-aws && bun run deploy", "deploy:cloudflare-vanilla": "cd deploy/cloudflare-vanilla && bun run deploy", "deploy:cloudflare-access": "cd deploy/cloudflare-access && bun run deploy", diff --git a/scripts/check-boundaries.ts b/scripts/check-boundaries.ts new file mode 100644 index 0000000..72408cf --- /dev/null +++ b/scripts/check-boundaries.ts @@ -0,0 +1,376 @@ +#!/usr/bin/env bun +/* + * Mechanizes the checkable cores of invariants 1 and 2 (see AGENTS.md). Runs + * first in the root `bun run ci` — it is pure static analysis and fails fast. + * + * 1. Effect-boundary lint: no `async` / `await` / `new Promise` / `.then(` / + * `Promise.*` in cli, server, or shared source outside the exact allowlist + * of boundary files below. A new boundary must be added to the allowlist + * with its rationale in the same PR; an entry whose file no longer trips + * the lint must be removed. The baseline may shrink, never grow silently. + * 2. Import boundary: cli never imports server and vice versa (the only code + * importable by both is shared); shared imports neither; renderer/src is + * standalone plain browser JS and imports none of them. + * 3. CLI route inventory: every server route the CLI calls is listed below + * with the shared schemas defining its wire contract, and those schemas + * are exported by shared/src/publish/api.ts — so a newly added CLI call + * cannot ship without a shared contract entry. + */ +import { readFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); + +/** + * Invariant 1's reviewed boundary allowlist: the only files in scope allowed + * to contain async/Promise constructs, each because the API it wraps + * inherently returns Promises. Keep boundary files tiny — extract async + * helpers into a dedicated module rather than allowlisting a large file. + */ +const ASYNC_BOUNDARIES: Readonly> = { + "shared/src/crypto/digest.ts": "Web Crypto SHA-256 (crypto.subtle returns Promises); shared by CLI PKCE and server content hashing", + "server/core/src/auth-crypto.ts": "Web Crypto HMAC + AES-GCM primitives behind the auth chokepoints", + "server/core/src/jwt-rs256.ts": "Web Crypto RS256 JWT verification chokepoint", + "server/core/src/google-jwt.ts": "Google OAuth token-endpoint POST + JWKS fetch — the identity-provider edge", + "server/core/src/cloudflare-jwt.ts": "Cloudflare Access JWKS fetch — the identity-provider edge", + "cli/src/commands/login-callback-server.ts": "Bun.serve loopback listener — platform entrypoint for the login callback", + "server/deploy-aws/src/handler.ts": "AWS Lambda entrypoint — the platform's contract is Promise-based", + "server/deploy-aws/src/s3-storage.ts": "AWS SDK S3 Promise APIs wrapped into the ObjectStorage service", + "server/deploy-aws/src/deploy.ts": "deploy tooling — deliberately plain Promise-based script code (runs once on a developer's machine)", + "server/deploy-cloudflare/src/worker.ts": "Workers fetch() entrypoint — the platform's contract is Promise-based", + "server/deploy-cloudflare/src/local-worker.ts": "local wrapper around the Workers fetch() entrypoint", + "server/deploy-cloudflare/src/r2-storage.ts": "Cloudflare R2 binding Promise APIs wrapped into the ObjectStorage service", + "server/deploy-cloudflare/src/d1-db.ts": "Cloudflare D1 binding Promise APIs wrapped into the PrimitiveDb service", + "server/deploy-cloudflare/src/deploy.ts": "deploy tooling — deliberately plain Promise-based script code (runs once on a developer's machine)", +}; + +/** + * Invariant 2's explicit inventory of server routes the CLI consumes. `route` + * is either a path literal as it appears in cli/src, a constant name imported + * from shared, or a `/api/projects/:project` suffix route built through + * projectApiUrl. Every schema name must be exported by + * shared/src/publish/api.ts. Adding a CLI call to a route not listed here + * fails this check; so does a stale entry for a call that no longer exists. + */ +const CLI_ROUTES: ReadonlyArray<{ route: string; schemas: readonly string[]; note: string }> = [ + { route: "/auth/login", schemas: [], note: "browser navigation that starts the login flow; redirects, not JSON" }, + { route: "CLI_TOKEN_EXCHANGE_PATH", schemas: ["CliTokenRequestSchema", "CliTokenResponseSchema"], note: "back-channel code + PKCE exchange" }, + { route: "/api/publish", schemas: ["PublishRequestBodySchema", "PublishResponseSchema"], note: "publish a bundle" }, + { route: "/api/me", schemas: [], note: "identity echo printed verbatim as JSON; no decoded contract" }, + { route: "/api/projects", schemas: ["ProjectsListResponseSchema"], note: "list the caller's projects" }, + { route: "/api/resolve", schemas: ["ProjectResponseSchema"], note: "resolve a published content path to its project" }, + { route: "/api/projects/:project", schemas: ["ProjectResponseSchema"], note: "project info (GET) and delete (DELETE)" }, + { route: "/api/projects/:project/unpublish", schemas: ["ProjectResponseSchema"], note: "make private and clear every grant" }, + { route: "/api/projects/:project/share", schemas: ["ShareResponseSchema"], note: "grant or revoke access" }, + { route: "/api/projects/:project/bundle", schemas: ["PublishBundleSchema"], note: "download a project's bundle (clone)" }, + { route: "error envelope", schemas: ["ApiErrorBodySchema"], note: "the {error} body every non-2xx JSON response carries" }, +]; + +/** The one place the parametrized project route is assembled from a template. */ +const PROJECT_ROUTE_BUILDER_FILE = "cli/src/api.ts"; +const PROJECT_ROUTE_BUILDER_PREFIX = "/api/projects/"; + +const failures: string[] = []; + +/** One string literal as the lexer saw it. For a template literal, `value` is + * the raw text up to the first interpolation and `template` is true. */ +interface StringLiteral { + readonly value: string; + readonly line: number; + readonly template: boolean; +} + +/** The three views of a source file the checks below consume. */ +interface LexedSource { + /** Source with comments blanked, strings kept — for import/call-site regexes. */ + readonly code: string; + /** Source with comments and string contents blanked — for keyword scans that + * must not be fooled by prose in comments or error messages. Code + * interpolated inside template literals stays visible. */ + readonly codeOnly: string; + /** Every string literal, so path-shaped strings can be told apart from + * path-shaped prose embedded inside longer messages. */ + readonly strings: readonly StringLiteral[]; +} + +/** + * A line-structure-preserving lexer for the subset of TS/JS these checks + * need: comments, the three string forms, and template interpolation. Regex + * literals are not modeled: an unescaped `//` or quote can only reach this + * scanner from inside a regex character class, which does not occur in scope. + */ +function lex(source: string): LexedSource { + const code = [...source]; + const codeOnly = [...source]; + const strings: StringLiteral[] = []; + const blankComment = (i: number) => { + if (source[i] !== "\n") { + code[i] = " "; + codeOnly[i] = " "; + } + }; + const blankString = (i: number) => { + if (source[i] !== "\n") codeOnly[i] = " "; + }; + type Mode = "code" | "line" | "block" | "single" | "double" | "template"; + let mode: Mode = "code"; + let line = 1; + let literalStart = -1; + let literalLine = 1; + const endLiteral = (end: number, template: boolean) => { + strings.push({ value: source.slice(literalStart, end), line: literalLine, template }); + }; + // Per enclosing template literal: how deep `{` nesting is inside its ${ }. + const interpolation: number[] = []; + for (let i = 0; i < source.length; i++) { + const c = source[i]; + const n = source[i + 1]; + if (c === "\n") line++; + switch (mode) { + case "code": + if (c === "/" && (n === "/" || n === "*")) { + blankComment(i); + blankComment(i + 1); + mode = n === "/" ? "line" : "block"; + i++; + } else if (c === "'" || c === '"' || c === "`") { + mode = c === "'" ? "single" : c === '"' ? "double" : "template"; + literalStart = i + 1; + literalLine = line; + } else if (c === "{" && interpolation.length > 0) interpolation[interpolation.length - 1]++; + else if (c === "}" && interpolation.length > 0) { + if (interpolation[interpolation.length - 1] === 0) { + interpolation.pop(); + mode = "template"; + // The tail after an interpolation is not recorded as a literal; + // route scanning only needs the head chunk. + literalStart = -1; + } else interpolation[interpolation.length - 1]--; + } + break; + case "line": + if (c === "\n") mode = "code"; + else blankComment(i); + break; + case "block": + if (c === "*" && n === "/") { + blankComment(i); + blankComment(i + 1); + i++; + mode = "code"; + } else blankComment(i); + break; + case "single": + case "double": + if (c === "\\") { + blankString(i); + blankString(i + 1); + if (n === "\n") line++; + i++; + } else if (c === (mode === "single" ? "'" : '"')) { + endLiteral(i, false); + mode = "code"; + } else if (c === "\n") mode = "code"; + else blankString(i); + break; + case "template": + if (c === "\\") { + blankString(i); + blankString(i + 1); + if (n === "\n") line++; + i++; + } else if (c === "`") { + if (literalStart >= 0) endLiteral(i, true); + mode = "code"; + } else if (c === "$" && n === "{") { + if (literalStart >= 0) endLiteral(i, true); + literalStart = -1; + interpolation.push(0); + i++; + mode = "code"; + } else blankString(i); + break; + } + } + return { code: code.join(""), codeOnly: codeOnly.join(""), strings }; +} + +/** Expands glob patterns from the repo root, excluding generated and declaration files. */ +function sources(patterns: string[]): string[] { + const files = new Set(); + for (const pattern of patterns) { + for (const file of new Bun.Glob(pattern).scanSync({ cwd: root })) { + if (file.includes("node_modules/") || file.includes("dist/")) continue; + if (file.endsWith(".d.ts") || file.endsWith(".generated.js")) continue; + files.add(file); + } + } + return [...files].sort(); +} + +const read = (file: string) => readFileSync(join(root, file), "utf8"); + +// ─── 1. Effect-boundary lint ──────────────────────────────────────────────── + +const BANNED: ReadonlyArray<{ token: string; re: RegExp }> = [ + { token: "async", re: /(?(); +for (const file of effectScope) { + const code = lex(read(file)).codeOnly; + const hits: string[] = []; + code.split("\n").forEach((line, index) => { + for (const { token, re } of BANNED) { + if (re.test(line)) hits.push(`${file}:${index + 1}: ${token}`); + } + }); + if (hits.length === 0) continue; + if (file in ASYNC_BOUNDARIES) { + boundariesSeen.add(file); + continue; + } + failures.push( + `Effect-boundary lint (invariant 1): ${file} uses Promise constructs outside the allowlist:\n` + + hits.map((hit) => ` ${hit}`).join("\n") + + "\n Make it Effect-native, or — only if it wraps an inherently Promise-based API — add it to\n" + + " ASYNC_BOUNDARIES in scripts/check-boundaries.ts with its rationale, in the same PR.", + ); +} +for (const file of Object.keys(ASYNC_BOUNDARIES)) { + if (!effectScope.includes(file)) { + failures.push(`Effect-boundary lint: allowlist entry ${file} does not exist — remove it.`); + } else if (!boundariesSeen.has(file)) { + failures.push( + `Effect-boundary lint: ${file} no longer uses Promise constructs — remove its allowlist entry (the baseline may shrink, never grow silently).`, + ); + } +} + +// ─── 2. Import boundary ───────────────────────────────────────────────────── + +const FORBIDDEN_IMPORTS: Readonly> = { + cli: ["server"], + server: ["cli"], + shared: ["cli", "server"], + renderer: ["cli", "server", "shared"], +}; + +/** Maps a repo-relative path or package specifier to its top-level zone. */ +function zoneOf(target: string): string | null { + if (target === "@scratchwork/shared" || target.startsWith("@scratchwork/shared/")) return "shared"; + if (target.startsWith("@scratchwork/server-")) return "server"; + if (target === "scratchwork-cli") return "cli"; + if (target === "scratchwork-renderer") return "renderer"; + for (const zone of Object.keys(FORBIDDEN_IMPORTS)) { + if (target === zone || target.startsWith(`${zone}/`)) return zone; + } + return null; +} + +const IMPORT_RE = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)["']([^"']+)["']/g; +// renderer scope is src only: renderer/test may exercise shared parity checks, +// but the shipped renderer sources must stay standalone. +const importScope = sources([ + "cli/**/*.{ts,js}", + "server/**/*.{ts,js}", + "shared/**/*.{ts,js}", + "renderer/src/**/*.js", +]); +for (const file of importScope) { + const zone = zoneOf(file); + if (zone == null) continue; + const banned = FORBIDDEN_IMPORTS[zone] ?? []; + const code = lex(read(file)).code; + for (const match of code.matchAll(IMPORT_RE)) { + const spec = match[1]; + const target = spec.startsWith(".") + ? relative(root, resolve(join(root, dirname(file)), spec)) + : spec; + if (target.startsWith("..")) continue; + const targetZone = zoneOf(target); + if (targetZone != null && targetZone !== zone && banned.includes(targetZone)) { + failures.push( + `Import boundary (invariant 2): ${file} imports "${spec}" (${targetZone}); ${zone} must not import ${targetZone}.` + + (zone === "cli" || zone === "server" ? " Hoist shared code into shared/." : ""), + ); + } + } +} + +// ─── 3. CLI route inventory ───────────────────────────────────────────────── + +const cliSources = sources(["cli/src/**/*.ts"]); +const usedRoutes = new Map(); // route → first use site +const recordRoute = (route: string, site: string) => { + if (!usedRoutes.has(route)) usedRoutes.set(route, site); +}; + +for (const file of cliSources) { + const lexed = lex(read(file)); + // Path-shaped string literals (catches raw fetches too). A template's value + // is its text before the first interpolation, so the projectApiUrl builder + // template surfaces as its "/api/projects/" prefix. + for (const literal of lexed.strings) { + if (!/^\/(?:api|auth)(?:\/|$)/.test(literal.value)) continue; + if (literal.value === PROJECT_ROUTE_BUILDER_PREFIX && literal.template && file === PROJECT_ROUTE_BUILDER_FILE) continue; + recordRoute(literal.value, `${file}:${literal.line}`); + } + lexed.code.split("\n").forEach((line, index) => { + const site = `${file}:${index + 1}`; + // Routes built through projectApiUrl(ref) / projectApiUrl(ref, "/suffix"). + if (file !== PROJECT_ROUTE_BUILDER_FILE) { + for (const match of line.matchAll(/projectApiUrl\(\s*[^,()]+\s*(?:,\s*"([^"]*)")?\s*\)/g)) { + recordRoute(`/api/projects/:project${match[1] ?? ""}`, site); + } + } + // Route constants imported from shared and passed to serverApiUrl. + for (const match of line.matchAll(/serverApiUrl\(\s*[^,()]+,\s*([A-Za-z_$][\w$]*)\s*\)/g)) { + recordRoute(match[1], site); + } + }); +} + +const inventory = new Set(CLI_ROUTES.map((entry) => entry.route)); +for (const [route, site] of usedRoutes) { + if (!inventory.has(route)) { + failures.push( + `CLI route inventory (invariant 2): ${site} calls unlisted route "${route}". Define its request/response\n` + + " schemas in shared/src/publish/api.ts and add the route to CLI_ROUTES in scripts/check-boundaries.ts.", + ); + } +} +for (const entry of CLI_ROUTES) { + if (entry.route === "error envelope") continue; + if (!usedRoutes.has(entry.route)) { + failures.push(`CLI route inventory: no CLI call to "${entry.route}" found — remove its stale CLI_ROUTES entry.`); + } +} +const sharedApi: Record = await import(join(root, "shared/src/publish/api.ts")); +for (const entry of CLI_ROUTES) { + for (const name of entry.schemas) { + if (!(name in sharedApi)) { + failures.push( + `CLI route inventory: schema ${name} (route "${entry.route}") is not exported by shared/src/publish/api.ts.`, + ); + } + } +} + +// ─── Report ───────────────────────────────────────────────────────────────── + +if (failures.length > 0) { + console.error(`check-boundaries: ${failures.length} violation(s)\n`); + for (const failure of failures) console.error(failure + "\n"); + process.exit(1); +} +console.log( + `check-boundaries: ${effectScope.length} files Effect-clean (${boundariesSeen.size} reviewed boundaries), ` + + `${importScope.length} files respect import boundaries, ${usedRoutes.size} CLI routes match the inventory.`, +); diff --git a/shared/src/publish/api.ts b/shared/src/publish/api.ts index 2de9a72..0788fb9 100644 --- a/shared/src/publish/api.ts +++ b/shared/src/publish/api.ts @@ -70,6 +70,13 @@ export const CliTokenResponseSchema = Schema.Struct({ /** The decoded CLI token-exchange response. */ export type CliTokenResponse = typeof CliTokenResponseSchema.Type; +/** The error envelope every non-2xx JSON API response carries. Decoded + * tolerantly on purpose: extra fields from a newer server are ignored. */ +export const ApiErrorBodySchema = Schema.Struct({ error: Schema.String }); + +/** The decoded error envelope. */ +export type ApiErrorBody = typeof ApiErrorBodySchema.Type; + /** A project's per-role grant lists (emails and @domain groups). */ export const ProjectPermissionsSchema = Schema.Struct({ read: Schema.Array(Schema.String), From f2c74f00175836e4d13a77157409273c26a162bc Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Fri, 17 Jul 2026 14:48:16 -0700 Subject: [PATCH 05/15] Phase 5: conformance test for the sanctioned component-scan duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shared/test/component-scan-conformance.test.ts runs both collectComponentNames implementations (renderer plain-JS copy and the shared original) over an adversarial markdown table — nested backtick runs, fences, comments, prefix slices — and fails on any disagreement. The duplication stays permitted only while this proves the copies agree. Co-Authored-By: Claude Fable 5 --- notes/improve-harness-plan.md | 2 +- .../test/component-scan-conformance.test.ts | 90 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 shared/test/component-scan-conformance.test.ts diff --git a/notes/improve-harness-plan.md b/notes/improve-harness-plan.md index dad1176..5ed6128 100644 --- a/notes/improve-harness-plan.md +++ b/notes/improve-harness-plan.md @@ -100,7 +100,7 @@ Six invariants (registry below). They live **directly in root `AGENTS.md`** — `[x]` Mechanize invariants 1 and 2's checkable cores in `bun run ci`: the Effect-boundary lint test (no async/await/Promise outside an exact, reviewed initial allowlist) and the import-boundary test (cli ⇄ server only via shared). *(Landed as `scripts/check-boundaries.ts`, first step of the root ci gate: Effect-boundary lint with a 14-file rationale-carrying allowlist, import-boundary check including shared/renderer layering, and the explicit CLI route inventory checked against shared schema exports; the `{error}` envelope moved into `shared/src/publish/api.ts` as `ApiErrorBodySchema`.)* -`[ ]` Component-scan conformance test in `bun run ci`: import both `collectComponentNames` implementations (`renderer/src/components.js` and `shared/src/site/components.ts`), run them over a shared table of adversarial markdown samples (nested backtick runs, tags in comments, fences), assert identical output. Guards the one sanctioned duplication (see invariant 2). +`[x]` Component-scan conformance test in `bun run ci`: import both `collectComponentNames` implementations (`renderer/src/components.js` and `shared/src/site/components.ts`), run them over a shared table of adversarial markdown samples (nested backtick runs, tags in comments, fences), assert identical output. Guards the one sanctioned duplication (see invariant 2). *(Landed as `shared/test/component-scan-conformance.test.ts`: a 23-sample adversarial table plus an every-prefix stress sweep.)* `[ ]` Adversarial token corpus test in `bun run ci`, split by property: diff --git a/shared/test/component-scan-conformance.test.ts b/shared/test/component-scan-conformance.test.ts new file mode 100644 index 0000000..17f6361 --- /dev/null +++ b/shared/test/component-scan-conformance.test.ts @@ -0,0 +1,90 @@ +/* + * Conformance test for the one sanctioned duplication (AGENTS.md, invariant 2): + * renderer/src/components.js deliberately duplicates the component-scan logic + * in shared/src/site/components.ts, because the renderer is standalone browser + * JS while the CLI dev diagnostics must predict what the renderer's loader + * will do. This suite runs both implementations over adversarial markdown and + * fails on any disagreement — the duplication is permitted only while this + * test proves the copies agree. + */ +import { describe, expect, test } from "bun:test"; +// eslint-disable-next-line import/extensions — the renderer ships plain .js +import { collectComponentNames as rendererScan } from "../../renderer/src/components.js"; +import { collectComponentNames as sharedScan } from "../src/site/components"; + +/** Adversarial markdown samples: `expected` documents the agreed behavior. */ +const SAMPLES: ReadonlyArray<{ name: string; text: string; expected: readonly string[] }> = [ + { name: "plain component", text: "", expected: ["Card"] }, + { name: "lowercase html is not a component", text: "
", expected: [] }, + { + name: "attributes continuing on the next line", + text: "", + expected: ["Card"], + }, + { name: "dotted, hyphenated, and numbered names", text: " ", expected: ["Foo.Bar", "My-Comp", "C1_x"] }, + { name: "name at end of line", text: "before \n```\n", expected: ["Real"] }, + { name: "tilde fences are skipped", text: "~~~\n\n~~~", expected: [] }, + { name: "fence with info string", text: "```jsx\n\n```", expected: [] }, + { name: "indented fence still toggles", text: " ```\n\n ```", expected: [] }, + { name: "unclosed fence skips the rest", text: "```\n\n", expected: [] }, + { + name: "backticks inside a fenced block do not open inline spans", + text: "```\n`\n```\n", + expected: ["Real"], + }, + { name: "inline code is skipped", text: "use `` here, and ", expected: ["Real"] }, + { + name: "double-backtick span containing a single backtick", + text: "``code ` with tick `` ", + expected: ["Real"], + }, + { + name: "span closes only on a run of exactly the opening length", + text: "`` ` still code ``` also closed? ``, and ", + expected: ["Real"], + }, + { + name: "unclosed backtick run leaves the rest scannable", + text: "`unclosed ", + expected: ["Scanned"], + }, + { name: "single-line html comment is skipped", text: " ", expected: ["Real"] }, + { + name: "multi-line html comment is NOT skipped (documented single-line rule)", + text: "", + expected: ["Visible"], + }, + { name: "two comments on one line", text: " ", expected: ["Real"] }, + { + name: "table row mixing inline code and components", + text: "| `` | | ``x`` |", + expected: ["Real"], + }, + { name: "duplicate names collapse", text: "", expected: ["Card"] }, + { name: "tag split by inline span boundary", text: "` ", expected: ["Real"] }, + { name: "empty document", text: "", expected: [] }, + { name: "angle bracket without a component name", text: "a < B when B is math, <1Card/>", expected: [] }, +]; + +describe("component-scan conformance (sanctioned duplication)", () => { + for (const sample of SAMPLES) { + test(sample.name, () => { + const fromShared = [...sharedScan(sample.text)].sort(); + const fromRenderer = [...rendererScan(sample.text)].sort(); + expect(fromRenderer).toEqual(fromShared); + expect(fromShared).toEqual([...sample.expected].sort()); + }); + } + + test("agree across every prefix of a stress document", () => { + // Slicing a document at every position exercises unterminated spans, + // comments, and fences at each boundary; the implementations must agree + // on all of them (expected values are whatever shared says). + const doc = SAMPLES.map((sample) => sample.text).join("\n"); + for (let end = 0; end <= doc.length; end++) { + const slice = doc.slice(0, end); + expect([...rendererScan(slice)].sort()).toEqual([...sharedScan(slice)].sort()); + } + }); +}); From cdadb2203d5c60188355c48500d8ee94559ddb4e Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Fri, 17 Jul 2026 14:57:09 -0700 Subject: [PATCH 06/15] Phase 5: adversarial token corpus, and the parser hardening it demanded server/core/test/token-corpus.test.ts drives all four signed token kinds (session, oauth-state, cli-code, project-access handoff+cookie) through their production verification paths: every single-bit flip, every cross-kind pairing, missing/extra/wrong-typed fields, boundary and non-finite timestamps, segment games, non-canonical base64url, oversized inputs, expiry boundaries, and an explicit statement of which stateless kinds stay replayable within their lifetime. Writing the corpus surfaced five gaps in verifySignedValue, now closed: extra token segments were silently ignored; excess payload properties were tolerated by the default Schema decode; 1e999 parsed to Infinity and would have made a token eternal; nothing rejected future issuance; and non-canonical base64url encodings of a valid payload were accepted. Co-Authored-By: Claude Fable 5 --- notes/improve-harness-plan.md | 4 +- server/core/src/auth.ts | 47 ++- server/core/test/token-corpus.test.ts | 432 ++++++++++++++++++++++++++ 3 files changed, 470 insertions(+), 13 deletions(-) create mode 100644 server/core/test/token-corpus.test.ts diff --git a/notes/improve-harness-plan.md b/notes/improve-harness-plan.md index 5ed6128..43f0af0 100644 --- a/notes/improve-harness-plan.md +++ b/notes/improve-harness-plan.md @@ -102,14 +102,14 @@ Six invariants (registry below). They live **directly in root `AGENTS.md`** — `[x]` Component-scan conformance test in `bun run ci`: import both `collectComponentNames` implementations (`renderer/src/components.js` and `shared/src/site/components.ts`), run them over a shared table of adversarial markdown samples (nested backtick runs, tags in comments, fences), assert identical output. Guards the one sanctioned duplication (see invariant 2). *(Landed as `shared/test/component-scan-conformance.test.ts`: a 23-sample adversarial table plus an every-prefix stress sweep.)* -`[ ]` Adversarial token corpus test in `bun run ci`, split by property: +`[x]` Adversarial token corpus test in `bun run ci`, split by property *(landed as `server/core/test/token-corpus.test.ts` — all four kinds through their production verification paths; hardened `verifySignedValue` along the way: exact two-segment parse, canonical-base64url re-encode check, strict excess-property decode, finite timestamps, future-issuance rejection, 16KB length cap)*: - Integrity: for each token kind (session, OAuth state, CLI authorization code, project-access handoff/cookie), a valid token rejects every single-byte bit-flip of encoded payload and signature. - Typed meaning: correctly sign malformed payloads and reject missing/extra/wrong-type fields, wrong `kind`/`version`/provider/use/project/scope/audience/nonce, extreme and boundary timestamps, future issuance, and every cross-kind pairing. - Parser hardening: reject truncation, empty/extra/swapped segments, duplicate delimiters, prefix/suffix garbage, non-canonical base64url, and oversized inputs. - Lifecycle: reject expiry and disallowed replay; explicitly document which short-lived stateless tokens remain replayable within their lifetime. -`[ ]` Session-secret length floor: retain the existing config failure below 32 bytes and add the missing regression test in `bun run ci` (length is checkable; entropy is not). +`[x]` Session-secret length floor: retain the existing config failure below 32 bytes and add the missing regression test in `bun run ci` (length is checkable; entropy is not). *(In `server/core/test/auth.test.ts`: 31/32-byte boundary, multibyte bytes-not-chars, both auth modes.)* `[ ]` Server-owned route-policy registry: define API routes once in production code (prefer `server/core/src/api-routes.ts`, or the server implementation layer when `HttpApi` lands), with handler, method/path, authentication mode, minimum project role, mutation/origin policy, and response-visibility policy attached to each entry. The router dispatches from this registry; the test matrix is generated from the same definitions rather than maintained as a second list. CI fails if an API route has no policy and exercises credential kind × role × endpoint × public/private status, denying every unspecified combination. diff --git a/server/core/src/auth.ts b/server/core/src/auth.ts index cc7e7df..3935442 100644 --- a/server/core/src/auth.ts +++ b/server/core/src/auth.ts @@ -67,6 +67,10 @@ export interface AuthUser { readonly picture?: string; } +/** Seconds-since-epoch claim. JSON can smuggle `1e999`, which parses to + * Infinity and would make a token eternal; finite() closes that. */ +const EpochSecondsSchema = Schema.Number.pipe(Schema.finite()); + /** The user object embedded in every session-token payload. */ const AuthUserSchema = Schema.Struct({ id: Schema.String, @@ -81,8 +85,8 @@ const SessionPayloadSchema = Schema.Struct({ kind: Schema.Literal("session"), provider: Schema.Literal("google", "cloudflare-access"), user: AuthUserSchema, - issuedAt: Schema.Number, - expiresAt: Schema.Number, + issuedAt: EpochSecondsSchema, + expiresAt: EpochSecondsSchema, }); type SessionPayload = typeof SessionPayloadSchema.Type; @@ -102,7 +106,7 @@ const OAuthStateSchema = Schema.Struct({ cliRedirect: Schema.optional(Schema.String), cliState: Schema.optional(Schema.String), cliCodeChallenge: Schema.optional(Schema.String), - expiresAt: Schema.Number, + expiresAt: EpochSecondsSchema, }); type OAuthState = typeof OAuthStateSchema.Type; @@ -123,7 +127,7 @@ const CliCodePayloadSchema = Schema.Struct({ * ride a loopback query string, so bearer credentials must never appear in their * signed-but-readable payload. */ encryptedCfToken: Schema.optional(Schema.String), - expiresAt: Schema.Number, + expiresAt: EpochSecondsSchema, }); /** The decoded CLI authorization-code payload. */ @@ -153,7 +157,7 @@ const ProjectAccessPayloadSchema = Schema.Struct({ project: Schema.String, scope: Schema.String, email: Schema.String, - expiresAt: Schema.Number, + expiresAt: EpochSecondsSchema, }); type ProjectAccessPayload = typeof ProjectAccessPayloadSchema.Type; @@ -675,7 +679,12 @@ export function verifyCliCodeExchange( }); } -/** Verifies one signed session token and applies current allow-list rules. */ +/** Tolerated clock skew before a session token's issuedAt reads as forged. */ +const ISSUED_AT_SKEW_SECONDS = 60; + +/** Verifies one signed session token and applies current allow-list rules. A + * token issued in the future (beyond clock skew) can only be a signing bug or + * a crafted payload, so it is rejected rather than trusted until expiry. */ function verifySessionToken( token: string, config: AuthConfig, @@ -683,6 +692,7 @@ function verifySessionToken( return Effect.gen(function* () { const payload = yield* verifySignedValue(token, config.sessionSecret, SessionPayloadSchema); if (payload.expiresAt <= epochSeconds()) return null; + if (payload.issuedAt > epochSeconds() + ISSUED_AT_SKEW_SECONDS) return null; if (!allowedUser(payload.user, config)) return null; return payload.user; }); @@ -790,7 +800,16 @@ function signValue(value: unknown, secret: string): Effect.Effect( token: string, secret: string, @@ -798,7 +817,10 @@ function verifySignedValue( ): Effect.Effect { const invalid = () => new AuthError({ status: 401, message: "Invalid auth token" }); return Effect.gen(function* () { - const [payload, signature] = token.split("."); + if (token.length > MAX_TOKEN_LENGTH) return yield* Effect.fail(invalid()); + const segments = token.split("."); + if (segments.length !== 2) return yield* Effect.fail(invalid()); + const [payload, signature] = segments; if (!payload || !signature) return yield* Effect.fail(invalid()); const expected = yield* Effect.tryPromise({ try: () => AuthCrypto.hmacSha256Base64Url(payload, secret), @@ -806,9 +828,12 @@ function verifySignedValue( }); if (!timingSafeEqual(signature, expected)) return yield* Effect.fail(invalid()); const bytes = yield* Encoding.decodeBase64Url(payload).pipe(Either.mapLeft(invalid)); - return yield* Schema.decodeUnknown(Schema.parseJson(schema))(new TextDecoder().decode(bytes)).pipe( - Effect.mapError(invalid), - ); + // Canonicality: exactly one token string may exist per payload, however + // tolerant the base64url decoder is of padding or alternate alphabets. + if (Encoding.encodeBase64Url(bytes) !== payload) return yield* Effect.fail(invalid()); + return yield* Schema.decodeUnknown(Schema.parseJson(schema), { onExcessProperty: "error" })( + new TextDecoder().decode(bytes), + ).pipe(Effect.mapError(invalid)); }); } diff --git a/server/core/test/token-corpus.test.ts b/server/core/test/token-corpus.test.ts new file mode 100644 index 0000000..98271fe --- /dev/null +++ b/server/core/test/token-corpus.test.ts @@ -0,0 +1,432 @@ +/* + * The adversarial token corpus (AGENTS.md, invariant 3): every signed HMAC + * token kind — session, oauth-state, cli-code, and project-access in both its + * handoff and cookie uses — exercised against four properties, each through + * the token's real production verification path: + * + * - Integrity: a valid token rejects every single-bit flip of any character. + * - Typed meaning: correctly signed but malformed payloads reject — missing, + * extra, and wrong-typed fields, wrong kind/version/provider/use/project/ + * scope, boundary and non-finite timestamps, future issuance, and every + * cross-kind pairing. + * - Parser hardening: truncation, segment games, non-canonical base64url, + * non-object JSON, and oversized inputs reject. + * - Lifecycle: expiry rejects at its exact boundary; which stateless kinds + * remain replayable within their lifetime is asserted explicitly. + * + * The test signer below produces byte-identical output to the production + * signValue chokepoint (same HMAC, same encoding, same secret), which is what + * lets the corpus craft payloads the server would never mint. + */ +import * as HttpServerRequest from "@effect/platform/HttpServerRequest"; +import * as HttpServerResponse from "@effect/platform/HttpServerResponse"; +import { describe, expect, test } from "bun:test"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import { + createSessionToken, + decodeCliAuthorizationCode, + issueCliAuthorizationCode, + makeAuth, + type AuthUser, +} from "../src/auth"; +import type { AuthConfig } from "../src/config"; + +const user: AuthUser = { id: "corpus-user-1", email: "corpus@example.com", name: "Corpus" }; + +const config: AuthConfig = { + mode: "oauth", + clientId: "corpus-client-id", + clientSecret: "corpus-client-secret", + sessionSecret: "corpus-session-secret-at-least-32-bytes", + allowedUsers: "public", + sessionTtlSeconds: 3600, +}; + +const auth = makeAuth(config); +const BASE_URL = "https://app.scratch.test"; +/** The opaque state parameter crafted oauth-state payloads carry. */ +const FIXED_STATE = "corpus-state-param"; + +const now = () => Math.floor(Date.now() / 1000); + +/** Fabricates an HttpServerRequest carrying the given headers. */ +function request(headers: Record): HttpServerRequest.HttpServerRequest { + return { headers } as HttpServerRequest.HttpServerRequest; +} + +/** Base64url without padding, byte-identical to production signValue. */ +function b64u(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** Signs an arbitrary payload segment string exactly as signValue would. */ +async function signRaw(payloadSegment: string, secret = config.sessionSecret): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payloadSegment)); + return `${payloadSegment}.${b64u(new Uint8Array(signature))}`; +} + +/** Signs a JSON payload exactly as the production signValue chokepoint does. */ +async function craft(payload: unknown, secret = config.sessionSecret): Promise { + return signRaw(b64u(new TextEncoder().encode(JSON.stringify(payload))), secret); +} + +/** Runs a mint function with the clock shifted, so expiry can be minted for real. */ +async function withShiftedClock(seconds: number, run: () => Promise): Promise { + const realNow = Date.now; + Date.now = () => realNow() + seconds * 1000; + try { + return await run(); + } finally { + Date.now = realNow; + } +} + +/** One token kind: how to mint it for real, its canonical crafted payload, and + * its acceptance oracle through the production verification path. */ +interface TokenKind { + readonly name: string; + readonly mint: () => Promise; + readonly validPayload: () => Record; + readonly accepts: (token: string) => Promise; +} + +/** oauth-state verifies inside the OAuth callback. The denial leg (`?error=`) + * exercises the state check without needing a token-endpoint mock: reaching + * the provider-error branch (an AuthError naming the provider, or a CLI + * loopback redirect) proves the state token was accepted; failing before it + * ("Invalid auth token" / "Invalid or expired OAuth state" / missing + * parameters) proves it was rejected. */ +async function oauthStateAccepts(token: string, stateParam: string = FIXED_STATE): Promise { + try { + const response = await Effect.runPromise(auth.callback( + request({ cookie: `__Host-scratchwork_oauth_state=${encodeURIComponent(token)}` }), + new URL(`${BASE_URL}/auth/callback/google?error=access_denied&state=${encodeURIComponent(stateParam)}`), + BASE_URL, + )); + return HttpServerResponse.toWeb(response).status === 302; + } catch (error) { + return String(error).includes("Google OAuth failed"); + } +} + +const CLI_REDIRECT = "http://127.0.0.1:5555/callback"; + +const KINDS: readonly TokenKind[] = [ + { + name: "session", + mint: () => Effect.runPromise(createSessionToken(user, config)), + validPayload: () => ({ + version: 1, + kind: "session", + provider: "google", + user: { id: user.id, email: user.email, name: user.name }, + issuedAt: now(), + expiresAt: now() + 3600, + }), + accepts: async (token) => + (await Effect.runPromise(auth.currentUser(request({ authorization: `Bearer ${token}` })))) != null, + }, + { + name: "oauth-state", + mint: async () => craft(KINDS[1].validPayload()), + validPayload: () => ({ + version: 1, + kind: "oauth-state", + state: FIXED_STATE, + nonce: "corpus-nonce", + codeVerifier: "corpus-verifier-corpus-verifier-corpus-veri1", + returnTo: "/", + expiresAt: now() + 600, + }), + accepts: (token) => oauthStateAccepts(token), + }, + { + name: "cli-code", + mint: () => + Effect.runPromise(issueCliAuthorizationCode(user, { + provider: "google", + codeChallenge: "corpus-challenge-corpus-challenge-corpus-ch1", + redirectUri: CLI_REDIRECT, + }, config)), + validPayload: () => ({ + version: 1, + kind: "cli-code", + id: "corpus-code-1", + user: { id: user.id, email: user.email }, + provider: "google", + codeChallenge: "corpus-challenge-corpus-challenge-corpus-ch1", + redirectUri: CLI_REDIRECT, + expiresAt: now() + 60, + }), + accepts: (token) => + Effect.runPromise(decodeCliAuthorizationCode(token, config)).then(() => true, () => false), + }, + { + name: "project-access (handoff)", + mint: () => Effect.runPromise(auth.issueProjectAccessToken("site", user, "handoff")), + validPayload: () => ({ + version: 1, + kind: "project-access", + use: "handoff", + project: "site", + scope: "/site", + email: user.email, + expiresAt: now() + 60, + }), + accepts: (token) => + Effect.runPromise(auth.verifyProjectAccessToken(token, "site", "handoff")).then( + (found) => found != null, + () => false, + ), + }, + { + name: "project-access (cookie)", + mint: () => Effect.runPromise(auth.issueProjectAccessToken("site", user, "cookie")), + validPayload: () => ({ + version: 1, + kind: "project-access", + use: "cookie", + project: "site", + scope: "/site", + email: user.email, + expiresAt: now() + 3600, + }), + accepts: (token) => + Effect.runPromise(auth.verifyProjectAccessToken(token, "site", "cookie")).then( + (found) => found != null, + () => false, + ), + }, +]; + +describe("token corpus: sanity", () => { + for (const kind of KINDS) { + test(`${kind.name}: the minted token verifies`, async () => { + expect(await kind.accepts(await kind.mint())).toBe(true); + }); + test(`${kind.name}: the crafted canonical payload verifies (signer parity)`, async () => { + expect(await kind.accepts(await craft(kind.validPayload()))).toBe(true); + }); + } + + test("a real OAuth login's state cookie verifies through the callback", async () => { + const login = HttpServerResponse.toWeb(await Effect.runPromise(auth.login( + request({}), + new URL(`${BASE_URL}/auth/login`), + BASE_URL, + ))); + const stateParam = new URL(login.headers.get("location") ?? "https://invalid").searchParams.get("state") ?? ""; + const setCookie = login.headers.get("set-cookie") ?? ""; + const pair = setCookie.split(";")[0] ?? ""; + const token = decodeURIComponent(pair.slice(pair.indexOf("=") + 1)); + expect(await oauthStateAccepts(token, stateParam)).toBe(true); + }); +}); + +describe("token corpus: integrity", () => { + for (const kind of KINDS) { + test(`${kind.name}: rejects every single-bit flip`, async () => { + const token = await kind.mint(); + const survivors: string[] = []; + for (let index = 0; index < token.length; index++) { + for (let bit = 0; bit < 8; bit++) { + const flipped = + token.slice(0, index) + + String.fromCharCode(token.charCodeAt(index) ^ (1 << bit)) + + token.slice(index + 1); + if (await kind.accepts(flipped)) survivors.push(`char ${index} bit ${bit}`); + } + } + expect(survivors).toEqual([]); + }, 60_000); + + test(`${kind.name}: rejects the same payload signed with a different secret`, async () => { + expect(await kind.accepts(await craft(kind.validPayload(), "other-secret-other-secret-32-bytes!!!"))).toBe(false); + }); + } +}); + +describe("token corpus: typed meaning", () => { + test("every cross-kind pairing rejects", async () => { + for (const payloadKind of KINDS) { + for (const verifier of KINDS) { + if (payloadKind.name === verifier.name) continue; + const token = await craft(payloadKind.validPayload()); + expect({ pair: `${payloadKind.name} → ${verifier.name}`, accepted: await verifier.accepts(token) }).toEqual({ + pair: `${payloadKind.name} → ${verifier.name}`, + accepted: false, + }); + } + } + }); + + for (const kind of KINDS) { + test(`${kind.name}: rejects each field missing, wrong-typed, or unknown`, async () => { + const payload = kind.validPayload(); + const cases: Array<{ label: string; payload: Record }> = []; + for (const field of Object.keys(payload)) { + const { [field]: _dropped, ...rest } = payload; + cases.push({ label: `missing ${field}`, payload: rest }); + cases.push({ label: `wrong-typed ${field}`, payload: { ...payload, [field]: {} } }); + } + cases.push({ label: "unknown extra field", payload: { ...payload, admin: true } }); + cases.push({ label: "version 0", payload: { ...payload, version: 0 } }); + cases.push({ label: "version 2", payload: { ...payload, version: 2 } }); + cases.push({ label: 'version "1"', payload: { ...payload, version: "1" } }); + cases.push({ label: "uppercased kind", payload: { ...payload, kind: String(payload.kind).toUpperCase() } }); + for (const item of cases) { + expect({ label: item.label, accepted: await kind.accepts(await craft(item.payload)) }).toEqual({ + label: item.label, + accepted: false, + }); + } + }); + + test(`${kind.name}: rejects boundary and non-finite timestamps`, async () => { + const payload = kind.validPayload(); + for (const expiresAt of [0, -1, now(), now() - 1]) { + expect(await kind.accepts(await craft({ ...payload, expiresAt }))).toBe(false); + } + // JSON.stringify cannot emit 1e999, so splice the literal into the wire + // form: it parses to Infinity, which must fail the finite() constraint + // rather than mint an eternal token. + const marker = 987654321098765; + const json = JSON.stringify({ ...payload, expiresAt: marker }).replace(String(marker), "1e999"); + expect(await kind.accepts(await signRaw(b64u(new TextEncoder().encode(json))))).toBe(false); + // Just inside the lifetime still verifies, so the boundary is exact. + expect(await kind.accepts(await craft({ ...payload, expiresAt: now() + 5 }))).toBe(true); + }); + } + + test("session: rejects future issuance beyond clock skew", async () => { + const payload = KINDS[0].validPayload(); + expect(await KINDS[0].accepts(await craft({ ...payload, issuedAt: now() + 3600 }))).toBe(false); + expect(await KINDS[0].accepts(await craft({ ...payload, issuedAt: now() + 5 }))).toBe(true); + }); + + test("session: rejects an unknown provider", async () => { + expect(await KINDS[0].accepts(await craft({ ...KINDS[0].validPayload(), provider: "github" }))).toBe(false); + }); + + test("cli-code: rejects an unknown provider", async () => { + expect(await KINDS[2].accepts(await craft({ ...KINDS[2].validPayload(), provider: "okta" }))).toBe(false); + }); + + test("project-access: rejects wrong use, project, and scope", async () => { + const handoff = KINDS[3]; + const payload = handoff.validPayload(); + expect(await handoff.accepts(await craft({ ...payload, use: "cookie" }))).toBe(false); + expect(await handoff.accepts(await craft({ ...payload, use: "admin" }))).toBe(false); + expect(await handoff.accepts(await craft({ ...payload, project: "other" }))).toBe(false); + expect(await handoff.accepts(await craft({ ...payload, scope: "/other" }))).toBe(false); + }); + + test("oauth-state: rejects a state parameter that does not match the payload", async () => { + const token = await craft(KINDS[1].validPayload()); + expect(await oauthStateAccepts(token, "different-state-param")).toBe(false); + }); +}); + +describe("token corpus: parser hardening", () => { + // The parser is the shared verifySignedValue chokepoint; the project-access + // oracle hands it the raw token string with no transport parsing in front + // (a bearer header would first normalize whitespace around the token), and + // the cross-kind suite proves every kind shares the same parser. + const accepts = (token: string) => KINDS[4].accepts(token); + + test("rejects malformed token structures", async () => { + const valid = await KINDS[4].mint(); + const [payload, signature] = valid.split("."); + const malformed: Array<{ label: string; token: string }> = [ + { label: "empty string", token: "" }, + { label: "lone delimiter", token: "." }, + { label: "double delimiter", token: ".." }, + { label: "payload only", token: payload }, + { label: "payload with trailing dot", token: `${payload}.` }, + { label: "signature only", token: `.${signature}` }, + { label: "swapped segments", token: `${signature}.${payload}` }, + { label: "extra segment", token: `${valid}.extra` }, + { label: "duplicated delimiter inside", token: `${payload}..${signature}` }, + { label: "prefix garbage", token: `x${valid}` }, + { label: "suffix garbage", token: `${valid}x` }, + { label: "leading whitespace", token: ` ${valid}` }, + { label: "internal whitespace", token: valid.replace(".", " .") }, + { label: "unicode garbage", token: "🎫🎫.🎫🎫" }, + { label: "oversized input", token: `${"x".repeat(20_000)}.${signature}` }, + ]; + for (const item of malformed) { + expect({ label: item.label, accepted: await accepts(item.token) }).toEqual({ + label: item.label, + accepted: false, + }); + } + }); + + test("rejects non-canonical base64url payload encodings of a valid payload", async () => { + const payloadJson = JSON.stringify(KINDS[4].validPayload()); + const canonical = b64u(new TextEncoder().encode(payloadJson)); + expect(await accepts(await signRaw(canonical))).toBe(true); + // Padded form of the same bytes, correctly signed. + const padded = canonical + "=".repeat((4 - (canonical.length % 4)) % 4 || 2); + expect(await accepts(await signRaw(padded))).toBe(false); + // Standard-alphabet form of the same bytes, correctly signed. + const standard = btoa(String.fromCharCode(...new TextEncoder().encode(payloadJson))); + if (standard !== canonical) { + expect(await accepts(await signRaw(standard))).toBe(false); + } + }); + + test("rejects correctly signed non-object and non-JSON payload segments", async () => { + for (const segment of ["null", "[]", '"session"', "{}", "{", "42", ""]) { + const token = await signRaw(b64u(new TextEncoder().encode(segment))); + expect({ segment, accepted: await accepts(token) }).toEqual({ segment, accepted: false }); + } + // A payload segment that is not base64url at all, correctly signed. + expect(await accepts(await signRaw("!!!not-base64url!!!"))).toBe(false); + }); +}); + +describe("token corpus: lifecycle", () => { + test("session: a token minted in the past rejects once its TTL elapses", async () => { + const expired = await withShiftedClock(-(config.sessionTtlSeconds + 60), () => + Effect.runPromise(createSessionToken(user, config))); + expect(await KINDS[0].accepts(expired)).toBe(false); + }); + + test("cli-code: expires after its ~60s window", async () => { + const expired = await withShiftedClock(-120, () => KINDS[2].mint()); + expect(await KINDS[2].accepts(expired)).toBe(false); + }); + + test("project-access handoff: expires after its ~60s window", async () => { + const expired = await withShiftedClock(-120, () => KINDS[3].mint()); + expect(await KINDS[3].accepts(expired)).toBe(false); + }); + + test("oauth-state: expires after the state TTL", async () => { + expect(await KINDS[1].accepts(await craft({ ...KINDS[1].validPayload(), expiresAt: now() - 1 }))).toBe(false); + }); + + test("stateless kinds are replayable within their lifetime — by design", async () => { + // Documented accepted trade-off (AGENTS.md invariant 3): sessions and + // project-access tokens are stateless HMAC, so a token verifies any number + // of times until expiry or an allow-list/SESSION_VERSION revocation. The + // cli-code kind is the deliberate exception: its one-time redemption + // record lives in PrimitiveDb at the /auth/cli/token exchange route + // (exercised in server/core/test/app.test.ts and the e2e auth-negative + // lanes), not in the stateless decode exercised here. + for (const kind of [KINDS[0], KINDS[3], KINDS[4]]) { + const token = await kind.mint(); + expect(await kind.accepts(token)).toBe(true); + expect(await kind.accepts(token)).toBe(true); + } + }); +}); From a7738c75cfe6771bd39f2d712608e9c98cbd2d29 Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Fri, 17 Jul 2026 14:57:09 -0700 Subject: [PATCH 07/15] Phase 5: session-secret length-floor regression test The 32-byte floor existed in readServerConfig but nothing pinned it. Covers the 31/32-byte boundary, bytes-not-characters (multibyte), and both auth modes. Co-Authored-By: Claude Fable 5 --- server/core/test/auth.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/server/core/test/auth.test.ts b/server/core/test/auth.test.ts index 8935f66..267930a 100644 --- a/server/core/test/auth.test.ts +++ b/server/core/test/auth.test.ts @@ -620,6 +620,35 @@ describe("readServerConfig", () => { ); }); + test("enforces the 32-byte session-secret floor in both auth modes", async () => { + // Length is checkable; entropy is not — the floor is the mechanized part + // of invariant 3's secret hygiene. The bound is bytes, not characters. + const oauthEnv = (secret: string) => ({ + SCRATCHWORK_AUTH: "oauth", + SCRATCHWORK_GOOGLE_CLIENT_ID: "client-id", + SCRATCHWORK_GOOGLE_CLIENT_SECRET: "client-secret", + SCRATCHWORK_SESSION_SECRET: secret, + }); + + await expect(Effect.runPromise(readServerConfig(oauthEnv("a".repeat(31))))).rejects.toThrow( + "SCRATCHWORK_SESSION_SECRET must be at least 32 bytes", + ); + expect((await Effect.runPromise(readServerConfig(oauthEnv("a".repeat(32))))).auth.sessionSecret).toHaveLength(32); + + // 16 two-byte characters are 32 bytes; 15 of them plus one ASCII byte are 31. + await expect(Effect.runPromise(readServerConfig(oauthEnv("é".repeat(15) + "a")))).rejects.toThrow( + "at least 32 bytes", + ); + await Effect.runPromise(readServerConfig(oauthEnv("é".repeat(16)))); + + await expect(Effect.runPromise(readServerConfig({ + SCRATCHWORK_AUTH: "cloudflare-access", + SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN: "myteam", + SCRATCHWORK_CF_ACCESS_AUD: "aud-tag-1", + SCRATCHWORK_SESSION_SECRET: "a".repeat(31), + }))).rejects.toThrow("at least 32 bytes"); + }); + test("defaults to user-set project names", async () => { const config = await Effect.runPromise( readServerConfig({ From 3a202689c7f16391c5a21c26cb6c99fe054fef91 Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Fri, 17 Jul 2026 15:09:47 -0700 Subject: [PATCH 08/15] Phase 5: server-owned route-policy registry with a generated test matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every JSON endpoint is now defined exactly once in api-routes.ts — method/path, auth mode, minimum project role, mutation flag, response visibility, handler — and dispatchApiRoute is the only way an API request reaches a handler: origin rejection first, then the declared principal resolution, then a read-gated project capability handed to the handler. app.ts keeps browser auth routes and content serving; the origin/URL helpers moved to http.ts. test/api-policy.test.ts derives the expected outcome of every route × credential kind (none, garbage, stranger/reader/writer/admin/owner) × public/private cell from the same registry, and denies everything the policy does not explicitly allow. Tightenings that fell out: all JSON routes reject cross-origin browser calls (previously only some mutations), sub-read callers get the existence mask on every project route including delete, and deleting a missing project is 404 instead of silently ok. Co-Authored-By: Claude Fable 5 --- notes/improve-harness-plan.md | 2 +- server/core/src/api-routes.ts | 466 ++++++++++++++++++++++++++++ server/core/src/app.ts | 460 ++------------------------- server/core/src/http.ts | 106 +++++++ server/core/test/api-policy.test.ts | 238 ++++++++++++++ server/core/test/app.test.ts | 9 +- 6 files changed, 842 insertions(+), 439 deletions(-) create mode 100644 server/core/src/api-routes.ts create mode 100644 server/core/test/api-policy.test.ts diff --git a/notes/improve-harness-plan.md b/notes/improve-harness-plan.md index 43f0af0..bda3ce3 100644 --- a/notes/improve-harness-plan.md +++ b/notes/improve-harness-plan.md @@ -111,7 +111,7 @@ Six invariants (registry below). They live **directly in root `AGENTS.md`** — `[x]` Session-secret length floor: retain the existing config failure below 32 bytes and add the missing regression test in `bun run ci` (length is checkable; entropy is not). *(In `server/core/test/auth.test.ts`: 31/32-byte boundary, multibyte bytes-not-chars, both auth modes.)* -`[ ]` Server-owned route-policy registry: define API routes once in production code (prefer `server/core/src/api-routes.ts`, or the server implementation layer when `HttpApi` lands), with handler, method/path, authentication mode, minimum project role, mutation/origin policy, and response-visibility policy attached to each entry. The router dispatches from this registry; the test matrix is generated from the same definitions rather than maintained as a second list. CI fails if an API route has no policy and exercises credential kind × role × endpoint × public/private status, denying every unspecified combination. +`[x]` Server-owned route-policy registry: define API routes once in production code (prefer `server/core/src/api-routes.ts`, or the server implementation layer when `HttpApi` lands), with handler, method/path, authentication mode, minimum project role, mutation/origin policy, and response-visibility policy attached to each entry. The router dispatches from this registry; the test matrix is generated from the same definitions rather than maintained as a second list. CI fails if an API route has no policy and exercises credential kind × role × endpoint × public/private status, denying every unspecified combination. *(Landed: `api-routes.ts` registry + policy middleware (origin → principal → read-gated project capability), `test/api-policy.test.ts` matrix over 7 credential kinds × public/private. Tightenings that fell out: every JSON route now rejects cross-origin browser calls (previously only some mutations), sub-read callers get the existence mask on all project routes including delete, and delete of a missing project is now 404 rather than silently ok.)* `[ ]` Cookie/origin browser-security suite: use real app/content/homepage hostnames in a headless browser to prove secure-cookie-name selection, host/path scoping, SameSite behavior, cross-origin mutation rejection, safe redirects, private subresource isolation, and that arbitrary published JavaScript cannot plant or override an app session. This is narrowly scoped browser security coverage; browser rendering fidelity remains a non-goal. diff --git a/server/core/src/api-routes.ts b/server/core/src/api-routes.ts new file mode 100644 index 0000000..d29be8f --- /dev/null +++ b/server/core/src/api-routes.ts @@ -0,0 +1,466 @@ +/** + * The server-owned API route-policy registry (AGENTS.md, invariant 4). Every + * JSON endpoint is defined exactly once in API_ROUTES: its method and path, + * how the caller authenticates, the minimum project role it demands, whether + * it mutates state, and what its response may reveal. The dispatcher below is + * the only way an API request reaches a handler, and it derives its behavior + * from the same definitions the policy test matrix enumerates — so an + * unregistered or policy-less route cannot exist, and unspecified + * credential/role combinations are denied by construction: + * + * - every route rejects cross-origin browser calls before anything else; + * - `auth: "bearer"` resolves the principal or fails 401 before the handler; + * - project routes resolve the project and require the declared minimum role + * up to "read" before the handler runs, with missing and forbidden both + * reading as 404 so unauthorized callers cannot probe which projects + * exist; roles above "read" are enforced by the SiteStore operation the + * handler calls (they are coupled to its conditional writes) and are + * declared here so the policy matrix can assert them end-to-end. + */ +import * as HttpServerRequest from "@effect/platform/HttpServerRequest"; +import type * as HttpServerResponse from "@effect/platform/HttpServerResponse"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as ParseResult from "effect/ParseResult"; +import * as Schema from "effect/Schema"; +import { + CliTokenRequestSchema, + type CliTokenRequest, + type CliTokenResponse, + type ProjectInfo, + type ProjectResponse, + type ProjectsListResponse, + type PublishResponse, + type ShareResponse, +} from "../../../shared/src/publish/api"; +import { accessGroupTerms, isSafeProjectIdentifier } from "./access"; +import { + Auth, + AuthError, + createSessionToken, + decodeCliAuthorizationCode, + decryptCliCloudflareToken, + verifyCliCodeExchange, + type AuthUser, +} from "./auth"; +import { ServerConfig, type ServerConfigShape } from "./config"; +import { PrimitiveDb } from "./db"; +import { + appBaseUrl, + contentBaseUrl, + HttpError, + jsonResponse, + projectUrl, + publishedUrl, + rejectCrossOriginApiRequest, +} from "./http"; +import { readPublishRequest } from "./publish-request"; +import { projectForRequest } from "./routes"; +import { readShareRequest } from "./share-request"; +import { type SiteRecord } from "./site-records"; +import { + canReadProject, + projectRole, + roleAtLeast, + SiteStore, + SiteStoreError, + type LoadedSite, + type ProjectRole, +} from "./site-store"; +import { StorageError } from "./storage"; + +/** Effect type shared by every API handler. */ +type ApiEffect = Effect.Effect< + HttpServerResponse.HttpServerResponse, + HttpError | AuthError | SiteStoreError | StorageError, + ServerConfig | SiteStore | Auth | PrimitiveDb +>; + +/** What the policy middleware hands a handler: the request, its parsed URL, + * the principal the declared auth mode resolved, and — for project routes — + * the read-gated project capability. Handlers never reconstruct these. */ +export interface ApiContext { + readonly request: HttpServerRequest.HttpServerRequest; + readonly url: URL; + /** The authenticated principal: non-null for `auth: "bearer"` routes, the + * optional cookie/bearer identity for `auth: "optional"`, null for + * `auth: "code-exchange"` (there the one-time code is the credential). */ + readonly user: AuthUser | null; + /** The loaded project for `/api/projects/:project` routes, already gated by + * the read-access mask; null on fixed-path routes. */ + readonly site: LoadedSite | null; +} + +/** One registered API route and its complete security policy. */ +export interface ApiRoute { + /** Stable route name used by the policy test matrix. */ + readonly name: string; + readonly method: "GET" | "POST" | "DELETE"; + /** Fixed pathname, or the project-parametrized form "/api/projects/:project(/action)". */ + readonly path: string; + /** + * How the caller authenticates. "bearer": a valid bearer session token or + * 401. "optional": bearer/cookie identity if present, anonymous otherwise. + * "code-exchange": no ambient credential — the signed one-time code in the + * body is the credential and the handler verifies it. + */ + readonly auth: "bearer" | "optional" | "code-exchange"; + /** + * Minimum project role the route requires. "read" is enforced by the + * dispatcher's project gate; higher roles are enforced by the SiteStore + * operation and asserted by the policy matrix. Null for routes without a + * project subject. + */ + readonly minimumRole: ProjectRole | null; + /** Whether a successful call mutates server state. Every route — mutation + * or not — rejects cross-origin browser calls; the flag feeds the matrix. */ + readonly mutation: boolean; + /** + * What the response may reveal. "identity": the caller's own identity. + * "own-projects": summaries of projects the caller can read. + * "project-summary": one summary whose `permissions` field (other users' + * emails) appears only for admin+ callers. "project-content": the full + * bundle, read-gated. "session-token": a freshly minted credential for the + * verified code holder. "status": a bare acknowledgment. + */ + readonly visibility: + | "identity" + | "own-projects" + | "project-summary" + | "project-content" + | "session-token" + | "status"; + readonly handler: (context: ApiContext) => ApiEffect; +} + +/** Namespace of the one-time CLI code redemption records. Records are tiny (one per + * CLI login) and expire with their 60-second codes; they are never read back except + * by the conditional create that detects a replay. */ +const CLI_CODE_NAMESPACE = "cli-code-redemptions"; +/** Generous ceiling for the exchange body: three short strings. */ +const MAX_CLI_TOKEN_BODY_BYTES = 64 * 1024; + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/** Handles `POST /auth/cli/token`: the back-channel exchange of a one-time CLI + * authorization code plus PKCE verifier for a bearer token. */ +function exchangeCliToken({ request }: ApiContext): ApiEffect { + return Effect.gen(function* () { + const config = yield* ServerConfig; + const db = yield* PrimitiveDb; + const body = yield* readCliTokenRequest(request); + const payload = yield* decodeCliAuthorizationCode(body.code, config.auth); + // Burn the code before checking possession: the first redemption attempt + // consumes it, so an intercepted code that is replayed — or raced with a wrong + // verifier — fails closed instead of staying redeemable within its lifetime. + yield* db.put( + CLI_CODE_NAMESPACE, + payload.id, + { redeemedAt: Math.floor(Date.now() / 1000) }, + { ifNoneMatch: "*", expiresAt: payload.expiresAt }, + ).pipe( + Effect.mapError((error) => + error._tag === "PrimitiveDbConflict" + ? new AuthError({ status: 400, message: "Authorization code already redeemed" }) + : new HttpError({ status: 500, message: "Could not record the code redemption" }), + ), + ); + const user = yield* verifyCliCodeExchange(payload, body.codeVerifier, body.redirectUri, config.auth); + const cfToken = yield* decryptCliCloudflareToken(payload, config.auth); + const token = yield* createSessionToken(user, config.auth); + const response: CliTokenResponse = { + token, + server: appBaseUrl(request, config), + email: user.email, + ...(cfToken != null ? { cfToken } : {}), + }; + return jsonResponse(response, 200); + }); +} + +/** Reads, size-limits, and strictly decodes the CLI token-exchange body. */ +function readCliTokenRequest( + request: HttpServerRequest.HttpServerRequest, +): Effect.Effect { + return Effect.gen(function* () { + const text = yield* request.text.pipe( + HttpServerRequest.withMaxBodySize(Option.some(MAX_CLI_TOKEN_BODY_BYTES)), + Effect.mapError((cause) => new HttpError({ status: 413, message: "Request body is too large", cause })), + ); + const parsed = yield* Schema.decodeUnknown(Schema.parseJson())(text).pipe( + Effect.mapError(() => new HttpError({ status: 400, message: "Invalid JSON body" })), + ); + return yield* Schema.decodeUnknown(CliTokenRequestSchema)(parsed, { + errors: "all", + onExcessProperty: "error", + }).pipe( + Effect.mapError((error) => + new HttpError({ status: 400, message: ParseResult.TreeFormatter.formatErrorSync(error) }), + ), + ); + }); +} + +/** Handles `GET /api/me`: reports the caller's own authentication state. */ +function me({ user }: ApiContext): ApiEffect { + return Effect.succeed(jsonResponse({ authenticated: user != null, user }, 200)); +} + +/** Handles `GET /health`. */ +function health(): ApiEffect { + return Effect.succeed(jsonResponse({ ok: true }, 200)); +} + +/** Handles `POST /api/publish` through bearer auth and SiteStore (which + * enforces write — and admin for a public/private flip — on updates). */ +function publish({ request, user }: ApiContext): ApiEffect { + return Effect.gen(function* () { + const config = yield* ServerConfig; + const publishRequest = yield* readPublishRequest(request); + const siteStore = yield* SiteStore; + const result = yield* siteStore.publish(publishRequest, user!, config); + const url = publishedUrl(contentBaseUrl(request, config), result.project, result.openPath, config); + return jsonResponse({ ...result, url } satisfies PublishResponse, 200); + }); +} + +/** Handles `GET /api/projects`: the authenticated user's own project index. */ +function listProjects({ request, user }: ApiContext): ApiEffect { + return Effect.gen(function* () { + const config = yield* ServerConfig; + const siteStore = yield* SiteStore; + const projects = yield* siteStore.listProjects(user!); + const contentBase = contentBaseUrl(request, config); + return jsonResponse({ + projects: projects.map((project) => projectSummary(project, contentBase, projectRole(project, user, config), config)), + } satisfies ProjectsListResponse, 200); + }); +} + +/** Handles `GET /api/resolve`: maps a published content path to its project. + * Kept as an endpoint (rather than a client-side parse) so validation, + * authorization, and URL-to-project resolution stay centralized. The project + * subject comes from the query string, so the read gate runs here rather than + * in the dispatcher — same mask, same policy. */ +function resolveProjectPath({ request, url, user }: ApiContext): ApiEffect { + return Effect.gen(function* () { + const path = url.searchParams.get("path"); + if (path == null || !path.startsWith("/")) { + return yield* Effect.fail(new HttpError({ status: 400, message: "Missing path" })); + } + const config = yield* ServerConfig; + const siteStore = yield* SiteStore; + const project = projectForRequest(path); + const loaded = project == null ? null : yield* siteStore.loadProject(project); + const site = yield* requireReadableSite(loaded, user, config); + return jsonResponse({ + project: projectSummary(site.record, contentBaseUrl(request, config), projectRole(site.record, user, config), config), + } satisfies ProjectResponse, 200); + }); +} + +/** Handles `GET /api/projects/:project`. */ +function projectInfo({ request, user, site }: ApiContext): ApiEffect { + return Effect.gen(function* () { + const config = yield* ServerConfig; + return jsonResponse({ + project: projectSummary(site!.record, contentBaseUrl(request, config), projectRole(site!.record, user, config), config), + } satisfies ProjectResponse, 200); + }); +} + +/** Handles `GET /api/projects/:project/bundle` for clone/read workflows. */ +function projectBundle({ site }: ApiContext): ApiEffect { + return Effect.gen(function* () { + const siteStore = yield* SiteStore; + const bundle = yield* siteStore.bundle(site!.record.project); + if (bundle == null) return yield* Effect.fail(new HttpError({ status: 404, message: "Project not found" })); + return jsonResponse({ bundle }, 200); + }); +} + +/** Handles `POST /api/projects/:project/unpublish` (SiteStore enforces admin). */ +function unpublishProject({ request, user, site }: ApiContext): ApiEffect { + return Effect.gen(function* () { + const config = yield* ServerConfig; + const siteStore = yield* SiteStore; + const record = yield* siteStore.unpublish(site!.record.project, user!, config); + return jsonResponse({ + project: projectSummary(record, contentBaseUrl(request, config), projectRole(record, user, config), config), + } satisfies ProjectResponse, 200); + }); +} + +/** Handles `POST /api/projects/:project/share` (SiteStore enforces admin). */ +function shareProject({ request, user, site }: ApiContext): ApiEffect { + return Effect.gen(function* () { + const config = yield* ServerConfig; + const changes = yield* readShareRequest(request); + const siteStore = yield* SiteStore; + const result = yield* siteStore.share(site!.record.project, user!, changes, config); + return jsonResponse({ + project: projectSummary(result.record, contentBaseUrl(request, config), projectRole(result.record, user, config), config), + warnings: result.warnings, + } satisfies ShareResponse, 200); + }); +} + +/** Handles `DELETE /api/projects/:project` (SiteStore enforces owner). */ +function deleteProject({ user, site }: ApiContext): ApiEffect { + return Effect.gen(function* () { + const siteStore = yield* SiteStore; + yield* siteStore.deleteProject(site!.record.project, user!); + return jsonResponse({ ok: true }, 200); + }); +} + +// --------------------------------------------------------------------------- +// The registry +// --------------------------------------------------------------------------- + +/** Every JSON endpoint this server exposes, with its complete policy. */ +export const API_ROUTES: ReadonlyArray = [ + { name: "health", method: "GET", path: "/health", auth: "optional", minimumRole: null, mutation: false, visibility: "status", handler: health }, + { name: "cli-token-exchange", method: "POST", path: "/auth/cli/token", auth: "code-exchange", minimumRole: null, mutation: true, visibility: "session-token", handler: exchangeCliToken }, + { name: "me", method: "GET", path: "/api/me", auth: "optional", minimumRole: null, mutation: false, visibility: "identity", handler: me }, + { name: "publish", method: "POST", path: "/api/publish", auth: "bearer", minimumRole: "write", mutation: true, visibility: "project-summary", handler: publish }, + { name: "projects-list", method: "GET", path: "/api/projects", auth: "bearer", minimumRole: null, mutation: false, visibility: "own-projects", handler: listProjects }, + { name: "resolve", method: "GET", path: "/api/resolve", auth: "bearer", minimumRole: "read", mutation: false, visibility: "project-summary", handler: resolveProjectPath }, + { name: "project-info", method: "GET", path: "/api/projects/:project", auth: "bearer", minimumRole: "read", mutation: false, visibility: "project-summary", handler: projectInfo }, + { name: "project-bundle", method: "GET", path: "/api/projects/:project/bundle", auth: "bearer", minimumRole: "read", mutation: false, visibility: "project-content", handler: projectBundle }, + { name: "project-unpublish", method: "POST", path: "/api/projects/:project/unpublish", auth: "bearer", minimumRole: "admin", mutation: true, visibility: "project-summary", handler: unpublishProject }, + { name: "project-share", method: "POST", path: "/api/projects/:project/share", auth: "bearer", minimumRole: "admin", mutation: true, visibility: "project-summary", handler: shareProject }, + { name: "project-delete", method: "DELETE", path: "/api/projects/:project", auth: "bearer", minimumRole: "owner", mutation: true, visibility: "status", handler: deleteProject }, +]; + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +/** + * Routes one request through the registry, or returns null when the path + * belongs to no registered route family (auth pages and published content are + * dispatched elsewhere). A registered path with the wrong method is 405; an + * unknown path under /api/ is 404 — both decided here so nothing else can + * answer for API paths. + */ +export function dispatchApiRoute( + request: HttpServerRequest.HttpServerRequest, + url: URL, +): Effect.Effect< + HttpServerResponse.HttpServerResponse | null, + HttpError | AuthError | SiteStoreError | StorageError, + ServerConfig | SiteStore | Auth | PrimitiveDb +> { + return Effect.gen(function* () { + const matches = API_ROUTES + .map((route) => ({ route, params: matchPath(route.path, url.pathname) })) + .filter((match) => match.params != null); + if (matches.length === 0) { + if (url.pathname.startsWith("/api/")) { + return yield* Effect.fail(new HttpError({ status: 404, message: "Not found" })); + } + return null; + } + // HEAD is answered by the matching GET route, per HTTP semantics. + const method = request.method === "HEAD" ? "GET" : request.method; + const match = matches.find((candidate) => candidate.route.method === method); + if (match == null) { + return yield* Effect.fail(new HttpError({ status: 405, message: "Method not allowed" })); + } + return yield* runRoute(match.route, match.params!.project ?? null, request, url); + }); +} + +/** Applies the declared policy in order — origin, principal, project gate — + * then runs the handler with the resolved context. */ +function runRoute( + route: ApiRoute, + project: string | null, + request: HttpServerRequest.HttpServerRequest, + url: URL, +): ApiEffect { + return Effect.gen(function* () { + const config = yield* ServerConfig; + yield* rejectCrossOriginApiRequest(request, appBaseUrl(request, config)); + + const auth = yield* Auth; + const user = route.auth === "bearer" + ? yield* auth.requireApiUser(request) + : route.auth === "optional" + ? yield* auth.currentUser(request) + : null; + + let site: LoadedSite | null = null; + if (project != null) { + const siteStore = yield* SiteStore; + site = yield* requireReadableSite(yield* siteStore.loadProject(project), user, config); + } + return yield* route.handler({ request, url, user, site }); + }); +} + +/** Matches a registry path pattern against a request pathname. The decoded + * :project segment is validated so malformed names become a 404 instead of + * reaching the store as backend keys. Returns null on no match. */ +function matchPath(pattern: string, pathname: string): { readonly project?: string } | null { + if (!pattern.includes(":project")) return pattern === pathname ? {} : null; + const match = /^\/api\/projects\/([^/]+)(\/[^/]+)?$/.exec(pathname); + if (match == null) return null; + const suffix = match[2] ?? ""; + if (pattern !== `/api/projects/:project${suffix}`) return null; + try { + const project = decodeURIComponent(match[1]); + return isSafeProjectIdentifier(project) ? { project } : null; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Shared gates and response shaping +// --------------------------------------------------------------------------- + +/** Gates a loaded site behind read access. Both the missing and the forbidden case read + * as "Project not found" so unauthorized callers cannot probe which projects exist. */ +export function requireReadableSite( + site: LoadedSite | null, + user: AuthUser | null, + config: ServerConfigShape, +): Effect.Effect { + if (site == null) return Effect.fail(new HttpError({ status: 404, message: "Project not found" })); + if (!canReadProject(site.record, user, config)) { + return Effect.fail(new HttpError({ status: 403, message: "Project not found" })); + } + return Effect.succeed(site); +} + +/** Shapes one project record for API responses (the shared ProjectInfo contract). + * `isPublic` is the public/private toggle; `permissions` lists the per-role grants and + * names other users' emails, so it is shown only to admins and the owner (the caller's + * role decides, never appears in the payload). */ +export function projectSummary(record: SiteRecord, contentBase: string, callerRole: ProjectRole, config: ServerConfigShape): ProjectInfo { + const permissions = roleAtLeast(callerRole, "admin") + ? { + permissions: { + read: accessGroupTerms(record.readers), + write: accessGroupTerms(record.writers), + admin: accessGroupTerms(record.admins), + }, + } + : {}; + return { + project: record.project, + isPublic: record.isPublic, + ...permissions, + url: projectUrl(record.project, contentBase, config), + owner: record.owner, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + currentOpenPath: record.currentOpenPath, + fileCount: record.fileCount, + totalBytes: record.totalBytes, + }; +} diff --git a/server/core/src/app.ts b/server/core/src/app.ts index e2ff819..4fd9962 100644 --- a/server/core/src/app.ts +++ b/server/core/src/app.ts @@ -1,53 +1,39 @@ /** * HTTP router for the scratchwork server. One app serves two origins: the app host - * (auth routes and /api/*) and the content host (published sites). Public projects are - * served directly; private ones are gated by a handoff flow — /auth/project on the - * app host authenticates the viewer and redirects to the content host with a one-time - * token (HANDOFF_PARAM) that redeemHandoffToken exchanges for a path-scoped cookie. + * (auth routes and the JSON API) and the content host (published sites). Every JSON + * endpoint dispatches through the route-policy registry in api-routes.ts (invariant 4); + * this file owns the browser-facing auth routes and published-content serving. Public + * projects are served directly; private ones are gated by a handoff flow — /auth/project + * on the app host authenticates the viewer and redirects to the content host with a + * one-time token (HANDOFF_PARAM) that redeemHandoffToken exchanges for a path-scoped + * cookie. */ import type * as HttpApp from "@effect/platform/HttpApp"; import * as HttpServerRequest from "@effect/platform/HttpServerRequest"; import * as HttpServerResponse from "@effect/platform/HttpServerResponse"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as ParseResult from "effect/ParseResult"; -import * as Schema from "effect/Schema"; -import { - CliTokenRequestSchema, - type CliTokenRequest, - type CliTokenResponse, - type ProjectInfo, - type ProjectResponse, - type ProjectsListResponse, - type PublishResponse, - type ShareResponse, -} from "../../../shared/src/publish/api"; import { SiteFiles } from "../../../shared/src/site/files"; import { servePath } from "../../../shared/src/site/serve"; -import { isLoopbackHost } from "../../../shared/src/util/url"; import { defaultRendererHtml } from "../../../shared/src/site/default-renderer.generated.js"; import FIGURE_SVG from "../../../shared/assets/figure.svg" with { type: "text" }; -import { accessGroupTerms, isSafeProjectIdentifier } from "./access"; -import { - Auth, - AuthError, - createSessionToken, - decodeCliAuthorizationCode, - decryptCliCloudflareToken, - verifyCliCodeExchange, - type AuthShape, - type AuthUser, -} from "./auth"; +import { isSafeProjectIdentifier } from "./access"; +import { dispatchApiRoute, requireReadableSite } from "./api-routes"; +import { Auth, AuthError, type AuthShape, type AuthUser } from "./auth"; import { ServerConfig, type ServerConfigShape } from "./config"; import { PrimitiveDb } from "./db"; import { projectAccessCookie, projectAccessCookieValues } from "./cookies"; import { acceptsHtmlPage, errorPageResponse, errorResponse } from "./error-pages"; -import { HttpError, jsonResponse, securityHeaders } from "./http"; -import { readPublishRequest } from "./publish-request"; -import { readShareRequest } from "./share-request"; +import { + appBaseUrl, + contentBaseUrl, + homepageBaseUrl, + HttpError, + requestBaseUrl, + sameOrigin, + securityHeaders, +} from "./http"; import { projectForRequest, routeRest } from "./routes"; -import { type SiteRecord } from "./site-records"; -import { canReadProject, projectRole, roleAtLeast, SiteStore, SiteStoreError, type LoadedSite, type ProjectRole } from "./site-store"; +import { canReadProject, SiteStore, SiteStoreError, type LoadedSite } from "./site-store"; import { StorageError } from "./storage"; const NO_STORE = "no-store, must-revalidate"; @@ -80,7 +66,8 @@ export const app: HttpApp.Default - error._tag === "PrimitiveDbConflict" - ? new AuthError({ status: 400, message: "Authorization code already redeemed" }) - : new HttpError({ status: 500, message: "Could not record the code redemption" }), - ), - ); - const user = yield* verifyCliCodeExchange(payload, body.codeVerifier, body.redirectUri, config.auth); - const cfToken = yield* decryptCliCloudflareToken(payload, config.auth); - const token = yield* createSessionToken(user, config.auth); - const response: CliTokenResponse = { - token, - server: appBaseUrl(request, config), - email: user.email, - ...(cfToken != null ? { cfToken } : {}), - }; - return jsonResponse(response, 200); - }); -} - -/** Reads, size-limits, and strictly decodes the CLI token-exchange body. */ -function readCliTokenRequest( - request: HttpServerRequest.HttpServerRequest, -): Effect.Effect { - return Effect.gen(function* () { - const text = yield* request.text.pipe( - HttpServerRequest.withMaxBodySize(Option.some(MAX_CLI_TOKEN_BODY_BYTES)), - Effect.mapError((cause) => new HttpError({ status: 413, message: "Request body is too large", cause })), - ); - const parsed = yield* Schema.decodeUnknown(Schema.parseJson())(text).pipe( - Effect.mapError(() => new HttpError({ status: 400, message: "Invalid JSON body" })), - ); - return yield* Schema.decodeUnknown(CliTokenRequestSchema)(parsed, { - errors: "all", - onExcessProperty: "error", - }).pipe( - Effect.mapError((error) => - new HttpError({ status: 400, message: ParseResult.TreeFormatter.formatErrorSync(error) }), - ), - ); - }); -} - -/** Handles `POST /api/publish` through bearer auth and SiteStore. */ -function publish(request: HttpServerRequest.HttpServerRequest): AppEffect { - return Effect.gen(function* () { - const config = yield* ServerConfig; - yield* rejectCrossOriginApiRequest(request, appBaseUrl(request, config)); - const auth = yield* Auth; - const user = yield* auth.requireApiUser(request); - const publishRequest = yield* readPublishRequest(request); - const siteStore = yield* SiteStore; - const result = yield* siteStore.publish(publishRequest, user, config); - const url = publishedUrl(contentBaseUrl(request, config), result.project, result.openPath, config); - return jsonResponse({ ...result, url } satisfies PublishResponse, 200); - }); -} - -/** Lists projects visible in the authenticated user's owner index. */ -function listProjects(request: HttpServerRequest.HttpServerRequest): AppEffect { - return Effect.gen(function* () { - const config = yield* ServerConfig; - yield* rejectCrossOriginApiRequest(request, appBaseUrl(request, config)); - const auth = yield* Auth; - const user = yield* auth.requireApiUser(request); - const siteStore = yield* SiteStore; - const projects = yield* siteStore.listProjects(user); - const contentBase = contentBaseUrl(request, config); - return jsonResponse({ - projects: projects.map((project) => projectSummary(project, contentBase, projectRole(project, user, config), config)), - } satisfies ProjectsListResponse, 200); - }); -} - -/** Resolves a published content path to its project. Kept as an endpoint (rather than a - * client-side parse) so validation, authorization, and URL-to-project resolution stay - * centralized on the server. */ -function resolveProjectPath(request: HttpServerRequest.HttpServerRequest, url: URL): AppEffect { - return Effect.gen(function* () { - const path = url.searchParams.get("path"); - if (path == null || !path.startsWith("/")) { - return yield* Effect.fail(new HttpError({ status: 400, message: "Missing path" })); - } - const config = yield* ServerConfig; - const auth = yield* Auth; - const user = yield* auth.requireApiUser(request); - const site = yield* requireReadableSite(yield* loadSiteForPath(path), user, config); - return jsonResponse({ - project: projectSummary(site.record, contentBaseUrl(request, config), projectRole(site.record, user, config), config), - } satisfies ProjectResponse, 200); - }); -} - -/** Returns metadata for one project. */ -function projectInfo(request: HttpServerRequest.HttpServerRequest, project: string): AppEffect { - return Effect.gen(function* () { - const config = yield* ServerConfig; - const auth = yield* Auth; - const user = yield* auth.requireApiUser(request); - const siteStore = yield* SiteStore; - const site = yield* requireReadableSite(yield* siteStore.loadProject(project), user, config); - return jsonResponse({ - project: projectSummary(site.record, contentBaseUrl(request, config), projectRole(site.record, user, config), config), - } satisfies ProjectResponse, 200); - }); -} - -/** Returns the current project bundle for clone/read workflows. */ -function projectBundle(request: HttpServerRequest.HttpServerRequest, project: string): AppEffect { - return Effect.gen(function* () { - const config = yield* ServerConfig; - const auth = yield* Auth; - const user = yield* auth.requireApiUser(request); - const siteStore = yield* SiteStore; - yield* requireReadableSite(yield* siteStore.loadProject(project), user, config); - const bundle = yield* siteStore.bundle(project); - if (bundle == null) return yield* Effect.fail(new HttpError({ status: 404, message: "Project not found" })); - return jsonResponse({ bundle }, 200); - }); -} - -/** Makes a project owner-only: private and with every grant cleared. */ -function unpublishProject(request: HttpServerRequest.HttpServerRequest, project: string): AppEffect { - return Effect.gen(function* () { - const config = yield* ServerConfig; - yield* rejectCrossOriginApiRequest(request, appBaseUrl(request, config)); - const auth = yield* Auth; - const user = yield* auth.requireApiUser(request); - const siteStore = yield* SiteStore; - const record = yield* siteStore.unpublish(project, user, config); - return jsonResponse({ - project: projectSummary(record, contentBaseUrl(request, config), projectRole(record, user, config), config), - } satisfies ProjectResponse, 200); - }); -} - -/** Grants or revokes email/@domain access by editing the project's grant groups. */ -function shareProject(request: HttpServerRequest.HttpServerRequest, project: string): AppEffect { - return Effect.gen(function* () { - const config = yield* ServerConfig; - yield* rejectCrossOriginApiRequest(request, appBaseUrl(request, config)); - const auth = yield* Auth; - const user = yield* auth.requireApiUser(request); - const changes = yield* readShareRequest(request); - const siteStore = yield* SiteStore; - const result = yield* siteStore.share(project, user, changes, config); - return jsonResponse({ - project: projectSummary(result.record, contentBaseUrl(request, config), projectRole(result.record, user, config), config), - warnings: result.warnings, - } satisfies ShareResponse, 200); - }); -} - -/** Deletes a project pointer and owner index, releasing the name. */ -function deleteProject(request: HttpServerRequest.HttpServerRequest, project: string): AppEffect { - return Effect.gen(function* () { - const config = yield* ServerConfig; - yield* rejectCrossOriginApiRequest(request, appBaseUrl(request, config)); - const auth = yield* Auth; - const user = yield* auth.requireApiUser(request); - const siteStore = yield* SiteStore; - yield* siteStore.deleteProject(project, user); - return jsonResponse({ ok: true }, 200); - }); -} - -/** Gates a loaded site behind read access. Both the missing and the forbidden case read - * as "Project not found" so unauthorized callers cannot probe which projects exist. */ -function requireReadableSite( - site: LoadedSite | null, - user: AuthUser | null, - config: ServerConfigShape, -): Effect.Effect { - if (site == null) return Effect.fail(new HttpError({ status: 404, message: "Project not found" })); - if (!canReadProject(site.record, user, config)) { - return Effect.fail(new HttpError({ status: 403, message: "Project not found" })); - } - return Effect.succeed(site); -} - -/** Rejects API mutations from cross-origin browser requests. */ -function rejectCrossOriginApiRequest( - request: HttpServerRequest.HttpServerRequest, - baseUrl: string, -): Effect.Effect { - const origin = request.headers.origin; - if (origin != null && origin !== baseUrl) { - return Effect.fail(new HttpError({ status: 403, message: "Cross-origin API request rejected" })); - } - const fetchSite = request.headers["sec-fetch-site"]?.toLowerCase(); - if (fetchSite === "cross-site") { - return Effect.fail(new HttpError({ status: 403, message: "Cross-site API request rejected" })); - } - return Effect.void; -} - -/** Shapes one project record for API responses (the shared ProjectInfo contract). - * `isPublic` is the public/private toggle; `permissions` lists the per-role grants and - * names other users' emails, so it is shown only to admins and the owner (the caller's - * role decides, never appears in the payload). */ -function projectSummary(record: SiteRecord, contentBase: string, callerRole: ProjectRole, config: ServerConfigShape): ProjectInfo { - const permissions = roleAtLeast(callerRole, "admin") - ? { - permissions: { - read: accessGroupTerms(record.readers), - write: accessGroupTerms(record.writers), - admin: accessGroupTerms(record.admins), - }, - } - : {}; - return { - project: record.project, - isPublic: record.isPublic, - ...permissions, - url: projectUrl(record.project, contentBase, config), - owner: record.owner, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - currentOpenPath: record.currentOpenPath, - fileCount: record.fileCount, - totalBytes: record.totalBytes, - }; -} - // --------------------------------------------------------------------------- // Published-site serving and the private-content handoff // --------------------------------------------------------------------------- @@ -898,32 +571,6 @@ function refererMatchesOrigin(request: HttpServerRequest.HttpServerRequest, orig return sameOrigin(referer, origin); } -// --------------------------------------------------------------------------- -// Origins and URLs -// --------------------------------------------------------------------------- - -/** Resolves the app-host origin (auth routes, API) for redirects and cookie scoping. */ -function appBaseUrl(request: HttpServerRequest.HttpServerRequest, config: { readonly appUrl?: string }): string { - return publicBaseUrl(request, config.appUrl); -} - -/** Resolves the content-host origin (published sites) for redirects and publish URLs. */ -function contentBaseUrl(request: HttpServerRequest.HttpServerRequest, config: { readonly contentUrl?: string }): string { - return publicBaseUrl(request, config.contentUrl); -} - -/** Resolves a public origin: the configured value, else the request's own origin. */ -function publicBaseUrl( - request: HttpServerRequest.HttpServerRequest, - configuredPublicUrl: string | undefined, -): string { - if (configuredPublicUrl != null) return configuredPublicUrl; - const requestUrl = new URL(request.url, "http://scratchwork.local"); - const requestBase = requestBaseUrl(request, requestUrl); - if (requestBase != null) return requestBase; - return requestUrl.origin; -} - /** Sends auth routes to the configured app origin before setting host-bound cookies. */ function canonicalAppRedirect( request: HttpServerRequest.HttpServerRequest, @@ -940,62 +587,3 @@ function canonicalAppRedirect( status: request.method === "GET" || request.method === "HEAD" ? 302 : 307, }); } - -/** Reconstructs the request origin from x-forwarded-host/-proto or the Host header; - * null when the request carries no usable host information. */ -function requestBaseUrl( - request: HttpServerRequest.HttpServerRequest, - requestUrl = new URL(request.url, "http://scratchwork.local"), -): string | null { - const forwardedHost = request.headers["x-forwarded-host"]; - const host = forwardedHost ?? request.headers.host; - if (host != null && host !== "") { - const forwardedProto = request.headers["x-forwarded-proto"]; - const proto = forwardedProto ?? defaultProtoForHost(requestUrl, host); - return `${proto}://${host}`; - } - return requestUrl.hostname === "scratchwork.local" ? null : requestUrl.origin; -} - -/** Guesses http/https for a Host header when no forwarded proto is present: - * loopback hosts get http, everything else https. */ -function defaultProtoForHost(requestUrl: URL, host: string): "http" | "https" { - if (requestUrl.hostname !== "scratchwork.local") return requestUrl.protocol === "http:" ? "http" : "https"; - return isLoopbackHost(host.replace(/:\d+$/, "")) ? "http" : "https"; -} - -/** Returns true when two URL strings share an origin; false for unparsable input. */ -function sameOrigin(left: string, right: string): boolean { - try { - return new URL(left).origin === new URL(right).origin; - } catch { - return false; - } -} - -/** Builds the user-facing URL returned by publish. The homepage project reports its - * canonical home origin; every other project reports its content route. */ -function publishedUrl(baseUrl: string, project: string, openPath: string, config: ServerConfigShape): string { - const homepageBase = homepageBaseUrl(project, config); - if (homepageBase != null) return `${homepageBase}${encodeOpenPath(openPath)}`; - return `${baseUrl}/${encodeURIComponent(project)}${encodeOpenPath(openPath)}`; -} - -/** The user-facing root URL of one project (see publishedUrl). */ -function projectUrl(project: string, contentBase: string, config: ServerConfigShape): string { - const homepageBase = homepageBaseUrl(project, config); - if (homepageBase != null) return `${homepageBase}/`; - return `${contentBase}/${encodeURIComponent(project)}/`; -} - -/** The canonical home origin when the project is the configured homepage, else null. */ -function homepageBaseUrl(project: string, config: ServerConfigShape): string | null { - return config.homepageProject != null && project === config.homepageProject - ? config.homepageUrls[0] ?? null - : null; -} - -/** URL-encodes each path segment without encoding slashes. */ -function encodeOpenPath(openPath: string): string { - return openPath.split("/").map((segment, index) => index === 0 ? "" : encodeURIComponent(segment)).join("/"); -} diff --git a/server/core/src/http.ts b/server/core/src/http.ts index 26a8568..d404cf2 100644 --- a/server/core/src/http.ts +++ b/server/core/src/http.ts @@ -1,5 +1,9 @@ +import * as HttpServerRequest from "@effect/platform/HttpServerRequest"; import * as HttpServerResponse from "@effect/platform/HttpServerResponse"; import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import { isLoopbackHost } from "../../../shared/src/util/url"; +import type { ServerConfigShape } from "./config"; /** Generic HTTP failure; `status` becomes the response status and `message` the error body. */ export class HttpError extends Data.TaggedError("HttpError")<{ @@ -29,3 +33,105 @@ export function securityHeaders(): Record { "X-Content-Type-Options": "nosniff", }; } + +/** Rejects API requests from cross-origin browser pages. Non-browser clients + * send no Origin or Sec-Fetch-Site header and pass untouched. */ +export function rejectCrossOriginApiRequest( + request: HttpServerRequest.HttpServerRequest, + baseUrl: string, +): Effect.Effect { + const origin = request.headers.origin; + if (origin != null && origin !== baseUrl) { + return Effect.fail(new HttpError({ status: 403, message: "Cross-origin API request rejected" })); + } + const fetchSite = request.headers["sec-fetch-site"]?.toLowerCase(); + if (fetchSite === "cross-site") { + return Effect.fail(new HttpError({ status: 403, message: "Cross-site API request rejected" })); + } + return Effect.void; +} + +// --------------------------------------------------------------------------- +// Origins and URLs +// --------------------------------------------------------------------------- + +/** Resolves the app-host origin (auth routes, API) for redirects and cookie scoping. */ +export function appBaseUrl(request: HttpServerRequest.HttpServerRequest, config: { readonly appUrl?: string }): string { + return publicBaseUrl(request, config.appUrl); +} + +/** Resolves the content-host origin (published sites) for redirects and publish URLs. */ +export function contentBaseUrl(request: HttpServerRequest.HttpServerRequest, config: { readonly contentUrl?: string }): string { + return publicBaseUrl(request, config.contentUrl); +} + +/** Resolves a public origin: the configured value, else the request's own origin. */ +function publicBaseUrl( + request: HttpServerRequest.HttpServerRequest, + configuredPublicUrl: string | undefined, +): string { + if (configuredPublicUrl != null) return configuredPublicUrl; + const requestUrl = new URL(request.url, "http://scratchwork.local"); + const requestBase = requestBaseUrl(request, requestUrl); + if (requestBase != null) return requestBase; + return requestUrl.origin; +} + +/** Reconstructs the request origin from x-forwarded-host/-proto or the Host header; + * null when the request carries no usable host information. */ +export function requestBaseUrl( + request: HttpServerRequest.HttpServerRequest, + requestUrl = new URL(request.url, "http://scratchwork.local"), +): string | null { + const forwardedHost = request.headers["x-forwarded-host"]; + const host = forwardedHost ?? request.headers.host; + if (host != null && host !== "") { + const forwardedProto = request.headers["x-forwarded-proto"]; + const proto = forwardedProto ?? defaultProtoForHost(requestUrl, host); + return `${proto}://${host}`; + } + return requestUrl.hostname === "scratchwork.local" ? null : requestUrl.origin; +} + +/** Guesses http/https for a Host header when no forwarded proto is present: + * loopback hosts get http, everything else https. */ +function defaultProtoForHost(requestUrl: URL, host: string): "http" | "https" { + if (requestUrl.hostname !== "scratchwork.local") return requestUrl.protocol === "http:" ? "http" : "https"; + return isLoopbackHost(host.replace(/:\d+$/, "")) ? "http" : "https"; +} + +/** Returns true when two URL strings share an origin; false for unparsable input. */ +export function sameOrigin(left: string, right: string): boolean { + try { + return new URL(left).origin === new URL(right).origin; + } catch { + return false; + } +} + +/** Builds the user-facing URL returned by publish. The homepage project reports its + * canonical home origin; every other project reports its content route. */ +export function publishedUrl(baseUrl: string, project: string, openPath: string, config: ServerConfigShape): string { + const homepageBase = homepageBaseUrl(project, config); + if (homepageBase != null) return `${homepageBase}${encodeOpenPath(openPath)}`; + return `${baseUrl}/${encodeURIComponent(project)}${encodeOpenPath(openPath)}`; +} + +/** The user-facing root URL of one project (see publishedUrl). */ +export function projectUrl(project: string, contentBase: string, config: ServerConfigShape): string { + const homepageBase = homepageBaseUrl(project, config); + if (homepageBase != null) return `${homepageBase}/`; + return `${contentBase}/${encodeURIComponent(project)}/`; +} + +/** The canonical home origin when the project is the configured homepage, else null. */ +export function homepageBaseUrl(project: string, config: ServerConfigShape): string | null { + return config.homepageProject != null && project === config.homepageProject + ? config.homepageUrls[0] ?? null + : null; +} + +/** URL-encodes each path segment without encoding slashes. */ +function encodeOpenPath(openPath: string): string { + return openPath.split("/").map((segment, index) => index === 0 ? "" : encodeURIComponent(segment)).join("/"); +} diff --git a/server/core/test/api-policy.test.ts b/server/core/test/api-policy.test.ts new file mode 100644 index 0000000..0a99bc4 --- /dev/null +++ b/server/core/test/api-policy.test.ts @@ -0,0 +1,238 @@ +/* + * The API policy matrix (AGENTS.md, invariant 4), generated from the same + * registry the dispatcher runs: for every registered route × credential kind + * (none, garbage bearer, and real bearers for a stranger, reader, writer, + * admin, and owner) × project visibility (private, public), the expected + * outcome is DERIVED from the route's declared policy — auth mode, minimum + * role, masking — and every combination the policy does not explicitly allow + * must be denied. Adding a route to API_ROUTES without policy metadata is a + * type error; adding one without a fixture here fails the completeness check. + */ +import { describe, expect, test } from "bun:test"; +import * as Effect from "effect/Effect"; +import { API_ROUTES, type ApiRoute } from "../src/api-routes"; +import { createSessionToken, type AuthUser } from "../src/auth"; +import type { AuthConfig } from "../src/config"; +import { roleAtLeast, type ProjectRole } from "../src/site-store"; +import { appHandler, bundle } from "./helpers"; + +/** Must match the appHandler defaults in helpers.ts so minted bearers verify. */ +const authConfig: AuthConfig = { + mode: "oauth", + clientId: "test-client-id", + clientSecret: "test-client-secret", + sessionSecret: "test-session-secret-test-session-secret", + allowedUsers: "public", + sessionTtlSeconds: 60, +}; + +const users = { + owner: { id: "owner-1", email: "owner@example.com" }, + admin: { id: "admin-1", email: "admin@example.com" }, + writer: { id: "writer-1", email: "writer@example.com" }, + reader: { id: "reader-1", email: "reader@example.com" }, + stranger: { id: "stranger-1", email: "stranger@example.com" }, +} satisfies Record; + +/** The credential kinds the matrix sweeps. `role` is the granted role on the + * private fixture project; effective role on the public project floors at read. */ +const CREDENTIALS: ReadonlyArray<{ name: string; user: AuthUser | null; garbage?: boolean; role: ProjectRole }> = [ + { name: "no credential", user: null, role: "none" }, + { name: "garbage bearer", user: null, garbage: true, role: "none" }, + { name: "stranger", user: users.stranger, role: "none" }, + { name: "reader", user: users.reader, role: "read" }, + { name: "writer", user: users.writer, role: "write" }, + { name: "admin", user: users.admin, role: "admin" }, + { name: "owner", user: users.owner, role: "owner" }, +]; + +/** Builds a success-shaped request for each route; the matrix swaps credentials in. */ +const FIXTURES: Record { path: string; method: string; body?: unknown }> = { + health: () => ({ path: "/health", method: "GET" }), + me: () => ({ path: "/api/me", method: "GET" }), + "cli-token-exchange": () => ({ + path: "/auth/cli/token", + method: "POST", + body: { code: "not-a-real-code", codeVerifier: "v".repeat(43), redirectUri: "http://127.0.0.1:1/cb" }, + }), + publish: (project) => ({ + path: "/api/publish", + method: "POST", + body: { bundle: bundle({ "index.html": "update" }), openPath: "/", project }, + }), + "projects-list": () => ({ path: "/api/projects", method: "GET" }), + resolve: (project) => ({ path: `/api/resolve?path=/${project}/`, method: "GET" }), + "project-info": (project) => ({ path: `/api/projects/${project}`, method: "GET" }), + "project-bundle": (project) => ({ path: `/api/projects/${project}/bundle`, method: "GET" }), + "project-unpublish": (project) => ({ path: `/api/projects/${project}/unpublish`, method: "POST", body: {} }), + "project-share": (project) => ({ + path: `/api/projects/${project}/share`, + method: "POST", + body: { role: "read", add: ["friend@example.com"] }, + }), + "project-delete": (project) => ({ path: `/api/projects/${project}`, method: "DELETE" }), +}; + +/** Mints a real bearer for a user with the fixture auth config. */ +async function bearer(user: AuthUser): Promise { + return Effect.runPromise(createSessionToken(user, authConfig)); +} + +/** A fresh server with one private and one public project and the standard + * grants. Rebuilt per mutating cell so no cell poisons another. */ +async function fixture() { + const handler = await appHandler({}); + const ownerToken = await bearer(users.owner); + const post = (path: string, body: unknown) => + handler(new Request(`https://scratch.test${path}`, { + method: "POST", + headers: { authorization: `Bearer ${ownerToken}`, "content-type": "application/json" }, + body: JSON.stringify(body), + })); + for (const [project, isPublic] of [["site", false], ["pub", true]] as const) { + const published = await post("/api/publish", { + bundle: bundle({ "index.html": "hello" }), + openPath: "/", + project, + isPublic, + }); + if (published.status !== 200) throw new Error(`fixture publish failed: ${await published.text()}`); + for (const [role, user] of [["read", users.reader], ["write", users.writer], ["admin", users.admin]] as const) { + const shared = await post(`/api/projects/${project}/share`, { role, add: [user.email] }); + if (shared.status !== 200) throw new Error(`fixture share failed: ${await shared.text()}`); + } + } + return handler; +} + +/** Runs one matrix cell: the route's fixture request with one credential. */ +async function callRoute( + handler: Awaited>, + route: ApiRoute, + credential: (typeof CREDENTIALS)[number], + project: string, + headers: Record = {}, +): Promise { + const shape = FIXTURES[route.name]; + if (shape == null) throw new Error(`no request fixture for route "${route.name}" — add one to FIXTURES`); + const { path, method, body } = shape(project); + const allHeaders: Record = { ...headers }; + if (credential.garbage) allHeaders.authorization = "Bearer garbage.token"; + else if (credential.user != null) allHeaders.authorization = `Bearer ${await bearer(credential.user)}`; + if (body !== undefined) allHeaders["content-type"] = "application/json"; + return handler(new Request(`https://scratch.test${path}`, { + method, + headers: allHeaders, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + })); +} + +/** The role a credential holds on a fixture project (public floors at read). */ +function effectiveRole(credential: (typeof CREDENTIALS)[number], isPublic: boolean): ProjectRole { + if (isPublic && !roleAtLeast(credential.role, "read")) return "read"; + return credential.role; +} + +describe("api route policy matrix", () => { + test("every registered route has a request fixture, and no fixture is stale", () => { + expect(Object.keys(FIXTURES).sort()).toEqual(API_ROUTES.map((route) => route.name).sort()); + expect(new Set(API_ROUTES.map((r) => r.name)).size).toBe(API_ROUTES.length); + expect(new Set(API_ROUTES.map((r) => `${r.method} ${r.path}`)).size).toBe(API_ROUTES.length); + }); + + for (const route of API_ROUTES.filter((candidate) => candidate.auth !== "code-exchange")) { + for (const subject of [{ project: "site", isPublic: false }, { project: "pub", isPublic: true }] as const) { + test(`${route.name} on ${subject.isPublic ? "public" : "private"} project denies every unauthorized credential`, async () => { + // Read-only cells share one server; mutating cells get a fresh one. + const shared = route.mutation ? null : await fixture(); + for (const credential of CREDENTIALS) { + const handler = shared ?? await fixture(); + const response = await callRoute(handler, route, credential, subject.project); + const label = `${route.name} × ${credential.name} × ${subject.project}`; + const outcome = { label, status: response.status }; + + if (route.auth === "bearer" && credential.user == null) { + expect(outcome).toEqual({ label, status: 401 }); + continue; + } + if (route.auth === "optional" || route.minimumRole == null) { + expect(outcome).toEqual({ label, status: 200 }); + continue; + } + const role = effectiveRole(credential, subject.isPublic); + if (roleAtLeast(role, route.minimumRole)) { + expect(outcome).toEqual({ label, status: 200 }); + } else if (!roleAtLeast(role, "read")) { + // Below read, the project's very existence is masked. Project + // routes answer "Project not found"; publish answers with the + // name-collision message a genuinely taken name would get. + const text = await response.text(); + if (route.name === "publish") { + expect({ label, status: response.status, masked: text.includes("already taken") }) + .toEqual({ label, status: 409, masked: true }); + } else { + expect([403, 404]).toContain(response.status); + expect({ label, masked: text.includes("Project not found") }).toEqual({ label, masked: true }); + } + } else { + // Readable but below the declared minimum: denied, never a mask + // bypass, never a success. + expect([403, 409]).toContain(response.status); + } + } + }, 30_000); + } + } + + test("every route rejects cross-origin browser calls", async () => { + const handler = await fixture(); + for (const route of API_ROUTES) { + const credential = CREDENTIALS[6]; // owner: the strongest credential must still be rejected + const response = await callRoute(handler, route, credential, "site", { origin: "https://evil.example" }); + expect({ route: route.name, status: response.status }).toEqual({ route: route.name, status: 403 }); + const fetchSite = await callRoute(handler, route, credential, "site", { "sec-fetch-site": "cross-site" }); + expect({ route: route.name, status: fetchSite.status }).toEqual({ route: route.name, status: 403 }); + } + }); + + test("unregistered methods on registered paths are 405, unknown API paths are 404", async () => { + const handler = await fixture(); + for (const path of new Set(API_ROUTES.map((route) => FIXTURES[route.name]!("site").path.split("?")[0]))) { + const response = await handler(new Request(`https://scratch.test${path}`, { method: "PUT" })); + expect({ path, status: response.status }).toEqual({ path, status: 405 }); + } + for (const path of ["/api/definitely-not-a-route", "/api/projects/site/evil", "/api/projects/site/bundle/extra"]) { + const response = await handler(new Request(`https://scratch.test${path}`)); + expect({ path, status: response.status }).toEqual({ path, status: 404 }); + } + }); + + test("the code-exchange route needs no ambient credential and never mints from garbage", async () => { + const handler = await fixture(); + const route = API_ROUTES.find((candidate) => candidate.auth === "code-exchange")!; + // No session, garbage code: the failure is the code's, not a 401 demand + // for a bearer — the one-time code is the credential. + const response = await callRoute(handler, route, CREDENTIALS[0], "site"); + expect(response.status).toBe(400); + const empty = await handler(new Request("https://scratch.test/auth/cli/token", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not json", + })); + expect(empty.status).toBe(400); + }); + + test("permissions are visible only to admin+ callers (declared visibility)", async () => { + const handler = await fixture(); + const info = async (credential: (typeof CREDENTIALS)[number]) => { + const route = API_ROUTES.find((candidate) => candidate.name === "project-info")!; + const response = await callRoute(handler, route, credential, "site"); + expect(response.status).toBe(200); + return (await response.json() as { project: Record }).project; + }; + expect((await info(CREDENTIALS[3])).permissions).toBeUndefined(); // reader + expect((await info(CREDENTIALS[4])).permissions).toBeUndefined(); // writer + expect((await info(CREDENTIALS[5])).permissions).toBeDefined(); // admin + expect((await info(CREDENTIALS[6])).permissions).toBeDefined(); // owner + }); +}); diff --git a/server/core/test/app.test.ts b/server/core/test/app.test.ts index 784bea4..e20f5e1 100644 --- a/server/core/test/app.test.ts +++ b/server/core/test/app.test.ts @@ -376,11 +376,16 @@ describe("server app", () => { isPublic: true, })); expect(adminPublish.status).toBe(200); - const adminUnpublish = await adminHandler(post("/api/projects/site/unpublish", {})); - expect(adminUnpublish.status).toBe(200); const adminDelete = await adminHandler(new Request("https://scratch.test/api/projects/site", { method: "DELETE" })); expect(adminDelete.status).toBe(403); expect(await adminDelete.text()).toContain("owner"); + const adminUnpublish = await adminHandler(post("/api/projects/site/unpublish", {})); + expect(adminUnpublish.status).toBe(200); + // Unpublishing cleared every grant, so the ex-admin is no longer a reader + // and the route policy masks the project's very existence. + const revokedDelete = await adminHandler(new Request("https://scratch.test/api/projects/site", { method: "DELETE" })); + expect(revokedDelete.status).toBe(403); + expect(await revokedDelete.text()).toContain("Project not found"); const ownerDelete = await ownerHandler(new Request("https://scratch.test/api/projects/site", { method: "DELETE" })); expect(ownerDelete.status).toBe(200); From 4f4c03ca47b11ab7db5a91825856838d6cf87f12 Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Fri, 17 Jul 2026 15:29:10 -0700 Subject: [PATCH 09/15] Phase 5: storage-adapter conformance suites, and the three bugs they caught MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One PrimitiveDb suite and one ObjectStorage suite (invariant 6), run unchanged against every implementation: memory (server/core), local file (server/deploy-local — its first real test), D1/R2 under miniflare and DynamoDB/S3 against LocalStack (e2e). Coverage: JSON/binary round trips, conditional create/update/delete conflicts, version and ETag behavior, UTF-8-byte-order listing with exclusive cursor pagination, expiry semantics, key/namespace validation, error-tag mapping, and concurrent single-winner races. First run caught three real divergences, all fixed: - DynamoDB list without a prefix always threw ValidationException — the adapter passed the #key expression name even when no expression used it. - LocalObjectStorageLive's conditional writes awaited between the precondition read and the write, so every concurrent conditional create won; a write semaphore makes read-check-write atomic. - The in-memory test storage had the same TOCTOU, plus unvalidated keys on reads. Co-Authored-By: Claude Fable 5 --- e2e/test/adapter-conformance-aws.test.ts | 96 ++++++ .../adapter-conformance-cloudflare.test.ts | 62 ++++ notes/improve-harness-plan.md | 2 +- server/core/src/storage.ts | 9 +- server/core/test/adapter-conformance.test.ts | 20 ++ .../core/test/conformance/object-storage.ts | 157 ++++++++++ server/core/test/conformance/primitive-db.ts | 273 ++++++++++++++++++ server/core/test/helpers.ts | 31 +- server/deploy-aws/src/dynamodb-db.ts | 4 +- .../test/adapter-conformance.test.ts | 31 ++ 10 files changed, 669 insertions(+), 16 deletions(-) create mode 100644 e2e/test/adapter-conformance-aws.test.ts create mode 100644 e2e/test/adapter-conformance-cloudflare.test.ts create mode 100644 server/core/test/adapter-conformance.test.ts create mode 100644 server/core/test/conformance/object-storage.ts create mode 100644 server/core/test/conformance/primitive-db.ts create mode 100644 server/deploy-local/test/adapter-conformance.test.ts diff --git a/e2e/test/adapter-conformance-aws.test.ts b/e2e/test/adapter-conformance-aws.test.ts new file mode 100644 index 0000000..745dc92 --- /dev/null +++ b/e2e/test/adapter-conformance-aws.test.ts @@ -0,0 +1,96 @@ +/* + * Runs the invariant-6 conformance suites against the AWS adapters — + * DynamoDbPrimitiveDb and S3ObjectStorage — pointed at LocalStack, with the + * same bucket/table shapes the deploy tooling provisions. Honors the same + * loud SCRATCHWORK_E2E_SKIP_AWS opt-out as the publish-loop lane (never + * honored in CI). + */ +import { afterAll } from "bun:test"; +import { CreateTableCommand, DynamoDBClient, UpdateTimeToLiveCommand, waitUntilTableExists } from "@aws-sdk/client-dynamodb"; +import { CreateBucketCommand } from "@aws-sdk/client-s3"; +import * as Effect from "effect/Effect"; +import { makeDynamoDbPrimitiveDb } from "@scratchwork/server-deploy-aws/dynamodb-db"; +import { S3ObjectStorageLive } from "@scratchwork/server-deploy-aws/s3-storage"; +import { ObjectStorage } from "@scratchwork/server-core/storage"; +import { awsLaneSkipped, ensureLocalStack, type LocalStack } from "../src/localstack"; +import { runPrimitiveDbConformance } from "../../server/core/test/conformance/primitive-db"; +import { runObjectStorageConformance } from "../../server/core/test/conformance/object-storage"; + +const suffix = crypto.randomUUID().slice(0, 8); +const bucket = `scratchwork-conformance-${suffix}`; +const table = `scratchwork-conformance-${suffix}`; +const region = "us-east-1"; +const credentials = { accessKeyId: "test", secretAccessKey: "test" }; + +let stack: LocalStack | null = null; + +/** One LocalStack container (with bucket and table) shared by both suites. */ +async function ensureStack(): Promise { + if (stack == null) { + stack = await ensureLocalStack(); + // S3ObjectStorageLive builds its client from the given env record, but the + // AWS SDK resolves credentials from the process environment. + process.env.AWS_ACCESS_KEY_ID = credentials.accessKeyId; + process.env.AWS_SECRET_ACCESS_KEY = credentials.secretAccessKey; + const dynamo = new DynamoDBClient({ region, endpoint: stack.endpoint, credentials }); + // The same table shape scripts/deploy.ts provisions in real AWS. + await dynamo.send(new CreateTableCommand({ + TableName: table, + AttributeDefinitions: [ + { AttributeName: "namespace", AttributeType: "S" }, + { AttributeName: "key", AttributeType: "S" }, + ], + KeySchema: [ + { AttributeName: "namespace", KeyType: "HASH" }, + { AttributeName: "key", KeyType: "RANGE" }, + ], + BillingMode: "PAY_PER_REQUEST", + })); + await waitUntilTableExists({ client: dynamo, maxWaitTime: 60 }, { TableName: table }); + await dynamo.send(new UpdateTimeToLiveCommand({ + TableName: table, + TimeToLiveSpecification: { AttributeName: "expiresAt", Enabled: true }, + })); + const { S3Client } = await import("@aws-sdk/client-s3"); + const s3 = new S3Client({ region, endpoint: stack.endpoint, credentials, forcePathStyle: true }); + await s3.send(new CreateBucketCommand({ Bucket: bucket })); + } + return stack; +} + +afterAll(async () => { + await stack?.stop(); +}); + +if (!awsLaneSkipped()) { + runPrimitiveDbConformance({ + name: "dynamodb (localstack)", + timeout: 120_000, + makeDb: async () => { + const localstack = await ensureStack(); + const client = new DynamoDBClient({ region, endpoint: localstack.endpoint, credentials }); + return makeDynamoDbPrimitiveDb(client, table); + }, + }); + + runObjectStorageConformance({ + name: "s3 (localstack)", + timeout: 120_000, + makeStorage: async () => { + const localstack = await ensureStack(); + return Effect.runPromise( + Effect.gen(function* () { + return yield* ObjectStorage; + }).pipe( + Effect.provide(S3ObjectStorageLive({ + SCRATCHWORK_S3_BUCKET: bucket, + SCRATCHWORK_S3_ENDPOINT: localstack.endpoint, + SCRATCHWORK_S3_REGION: region, + AWS_ACCESS_KEY_ID: credentials.accessKeyId, + AWS_SECRET_ACCESS_KEY: credentials.secretAccessKey, + })), + ), + ); + }, + }); +} diff --git a/e2e/test/adapter-conformance-cloudflare.test.ts b/e2e/test/adapter-conformance-cloudflare.test.ts new file mode 100644 index 0000000..07a639b --- /dev/null +++ b/e2e/test/adapter-conformance-cloudflare.test.ts @@ -0,0 +1,62 @@ +/* + * Runs the invariant-6 conformance suites against the Cloudflare adapters — + * D1PrimitiveDb and R2ObjectStorage — under miniflare/workerd bindings, the + * same simulation the cloudflare publish-loop lane uses. No HTTP server is + * involved: the bindings are exercised directly through the adapters. + */ +import { afterAll } from "bun:test"; +import * as Effect from "effect/Effect"; +import { Miniflare } from "miniflare"; +import { PrimitiveDb } from "@scratchwork/server-core/db"; +import { D1PrimitiveDbLive, type D1DatabaseBinding } from "@scratchwork/server-deploy-cloudflare/d1-db"; +import { R2ObjectStorageLive, type R2BucketBinding } from "@scratchwork/server-deploy-cloudflare/r2-storage"; +import { ObjectStorage } from "@scratchwork/server-core/storage"; +import { runPrimitiveDbConformance } from "../../server/core/test/conformance/primitive-db"; +import { runObjectStorageConformance } from "../../server/core/test/conformance/object-storage"; + +let miniflare: Miniflare | null = null; + +/** One miniflare instance shared by both suites; created at first use. */ +async function ensureMiniflare(): Promise { + if (miniflare == null) { + miniflare = new Miniflare({ + modules: true, + // The worker script is never fetched; miniflare just hosts the bindings. + script: "export default { fetch() { return new Response(null, { status: 404 }); } }", + r2Buckets: ["CONFORMANCE_BUCKET"], + d1Databases: ["CONFORMANCE_DB"], + }); + await miniflare.ready; + } + return miniflare; +} + +afterAll(async () => { + await miniflare?.dispose(); +}); + +runPrimitiveDbConformance({ + name: "d1 (miniflare)", + makeDb: async () => { + const mf = await ensureMiniflare(); + const database = (await mf.getD1Database("CONFORMANCE_DB")) as unknown as D1DatabaseBinding; + return Effect.runPromise( + Effect.gen(function* () { + return yield* PrimitiveDb; + }).pipe(Effect.provide(D1PrimitiveDbLive(database))), + ); + }, +}); + +runObjectStorageConformance({ + name: "r2 (miniflare)", + makeStorage: async () => { + const mf = await ensureMiniflare(); + const bucket = (await mf.getR2Bucket("CONFORMANCE_BUCKET")) as unknown as R2BucketBinding; + return Effect.runPromise( + Effect.gen(function* () { + return yield* ObjectStorage; + }).pipe(Effect.provide(R2ObjectStorageLive(bucket))), + ); + }, +}); diff --git a/notes/improve-harness-plan.md b/notes/improve-harness-plan.md index bda3ce3..882a8fd 100644 --- a/notes/improve-harness-plan.md +++ b/notes/improve-harness-plan.md @@ -115,7 +115,7 @@ Six invariants (registry below). They live **directly in root `AGENTS.md`** — `[ ]` Cookie/origin browser-security suite: use real app/content/homepage hostnames in a headless browser to prove secure-cookie-name selection, host/path scoping, SameSite behavior, cross-origin mutation rejection, safe redirects, private subresource isolation, and that arbitrary published JavaScript cannot plant or override an app session. This is narrowly scoped browser security coverage; browser rendering fidelity remains a non-goal. -`[ ]` Reusable backend conformance suites for `PrimitiveDb` and `ObjectStorage`, run unchanged against in-memory/file, D1/R2, and DynamoDB/S3 implementations. Cover conditional create/update conflicts, version/ETag behavior, pagination/cursor boundaries, missing records, concurrent writers, binary round trips, key validation, and identical error mapping. +`[x]` Reusable backend conformance suites for `PrimitiveDb` and `ObjectStorage`, run unchanged against in-memory/file, D1/R2, and DynamoDB/S3 implementations. Cover conditional create/update conflicts, version/ETag behavior, pagination/cursor boundaries, missing records, concurrent writers, binary round trips, key validation, and identical error mapping. *(Landed as `server/core/test/conformance/{primitive-db,object-storage}.ts`, run from server/core (memory), server/deploy-local (file), and e2e (miniflare D1/R2, LocalStack DynamoDB/S3). Real bugs caught on first run: DynamoDB `list` without a prefix always threw ValidationException (unused `#key` expression name), and the local-file + memory storage backends had a TOCTOU letting every concurrent conditional create win — fixed with a write semaphore and check/write reordering.)* `[ ]` A `check-invariants` skill (`.claude/skills/check-invariants/SKILL.md`) for the agent-pass residue: diff against main → report obeys/violates with file:line evidence. Claude-only convenience; Codex and OpenCode rely on the AGENTS.md standing rule + CI. diff --git a/server/core/src/storage.ts b/server/core/src/storage.ts index 2346486..c76c3ff 100644 --- a/server/core/src/storage.ts +++ b/server/core/src/storage.ts @@ -71,6 +71,11 @@ export function LocalObjectStorageLive( const fs = yield* FileSystem.FileSystem; const paths = yield* Path.Path; const root = paths.resolve(process.cwd(), directory); + // Conditional writes read the current object before deciding; without + // mutual exclusion two concurrent ifNoneMatch creates both pass the + // check and both win. One lock keeps the read-check-write atomic — + // the local target is a single low-throughput process. + const writeLock = yield* Effect.makeSemaphore(1); /** Resolves one object key to an absolute path under the storage root. */ const resolveKey = (key: string): Effect.Effect => @@ -124,7 +129,7 @@ export function LocalObjectStorageLive( const putObject: ObjectStorageShape["putObject"] = (key, value, options) => resolveKey(key).pipe( Effect.flatMap((path) => - Effect.gen(function* () { + writeLock.withPermits(1)(Effect.gen(function* () { const existing = yield* readExisting(path); if (options?.ifNoneMatch === "*" && existing != null) { return yield* Effect.fail( @@ -148,7 +153,7 @@ export function LocalObjectStorageLive( yield* fs.makeDirectory(paths.dirname(path), { recursive: true }); yield* fs.writeFile(path, value); return { etag: yield* sha256Hex(value) }; - }).pipe( + })).pipe( Effect.catchTags({ SystemError: (error) => Effect.fail(new StorageError({ message: `Could not write object: ${key}`, cause: error })), diff --git a/server/core/test/adapter-conformance.test.ts b/server/core/test/adapter-conformance.test.ts new file mode 100644 index 0000000..64ef086 --- /dev/null +++ b/server/core/test/adapter-conformance.test.ts @@ -0,0 +1,20 @@ +/* + * Runs the invariant-6 conformance suites against the in-memory reference + * implementations. The same suites run unchanged against D1/R2 (miniflare) + * and DynamoDB/S3 (LocalStack) in e2e/test/adapter-conformance-*.test.ts, + * and against the local file storage in server/deploy-local. + */ +import { makeMemoryPrimitiveDb } from "../src/db"; +import { runPrimitiveDbConformance } from "./conformance/primitive-db"; +import { runObjectStorageConformance } from "./conformance/object-storage"; +import { memoryStorage } from "./helpers"; + +runPrimitiveDbConformance({ + name: "memory", + makeDb: async () => makeMemoryPrimitiveDb(), +}); + +runObjectStorageConformance({ + name: "memory", + makeStorage: async () => memoryStorage(new Map()), +}); diff --git a/server/core/test/conformance/object-storage.ts b/server/core/test/conformance/object-storage.ts new file mode 100644 index 0000000..90de003 --- /dev/null +++ b/server/core/test/conformance/object-storage.ts @@ -0,0 +1,157 @@ +/* + * The ObjectStorage conformance suite (AGENTS.md, invariant 6): one set of + * behavioral tests every blob backend — in-memory, local file, R2, S3 — must + * pass unchanged: round trips, ETag-conditional writes, key validation, + * concurrency, and error mapping. + * + * Two declared capability differences, parameterized rather than pinned: + * - `preservesContentType`: the local file backend stores bare bytes and + * re-derives content types from extensions at serve time; provider + * backends persist the metadata. + * - ifMatch against a MISSING key must fail, but backends disagree on the + * flavor (a conflict vs. a provider not-found error); the suite requires + * failure without pinning the tag. + */ +import { describe, expect, test } from "bun:test"; +import * as Effect from "effect/Effect"; +import * as Either from "effect/Either"; +import type { ObjectStorageShape, StorageConflict, StorageError } from "../../src/storage"; + +/** One backend under conformance test. */ +export interface ObjectStorageConformanceOptions { + /** Backend label shown in test names. */ + readonly name: string; + /** Supplies the backend; called once, lazily, at the first test. */ + readonly makeStorage: () => Promise; + /** Whether get() reports the contentType given to put(). Default true. */ + readonly preservesContentType?: boolean; + /** Per-test timeout for emulator-backed runs. */ + readonly timeout?: number; +} + +type StorageFailure = StorageError | StorageConflict; + +/** Registers the conformance suite for one backend. */ +export function runObjectStorageConformance(options: ObjectStorageConformanceOptions): void { + const timeout = options.timeout ?? 20_000; + let cached: Promise | null = null; + const storage = () => (cached ??= options.makeStorage()); + + // Keys are per-test so tests are order-independent on a shared bucket. + const base = `conf/${crypto.randomUUID().slice(0, 12)}`; + let counter = 0; + const key = (suffix = "object") => `${base}/${++counter}/${suffix}`; + + const run = async
(effect: Effect.Effect): Promise => Effect.runPromise(effect); + const attempt = async (effect: Effect.Effect): Promise> => + Effect.runPromise(Effect.either(effect)); + + const expectConflict = (result: Either.Either, expectedKey: string) => { + if (!Either.isLeft(result)) throw new Error(`expected StorageConflict, got success`); + expect(result.left._tag).toBe("StorageConflict"); + expect((result.left as StorageConflict).key).toBe(expectedKey); + }; + const expectFailure = (result: Either.Either) => { + if (!Either.isLeft(result)) throw new Error("expected a failure, got success"); + expect(["StorageConflict", "StorageError"]).toContain(result.left._tag); + }; + const expectError = (result: Either.Either) => { + if (!Either.isLeft(result)) throw new Error("expected StorageError, got success"); + expect(result.left._tag).toBe("StorageError"); + }; + + describe(`ObjectStorage conformance [${options.name}]`, () => { + test("a missing key reads as null", async () => { + const s = await storage(); + expect(await run(s.getObject(key("missing")))).toBeNull(); + }, timeout); + + test("binary bodies round-trip byte-for-byte, including empty and all byte values", async () => { + const s = await storage(); + const everyByte = Uint8Array.from({ length: 256 * 32 }, (_, index) => index % 256); + for (const body of [everyByte, new Uint8Array(0), Uint8Array.from([0, 255, 10, 13, 0])]) { + const k = key("bin"); + await run(s.putObject(k, body)); + const got = await run(s.getObject(k)); + expect(got?.key).toBe(k); + expect(got == null ? null : Array.from(got.body)).toEqual(Array.from(body)); + } + }, timeout); + + test("putText stores UTF-8 exactly", async () => { + const s = await storage(); + const k = key("text.txt"); + const text = "héllo → wörld 🎉\nline two"; + await run(s.putText(k, text)); + const got = await run(s.getObject(k)); + expect(new TextDecoder().decode(got?.body)).toBe(text); + }, timeout); + + test("content types are preserved (when the backend stores metadata)", async () => { + const s = await storage(); + const k = key("page.html"); + await run(s.putText(k, "", { contentType: "text/html; charset=utf-8" })); + const got = await run(s.getObject(k)); + if (options.preservesContentType !== false) { + expect(got?.contentType).toBe("text/html; charset=utf-8"); + } + }, timeout); + + test("ETags are stable across put/get and change when content changes", async () => { + const s = await storage(); + const k = key("etag"); + const first = await run(s.putText(k, "generation one")); + expect(first.etag).toBeDefined(); + const got = await run(s.getObject(k)); + expect(got?.etag).toBe(first.etag); + const second = await run(s.putText(k, "generation two")); + expect(second.etag).toBeDefined(); + expect(second.etag).not.toBe(first.etag); + expect((await run(s.getObject(k)))?.etag).toBe(second.etag); + }, timeout); + + test("conditional create succeeds once and never clobbers the winner", async () => { + const s = await storage(); + const k = key("create"); + await run(s.putText(k, "first", { ifNoneMatch: "*" })); + expectConflict(await attempt(s.putText(k, "second", { ifNoneMatch: "*" })), k); + expect(new TextDecoder().decode((await run(s.getObject(k)))?.body)).toBe("first"); + }, timeout); + + test("conditional update honors the current ETag and rejects stale ones", async () => { + const s = await storage(); + const k = key("update"); + const first = await run(s.putText(k, "v1")); + const updated = await run(s.putText(k, "v2", { ifMatch: first.etag })); + expect(updated.etag).not.toBe(first.etag); + expectConflict(await attempt(s.putText(k, "v3", { ifMatch: first.etag })), k); + expect(new TextDecoder().decode((await run(s.getObject(k)))?.body)).toBe("v2"); + }, timeout); + + test("conditional update of a missing key fails", async () => { + const s = await storage(); + expectFailure(await attempt(s.putText(key("absent"), "v", { ifMatch: "some-etag" }))); + }, timeout); + + test("unsafe keys are rejected for reads and writes alike", async () => { + const s = await storage(); + for (const bad of ["", "/absolute", "a//b", "dot/./seg", "../up", "up/..", "back\\slash", "nul\0", "x".repeat(1025)]) { + expectError(await attempt(s.getObject(bad))); + expectError(await attempt(s.putText(bad, "v"))); + } + }, timeout); + + test("concurrent conditional creates admit exactly one winner", async () => { + const s = await storage(); + const k = key("race"); + const results = await Promise.all( + Array.from({ length: 6 }, (_, index) => attempt(s.putText(k, `writer-${index}`, { ifNoneMatch: "*" }))), + ); + const winners = results.filter(Either.isRight); + expect(winners).toHaveLength(1); + for (const loser of results.filter(Either.isLeft)) { + expect(loser.left._tag).toBe("StorageConflict"); + } + }, timeout); + }); +} diff --git a/server/core/test/conformance/primitive-db.ts b/server/core/test/conformance/primitive-db.ts new file mode 100644 index 0000000..e5c5700 --- /dev/null +++ b/server/core/test/conformance/primitive-db.ts @@ -0,0 +1,273 @@ +/* + * The PrimitiveDb conformance suite (AGENTS.md, invariant 6): one set of + * behavioral tests every backend — in-memory, D1, DynamoDB — must pass + * unchanged, so a deploy target cannot weaken conditional-write, versioning, + * pagination, expiry, validation, or error-mapping guarantees through adapter + * drift. Call runPrimitiveDbConformance from a workspace test file with a + * factory for the backend under test; the factory runs lazily at the first + * test so emulator startup failures surface as test failures. + * + * Deliberately unpinned: the version counter of an UNCONDITIONAL overwrite of + * an expired-but-not-yet-purged record (DynamoDB's async TTL can resume the + * old counter where memory/D1 restart at 1). Nothing in the server observes + * that version; conditional creates over expired records ARE pinned to + * restart at 1. + */ +import { describe, expect, test } from "bun:test"; +import * as Effect from "effect/Effect"; +import * as Either from "effect/Either"; +import type { JsonValue, PrimitiveDbConflict, PrimitiveDbError, PrimitiveDbShape } from "../../src/db"; + +/** One backend under conformance test. */ +export interface PrimitiveDbConformanceOptions { + /** Backend label shown in test names. */ + readonly name: string; + /** Supplies the backend; called once, lazily, at the first test. */ + readonly makeDb: () => Promise; + /** Per-test timeout for emulator-backed runs. */ + readonly timeout?: number; +} + +type DbFailure = PrimitiveDbError | PrimitiveDbConflict; + +/** Registers the conformance suite for one backend. */ +export function runPrimitiveDbConformance(options: PrimitiveDbConformanceOptions): void { + const timeout = options.timeout ?? 20_000; + let cached: Promise | null = null; + const db = () => (cached ??= options.makeDb()); + + // Namespaces are per-test so tests are order-independent and a shared + // emulator (one D1 table, one DynamoDB table) never leaks state across them. + const base = `conf${crypto.randomUUID().replace(/-/g, "").slice(0, 10)}`; + let counter = 0; + const namespace = () => `${base}.${++counter}`; + + const run = async (effect: Effect.Effect): Promise => Effect.runPromise(effect); + const attempt = async (effect: Effect.Effect): Promise> => + Effect.runPromise(Effect.either(effect)); + + const expectConflict = (result: Either.Either, ns: string, key: string) => { + if (!Either.isLeft(result)) throw new Error(`expected PrimitiveDbConflict, got success: ${JSON.stringify(result.right)}`); + expect(result.left._tag).toBe("PrimitiveDbConflict"); + expect((result.left as PrimitiveDbConflict).namespace).toBe(ns); + expect((result.left as PrimitiveDbConflict).key).toBe(key); + }; + const expectError = (result: Either.Either) => { + if (!Either.isLeft(result)) throw new Error(`expected PrimitiveDbError, got success: ${JSON.stringify(result.right)}`); + expect(result.left._tag).toBe("PrimitiveDbError"); + }; + + describe(`PrimitiveDb conformance [${options.name}]`, () => { + test("a missing key reads as null and deletes as a no-op", async () => { + const ns = namespace(); + expect(await run(Effect.flatMap(Effect.promise(db), (d) => d.get(ns, "missing")))).toBeNull(); + await run(Effect.flatMap(Effect.promise(db), (d) => d.delete(ns, "missing"))); + }, timeout); + + test("JSON values round-trip exactly, with version 1 and an ISO timestamp", async () => { + const d = await db(); + const ns = namespace(); + const values: JsonValue[] = [ + "text", + "", + 0, + 1.5, + -3, + true, + false, + null, + [], + [1, "a", null, { deep: true }], + { nested: { list: [1, 2, 3], flag: false }, "unicode-🎉": "héllo→🎉" }, + ]; + for (const [index, value] of values.entries()) { + const put = await run(d.put(ns, `k${index}`, value)); + expect(put.value).toEqual(value); + expect(put.version).toBe(1); + expect(put.namespace).toBe(ns); + expect(put.key).toBe(`k${index}`); + expect(Number.isNaN(Date.parse(put.updatedAt))).toBe(false); + const got = await run(d.get(ns, `k${index}`)); + expect(got?.value).toEqual(value); + expect(got?.version).toBe(1); + } + }, timeout); + + test("unconditional overwrites bump the version and replace the value", async () => { + const d = await db(); + const ns = namespace(); + await run(d.put(ns, "k", { generation: 1 })); + const second = await run(d.put(ns, "k", { generation: 2 })); + expect(second.version).toBe(2); + const got = await run(d.get(ns, "k")); + expect(got?.value).toEqual({ generation: 2 }); + expect(got?.version).toBe(2); + }, timeout); + + test("conditional create succeeds once, then conflicts until the key is deleted", async () => { + const d = await db(); + const ns = namespace(); + const first = await run(d.put(ns, "k", "one", { ifNoneMatch: "*" })); + expect(first.version).toBe(1); + expectConflict(await attempt(d.put(ns, "k", "two", { ifNoneMatch: "*" })), ns, "k"); + expect((await run(d.get(ns, "k")))?.value).toBe("one"); + await run(d.delete(ns, "k")); + expect((await run(d.put(ns, "k", "three", { ifNoneMatch: "*" }))).version).toBe(1); + }, timeout); + + test("conditional update succeeds on the exact version and conflicts on any other", async () => { + const d = await db(); + const ns = namespace(); + const created = await run(d.put(ns, "k", "v1")); + const updated = await run(d.put(ns, "k", "v2", { ifMatch: created.version })); + expect(updated.version).toBe(created.version + 1); + expectConflict(await attempt(d.put(ns, "k", "stale", { ifMatch: created.version })), ns, "k"); + expectConflict(await attempt(d.put(ns, "k", "future", { ifMatch: created.version + 5 })), ns, "k"); + expectConflict(await attempt(d.put(ns, "absent", "v", { ifMatch: 1 })), ns, "absent"); + expect((await run(d.get(ns, "k")))?.value).toBe("v2"); + }, timeout); + + test("combining both write preconditions is rejected as invalid, not as a conflict", async () => { + const d = await db(); + const ns = namespace(); + expectError(await attempt(d.put(ns, "k", "v", { ifNoneMatch: "*", ifMatch: 1 }))); + expectError(await attempt(d.put(ns, "k", "v", { ifMatch: 0 }))); + expectError(await attempt(d.put(ns, "k", "v", { ifMatch: 1.5 }))); + }, timeout); + + test("conditional delete honors the version precondition", async () => { + const d = await db(); + const ns = namespace(); + const record = await run(d.put(ns, "k", "v")); + expectConflict(await attempt(d.delete(ns, "k", { ifMatch: record.version + 1 })), ns, "k"); + expect(await run(d.get(ns, "k"))).not.toBeNull(); + await run(d.delete(ns, "k", { ifMatch: record.version })); + expect(await run(d.get(ns, "k"))).toBeNull(); + expectConflict(await attempt(d.delete(ns, "k", { ifMatch: record.version })), ns, "k"); + }, timeout); + + test("namespaces isolate records completely", async () => { + const d = await db(); + const left = namespace(); + const right = namespace(); + await run(d.put(left, "shared-key", "left")); + await run(d.put(right, "shared-key", "right")); + await run(d.delete(left, "shared-key")); + expect(await run(d.get(left, "shared-key"))).toBeNull(); + expect((await run(d.get(right, "shared-key")))?.value).toBe("right"); + expect((await run(d.list(left))).records).toHaveLength(0); + }, timeout); + + test("listing returns UTF-8 byte order, honors prefixes, and paginates exclusively", async () => { + const d = await db(); + const ns = namespace(); + // Insertion order is deliberately scrambled; expected order is UTF-8 + // byte order ("a/b" < "ab" because 0x2F < 0x62; "é" sorts after "~"). + const ordered = ["a", "a/b", "a/c", "ab", "b", "z", "~tilde", "é-accent"]; + for (const key of [...ordered].reverse()) { + await run(d.put(ns, key, `value:${key}`)); + } + + const full = await run(d.list(ns)); + expect(full.records.map((record) => record.key)).toEqual(ordered); + + const prefixed = await run(d.list(ns, { prefix: "a/" })); + expect(prefixed.records.map((record) => record.key)).toEqual(["a/b", "a/c"]); + const bare = await run(d.list(ns, { prefix: "a" })); + expect(bare.records.map((record) => record.key)).toEqual(["a", "a/b", "a/c", "ab"]); + + const after = await run(d.list(ns, { prefix: "a", startAfter: "a/b" })); + expect(after.records.map((record) => record.key)).toEqual(["a/c", "ab"]); + + const paged: string[] = []; + let cursor: string | undefined; + let pages = 0; + do { + const page = await run(d.list(ns, { limit: 3, startAfter: cursor })); + expect(page.records.length).toBeLessThanOrEqual(3); + paged.push(...page.records.map((record) => record.key)); + cursor = page.cursor; + pages += 1; + if (pages > 10) throw new Error("pagination did not terminate"); + } while (cursor != null); + expect(paged).toEqual(ordered); + }, timeout); + + test("list limits and cursors are validated", async () => { + const d = await db(); + const ns = namespace(); + for (const limit of [0, -5, 1.5, 1001]) { + expectError(await attempt(d.list(ns, { limit }))); + } + expectError(await attempt(d.list(ns, { startAfter: "/bad" }))); + expectError(await attempt(d.list(ns, { prefix: "bad//prefix" }))); + }, timeout); + + test("unsafe namespaces and keys are rejected identically across operations", async () => { + const d = await db(); + const ns = namespace(); + for (const bad of ["", " ", "-leading", "ns/slash", "x".repeat(129)]) { + expectError(await attempt(d.get(bad, "k"))); + expectError(await attempt(d.put(bad, "k", "v"))); + expectError(await attempt(d.delete(bad, "k"))); + expectError(await attempt(d.list(bad))); + } + for (const bad of ["", "/lead", "a//b", "a/./b", "a/../b", "back\\slash", "nul\0", ".", "..", "x".repeat(1025)]) { + expectError(await attempt(d.get(ns, bad))); + expectError(await attempt(d.put(ns, bad, "v"))); + expectError(await attempt(d.delete(ns, bad))); + } + }, timeout); + + test("non-JSON-serializable values are rejected before they reach the backend", async () => { + const d = await db(); + const ns = namespace(); + expectError(await attempt(d.put(ns, "k", Number.NaN as unknown as JsonValue))); + expectError(await attempt(d.put(ns, "k", Number.POSITIVE_INFINITY as unknown as JsonValue))); + expect(await run(d.get(ns, "k"))).toBeNull(); + }, timeout); + + test("expired records read as absent and their keys accept a fresh conditional create", async () => { + const d = await db(); + const ns = namespace(); + const now = Math.floor(Date.now() / 1000); + await run(d.put(ns, "gone", "expired", { expiresAt: now - 10 })); + await run(d.put(ns, "alive", "current", { expiresAt: now + 3600 })); + expect(await run(d.get(ns, "gone"))).toBeNull(); + expect((await run(d.get(ns, "alive")))?.value).toBe("current"); + expect((await run(d.list(ns))).records.map((record) => record.key)).toEqual(["alive"]); + const recreated = await run(d.put(ns, "gone", "fresh", { ifNoneMatch: "*", expiresAt: now + 3600 })); + expect(recreated.version).toBe(1); + expectError(await attempt(d.put(ns, "k", "v", { expiresAt: 0 }))); + expectError(await attempt(d.put(ns, "k", "v", { expiresAt: 1.5 }))); + }, timeout); + + test("concurrent conditional creates admit exactly one winner", async () => { + const d = await db(); + const ns = namespace(); + const results = await Promise.all( + Array.from({ length: 6 }, (_, index) => attempt(d.put(ns, "seat", `writer-${index}`, { ifNoneMatch: "*" }))), + ); + const winners = results.filter(Either.isRight); + expect(winners).toHaveLength(1); + for (const loser of results.filter(Either.isLeft)) { + expect(loser.left._tag).toBe("PrimitiveDbConflict"); + } + expect((await run(d.get(ns, "seat")))?.value).toBe(winners[0].right.value); + }, timeout); + + test("concurrent conditional updates from one version admit exactly one winner", async () => { + const d = await db(); + const ns = namespace(); + const created = await run(d.put(ns, "doc", "base")); + const results = await Promise.all( + Array.from({ length: 6 }, (_, index) => attempt(d.put(ns, "doc", `writer-${index}`, { ifMatch: created.version }))), + ); + const winners = results.filter(Either.isRight); + expect(winners).toHaveLength(1); + const final = await run(d.get(ns, "doc")); + expect(final?.value).toBe(winners[0].right.value); + expect(final?.version).toBe(created.version + 1); + }, timeout); + }); +} diff --git a/server/core/test/helpers.ts b/server/core/test/helpers.ts index 572e4e4..4609618 100644 --- a/server/core/test/helpers.ts +++ b/server/core/test/helpers.ts @@ -96,24 +96,33 @@ export async function json(response: Response): Promise { } /** Implements ObjectStorage against a mutable test map. */ -function memoryStorage(map: Map): ObjectStorageShape { +export function memoryStorage(map: Map): ObjectStorageShape { const getObject: ObjectStorageShape["getObject"] = (key) => - Effect.sync(() => { + Effect.suspend(() => { + if (!safeObjectKey(key)) { + return Effect.fail(new StorageError({ message: `Invalid object key: ${key}` })); + } const object = map.get(key); - return object == null - ? null - : { - key, - body: object.body.slice(), - contentType: object.contentType, - etag: object.etag, - } satisfies StoredObject; + return Effect.succeed( + object == null + ? null + : { + key, + body: object.body.slice(), + contentType: object.contentType, + etag: object.etag, + } satisfies StoredObject, + ); }); const putObject: ObjectStorageShape["putObject"] = (key, value, options) => Effect.tryPromise({ try: async () => { if (!safeObjectKey(key)) throw new StorageError({ message: `Invalid object key: ${key}` }); + const body = value.slice(); + // Hash before the precondition check: an await between check and set + // would let concurrent conditional writes all pass the check. + const etag = Encoding.encodeHex(new Uint8Array(await crypto.subtle.digest("SHA-256", body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) as ArrayBuffer))); const existing = map.get(key); if (options?.ifNoneMatch === "*" && existing != null) { throw new StorageConflict({ key, message: `Object already exists: ${key}` }); @@ -121,8 +130,6 @@ function memoryStorage(map: Map): ObjectStorageShape if (options?.ifMatch != null && existing?.etag !== options.ifMatch) { throw new StorageConflict({ key, message: `Object ETag mismatch: ${key}` }); } - const body = value.slice(); - const etag = Encoding.encodeHex(new Uint8Array(await crypto.subtle.digest("SHA-256", body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) as ArrayBuffer))); map.set(key, { body, contentType: options?.contentType, etag }); return { etag }; }, diff --git a/server/deploy-aws/src/dynamodb-db.ts b/server/deploy-aws/src/dynamodb-db.ts index 3fb9d56..6951af6 100644 --- a/server/deploy-aws/src/dynamodb-db.ts +++ b/server/deploy-aws/src/dynamodb-db.ts @@ -182,7 +182,9 @@ export function makeDynamoDbPrimitiveDb(client: DynamoDBClient, tableName: strin try: () => client.send(new QueryCommand({ TableName: tableName, KeyConditionExpression: prefix === "" ? "#namespace = :namespace" : "#namespace = :namespace AND begins_with(#key, :prefix)", - ExpressionAttributeNames: { "#namespace": NAMESPACE, "#key": KEY }, + // DynamoDB rejects attribute names that no expression uses, so #key + // may appear only when the prefix condition does. + ExpressionAttributeNames: prefix === "" ? { "#namespace": NAMESPACE } : { "#namespace": NAMESPACE, "#key": KEY }, ExpressionAttributeValues: prefix === "" ? { ":namespace": { S: namespace } } : { ":namespace": { S: namespace }, ":prefix": { S: prefix } }, Limit: limit, ScanIndexForward: true, diff --git a/server/deploy-local/test/adapter-conformance.test.ts b/server/deploy-local/test/adapter-conformance.test.ts new file mode 100644 index 0000000..8004c47 --- /dev/null +++ b/server/deploy-local/test/adapter-conformance.test.ts @@ -0,0 +1,31 @@ +/* + * Runs the invariant-6 ObjectStorage conformance suite against the local + * filesystem backend this deploy target serves from. Content types are not + * preserved by design: the file backend stores bare bytes and the site server + * re-derives types from extensions. + */ +import { afterAll } from "bun:test"; +import { BunContext } from "@effect/platform-bun"; +import * as Effect from "effect/Effect"; +import { ObjectStorage, LocalObjectStorageLive } from "@scratchwork/server-core/storage"; +import { runObjectStorageConformance } from "../../core/test/conformance/object-storage"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const directory = mkdtempSync(join(tmpdir(), "scratchwork-storage-conformance-")); +afterAll(() => rmSync(directory, { recursive: true, force: true })); + +runObjectStorageConformance({ + name: "local-file", + preservesContentType: false, + makeStorage: () => + Effect.runPromise( + Effect.gen(function* () { + return yield* ObjectStorage; + }).pipe( + Effect.provide(LocalObjectStorageLive(directory)), + Effect.provide(BunContext.layer), + ), + ), +}); From e387982b2e04d260dbe4a91e0fbeeeff41440d2a Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Fri, 17 Jul 2026 18:04:49 -0700 Subject: [PATCH 10/15] Phase 5: cross-host browser security suite in real Chromium MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e2e/test/browser-security.test.ts drives headless Chromium (system Chrome when present, playwright-managed otherwise) against the local-dev backend on three real origins — localhost app, pages.localhost content, home.localhost homepage — asserting what only a real browser can prove: the session cookie is host-only/HttpOnly/Lax and never crosses to the content host on the wire; handoff cookies stay path-scoped; published JavaScript cannot plant or override an app session (parent-domain plants die on the *.localhost public-suffix rule, forged same-host cookies die on signature verification); other projects cannot load private content as subresources (real Referer + Sec-Fetch semantics); cross-origin form POSTs are rejected by origin policy before auth; returnTo redirects never leave their origins; and the private homepage origin is isolated from content-host pages. Co-Authored-By: Claude Fable 5 --- bun.lock | 10 +- e2e/package.json | 1 + e2e/test/browser-security.test.ts | 322 ++++++++++++++++++++++++++++++ notes/improve-harness-plan.md | 2 +- 4 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 e2e/test/browser-security.test.ts diff --git a/bun.lock b/bun.lock index eafae5d..a210556 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "scratchwork", @@ -87,6 +88,7 @@ "devDependencies": { "@types/aws-lambda": "^8.10.152", "@types/bun": "^1.3.14", + "playwright": "^1.61.1", "typescript": "^6.0.3", }, }, @@ -544,7 +546,7 @@ "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -612,6 +614,10 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], + + "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], @@ -682,6 +688,8 @@ "wrangler/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "wrangler/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@aws-sdk/dynamodb-codec/@aws-sdk/core/@aws-sdk/types": ["@aws-sdk/types@3.973.14", "", { "dependencies": { "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-vH4pEu9YBEwr67yT+GVcmKX0GzfIrIYUn+MF5vXg9OspouVnAekuyVyawFvZHEK7WlcwVDwNrqI3ZBDUAiyu9A=="], "@aws-sdk/dynamodb-codec/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.32", "", { "dependencies": { "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-2loKuOMRFDg1nwdni5AtJ9S5juVbRNPNsPC7tWTfkHyycPwACMhxepspUHi8GhvfNlL2cQo3sPMod1uib+KZ0w=="], diff --git a/e2e/package.json b/e2e/package.json index f8787cf..459fd84 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -20,6 +20,7 @@ "devDependencies": { "@types/aws-lambda": "^8.10.152", "@types/bun": "^1.3.14", + "playwright": "^1.61.1", "typescript": "^6.0.3" } } diff --git a/e2e/test/browser-security.test.ts b/e2e/test/browser-security.test.ts new file mode 100644 index 0000000..315e43c --- /dev/null +++ b/e2e/test/browser-security.test.ts @@ -0,0 +1,322 @@ +/* + * The cross-host browser security suite (AGENTS.md, invariant 5), run in a + * real headless Chromium against the local-dev backend with real app + * (localhost), content (pages.localhost), and homepage (home.localhost) + * hostnames. These guarantees exist only in a real browser — host-only cookie + * scoping, the public-suffix rule for *.localhost, real Origin/Referer/ + * Sec-Fetch-* emission, and what arbitrary published JavaScript can actually + * do — which is why the fetch-level jar in harness.ts cannot stand in here. + * Browser rendering fidelity remains a non-goal; only security behavior is + * asserted. + * + * Browser bootstrap: system Chrome when available, else the + * playwright-managed chromium (auto-installed on first run, like the AWS + * lane's LocalStack image pull). + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { chromium, type Browser as ChromiumBrowser, type BrowserContext, type Page } from "playwright"; +import { nextPort, runCli, startBackend, tempDir, type Backend } from "../src/harness"; +import { loginCli, type LaneContext } from "../src/suite"; +import { startOauthProvider, type OauthProvider } from "../src/oauth-provider"; + +const CLIENT_ID = "e2e-client-id"; +const CLIENT_SECRET = "e2e-client-secret"; +const SESSION_SECRET = "e2e-session-secret-e2e-session-secret"; +const OWNER = { sub: "owner-1", email: "owner@example.com" }; + +const SESSION_COOKIE = "scratchwork_session"; + +/** Launches a headless browser: system Chrome first, then playwright's + * chromium, installing it on demand. */ +async function launchChromium(): Promise { + try { + return await chromium.launch({ headless: true, channel: "chrome" }); + } catch { + // fall through to the managed browser + } + try { + return await chromium.launch({ headless: true }); + } catch { + const install = Bun.spawnSync(["bun", "x", "playwright", "install", "chromium"], { + stdout: "inherit", + stderr: "inherit", + }); + if (!install.success) { + throw new Error("no Chrome available and `playwright install chromium` failed — install Google Chrome or run `bun x playwright install chromium`"); + } + return chromium.launch({ headless: true }); + } +} + +describe("browser security [local-dev]", () => { + let provider: OauthProvider; + let backend: Backend; + let context: LaneContext; + let chrome: ChromiumBrowser; + let appUrl: string; + let contentUrl: string; + let homeUrl: string; + const ownerHome = tempDir("scratchwork-browser-home-"); + const privateSite = tempDir("scratchwork-browser-private-"); + const attackerSite = tempDir("scratchwork-browser-attacker-"); + const homeSite = tempDir("scratchwork-browser-homepage-"); + + beforeAll(async () => { + const port = nextPort(); + appUrl = `http://localhost:${port}`; + contentUrl = `http://pages.localhost:${port}`; + homeUrl = `http://home.localhost:${port}`; + provider = await startOauthProvider({ + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + redirectUri: `${appUrl}/auth/callback/google`, + user: OWNER, + }); + backend = await startBackend("local-dev", { + port, + providerEnv: provider.env, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + sessionSecret: SESSION_SECRET, + extraEnv: { + SCRATCHWORK_HOMEPAGE_DOMAINS: homeUrl, + SCRATCHWORK_HOMEPAGE_PROJECT: "sec-home", + }, + }); + context = { provider, backend, appUrl: backend.appUrl, contentUrl: backend.contentUrl }; + + writeFileSync(join(privateSite.path, "index.html"), "

private-secret

"); + writeFileSync(join(privateSite.path, "data.txt"), "secret-data"); + writeFileSync(join(attackerSite.path, "index.html"), "

attacker-page

"); + writeFileSync(join(homeSite.path, "index.html"), "

home-secret

"); + + await loginCli(context, ownerHome.path, privateSite.path); + for (const [dir, name, visibility] of [ + [privateSite.path, "sec-private", "--private"], + [attackerSite.path, "sec-attacker", "--public"], + [homeSite.path, "sec-home", "--private"], + ] as const) { + const published = await runCli( + ["publish", ".", "--server", appUrl, "--project", name, visibility], + dir, + { SCRATCHWORK_HOME: ownerHome.path }, + ); + expect(published.stderr).toBe(""); + expect(published.code).toBe(0); + } + + chrome = await launchChromium(); + }, 240_000); + + afterAll(async () => { + await chrome?.close(); + await backend?.stop(); + provider?.stop(); + ownerHome.remove(); + privateSite.remove(); + attackerSite.remove(); + homeSite.remove(); + }); + + /** Completes the browser login redirect dance through the hermetic provider. */ + async function loginChrome(page: Page): Promise { + await page.goto(`${appUrl}/auth/login?returnTo=/`, { waitUntil: "domcontentloaded" }); + expect(new URL(page.url()).origin).toBe(appUrl); + } + + /** A fresh signed-in browser profile. */ + async function signedInContext(): Promise<{ ctx: BrowserContext; page: Page }> { + const ctx = await chrome.newContext(); + const page = await ctx.newPage(); + await loginChrome(page); + return { ctx, page }; + } + + test("the session cookie is host-only on the app origin, HttpOnly, and SameSite=Lax", async () => { + const { ctx, page } = await signedInContext(); + try { + const cookies = await ctx.cookies(appUrl); + const session = cookies.find((cookie) => cookie.name === SESSION_COOKIE); + expect(session).toBeDefined(); + expect(session?.domain).toBe("localhost"); // host-only: no leading dot + expect(session?.path).toBe("/"); + expect(session?.httpOnly).toBe(true); + expect(session?.sameSite).toBe("Lax"); + + // The content host never receives it on the wire (Playwright's cookie + // listing suffix-matches domains, so inspect a real request instead), + // and page JavaScript cannot read it on its own origin. + const contentRequest = page.waitForRequest(`${contentUrl}/sec-attacker/`); + await page.goto(`${contentUrl}/sec-attacker/`, { waitUntil: "domcontentloaded" }); + const sentCookies = (await (await contentRequest).allHeaders())["cookie"] ?? ""; + expect(sentCookies).not.toContain(SESSION_COOKIE); + await page.goto(appUrl, { waitUntil: "domcontentloaded" }); + expect(await page.evaluate(() => document.cookie)).not.toContain(SESSION_COOKIE); + } finally { + await ctx.close(); + } + }, 60_000); + + test("private content is served through a handoff cookie scoped to its own path", async () => { + const { ctx, page } = await signedInContext(); + try { + await page.goto(`${contentUrl}/sec-private/`, { waitUntil: "domcontentloaded" }); + expect(page.url()).toBe(`${contentUrl}/sec-private/`); + expect(await page.textContent("h1")).toBe("private-secret"); + + const cookies = await ctx.cookies(`${contentUrl}/sec-private/`); + const access = cookies.find((cookie) => cookie.name === "scratchwork_access_sec-private"); + expect(access).toBeDefined(); + expect(access?.domain).toBe("pages.localhost"); + expect(access?.path).toBe("/sec-private"); + expect(access?.httpOnly).toBe(true); + // Outside its path the browser does not send it. + const rootCookies = await ctx.cookies(`${contentUrl}/`); + expect(rootCookies.find((cookie) => cookie.name === "scratchwork_access_sec-private")).toBeUndefined(); + } finally { + await ctx.close(); + } + }, 60_000); + + test("published JavaScript cannot plant or override an app session", async () => { + const { ctx, page } = await signedInContext(); + try { + const before = (await ctx.cookies(appUrl)).find((cookie) => cookie.name === SESSION_COOKIE); + await page.goto(`${contentUrl}/sec-attacker/`, { waitUntil: "domcontentloaded" }); + await page.evaluate(() => { + // Host-only plant on the content host, and two parent-domain attempts: + // *.localhost sits on the public suffix list, so browsers refuse the + // Domain= variants outright. + document.cookie = "scratchwork_session=evil; path=/"; + document.cookie = "scratchwork_session=evil; domain=localhost; path=/"; + document.cookie = "scratchwork_session=evil; domain=.localhost; path=/"; + }); + + const after = (await ctx.cookies(appUrl)).find((cookie) => cookie.name === SESSION_COOKIE); + expect(after?.value).toBe(before!.value); + expect(after?.domain).toBe("localhost"); + + // The app still authenticates the real owner. + await page.goto(`${appUrl}/api/me`, { waitUntil: "domcontentloaded" }); + const me = JSON.parse((await page.textContent("body")) ?? "{}") as { user?: { email?: string } }; + expect(me.user?.email).toBe(OWNER.email); + } finally { + await ctx.close(); + } + }, 60_000); + + test("a forged access cookie planted by published JavaScript grants nothing", async () => { + const ctx = await chrome.newContext(); // anonymous profile + const page = await ctx.newPage(); + provider.authorizeResult = "deny"; // the redirect chain must dead-end at auth, not auto-login + try { + await page.goto(`${contentUrl}/sec-attacker/`, { waitUntil: "domcontentloaded" }); + await page.evaluate(() => { + // Same-host, so the browser accepts it — the server must not. + document.cookie = "scratchwork_access_sec-private=forged.payload; path=/sec-private"; + }); + const response = await page.goto(`${contentUrl}/sec-private/`, { waitUntil: "domcontentloaded" }); + expect(await page.content()).not.toContain("private-secret"); + expect(response?.ok()).toBe(false); + } finally { + provider.authorizeResult = "success"; + await ctx.close(); + } + }, 60_000); + + test("another project's page cannot load private content as a subresource", async () => { + const { ctx, page } = await signedInContext(); + try { + // Establish the viewer's legitimate access cookie first. + await page.goto(`${contentUrl}/sec-private/`, { waitUntil: "domcontentloaded" }); + expect(await page.textContent("h1")).toBe("private-secret"); + + // Same-origin fetch from another project: the cookie is in scope for + // the path, but the Referer proves the requesting page is foreign. + await page.goto(`${contentUrl}/sec-attacker/`, { waitUntil: "domcontentloaded" }); + const fetchStatus = await page.evaluate(async () => (await fetch("/sec-private/data.txt")).status); + expect(fetchStatus).toBe(403); + const imgLoaded = await page.evaluate(() => + new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(true); + img.onerror = () => resolve(false); + img.src = "/sec-private/data.txt"; + }), + ); + expect(imgLoaded).toBe(false); + + // Top-level navigation stays unrestricted for the authorized viewer. + const direct = await page.goto(`${contentUrl}/sec-private/data.txt`, { waitUntil: "domcontentloaded" }); + expect(direct?.status()).toBe(200); + expect(await direct?.text()).toBe("secret-data"); + } finally { + await ctx.close(); + } + }, 60_000); + + test("cross-origin API mutations are rejected by origin policy before anything else", async () => { + const { ctx, page } = await signedInContext(); + try { + await page.goto(`${contentUrl}/sec-attacker/`, { waitUntil: "domcontentloaded" }); + // A top-level form POST carries a real cross-origin Origin header and no + // CORS preflight — the server itself must reject it (403 origin policy, + // not 401 missing bearer, proving the origin gate runs first). + const [response] = await Promise.all([ + page.waitForNavigation({ waitUntil: "domcontentloaded" }), + page.evaluate((target) => { + const form = document.createElement("form"); + form.method = "POST"; + form.action = `${target}/api/projects/sec-private/share`; + document.body.append(form); + form.submit(); + }, appUrl), + ]); + expect(response?.status()).toBe(403); + expect(await page.content()).toContain("Cross-origin"); + } finally { + await ctx.close(); + } + }, 60_000); + + test("auth redirects never leave their intended origins", async () => { + const { ctx, page } = await signedInContext(); + try { + await page.goto(`${appUrl}/auth/login?returnTo=https://evil.example/`, { waitUntil: "domcontentloaded" }); + expect(new URL(page.url()).origin).toBe(appUrl); + + await page.goto(`${appUrl}/auth/project?route=sec-private&returnTo=https://evil.example/`, { waitUntil: "domcontentloaded" }); + expect(page.url().startsWith(`${contentUrl}/sec-private`)).toBe(true); + } finally { + await ctx.close(); + } + }, 60_000); + + test("the private homepage origin is isolated from content-host pages", async () => { + const { ctx, page } = await signedInContext(); + try { + // The owner reads the homepage through the handoff; the cookie is scoped + // to the home origin's root. + await page.goto(`${homeUrl}/`, { waitUntil: "domcontentloaded" }); + expect(await page.textContent("h1")).toBe("home-secret"); + const cookies = await ctx.cookies(homeUrl); + const access = cookies.find((cookie) => cookie.name === "scratchwork_access_sec-home"); + expect(access?.domain).toBe("home.localhost"); + + // A content-host page cannot pull the private homepage in as a + // subresource: its request carries no home-origin referer page. + await page.goto(`${contentUrl}/sec-attacker/`, { waitUntil: "domcontentloaded" }); + const observed = page.waitForResponse((candidate) => candidate.url().startsWith(homeUrl)); + await page.evaluate( + (target) => fetch(target, { mode: "no-cors", credentials: "include" }).catch(() => null), + `${homeUrl}/`, + ); + const response = await observed; + expect([401, 403]).toContain(response.status()); + } finally { + await ctx.close(); + } + }, 60_000); +}); diff --git a/notes/improve-harness-plan.md b/notes/improve-harness-plan.md index 882a8fd..11e84ac 100644 --- a/notes/improve-harness-plan.md +++ b/notes/improve-harness-plan.md @@ -113,7 +113,7 @@ Six invariants (registry below). They live **directly in root `AGENTS.md`** — `[x]` Server-owned route-policy registry: define API routes once in production code (prefer `server/core/src/api-routes.ts`, or the server implementation layer when `HttpApi` lands), with handler, method/path, authentication mode, minimum project role, mutation/origin policy, and response-visibility policy attached to each entry. The router dispatches from this registry; the test matrix is generated from the same definitions rather than maintained as a second list. CI fails if an API route has no policy and exercises credential kind × role × endpoint × public/private status, denying every unspecified combination. *(Landed: `api-routes.ts` registry + policy middleware (origin → principal → read-gated project capability), `test/api-policy.test.ts` matrix over 7 credential kinds × public/private. Tightenings that fell out: every JSON route now rejects cross-origin browser calls (previously only some mutations), sub-read callers get the existence mask on all project routes including delete, and delete of a missing project is now 404 rather than silently ok.)* -`[ ]` Cookie/origin browser-security suite: use real app/content/homepage hostnames in a headless browser to prove secure-cookie-name selection, host/path scoping, SameSite behavior, cross-origin mutation rejection, safe redirects, private subresource isolation, and that arbitrary published JavaScript cannot plant or override an app session. This is narrowly scoped browser security coverage; browser rendering fidelity remains a non-goal. +`[x]` Cookie/origin browser-security suite: use real app/content/homepage hostnames in a headless browser to prove secure-cookie-name selection, host/path scoping, SameSite behavior, cross-origin mutation rejection, safe redirects, private subresource isolation, and that arbitrary published JavaScript cannot plant or override an app session. This is narrowly scoped browser security coverage; browser rendering fidelity remains a non-goal. *(Landed as `e2e/test/browser-security.test.ts`: real headless Chromium (system Chrome, else auto-installed) against local-dev with localhost / pages.localhost / home.localhost origins — host-only session scoping verified on the wire, path-scoped handoff cookies, JS cookie-planting defeated by the *.localhost public-suffix rule and signature verification, Referer-gated private subresources, form-POST Origin rejection, sanitized returnTo redirects, and homepage-origin isolation. Secure-name (__Host-) selection over HTTPS remains covered at the unit level — the loopback lanes are deliberately plain HTTP.)* `[x]` Reusable backend conformance suites for `PrimitiveDb` and `ObjectStorage`, run unchanged against in-memory/file, D1/R2, and DynamoDB/S3 implementations. Cover conditional create/update conflicts, version/ETag behavior, pagination/cursor boundaries, missing records, concurrent writers, binary round trips, key validation, and identical error mapping. *(Landed as `server/core/test/conformance/{primitive-db,object-storage}.ts`, run from server/core (memory), server/deploy-local (file), and e2e (miniflare D1/R2, LocalStack DynamoDB/S3). Real bugs caught on first run: DynamoDB `list` without a prefix always threw ValidationException (unused `#key` expression name), and the local-file + memory storage backends had a TOCTOU letting every concurrent conditional create win — fixed with a write semaphore and check/write reordering.)* From d74228ea27988c8f1113a1ab1e6eb261ecc9a281 Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Fri, 17 Jul 2026 18:05:30 -0700 Subject: [PATCH 11/15] Phase 5: check-invariants skill for the agent-pass residue Diff against main, judge the changed files against each invariant's agent-pass bullets (the calls CI cannot mechanize), report obeys/violates with file:line evidence, and end with a pass/fail verdict. AGENTS.md stays the normative copy; the skill only encodes the procedure. Co-Authored-By: Claude Fable 5 --- .claude/skills/check-invariants/SKILL.md | 52 ++++++++++++++++++++++++ notes/improve-harness-plan.md | 2 +- 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/check-invariants/SKILL.md diff --git a/.claude/skills/check-invariants/SKILL.md b/.claude/skills/check-invariants/SKILL.md new file mode 100644 index 0000000..0bf6c65 --- /dev/null +++ b/.claude/skills/check-invariants/SKILL.md @@ -0,0 +1,52 @@ +--- +name: check-invariants +description: Verify the current branch's diff against the six invariants in AGENTS.md — the agent-pass judgment calls CI cannot check. Use before committing or when asked to review a diff for invariant compliance. +--- + +# Check invariants + +Verify the working diff against the six invariants in the root `AGENTS.md` (the sole +normative copy — read it first). CI already mechanizes the checkable cores +(`scripts/check-boundaries.ts`, the conformance suites, the token corpus, the API +policy matrix); **your job is the residue: the judgment calls a grep cannot make.** + +## Procedure + +1. Read `AGENTS.md` in full. +2. Collect the diff: `git diff main...HEAD` plus `git diff` and `git diff --cached` + for uncommitted work. If the diff is empty, say so and stop. +3. For each changed file, decide which invariants apply (scope lists live under each + invariant in AGENTS.md) and check the diff against the **agent-pass bullets**: + - **Invariant 1 (Effect-native):** Schema over hand-rolled validation; Effect stdlib + over reimplemented utilities; services/layers over ambient dependencies; scoped + resources over manual cleanup; error channels over thrown exceptions; no parallel + legacy implementation left behind. A new allowlisted boundary file must be tiny and + its rationale must hold. + - **Invariant 2 (contracts in shared):** near-duplicate logic between cli and server + that should be hoisted; new CLI-consumed wire shapes defined outside + `shared/src/publish/api.ts`; any new duplication citing the sanctioned + component-scan exception as precedent (there is exactly one exception). + - **Invariant 3 (auth chokepoints):** any `===` on secrets/tags; inline + `crypto.subtle` signing/verifying outside the chokepoint modules; a new token kind + without `version` + `kind` claims and corpus coverage in the same PR; any new + credential-transport path outside the chokepoints. + - **Invariant 4 (route policy):** a handler bypassing the registry or independently + reconstructing the principal/capability; sensitive response fields not gated by the + declared visibility policy; a policy declaration that got weaker without + justification. + - **Invariant 5 (origin isolation):** any new cookie, redirect, forwarded header, + CORS rule, or cross-origin flow — each needs an explicit threat-boundary review in + the PR; flag any that lack one. + - **Invariant 6 (adapter semantics):** adapter changes without a matching + conformance-suite update; provider-specific behavior papered over instead of + documented and mapped. +4. Run the mechanized gate too (cheap, catches drift while you review): + `bun scripts/check-boundaries.ts`. + +## Report format + +One line per invariant: `obeys` / `violates` / `not touched`, with `file:line` +evidence for every `violates` and for any `obeys` that needed judgment. End with a +verdict: **pass** (commit away) or **fail** (list what must change first). Do not +soften violations into suggestions — the standing rule in AGENTS.md is fix or flag, +never commit around silently. diff --git a/notes/improve-harness-plan.md b/notes/improve-harness-plan.md index 11e84ac..a851e72 100644 --- a/notes/improve-harness-plan.md +++ b/notes/improve-harness-plan.md @@ -117,7 +117,7 @@ Six invariants (registry below). They live **directly in root `AGENTS.md`** — `[x]` Reusable backend conformance suites for `PrimitiveDb` and `ObjectStorage`, run unchanged against in-memory/file, D1/R2, and DynamoDB/S3 implementations. Cover conditional create/update conflicts, version/ETag behavior, pagination/cursor boundaries, missing records, concurrent writers, binary round trips, key validation, and identical error mapping. *(Landed as `server/core/test/conformance/{primitive-db,object-storage}.ts`, run from server/core (memory), server/deploy-local (file), and e2e (miniflare D1/R2, LocalStack DynamoDB/S3). Real bugs caught on first run: DynamoDB `list` without a prefix always threw ValidationException (unused `#key` expression name), and the local-file + memory storage backends had a TOCTOU letting every concurrent conditional create win — fixed with a write semaphore and check/write reordering.)* -`[ ]` A `check-invariants` skill (`.claude/skills/check-invariants/SKILL.md`) for the agent-pass residue: diff against main → report obeys/violates with file:line evidence. Claude-only convenience; Codex and OpenCode rely on the AGENTS.md standing rule + CI. +`[x]` A `check-invariants` skill (`.claude/skills/check-invariants/SKILL.md`) for the agent-pass residue: diff against main → report obeys/violates with file:line evidence. Claude-only convenience; Codex and OpenCode rely on the AGENTS.md standing rule + CI. `[ ]` Stretch: migrate the shared CLI API contract to `@effect/platform` `HttpApi` (see invariant 2) — the structural fix that makes contract drift impossible. Auth callbacks, published-content routes, health checks, and other server-only endpoints remain server-owned. From 11e8407b0464d9c82cd8e62e2a48982f87f6f623 Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Fri, 17 Jul 2026 18:28:01 -0700 Subject: [PATCH 12/15] Make deploy config workspaces' empty test suites version-tolerant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bun 1.3 exits 1 when a workspace has zero test files, where the pinned 1.2.15 exits 0 — so the gate failed on newer local Buns in the four per-domain deploy projects that legitimately have no tests. --pass-with-no-tests is accepted by both versions and keeps the behavior identical. Co-Authored-By: Claude Fable 5 --- deploy/cloudflare-access/package.json | 2 +- deploy/cloudflare-vanilla/package.json | 2 +- deploy/generic-aws/package.json | 2 +- deploy/local-dev/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deploy/cloudflare-access/package.json b/deploy/cloudflare-access/package.json index a1342d7..f7d896e 100644 --- a/deploy/cloudflare-access/package.json +++ b/deploy/cloudflare-access/package.json @@ -7,7 +7,7 @@ "ci": "bun run typecheck && bun run test", "deploy": "bun deploy.ts", "local": "bun local.ts", - "test": "bun test", + "test": "bun test --pass-with-no-tests", "typecheck": "tsc -p tsconfig.json" }, "dependencies": { diff --git a/deploy/cloudflare-vanilla/package.json b/deploy/cloudflare-vanilla/package.json index f57c359..59c7853 100644 --- a/deploy/cloudflare-vanilla/package.json +++ b/deploy/cloudflare-vanilla/package.json @@ -7,7 +7,7 @@ "ci": "bun run typecheck && bun run test", "deploy": "bun deploy.ts", "local": "bun local.ts", - "test": "bun test", + "test": "bun test --pass-with-no-tests", "typecheck": "tsc -p tsconfig.json" }, "dependencies": { diff --git a/deploy/generic-aws/package.json b/deploy/generic-aws/package.json index caef00b..7dbf089 100644 --- a/deploy/generic-aws/package.json +++ b/deploy/generic-aws/package.json @@ -7,7 +7,7 @@ "ci": "bun run typecheck && bun run test", "deploy": "bun deploy.ts", "local": "bun local.ts", - "test": "bun test", + "test": "bun test --pass-with-no-tests", "typecheck": "tsc -p tsconfig.json" }, "dependencies": { diff --git a/deploy/local-dev/package.json b/deploy/local-dev/package.json index f66775f..41daeb8 100644 --- a/deploy/local-dev/package.json +++ b/deploy/local-dev/package.json @@ -6,7 +6,7 @@ "scripts": { "ci": "bun run typecheck && bun run test", "local": "bun local.ts", - "test": "bun test", + "test": "bun test --pass-with-no-tests", "typecheck": "tsc -p tsconfig.json" }, "dependencies": { From 278e9126176b3c98cc0ab6c68ea566917a69e745 Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Sat, 18 Jul 2026 15:49:38 -0700 Subject: [PATCH 13/15] Replace the AGENTS.md workspace table with a compact list The three-column table was far too wide to read in a terminal; the package column carried almost no information since the names follow one rule. A nested list keeps every line readable. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bddfe2b..1b37001 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,18 +7,27 @@ this file covers how to change the code safely. ## Workspace map -| Workspace | Package | What it is | -|---|---|---| -| `renderer/` | `scratchwork-renderer` | Build-once pipeline producing the single-file universal renderer (`index.html`). **Deliberately plain browser JS — the one exception to invariant 1.** | -| `shared/` | `@scratchwork/shared` | Code shared between CLI and server: the publish API contract (`src/publish/api.ts`), site serving helpers, crypto digest boundary. The only code importable by both cli and server. | -| `cli/` | `scratchwork-cli` | The `scratchwork` CLI, built with Effect: dev server with hot reload, publishing, login. | -| `server/` | `scratchwork-server` | Deploy tooling scripts only; the server itself lives in the sub-workspaces below. | -| `server/core/` | `@scratchwork/server-core` | Platform-neutral publishing server core: auth, routing, storage contracts. | -| `server/deploy-aws/` | `@scratchwork/server-deploy-aws` | AWS Lambda + S3/DynamoDB adapters. | -| `server/deploy-cloudflare/` | `@scratchwork/server-deploy-cloudflare` | Cloudflare Worker + R2/D1 adapters. | -| `server/deploy-local/` | `@scratchwork/server-deploy-local` | Local Bun deploy target. | -| `deploy/*` | `@scratchwork/deploy-*` | One deployment project per domain (generic-aws, cloudflare-vanilla, cloudflare-access, local-dev), each deployable with one command. | -| `e2e/` | `@scratchwork/e2e` | Full-loop publish e2e: real server (local-dev, miniflare, LocalStack) driven by the real CLI, hermetic OAuth provider standing in for Google. | +Package names are `@scratchwork/` for everything under `server/` and `deploy/` +(plus `@scratchwork/shared` and `@scratchwork/e2e`); the two odd ones out are +`scratchwork-renderer` and `scratchwork-cli`. + +- `renderer/` — build-once pipeline producing the single-file universal renderer + (`index.html`). **Deliberately plain browser JS — the one exception to invariant 1.** +- `shared/` — code shared between CLI and server: the publish API contract + (`src/publish/api.ts`), site serving helpers, crypto digest boundary. The only code + importable by both cli and server. +- `cli/` — the `scratchwork` CLI, built with Effect: dev server with hot reload, + publishing, login. +- `server/` — deploy tooling scripts only; the server itself lives in its sub-workspaces: + - `server/core/` — platform-neutral publishing server core: auth, routing, storage + contracts. + - `server/deploy-aws/` — AWS Lambda + S3/DynamoDB adapters. + - `server/deploy-cloudflare/` — Cloudflare Worker + R2/D1 adapters. + - `server/deploy-local/` — local Bun deploy target. +- `deploy/*` — one deployment project per domain (generic-aws, cloudflare-vanilla, + cloudflare-access, local-dev), each deployable with one command. +- `e2e/` — full-loop publish e2e: real server (local-dev, miniflare, LocalStack) driven + by the real CLI, hermetic OAuth provider standing in for Google. `examples/` and `notes/` are deliberately outside the gate: examples are user-facing sample content, notes are working documents. Nothing in them is imported by shipped code. From f89313d4d0d2e99646cee1cf5ca016d9ea9ef19c Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Sun, 19 Jul 2026 15:37:19 -0700 Subject: [PATCH 14/15] Tighten the AGENTS.md intro wording Co-Authored-By: Claude Fable 5 --- AGENTS.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1b37001..dfa1b9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,6 @@ # Working in this repository -Scratchwork is a local development tool for static HTML and Markdown artifacts: a CLI -(`scratchwork dev` / `publish` / `share`), a single-file Markdown renderer, and a -publishing server that runs locally, on AWS, or on Cloudflare. `README.md` covers usage; -this file covers how to change the code safely. +Scratchwork is tool for publishing static HTML and Markdown artifacts publicly and privately: a CLI (`scratchwork dev` / `publish` / `share`), a single-file Markdown renderer, and a publishing server that runs locally, on AWS, or on Cloudflare. `README.md` covers usage; this file covers how to change the code safely. ## Workspace map @@ -52,8 +49,8 @@ sample content, notes are working documents. Nothing in them is imported by ship ## Standing rule **Verify every diff against the six invariants below before committing.** Each invariant -is enforced at up to three layers, strongest first: *structural* (the architecture makes -violation impossible) → *mechanized* (a test in `bun run ci` fails) → *agent-pass* (the +is enforced at up to three layers, strongest first: _structural_ (the architecture makes +violation impossible) → _mechanized_ (a test in `bun run ci` fails) → _agent-pass_ (the judgment calls listed under each invariant — that's you). When you find a violation, fix it or flag it; never commit around it silently. @@ -103,7 +100,7 @@ server-only endpoints stay in server. - **Sanctioned exception:** `renderer/src/components.js` deliberately duplicates the component-scan logic in `shared/src/site/components.ts` — the renderer is plain browser JS and must not depend on shared, and the CLI dev diagnostics must predict - what the renderer's loader will do. This is the *only* permitted duplication, and only + what the renderer's loader will do. This is the _only_ permitted duplication, and only because a ci conformance test asserts the two implementations agree. Don't "fix" it by hoisting, and don't cite it as precedent for new duplication. From 539bddc3ac2cd398cdea98f1176c9fb6a14fae9d Mon Sep 17 00:00:00 2001 From: Peter Koomen Date: Sun, 19 Jul 2026 23:35:38 -0700 Subject: [PATCH 15/15] Migrate the CLI API contract to @effect/platform HttpApi The invariant-2 structural fix: the JSON API is now defined once as an HttpApi object (ScratchworkApi in shared/src/publish/api.ts) that both sides derive from, so contract drift cannot typecheck. - shared: ScratchworkApi declares all 11 endpoints (method, typed :project path param, payload/urlParams schema, success schema). New shared schemas: MeResponseSchema, ShareRequestSchema (moved from server), OkResponseSchema, ProjectBundleResponseSchema. - server: API_POLICY is a mapped-type record keyed exhaustively by contract endpoint names; bodies are size-capped and strictly decoded through the contract payload schemas in one dispatcher path, and each handler's return type is compile-checked against and encoded through the endpoint's success schema. The bespoke dispatcher stays: HttpApiBuilder's router semantics (400 on bad path params, 404 on method mismatch, its own error envelope) conflict with the masking/405/origin behavior the policy matrix pins. - cli: the client is HttpApiClient-derived; bearer/CF-Access headers, edge-block detection, and {error}-envelope extraction live in one transformed HttpClient, and commands map failures via mapApiErrors with unchanged user-facing messages. Malformed project names fail fast at resolveProjectRef. - gate/docs: check-boundaries section 3 becomes the no-hand-built-URLs check (allowlist: the /auth/login browser navigation); AGENTS.md invariant 2 updated from planned to landed; plan stretch item closed. Also commits the regenerated default-renderer.generated.js, stale since the lockfile change (the renderer source hash covers bun.lock; the rendered HTML is unchanged). Co-Authored-By: Claude Fable 5 --- AGENTS.md | 21 +- cli/src/api.ts | 352 +++++++++++------- cli/src/commands/login.ts | 19 +- cli/src/commands/projects.ts | 83 +++-- cli/src/commands/publish.ts | 46 +-- cli/src/project-config.ts | 16 +- cli/test/api.test.js | 109 ++++-- notes/improve-harness-plan.md | 2 +- scripts/check-boundaries.ts | 110 ++---- server/core/src/api-routes.ts | 328 ++++++++++------ server/core/src/publish-request.ts | 52 +-- server/core/src/share-request.ts | 62 +-- server/core/test/publish-request.test.ts | 12 +- shared/src/publish/api.ts | 149 +++++++- shared/src/site/default-renderer.generated.js | 2 +- 15 files changed, 800 insertions(+), 563 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dfa1b9d..9aa3f82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,13 +88,16 @@ Effect Schemas in `shared/src/publish/api.ts` (extend there, never inline). Auth callbacks, published-content routing, health checks, deployment hooks, and other server-only endpoints stay in server. -- Structural (planned): migrate the shared CLI API contract to `@effect/platform` - `HttpApi`, so the server implements it and the CLI derives its client from the same - object — duplication becomes impossible rather than forbidden. +- Structural: the JSON API is defined once as an `@effect/platform` `HttpApi` object + (`ScratchworkApi` in `shared/src/publish/api.ts`). The CLI derives its client from it + (`HttpApiClient` in `cli/src/api.ts`), and the server's route registry, request + decoding, and response encoding derive from the same object + (`server/core/src/api-routes.ts`) — an endpoint, method, path, or schema that drifts + between the two sides cannot typecheck. - Mechanized: the import-boundary test in `bun run ci` — `cli/**` never imports from - `server/**` and vice versa; the only code importable by both is `shared/**`. Every - CLI-consumed JSON route's request/response schema is imported from shared against an - explicit route inventory. + `server/**` and vice versa; the only code importable by both is `shared/**`. The CLI + API-surface check fails on any hand-built `/api/` or `/auth/` URL in `cli/src` + outside the explicit browser-navigation allowlist. - Agent-pass: near-duplicate logic between cli and server that should be hoisted into shared (imports can't catch a reimplementation). - **Sanctioned exception:** `renderer/src/components.js` deliberately duplicates the @@ -136,8 +139,10 @@ unclassified route is impossible and the default for an unspecified credential/r combination is denial. - Scope: the server API router/registry, route handlers, and shared CLI API contract. -- Structural: the router dispatches only registered route definitions; handlers receive - the authenticated principal/authorized project capability produced by policy +- Structural: the router dispatches only registered route definitions, and the registry + is a mapped type keyed by the shared contract's endpoint names — a contract endpoint + without a policy, or a policy for a nonexistent endpoint, is a compile error; handlers + receive the authenticated principal/authorized project capability produced by policy middleware rather than independently reconstructing it. - Mechanized: enumerate the registry and exercise credential kind × role × endpoint × public/private status; fail on missing policy metadata and deny every unspecified cell. diff --git a/cli/src/api.ts b/cli/src/api.ts index 7512adc..a05a842 100644 --- a/cli/src/api.ts +++ b/cli/src/api.ts @@ -1,45 +1,53 @@ /* - * HTTP client for the Scratchwork server's JSON API. + * HTTP client for the Scratchwork server's JSON API, derived from the shared + * HttpApi contract (invariant 2): apiClient returns the typed client that + * HttpApiClient derives from ScratchworkApi, so URLs, request encoding, and + * response decoding all come from the one contract object and cannot drift + * from the server. Responses are decoded with the schema defaults (tolerant: + * unknown fields from a newer server are ignored). * - * Every command talks to the server through apiRequest/apiJson so that - * transport failures, error-body decoding, and bearer-token auth are handled - * in exactly one place. All failures become CliError, prefixed with the - * calling command's context string (for example "scratchwork publish"). + * One transformed HttpClient underneath the derived client owns every + * transport concern in exactly one place: bearer-token and Cloudflare Access + * credentials on the way out; transport failures, Cloudflare edge blocks, and + * the shared {error}-envelope on the way back, each normalized into an + * ApiError that commands turn into CliError via mapApiErrors (prefixed with + * the calling command's context string, for example "scratchwork publish"). + * Requests are interruption-safe: Ctrl-C aborts the in-flight request. */ import type { PlatformError } from "@effect/platform/Error"; import type * as FileSystem from "@effect/platform/FileSystem"; +import * as HttpApiClient from "@effect/platform/HttpApiClient"; +import type * as HttpApiError from "@effect/platform/HttpApiError"; import * as HttpClient from "@effect/platform/HttpClient"; +import type * as HttpClientError from "@effect/platform/HttpClientError"; import * as HttpClientRequest from "@effect/platform/HttpClientRequest"; +import type * as HttpClientResponse from "@effect/platform/HttpClientResponse"; import type * as Path from "@effect/platform/Path"; +import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Either from "effect/Either"; import * as Option from "effect/Option"; +import type * as ParseResult from "effect/ParseResult"; import * as Schema from "effect/Schema"; -import { ApiErrorBodySchema, CLI_TOKEN_EXCHANGE_PATH, ProjectResponseSchema } from "../../shared/src/publish/api"; -import { readAuthToken, readCfToken, serverApiUrl } from "./auth"; +import { ApiErrorBodySchema, CLI_TOKEN_EXCHANGE_PATH, ScratchworkApi } from "../../shared/src/publish/api"; +import { readAuthToken, readCfToken } from "./auth"; import { CliError, errorMessage } from "./errors"; -/** Decodes a response body as JSON, or null when it is not JSON. */ -const parseBodyJson = (text: string): unknown => - Either.getOrNull(Schema.decodeUnknownEither(Schema.parseJson())(text)); - -/** Matches the shared `{"error": "..."}` envelope, tolerating extra fields. */ -const decodeErrorBody = Schema.decodeUnknownOption(ApiErrorBodySchema); - -/** A completed API exchange: HTTP status plus the raw and JSON-decoded body. */ -export interface ApiResponse { - readonly status: number; - readonly ok: boolean; - readonly text: string; - readonly json: unknown; -} - -/** Options for one API request; `token` adds a bearer Authorization header. */ -export interface ApiRequestOptions { - readonly method?: "GET" | "POST" | "DELETE"; - readonly token?: string; - readonly body?: unknown; -} +/** + * A normalized JSON API failure. `status` is the HTTP status of the server's + * response, or null when no API response was received at all — transport + * failures (unreachable server, aborted response) and requests Cloudflare's + * edge blocked before they reached the server. `message` is the most useful + * error text: the shared {error}-envelope's message when the server sent one, + * a re-auth hint for Cloudflare Access blocks, and a short summary otherwise + * (an HTML page is reduced to its title and anything long is truncated, so + * proxy error pages and stack dumps are never echoed wholesale into the + * terminal). + */ +export class ApiError extends Data.TaggedError("ApiError")<{ + readonly status: number | null; + readonly message: string; +}> {} /** A project name fully resolved to the server that hosts it. */ export interface ResolvedProjectRef { @@ -47,83 +55,126 @@ export interface ResolvedProjectRef { readonly project: string; } +/** Every failure a derived-client call can produce: the normalized ApiError, + * a decode failure for a 2xx body outside the contract (ParseError, + * ResponseError, or the contract's implicit HttpApiDecodeError — the latter + * two only in the type, since normalizeFailures consumes every non-2xx before + * the decode map sees it), or a client error that escaped normalization. */ +type ApiClientError = + | ApiError + | HttpApiError.HttpApiDecodeError + | HttpClientError.HttpClientError + | ParseResult.ParseError; + /** - * Executes one JSON API request. Transport failures (unreachable server, - * aborted response) fail with a context-prefixed CliError; HTTP error statuses - * are returned in the ApiResponse for the caller to interpret — except a - * Cloudflare Access edge block, which fails with a re-auth hint since no - * caller can act on it. Requests are interruption-safe: Ctrl-C aborts the - * in-flight request. + * Builds the contract-derived API client for one server. `token` adds a + * bearer Authorization header to every request; Cloudflare Access credentials + * (the Access JWT relayed at login, and the service-token env vars for CI) + * are attached automatically. Servers without Access ignore the extra + * headers. */ -export function apiRequest( - context: string, - url: URL | string, - options: ApiRequestOptions = {}, -): Effect.Effect { +export function apiClient( + server: string, + options: { readonly token?: string } = {}, +): Effect.Effect< + ScratchworkApiClient, + CliError, + HttpClient.HttpClient | FileSystem.FileSystem | Path.Path +> { return Effect.gen(function* () { - const client = yield* HttpClient.HttpClient; - let request = HttpClientRequest.make(options.method ?? "GET")(url); - if (options.token != null) request = HttpClientRequest.bearerToken(request, options.token); - request = yield* attachCloudflareAccess(request, url); - if (options.body !== undefined) request = HttpClientRequest.bodyUnsafeJson(request, options.body); - const response = yield* client.execute(request); - const text = yield* response.text; - if (edgeBlocked(response.status, response.headers, text)) { - return yield* apiFail(context, cloudflareAccessBlockedMessage(url)); - } - return { - status: response.status, - ok: response.status >= 200 && response.status < 300, - text, - json: parseBodyJson(text), - }; - }).pipe( - Effect.catchTags({ - RequestError: (error) => apiFail(context, errorMessage(error.cause ?? error)), - ResponseError: (error) => apiFail(context, errorMessage(error.cause ?? error)), - }), - ); + const http = yield* HttpClient.HttpClient; + const cfToken = yield* readCfToken(new URL(server).origin); + const client = http.pipe( + HttpClient.mapRequest(attachCredentials(options.token, cfToken)), + HttpClient.transform(normalizeFailures), + ); + return yield* HttpApiClient.makeWith(ScratchworkApi, { httpClient: client, baseUrl: server }); + }); } -/** Explains the circular first-login failure separately from an expired Access - * session on an ordinary API request. The exchange cannot use a stored Access - * JWT because successfully calling it is how the CLI obtains that JWT. */ -function cloudflareAccessBlockedMessage(url: URL | string): string { - const pathname = new URL(url).pathname; - if (pathname.endsWith(CLI_TOKEN_EXCHANGE_PATH)) { - return "Cloudflare Access blocked the CLI token exchange. The server administrator must configure a Bypass / Everyone policy limited to `/auth/cli/token`, then run `scratchwork login` again. The Worker still validates the signed one-time code and PKCE verifier."; +/** The typed client HttpApiClient derives from the shared contract. */ +export type ScratchworkApiClient = Effect.Effect.Success>; + +/** Type helper: the un-narrowed HttpApiClient constructor for the contract. */ +function makeRawClient(httpClient: HttpClient.HttpClient.With) { + return HttpApiClient.makeWith(ScratchworkApi, { httpClient }); +} + +/** + * Maps every API-client failure to a context-prefixed CliError: an ApiError + * carries its normalized message; a ParseError or ResponseError from decoding + * a 2xx body means the server answered outside the contract. Errors of other + * types (for example a command's own) pass through untouched. + */ +export function mapApiErrors(context: string) { + return (effect: Effect.Effect): Effect.Effect, R> => + Effect.catchIf(effect, isApiClientError, (error) => apiFail(context, apiClientErrorMessage(error))) as Effect.Effect< + A, + CliError | Exclude, + R + >; +} + +/** Narrows an unknown failure to the client error union by tag. */ +function isApiClientError(error: unknown): error is ApiClientError { + return typeof error === "object" && error != null && "_tag" in error + && (error._tag === "ApiError" || error._tag === "ParseError" + || error._tag === "RequestError" || error._tag === "ResponseError" + || error._tag === "HttpApiDecodeError"); +} + +/** The CliError message for one client failure (see mapApiErrors). */ +function apiClientErrorMessage(error: ApiClientError): string { + switch (error._tag) { + case "ApiError": + return error.message; + case "RequestError": + return errorMessage(error.cause ?? error); + default: + return "invalid server response"; } - return "Cloudflare Access blocked this request. Run `scratchwork login` again (your Access session may have expired), or set SCRATCHWORK_CF_ACCESS_CLIENT_ID and SCRATCHWORK_CF_ACCESS_CLIENT_SECRET for automation."; } -/** Executes an API request and fails with the server-reported error on non-2xx statuses. */ -export function apiJson( - context: string, - url: URL | string, - options: ApiRequestOptions = {}, -): Effect.Effect { +/** + * Asks the server which project a published content path belongs to. The + * endpoint centralizes validation and authorization, and stays host-aware for + * URL shapes a local parse cannot resolve. + */ +export function resolveProjectByPath( + server: string, + pathname: string, + command: string, +): Effect.Effect< + { readonly project: string }, + PlatformError | CliError, + HttpClient.HttpClient | FileSystem.FileSystem | Path.Path +> { return Effect.gen(function* () { - const response = yield* apiRequest(context, url, options); - if (!response.ok) return yield* apiFail(context, apiErrorText(response)); - return response.json; + const token = yield* readAuthToken(server); + const client = yield* apiClient(server, { token }); + const response = yield* client.resolve({ urlParams: { path: pathname === "" ? "/" : pathname } }).pipe( + mapApiErrors(`scratchwork ${command}`), + ); + return { project: response.project.project }; }); } +// --------------------------------------------------------------------------- +// Outgoing credentials +// --------------------------------------------------------------------------- + /** - * Attaches Cloudflare Access credentials so requests pass an Access-protected - * edge: the Access JWT the server relayed at login (stored per origin, sent as - * `cf-access-token`), and the service-token headers from + * Attaches the bearer token and Cloudflare Access credentials to one outgoing + * request: the Access JWT the server relayed at login (stored per origin, + * sent as `cf-access-token`), and the service-token headers from * SCRATCHWORK_CF_ACCESS_CLIENT_ID/SECRET for CI and headless automation. * Cloudflare validates either at the edge; the server still identifies the - * user by the bearer token. Servers without Access ignore the extra headers. + * user by the bearer token. */ -function attachCloudflareAccess( - request: HttpClientRequest.HttpClientRequest, - url: URL | string, -): Effect.Effect { - return Effect.gen(function* () { +function attachCredentials(token: string | undefined, cfToken: string | undefined) { + return (request: HttpClientRequest.HttpClientRequest): HttpClientRequest.HttpClientRequest => { let result = request; - const cfToken = yield* readCfToken(new URL(url).origin); + if (token != null) result = HttpClientRequest.bearerToken(result, token); if (cfToken != null) result = HttpClientRequest.setHeader(result, "cf-access-token", cfToken); const clientId = process.env.SCRATCHWORK_CF_ACCESS_CLIENT_ID; const clientSecret = process.env.SCRATCHWORK_CF_ACCESS_CLIENT_SECRET; @@ -134,18 +185,68 @@ function attachCloudflareAccess( }); } return result; + }; +} + +// --------------------------------------------------------------------------- +// Incoming failure normalization +// --------------------------------------------------------------------------- + +/** Normalizes one exchange: transport failures become ApiError immediately; + * responses are classified before the contract decode ever sees them. */ +function normalizeFailures( + effect: Effect.Effect, + request: HttpClientRequest.HttpClientRequest, +): Effect.Effect { + return effect.pipe( + Effect.catchAll((error) => + Effect.fail(new ApiError({ status: null, message: errorMessage(error.cause ?? error) })), + ), + Effect.flatMap((response) => classifyResponse(request, response)), + ); +} + +/** + * Classifies one received response. A 2xx JSON response passes through with + * its body untouched for the contract decode. Everything else is consumed + * here and becomes an ApiError: a Cloudflare edge block (a 403 the edge tags + * with `cf-mitigated`, or an Access login page — HTML with Access markers — + * where the API would have answered JSON, the shape a 302 to the login page + * takes after redirect following) gets a re-auth hint; any other non-2xx (or + * an HTML body where the API answers only JSON) gets its extracted error + * text. + */ +function classifyResponse( + request: HttpClientRequest.HttpClientRequest, + response: HttpClientResponse.HttpClientResponse, +): Effect.Effect { + const ok = response.status >= 200 && response.status < 300; + const html = /text\/html/i.test(response.headers["content-type"] ?? ""); + if (ok && !html) return Effect.succeed(response); + return Effect.gen(function* () { + const text = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + if (edgeBlocked(response.status, response.headers, text)) { + return yield* Effect.fail(new ApiError({ status: null, message: cloudflareAccessBlockedMessage(request.url) })); + } + return yield* Effect.fail(new ApiError({ status: response.status, message: apiErrorText(response.status, text) })); }); } +/** Explains the circular first-login failure separately from an expired Access + * session on an ordinary API request. The exchange cannot use a stored Access + * JWT because successfully calling it is how the CLI obtains that JWT. */ +function cloudflareAccessBlockedMessage(url: string): string { + const pathname = new URL(url).pathname; + if (pathname.endsWith(CLI_TOKEN_EXCHANGE_PATH)) { + return "Cloudflare Access blocked the CLI token exchange. The server administrator must configure a Bypass / Everyone policy limited to `/auth/cli/token`, then run `scratchwork login` again. The Worker still validates the signed one-time code and PKCE verifier."; + } + return "Cloudflare Access blocked this request. Run `scratchwork login` again (your Access session may have expired), or set SCRATCHWORK_CF_ACCESS_CLIENT_ID and SCRATCHWORK_CF_ACCESS_CLIENT_SECRET for automation."; +} + /** Matches Cloudflare Access artifacts in an HTML body where JSON was expected. */ const ACCESS_PAGE_MARKERS = /cloudflareaccess|CF_Authorization/i; -/** - * Detects a request Cloudflare's edge blocked before it reached the server: a - * 403 the edge tags with `cf-mitigated`, or an Access login page — HTML with - * Access markers — where the API would have answered with JSON (the shape a - * 302 to the login page takes after redirect following). - */ +/** Detects a request Cloudflare's edge blocked before it reached the server. */ function edgeBlocked( status: number, headers: Readonly>, @@ -155,60 +256,25 @@ function edgeBlocked( return /^\s*<(!doctype|html)/i.test(text) && ACCESS_PAGE_MARKERS.test(text); } -/** Extracts the most useful error text from a failed response: the JSON `error` field - * when the server sent one, otherwise a short summary. Non-JSON bodies (Cloudflare or - * proxy HTML error pages, stack dumps) are never echoed wholesale into the terminal — - * an HTML page is reduced to its title and anything long is truncated. */ -export function apiErrorText(response: ApiResponse): string { - const errorBody = Option.getOrNull(decodeErrorBody(response.json)); +/** Matches the shared `{"error": "..."}` envelope, tolerating extra fields. */ +const decodeErrorBody = Schema.decodeUnknownOption(ApiErrorBodySchema); + +/** Extracts the most useful error text from a failed response body (see ApiError). */ +function apiErrorText(status: number, text: string): string { + const json = Either.getOrNull(Schema.decodeUnknownEither(Schema.parseJson())(text)); + const errorBody = Option.getOrNull(decodeErrorBody(json)); if (errorBody != null && errorBody.error !== "") { return errorBody.error; } - const text = response.text.trim(); - if (text === "") return `server returned ${response.status}`; - if (/^<(!doctype|html|!--)/i.test(text)) { - const title = /]*>([^<]*)<\/title>/i.exec(text)?.[1]?.trim(); + const trimmed = text.trim(); + if (trimmed === "") return `server returned ${status}`; + if (/^<(!doctype|html|!--)/i.test(trimmed)) { + const title = /]*>([^<]*)<\/title>/i.exec(trimmed)?.[1]?.trim(); return title != null && title !== "" - ? `server returned ${response.status}: ${title}` - : `server returned ${response.status} with an HTML error page`; + ? `server returned ${status}: ${title}` + : `server returned ${status} with an HTML error page`; } - return text.length > 300 ? `server returned ${response.status}: ${text.slice(0, 300)}…` : text; -} - -/** Builds the API URL for one project, preserving any server path prefix. */ -export function projectApiUrl(ref: ResolvedProjectRef, suffix = ""): URL { - return serverApiUrl( - ref.server, - `/api/projects/${encodeURIComponent(ref.project)}${suffix}`, - ); -} - -/** - * Asks the server which project a published content path belongs to. The - * endpoint centralizes validation and authorization, and stays host-aware for - * URL shapes a local parse cannot resolve. - */ -export function resolveProjectByPath( - server: string, - pathname: string, - command: string, -): Effect.Effect< - { readonly project: string }, - PlatformError | CliError, - HttpClient.HttpClient | FileSystem.FileSystem | Path.Path -> { - return Effect.gen(function* () { - const token = yield* readAuthToken(server); - const url = serverApiUrl(server, "/api/resolve"); - url.searchParams.set("path", pathname === "" ? "/" : pathname); - const body = yield* apiJson(`scratchwork ${command}`, url, { token }); - // Tolerant decoding on purpose: unknown fields from a newer server are ignored. - const decoded = Schema.decodeUnknownOption(ProjectResponseSchema)(body); - if (Option.isNone(decoded)) { - return yield* Effect.fail(new CliError({ code: 1, message: `scratchwork ${command}: invalid server response` })); - } - return { project: decoded.value.project.project }; - }); + return trimmed.length > 300 ? `server returned ${status}: ${trimmed.slice(0, 300)}…` : trimmed; } /** Fails with a context-prefixed CliError. */ diff --git a/cli/src/commands/login.ts b/cli/src/commands/login.ts index 78458ee..e38144a 100644 --- a/cli/src/commands/login.ts +++ b/cli/src/commands/login.ts @@ -15,20 +15,15 @@ import type * as Path from "@effect/platform/Path"; import * as Console from "effect/Console"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; import { - CLI_TOKEN_EXCHANGE_PATH, - CliTokenResponseSchema, type CliTokenRequest, type CliTokenResponse, } from "../../../shared/src/publish/api"; -import { apiJson } from "../api"; +import { apiClient, mapApiErrors } from "../api"; import { generateLoginProof, loginUrl, normalizeServerUrl, - serverApiUrl, writeAuthToken, type LoginCallback, } from "../auth"; @@ -126,16 +121,8 @@ function exchangeLoginCode( ): Effect.Effect { return Effect.gen(function* () { const body: CliTokenRequest = { code, codeVerifier, redirectUri }; - const json = yield* apiJson("scratchwork login", serverApiUrl(server, CLI_TOKEN_EXCHANGE_PATH), { - method: "POST", - body, - }); - // Tolerant decoding on purpose: unknown fields from a newer server are ignored. - const decoded = Schema.decodeUnknownOption(CliTokenResponseSchema)(json); - if (Option.isNone(decoded)) { - return yield* Effect.fail(new CliError({ code: 1, message: "scratchwork login: invalid server response" })); - } - return decoded.value; + const client = yield* apiClient(server); + return yield* client["cli-token-exchange"]({ payload: body }).pipe(mapApiErrors("scratchwork login")); }); } diff --git a/cli/src/commands/projects.ts b/cli/src/commands/projects.ts index f64f64e..ac64240 100644 --- a/cli/src/commands/projects.ts +++ b/cli/src/commands/projects.ts @@ -10,16 +10,12 @@ import type * as HttpClient from "@effect/platform/HttpClient"; import * as Path from "@effect/platform/Path"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as Either from "effect/Either"; import * as Encoding from "effect/Encoding"; -import { ProjectsListResponseSchema } from "../../../shared/src/publish/api"; -import { PublishBundleSchema } from "../../../shared/src/publish/bundle"; import { isSafeProjectIdentifier } from "../../../shared/src/site/identifiers"; -import { apiErrorText, apiJson, apiRequest, projectApiUrl } from "../api"; -import { readAuthToken, serverApiUrl } from "../auth"; +import { apiClient, mapApiErrors } from "../api"; +import { readAuthToken } from "../auth"; import { SKIPPED_DIRECTORIES } from "../dev/target"; import { CliError, errorMessage } from "../errors"; import { PROJECT_CONFIG_FILE, resolveProjectRef, resolveServerFromCwd, writeProjectConfig } from "../project-config"; @@ -36,7 +32,8 @@ export function runMe( return Effect.gen(function* () { const server = yield* resolveServerFromCwd(config.server, "me"); const token = yield* readAuthToken(server); - const body = yield* apiJson("scratchwork me", serverApiUrl(server, "/api/me"), { token }); + const client = yield* apiClient(server, { token }); + const body = yield* client.me().pipe(mapApiErrors("scratchwork me")); yield* Console.log(JSON.stringify(body, null, 2)); }); } @@ -48,11 +45,17 @@ export function runProjects( return Effect.gen(function* () { const server = yield* resolveServerFromCwd(config.server, "projects"); const token = yield* readAuthToken(server); - const body = yield* apiJson("scratchwork projects", serverApiUrl(server, "/api/projects"), { token }); - // Tolerant decoding on purpose: unknown fields from a newer server are ignored, - // and an undecodable body degrades to an empty listing (as it always has). - const decoded = Schema.decodeUnknownOption(ProjectsListResponseSchema)(body); - const projects = Option.isSome(decoded) ? decoded.value.projects : []; + const client = yield* apiClient(server, { token }); + // An undecodable 2xx body degrades to an empty listing (as it always has); + // unknown fields from a newer server are ignored by the tolerant decode. + const body = yield* client["projects-list"]().pipe( + Effect.catchTags({ + ParseError: () => Effect.succeed({ projects: [] }), + ResponseError: () => Effect.succeed({ projects: [] }), + }), + mapApiErrors("scratchwork projects"), + ); + const projects = body.projects; if (projects.length === 0) { yield* Console.log("No projects."); return; @@ -70,7 +73,10 @@ export function runInfo( return Effect.gen(function* () { const ref = yield* resolveProjectRef({ ...config, command: "info" }); const token = yield* readAuthToken(ref.server); - const body = yield* apiJson("scratchwork info", projectApiUrl(ref), { token }); + const client = yield* apiClient(ref.server, { token }); + const body = yield* client["project-info"]({ path: { project: ref.project } }).pipe( + mapApiErrors("scratchwork info"), + ); yield* Console.log(JSON.stringify(body, null, 2)); }); } @@ -82,7 +88,10 @@ export function runUnpublish( return Effect.gen(function* () { const ref = yield* resolveProjectRef({ ...config, command: "unpublish" }); const token = yield* readAuthToken(ref.server); - const body = yield* apiJson("scratchwork unpublish", projectApiUrl(ref, "/unpublish"), { method: "POST", token }); + const client = yield* apiClient(ref.server, { token }); + const body = yield* client["project-unpublish"]({ path: { project: ref.project } }).pipe( + mapApiErrors("scratchwork unpublish"), + ); yield* Console.log(JSON.stringify(body, null, 2)); }); } @@ -128,23 +137,23 @@ function runShareChange( } const ref = yield* resolveProjectRef({ command, pathOrUrl: refs[0], server: config.server, project: config.project }); const token = yield* readAuthToken(ref.server); - const response = yield* apiRequest(`scratchwork ${command}`, projectApiUrl(ref, "/share"), { - method: "POST", - token, - body: change.add ? { add: targets, role: change.role } : { remove: targets }, - }); - // A missing project reads "Project not found"; a bare route-level "Not found" means - // the server predates the /share API and would otherwise be indistinguishable noise. - if (response.status === 404 && apiErrorText(response) === "Not found") { - return yield* Effect.fail(new CliError({ - code: 1, - message: `scratchwork ${command}: ${ref.server} does not support sharing yet (no /share API). Update the server to a newer Scratchwork release and try again.`, - })); - } - if (!response.ok) { - return yield* Effect.fail(new CliError({ code: 1, message: `scratchwork ${command}: ${apiErrorText(response)}` })); - } - yield* Console.log(JSON.stringify(response.json, null, 2)); + const client = yield* apiClient(ref.server, { token }); + const response = yield* client["project-share"]({ + path: { project: ref.project }, + payload: change.add ? { add: targets, role: change.role } : { remove: targets }, + }).pipe( + // A missing project reads "Project not found"; a bare route-level "Not found" means + // the server predates the /share API and would otherwise be indistinguishable noise. + Effect.catchIf( + (error) => error._tag === "ApiError" && error.status === 404 && error.message === "Not found", + () => Effect.fail(new CliError({ + code: 1, + message: `scratchwork ${command}: ${ref.server} does not support sharing yet (no /share API). Update the server to a newer Scratchwork release and try again.`, + })), + ), + mapApiErrors(`scratchwork ${command}`), + ); + yield* Console.log(JSON.stringify(response, null, 2)); }); } @@ -155,7 +164,8 @@ export function runDelete( return Effect.gen(function* () { const ref = yield* resolveProjectRef({ ...config, command: "delete" }); const token = yield* readAuthToken(ref.server); - yield* apiJson("scratchwork delete", projectApiUrl(ref), { method: "DELETE", token }); + const client = yield* apiClient(ref.server, { token }); + yield* client["project-delete"]({ path: { project: ref.project } }).pipe(mapApiErrors("scratchwork delete")); yield* Console.log(`Deleted ${ref.project}`); }); } @@ -172,13 +182,10 @@ export function runClone( return yield* Effect.fail(new CliError({ code: 1, message: `scratchwork clone: unsafe project name ${ref.project}` })); } const token = yield* readAuthToken(ref.server); - const body = yield* apiJson("scratchwork clone", projectApiUrl(ref, "/bundle"), { token }); - const envelope = Option.getOrNull( - Schema.decodeUnknownOption(Schema.Struct({ bundle: PublishBundleSchema }))(body), + const client = yield* apiClient(ref.server, { token }); + const envelope = yield* client["project-bundle"]({ path: { project: ref.project } }).pipe( + mapApiErrors("scratchwork clone"), ); - if (envelope == null) { - return yield* Effect.fail(new CliError({ code: 1, message: "scratchwork clone: invalid server response" })); - } const fs = yield* FileSystem.FileSystem; const paths = yield* Path.Path; diff --git a/cli/src/commands/publish.ts b/cli/src/commands/publish.ts index 93871c5..da67614 100644 --- a/cli/src/commands/publish.ts +++ b/cli/src/commands/publish.ts @@ -10,20 +10,17 @@ import type * as HttpClient from "@effect/platform/HttpClient"; import * as Path from "@effect/platform/Path"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; import * as Encoding from "effect/Encoding"; import { decodedBase64ByteLength } from "../../../shared/src/encoding/base64"; import { - PublishResponseSchema, type PublishRequestBody, type PublishResponse, } from "../../../shared/src/publish/api"; import { PUBLISH_BUNDLE_VERSION, type PublishBundle } from "../../../shared/src/publish/bundle"; import { isSafeProjectIdentifier, slugifyIdentifier } from "../../../shared/src/site/identifiers"; import { isSafeSitePath, type SitePath } from "../../../shared/src/site/paths"; -import { apiErrorText, apiRequest } from "../api"; -import { readAuthToken, serverApiUrl } from "../auth"; +import { apiClient, mapApiErrors } from "../api"; +import { readAuthToken } from "../auth"; import { openBrowser } from "../browser"; import { resolveDevTarget, SKIPPED_DIRECTORIES } from "../dev/target"; import { CliError, errorMessage } from "../errors"; @@ -178,32 +175,19 @@ function postPublish( authToken: string | undefined, ): Effect.Effect { return Effect.gen(function* () { - const response = yield* apiRequest("scratchwork publish", serverApiUrl(server, "/api/publish"), { - method: "POST", - token: authToken, - body, - }); - if (response.status === 401) { - return yield* Effect.fail( - new PublishAuthRequired({ - code: 1, - message: `scratchwork publish: authentication required. Run \`scratchwork login ${server}\`.`, - }), - ); - } - if (!response.ok) { - return yield* Effect.fail( - new CliError({ code: 1, message: `scratchwork publish: ${apiErrorText(response)}` }), - ); - } - // Tolerant decoding on purpose: unknown fields from a newer server are ignored. - const parsed = Schema.decodeUnknownOption(PublishResponseSchema)(response.json); - if (Option.isNone(parsed)) { - return yield* Effect.fail( - new CliError({ code: 1, message: "scratchwork publish: invalid server response" }), - ); - } - return parsed.value; + const client = yield* apiClient(server, { token: authToken }); + return yield* client.publish({ payload: body }).pipe( + Effect.catchIf( + (error) => error._tag === "ApiError" && error.status === 401, + () => Effect.fail( + new PublishAuthRequired({ + code: 1, + message: `scratchwork publish: authentication required. Run \`scratchwork login ${server}\`.`, + }), + ), + ), + mapApiErrors("scratchwork publish"), + ); }); } diff --git a/cli/src/project-config.ts b/cli/src/project-config.ts index f95b1a6..039afd9 100644 --- a/cli/src/project-config.ts +++ b/cli/src/project-config.ts @@ -15,6 +15,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Either from "effect/Either"; import * as Predicate from "effect/Predicate"; +import { isSafeProjectIdentifier } from "../../shared/src/site/identifiers"; import { resolveProjectByPath, type ResolvedProjectRef } from "./api"; import { normalizeServerUrl } from "./auth"; import { CliError } from "./errors"; @@ -149,9 +150,9 @@ export function resolveProjectRef(input: { catch: () => new CliError({ code: 1, message: `scratchwork ${input.command}: invalid server` }), }); const project = input.project; - if (project) return { server, project }; + if (project) return yield* safeProjectRef(input.command, { server, project }); const resolved = yield* resolveProjectByPath(server, projectUrl.pathname, input.command); - return { server, project: resolved.project }; + return yield* safeProjectRef(input.command, { server, project: resolved.project }); } const paths = yield* Path.Path; @@ -167,10 +168,19 @@ export function resolveProjectRef(input: { message: `scratchwork ${input.command}: project is required`, })); } - return { server, project }; + return yield* safeProjectRef(input.command, { server, project }); }); } +/** Rejects a project name (from flags, a config file, or the server) that the + * contract's project-identifier grammar would refuse, so commands fail with a + * clear message instead of a request-encoding error. */ +function safeProjectRef(command: string, ref: ResolvedProjectRef): Effect.Effect { + return isSafeProjectIdentifier(ref.project) + ? Effect.succeed(ref) + : Effect.fail(new CliError({ code: 1, message: `scratchwork ${command}: unsafe project name ${ref.project}` })); +} + /** Maps a path argument to the directory whose config should be consulted. */ function directoryForConfig(path: string): Effect.Effect { return Effect.gen(function* () { diff --git a/cli/test/api.test.js b/cli/test/api.test.js index cc43128..f9a9221 100644 --- a/cli/test/api.test.js +++ b/cli/test/api.test.js @@ -1,9 +1,10 @@ /* - * Tests for apiRequest's Cloudflare Access behavior, run against real loopback - * HTTP servers (nothing mocked): the stored Access JWT and service-token env - * vars ride outgoing requests as headers, and responses blocked by Cloudflare's - * edge (403 + cf-mitigated, or an Access login page) fail with a re-auth hint - * instead of leaking an HTML page into the terminal. + * Tests for the contract-derived API client's transport behavior, run against + * real loopback HTTP servers (nothing mocked): the bearer token, stored Access + * JWT, and service-token env vars ride outgoing requests as headers; responses + * blocked by Cloudflare's edge (403 + cf-mitigated, or an Access login page) + * fail with a re-auth hint; and other failures surface as ApiError with the + * extracted error text instead of leaking an HTML page into the terminal. */ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; @@ -13,12 +14,16 @@ import * as BunContext from "@effect/platform-bun/BunContext"; import * as FetchHttpClient from "@effect/platform/FetchHttpClient"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { apiRequest } from "../src/api"; +import { apiClient, mapApiErrors } from "../src/api"; import { writeAuthToken } from "../src/auth"; const TestLayer = Layer.mergeAll(FetchHttpClient.layer, BunContext.layer); const run = (effect) => Effect.runPromise(Effect.provide(effect, TestLayer)); +/** Calls GET /api/me through the derived client. */ +const callMe = (origin, options = {}) => + Effect.flatMap(apiClient(origin, options), (client) => client.me()); + let home; let previousHome; beforeAll(() => { @@ -32,38 +37,51 @@ afterAll(() => { rmSync(home, { recursive: true, force: true }); }); -/** Serves one canned response for the duration of a test. */ +/** Serves one canned response for the duration of a test, capturing request headers. */ function withServer(handler, use) { - const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: handler }); + const seen = { headers: null }; + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: (request) => { + seen.headers = Object.fromEntries(request.headers); + return handler(request); + }, + }); const origin = `http://127.0.0.1:${server.port}`; - return Promise.resolve(use(origin)).finally(() => server.stop(true)); + return Promise.resolve(use(origin, seen)).finally(() => server.stop(true)); } -describe("apiRequest Cloudflare Access headers", () => { +const ME_BODY = { authenticated: true, user: { id: "u1", email: "a@b.co" } }; + +describe("apiClient Cloudflare Access headers", () => { afterEach(() => { delete process.env.SCRATCHWORK_CF_ACCESS_CLIENT_ID; delete process.env.SCRATCHWORK_CF_ACCESS_CLIENT_SECRET; }); - test("sends the stored Access JWT as cf-access-token", async () => { + test("sends the bearer token and the stored Access JWT as cf-access-token", async () => { await withServer( - (request) => Response.json({ headers: Object.fromEntries(request.headers) }), - async (origin) => { + () => Response.json(ME_BODY), + async (origin, seen) => { await run(writeAuthToken(origin, "bearer-1", "a@b.co", "cf-jwt-1")); - const response = await run(apiRequest("scratchwork test", `${origin}/api/me`, { token: "bearer-1" })); - expect(response.json.headers["cf-access-token"]).toBe("cf-jwt-1"); - expect(response.json.headers.authorization).toBe("Bearer bearer-1"); + const body = await run(callMe(origin, { token: "bearer-1" })); + expect(seen.headers["cf-access-token"]).toBe("cf-jwt-1"); + expect(seen.headers.authorization).toBe("Bearer bearer-1"); + // The 2xx body is decoded through the shared contract schema. + expect(body).toEqual(ME_BODY); }, ); }); test("sends nothing extra when no Access JWT is stored for the origin", async () => { await withServer( - (request) => Response.json({ headers: Object.fromEntries(request.headers) }), - async (origin) => { - const response = await run(apiRequest("scratchwork test", `${origin}/api/me`, {})); - expect(response.json.headers["cf-access-token"]).toBeUndefined(); - expect(response.json.headers["cf-access-client-id"]).toBeUndefined(); + () => Response.json(ME_BODY), + async (origin, seen) => { + await run(callMe(origin)); + expect(seen.headers["cf-access-token"]).toBeUndefined(); + expect(seen.headers["cf-access-client-id"]).toBeUndefined(); + expect(seen.headers.authorization).toBeUndefined(); }, ); }); @@ -72,11 +90,11 @@ describe("apiRequest Cloudflare Access headers", () => { process.env.SCRATCHWORK_CF_ACCESS_CLIENT_ID = "svc-id"; process.env.SCRATCHWORK_CF_ACCESS_CLIENT_SECRET = "svc-secret"; await withServer( - (request) => Response.json({ headers: Object.fromEntries(request.headers) }), - async (origin) => { - const response = await run(apiRequest("scratchwork test", `${origin}/api/me`, {})); - expect(response.json.headers["cf-access-client-id"]).toBe("svc-id"); - expect(response.json.headers["cf-access-client-secret"]).toBe("svc-secret"); + () => Response.json(ME_BODY), + async (origin, seen) => { + await run(callMe(origin)); + expect(seen.headers["cf-access-client-id"]).toBe("svc-id"); + expect(seen.headers["cf-access-client-secret"]).toBe("svc-secret"); }, ); }); @@ -84,21 +102,26 @@ describe("apiRequest Cloudflare Access headers", () => { test("ignores a service-token id without a secret", async () => { process.env.SCRATCHWORK_CF_ACCESS_CLIENT_ID = "svc-id"; await withServer( - (request) => Response.json({ headers: Object.fromEntries(request.headers) }), - async (origin) => { - const response = await run(apiRequest("scratchwork test", `${origin}/api/me`, {})); - expect(response.json.headers["cf-access-client-id"]).toBeUndefined(); + () => Response.json(ME_BODY), + async (origin, seen) => { + await run(callMe(origin)); + expect(seen.headers["cf-access-client-id"]).toBeUndefined(); }, ); }); }); -describe("apiRequest Cloudflare edge-block detection", () => { +describe("apiClient Cloudflare edge-block detection", () => { test("a blocked CLI exchange explains the required narrow Access bypass", async () => { await withServer( () => new Response("Forbidden", { status: 403, headers: { "cf-mitigated": "challenge" } }), async (origin) => { - await expect(run(apiRequest("scratchwork login", `${origin}/auth/cli/token`, { method: "POST" }))) + const exchange = Effect.flatMap(apiClient(origin), (client) => + client["cli-token-exchange"]({ + payload: { code: "not-a-real-code", codeVerifier: "v".repeat(43), redirectUri: "http://127.0.0.1:1/cb" }, + }), + ).pipe(mapApiErrors("scratchwork login")); + await expect(run(exchange)) .rejects.toThrow("configure a Bypass / Everyone policy limited to `/auth/cli/token`"); }, ); @@ -108,7 +131,7 @@ describe("apiRequest Cloudflare edge-block detection", () => { await withServer( () => new Response("Forbidden", { status: 403, headers: { "cf-mitigated": "challenge" } }), async (origin) => { - await expect(run(apiRequest("scratchwork publish", `${origin}/api/publish`, {}))) + await expect(run(callMe(origin).pipe(mapApiErrors("scratchwork publish")))) .rejects.toThrow("Cloudflare Access blocked this request. Run `scratchwork login` again"); }, ); @@ -119,19 +142,21 @@ describe("apiRequest Cloudflare edge-block detection", () => { await withServer( () => new Response(loginPage, { headers: { "content-type": "text/html" } }), async (origin) => { - await expect(run(apiRequest("scratchwork projects", `${origin}/api/projects`, {}))) - .rejects.toThrow("Cloudflare Access blocked this request"); + const list = Effect.flatMap(apiClient(origin), (client) => client["projects-list"]()) + .pipe(mapApiErrors("scratchwork projects")); + await expect(run(list)).rejects.toThrow("Cloudflare Access blocked this request"); }, ); }); - test("an ordinary 403 from the server is returned for the caller to interpret", async () => { + test("an ordinary error envelope surfaces as ApiError with its status and message", async () => { await withServer( () => Response.json({ error: "Forbidden" }, { status: 403 }), async (origin) => { - const response = await run(apiRequest("scratchwork test", `${origin}/api/me`, {})); - expect(response.status).toBe(403); - expect(response.json).toEqual({ error: "Forbidden" }); + const error = await run(callMe(origin).pipe(Effect.flip)); + expect(error._tag).toBe("ApiError"); + expect(error.status).toBe(403); + expect(error.message).toBe("Forbidden"); }, ); }); @@ -143,8 +168,10 @@ describe("apiRequest Cloudflare edge-block detection", () => { headers: { "content-type": "text/html" }, }), async (origin) => { - const response = await run(apiRequest("scratchwork test", `${origin}/api/me`, {})); - expect(response.status).toBe(502); + const error = await run(callMe(origin).pipe(Effect.flip)); + expect(error._tag).toBe("ApiError"); + expect(error.status).toBe(502); + expect(error.message).toBe("server returned 502: 502 Bad Gateway"); }, ); }); diff --git a/notes/improve-harness-plan.md b/notes/improve-harness-plan.md index a851e72..23c9577 100644 --- a/notes/improve-harness-plan.md +++ b/notes/improve-harness-plan.md @@ -119,7 +119,7 @@ Six invariants (registry below). They live **directly in root `AGENTS.md`** — `[x]` A `check-invariants` skill (`.claude/skills/check-invariants/SKILL.md`) for the agent-pass residue: diff against main → report obeys/violates with file:line evidence. Claude-only convenience; Codex and OpenCode rely on the AGENTS.md standing rule + CI. -`[ ]` Stretch: migrate the shared CLI API contract to `@effect/platform` `HttpApi` (see invariant 2) — the structural fix that makes contract drift impossible. Auth callbacks, published-content routes, health checks, and other server-only endpoints remain server-owned. +`[x]` Stretch: migrate the shared CLI API contract to `@effect/platform` `HttpApi` (see invariant 2) — the structural fix that makes contract drift impossible. Auth callbacks, published-content routes, and other browser-facing endpoints remain server-owned. *(Landed: `ScratchworkApi` in `shared/src/publish/api.ts` declares every JSON endpoint once; the CLI client is `HttpApiClient`-derived (`cli/src/api.ts`, transport concerns — bearer/CF-Access headers, edge-block detection, `{error}`-envelope extraction — in one transformed HttpClient), and the server registry joins the contract endpoints with a mapped-type policy record (`server/core/src/api-routes.ts`) that strictly decodes payloads through and encodes responses with the contract schemas. The bespoke dispatcher stays — `HttpApiBuilder`'s router semantics (400 on bad path params, 404 on method mismatch, its own error envelope) conflict with the masking/405/origin behavior the policy matrix pins. The `/api/me` response gained a shared `MeResponseSchema`; the share request schema moved to shared; check-boundaries §3 became the no-hand-built-URLs check.)* ### Phase 6 — Housekeeping diff --git a/scripts/check-boundaries.ts b/scripts/check-boundaries.ts index 72408cf..d19a758 100644 --- a/scripts/check-boundaries.ts +++ b/scripts/check-boundaries.ts @@ -11,10 +11,13 @@ * 2. Import boundary: cli never imports server and vice versa (the only code * importable by both is shared); shared imports neither; renderer/src is * standalone plain browser JS and imports none of them. - * 3. CLI route inventory: every server route the CLI calls is listed below - * with the shared schemas defining its wire contract, and those schemas - * are exported by shared/src/publish/api.ts — so a newly added CLI call - * cannot ship without a shared contract entry. + * 3. CLI API surface: the CLI reaches every JSON endpoint through the client + * derived from the shared HttpApi contract (ScratchworkApi in + * shared/src/publish/api.ts, consumed by cli/src/api.ts), so a route and + * its wire schemas cannot exist outside the contract. The only API-shaped + * URLs the CLI may build by hand are the browser navigations on the + * explicit allowlist below — any other /api/ or /auth/ literal in cli/src + * fails this check. */ import { readFileSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; @@ -46,31 +49,15 @@ const ASYNC_BOUNDARIES: Readonly> = { }; /** - * Invariant 2's explicit inventory of server routes the CLI consumes. `route` - * is either a path literal as it appears in cli/src, a constant name imported - * from shared, or a `/api/projects/:project` suffix route built through - * projectApiUrl. Every schema name must be exported by - * shared/src/publish/api.ts. Adding a CLI call to a route not listed here - * fails this check; so does a stale entry for a call that no longer exists. + * Invariant 2's allowlist of API-shaped URLs the CLI may build by hand: only + * browser navigations, which redirect rather than answer JSON, and so have no + * wire schema to share. Every JSON call must go through the contract-derived + * client instead. A stale entry for a URL no longer built fails this check. */ -const CLI_ROUTES: ReadonlyArray<{ route: string; schemas: readonly string[]; note: string }> = [ - { route: "/auth/login", schemas: [], note: "browser navigation that starts the login flow; redirects, not JSON" }, - { route: "CLI_TOKEN_EXCHANGE_PATH", schemas: ["CliTokenRequestSchema", "CliTokenResponseSchema"], note: "back-channel code + PKCE exchange" }, - { route: "/api/publish", schemas: ["PublishRequestBodySchema", "PublishResponseSchema"], note: "publish a bundle" }, - { route: "/api/me", schemas: [], note: "identity echo printed verbatim as JSON; no decoded contract" }, - { route: "/api/projects", schemas: ["ProjectsListResponseSchema"], note: "list the caller's projects" }, - { route: "/api/resolve", schemas: ["ProjectResponseSchema"], note: "resolve a published content path to its project" }, - { route: "/api/projects/:project", schemas: ["ProjectResponseSchema"], note: "project info (GET) and delete (DELETE)" }, - { route: "/api/projects/:project/unpublish", schemas: ["ProjectResponseSchema"], note: "make private and clear every grant" }, - { route: "/api/projects/:project/share", schemas: ["ShareResponseSchema"], note: "grant or revoke access" }, - { route: "/api/projects/:project/bundle", schemas: ["PublishBundleSchema"], note: "download a project's bundle (clone)" }, - { route: "error envelope", schemas: ["ApiErrorBodySchema"], note: "the {error} body every non-2xx JSON response carries" }, +const CLI_URL_ALLOWLIST: ReadonlyArray<{ route: string; file: string; note: string }> = [ + { route: "/auth/login", file: "cli/src/auth.ts", note: "browser navigation that starts the login flow; redirects, not JSON" }, ]; -/** The one place the parametrized project route is assembled from a template. */ -const PROJECT_ROUTE_BUILDER_FILE = "cli/src/api.ts"; -const PROJECT_ROUTE_BUILDER_PREFIX = "/api/projects/"; - const failures: string[] = []; /** One string literal as the lexer saw it. For a template literal, `value` is @@ -304,63 +291,46 @@ for (const file of importScope) { } } -// ─── 3. CLI route inventory ───────────────────────────────────────────────── +// ─── 3. CLI API surface ───────────────────────────────────────────────────── const cliSources = sources(["cli/src/**/*.ts"]); -const usedRoutes = new Map(); // route → first use site -const recordRoute = (route: string, site: string) => { - if (!usedRoutes.has(route)) usedRoutes.set(route, site); -}; - +const allowlistSeen = new Set(); for (const file of cliSources) { const lexed = lex(read(file)); - // Path-shaped string literals (catches raw fetches too). A template's value - // is its text before the first interpolation, so the projectApiUrl builder - // template surfaces as its "/api/projects/" prefix. + // Path-shaped string literals (catches raw fetches too). Every JSON route + // lives in the shared contract, so cli/src may only hand-build the browser + // navigations on the allowlist. for (const literal of lexed.strings) { if (!/^\/(?:api|auth)(?:\/|$)/.test(literal.value)) continue; - if (literal.value === PROJECT_ROUTE_BUILDER_PREFIX && literal.template && file === PROJECT_ROUTE_BUILDER_FILE) continue; - recordRoute(literal.value, `${file}:${literal.line}`); - } - lexed.code.split("\n").forEach((line, index) => { - const site = `${file}:${index + 1}`; - // Routes built through projectApiUrl(ref) / projectApiUrl(ref, "/suffix"). - if (file !== PROJECT_ROUTE_BUILDER_FILE) { - for (const match of line.matchAll(/projectApiUrl\(\s*[^,()]+\s*(?:,\s*"([^"]*)")?\s*\)/g)) { - recordRoute(`/api/projects/:project${match[1] ?? ""}`, site); - } + const allowed = CLI_URL_ALLOWLIST.find((entry) => entry.route === literal.value && entry.file === file); + if (allowed != null) { + allowlistSeen.add(allowed.route); + continue; } - // Route constants imported from shared and passed to serverApiUrl. - for (const match of line.matchAll(/serverApiUrl\(\s*[^,()]+,\s*([A-Za-z_$][\w$]*)\s*\)/g)) { - recordRoute(match[1], site); - } - }); -} - -const inventory = new Set(CLI_ROUTES.map((entry) => entry.route)); -for (const [route, site] of usedRoutes) { - if (!inventory.has(route)) { failures.push( - `CLI route inventory (invariant 2): ${site} calls unlisted route "${route}". Define its request/response\n` + - " schemas in shared/src/publish/api.ts and add the route to CLI_ROUTES in scripts/check-boundaries.ts.", + `CLI API surface (invariant 2): ${file}:${literal.line} hand-builds the URL "${literal.value}". Declare the\n` + + " endpoint on ScratchworkApi in shared/src/publish/api.ts and call it through the derived client\n" + + " (apiClient in cli/src/api.ts), or — only for a browser navigation — add it to CLI_URL_ALLOWLIST\n" + + " in scripts/check-boundaries.ts with its rationale, in the same PR.", ); } } -for (const entry of CLI_ROUTES) { - if (entry.route === "error envelope") continue; - if (!usedRoutes.has(entry.route)) { - failures.push(`CLI route inventory: no CLI call to "${entry.route}" found — remove its stale CLI_ROUTES entry.`); +for (const entry of CLI_URL_ALLOWLIST) { + if (!allowlistSeen.has(entry.route)) { + failures.push(`CLI API surface: "${entry.route}" is no longer built in ${entry.file} — remove its stale allowlist entry.`); } } + +// The contract object both sides derive from must exist, define endpoints, +// and be what the CLI's client module actually consumes. const sharedApi: Record = await import(join(root, "shared/src/publish/api.ts")); -for (const entry of CLI_ROUTES) { - for (const name of entry.schemas) { - if (!(name in sharedApi)) { - failures.push( - `CLI route inventory: schema ${name} (route "${entry.route}") is not exported by shared/src/publish/api.ts.`, - ); - } - } +const contract = sharedApi.ScratchworkApi as { groups?: Record }> } | undefined; +const contractEndpoints = Object.values(contract?.groups ?? {}).flatMap((group) => Object.keys(group.endpoints)); +if (contractEndpoints.length === 0) { + failures.push("CLI API surface: shared/src/publish/api.ts no longer exports the ScratchworkApi contract with endpoints."); +} +if (!/\bScratchworkApi\b/.test(lex(read("cli/src/api.ts")).code)) { + failures.push("CLI API surface: cli/src/api.ts no longer derives its client from the shared ScratchworkApi contract."); } // ─── Report ───────────────────────────────────────────────────────────────── @@ -372,5 +342,5 @@ if (failures.length > 0) { } console.log( `check-boundaries: ${effectScope.length} files Effect-clean (${boundariesSeen.size} reviewed boundaries), ` + - `${importScope.length} files respect import boundaries, ${usedRoutes.size} CLI routes match the inventory.`, + `${importScope.length} files respect import boundaries, ${contractEndpoints.length} contract endpoints drive the CLI API surface.`, ); diff --git a/server/core/src/api-routes.ts b/server/core/src/api-routes.ts index d29be8f..e968007 100644 --- a/server/core/src/api-routes.ts +++ b/server/core/src/api-routes.ts @@ -1,12 +1,18 @@ /** - * The server-owned API route-policy registry (AGENTS.md, invariant 4). Every - * JSON endpoint is defined exactly once in API_ROUTES: its method and path, - * how the caller authenticates, the minimum project role it demands, whether - * it mutates state, and what its response may reveal. The dispatcher below is - * the only way an API request reaches a handler, and it derives its behavior - * from the same definitions the policy test matrix enumerates — so an - * unregistered or policy-less route cannot exist, and unspecified - * credential/role combinations are denied by construction: + * The server-owned API route-policy registry (AGENTS.md, invariant 4), keyed + * by the shared HttpApi contract (invariant 2). The contract object + * (ScratchworkApi in shared/src/publish/api.ts) is the single definition of + * every JSON endpoint's method, path, request payload, and success schema; + * API_POLICY below attaches the server's security policy and handler to every + * contract endpoint — the mapped type makes a contract endpoint without a + * policy (or a policy for a nonexistent endpoint) a compile error, and each + * handler's return type is checked against and encoded through the endpoint's + * declared success schema, so a response that drifts from the contract cannot + * typecheck. The dispatcher below is the only way an API request reaches a + * handler, and it derives its match table from the same definitions the + * policy test matrix enumerates — so an unregistered or policy-less route + * cannot exist, and unspecified credential/role combinations are denied by + * construction: * * - every route rejects cross-origin browser calls before anything else; * - `auth: "bearer"` resolves the principal or fails 401 before the handler; @@ -15,8 +21,12 @@ * reading as 404 so unauthorized callers cannot probe which projects * exist; roles above "read" are enforced by the SiteStore operation the * handler calls (they are coupled to its conditional writes) and are - * declared here so the policy matrix can assert them end-to-end. + * declared here so the policy matrix can assert them end-to-end; + * - request bodies are size-capped per route and decoded strictly (errors: + * "all", onExcessProperty: "error") through the contract payload schema + * before the handler runs. */ +import type * as HttpApiEndpoint from "@effect/platform/HttpApiEndpoint"; import * as HttpServerRequest from "@effect/platform/HttpServerRequest"; import type * as HttpServerResponse from "@effect/platform/HttpServerResponse"; import * as Effect from "effect/Effect"; @@ -24,14 +34,11 @@ import * as Option from "effect/Option"; import * as ParseResult from "effect/ParseResult"; import * as Schema from "effect/Schema"; import { - CliTokenRequestSchema, - type CliTokenRequest, - type CliTokenResponse, + ScratchworkApi, + type EndpointPayload, + type EndpointSuccess, type ProjectInfo, - type ProjectResponse, - type ProjectsListResponse, - type PublishResponse, - type ShareResponse, + type ScratchworkEndpointName, } from "../../../shared/src/publish/api"; import { accessGroupTerms, isSafeProjectIdentifier } from "./access"; import { @@ -54,9 +61,9 @@ import { publishedUrl, rejectCrossOriginApiRequest, } from "./http"; -import { readPublishRequest } from "./publish-request"; +import { MAX_PUBLISH_BODY_BYTES, normalizePublishRequest } from "./publish-request"; import { projectForRequest } from "./routes"; -import { readShareRequest } from "./share-request"; +import { MAX_SHARE_BODY_BYTES, validateShareChanges } from "./share-request"; import { type SiteRecord } from "./site-records"; import { canReadProject, @@ -69,17 +76,17 @@ import { } from "./site-store"; import { StorageError } from "./storage"; -/** Effect type shared by every API handler. */ -type ApiEffect = Effect.Effect< - HttpServerResponse.HttpServerResponse, - HttpError | AuthError | SiteStoreError | StorageError, - ServerConfig | SiteStore | Auth | PrimitiveDb ->; +/** Failures any API handler may raise. */ +type RouteError = HttpError | AuthError | SiteStoreError | StorageError; + +/** Services available to every API handler. */ +type RouteServices = ServerConfig | SiteStore | Auth | PrimitiveDb; /** What the policy middleware hands a handler: the request, its parsed URL, - * the principal the declared auth mode resolved, and — for project routes — - * the read-gated project capability. Handlers never reconstruct these. */ -export interface ApiContext { + * the principal the declared auth mode resolved, for project routes the + * read-gated project capability, and for payload endpoints the strictly + * decoded body. Handlers never reconstruct these. */ +export interface ApiContext { readonly request: HttpServerRequest.HttpServerRequest; readonly url: URL; /** The authenticated principal: non-null for `auth: "bearer"` routes, the @@ -89,15 +96,22 @@ export interface ApiContext { /** The loaded project for `/api/projects/:project` routes, already gated by * the read-access mask; null on fixed-path routes. */ readonly site: LoadedSite | null; + /** The request body, size-capped and strictly decoded through the contract + * payload schema; `never` for endpoints that declare no payload. */ + readonly payload: EndpointPayload; } -/** One registered API route and its complete security policy. */ -export interface ApiRoute { - /** Stable route name used by the policy test matrix. */ - readonly name: string; - readonly method: "GET" | "POST" | "DELETE"; - /** Fixed pathname, or the project-parametrized form "/api/projects/:project(/action)". */ - readonly path: string; +/** The size cap applied while reading a payload endpoint's request body. */ +interface PayloadLimit { + readonly maxBytes: number; + /** The message of the 413 an oversized body receives. */ + readonly message: string; +} + +/** The security policy and handler attached to one contract endpoint. The + * handler must return the endpoint's declared success type; endpoints that + * declare a payload must declare a body size cap. */ +type ApiPolicy = { /** * How the caller authenticates. "bearer": a valid bearer session token or * 401. "optional": bearer/cookie identity if present, anonymous otherwise. @@ -130,7 +144,29 @@ export interface ApiRoute { | "project-content" | "session-token" | "status"; - readonly handler: (context: ApiContext) => ApiEffect; + readonly handler: (context: ApiContext) => Effect.Effect, RouteError, RouteServices>; +} & ([EndpointPayload] extends [never] ? { readonly payloadLimit?: undefined } + : { readonly payloadLimit: PayloadLimit }); + +/** One registered API route: a contract endpoint joined with its policy. */ +export interface ApiRoute { + /** Stable route name (the contract endpoint name) used by the policy test matrix. */ + readonly name: ScratchworkEndpointName; + readonly method: string; + /** The contract path: fixed, or the project-parametrized form + * "/api/projects/:project(/action)". */ + readonly path: string; + readonly auth: ApiPolicy["auth"]; + readonly minimumRole: ProjectRole | null; + readonly mutation: boolean; + readonly visibility: ApiPolicy["visibility"]; +} + +/** An ApiRoute with the internals dispatch needs (type-erased handler). */ +interface RegisteredRoute extends ApiRoute { + readonly endpoint: HttpApiEndpoint.HttpApiEndpoint.AnyWithProps; + readonly payloadLimit: PayloadLimit | undefined; + readonly handler: (context: ApiContext) => Effect.Effect; } /** Namespace of the one-time CLI code redemption records. Records are tiny (one per @@ -146,20 +182,19 @@ const MAX_CLI_TOKEN_BODY_BYTES = 64 * 1024; /** Handles `POST /auth/cli/token`: the back-channel exchange of a one-time CLI * authorization code plus PKCE verifier for a bearer token. */ -function exchangeCliToken({ request }: ApiContext): ApiEffect { +function exchangeCliToken({ request, payload }: ApiContext<"cli-token-exchange">) { return Effect.gen(function* () { const config = yield* ServerConfig; const db = yield* PrimitiveDb; - const body = yield* readCliTokenRequest(request); - const payload = yield* decodeCliAuthorizationCode(body.code, config.auth); + const code = yield* decodeCliAuthorizationCode(payload.code, config.auth); // Burn the code before checking possession: the first redemption attempt // consumes it, so an intercepted code that is replayed — or raced with a wrong // verifier — fails closed instead of staying redeemable within its lifetime. yield* db.put( CLI_CODE_NAMESPACE, - payload.id, + code.id, { redeemedAt: Math.floor(Date.now() / 1000) }, - { ifNoneMatch: "*", expiresAt: payload.expiresAt }, + { ifNoneMatch: "*", expiresAt: code.expiresAt }, ).pipe( Effect.mapError((error) => error._tag === "PrimitiveDbConflict" @@ -167,75 +202,51 @@ function exchangeCliToken({ request }: ApiContext): ApiEffect { : new HttpError({ status: 500, message: "Could not record the code redemption" }), ), ); - const user = yield* verifyCliCodeExchange(payload, body.codeVerifier, body.redirectUri, config.auth); - const cfToken = yield* decryptCliCloudflareToken(payload, config.auth); + const user = yield* verifyCliCodeExchange(code, payload.codeVerifier, payload.redirectUri, config.auth); + const cfToken = yield* decryptCliCloudflareToken(code, config.auth); const token = yield* createSessionToken(user, config.auth); - const response: CliTokenResponse = { + return { token, server: appBaseUrl(request, config), email: user.email, ...(cfToken != null ? { cfToken } : {}), }; - return jsonResponse(response, 200); - }); -} - -/** Reads, size-limits, and strictly decodes the CLI token-exchange body. */ -function readCliTokenRequest( - request: HttpServerRequest.HttpServerRequest, -): Effect.Effect { - return Effect.gen(function* () { - const text = yield* request.text.pipe( - HttpServerRequest.withMaxBodySize(Option.some(MAX_CLI_TOKEN_BODY_BYTES)), - Effect.mapError((cause) => new HttpError({ status: 413, message: "Request body is too large", cause })), - ); - const parsed = yield* Schema.decodeUnknown(Schema.parseJson())(text).pipe( - Effect.mapError(() => new HttpError({ status: 400, message: "Invalid JSON body" })), - ); - return yield* Schema.decodeUnknown(CliTokenRequestSchema)(parsed, { - errors: "all", - onExcessProperty: "error", - }).pipe( - Effect.mapError((error) => - new HttpError({ status: 400, message: ParseResult.TreeFormatter.formatErrorSync(error) }), - ), - ); }); } /** Handles `GET /api/me`: reports the caller's own authentication state. */ -function me({ user }: ApiContext): ApiEffect { - return Effect.succeed(jsonResponse({ authenticated: user != null, user }, 200)); +function me({ user }: ApiContext<"me">) { + return Effect.succeed({ authenticated: user != null, user }); } /** Handles `GET /health`. */ -function health(): ApiEffect { - return Effect.succeed(jsonResponse({ ok: true }, 200)); +function health() { + return Effect.succeed({ ok: true }); } /** Handles `POST /api/publish` through bearer auth and SiteStore (which * enforces write — and admin for a public/private flip — on updates). */ -function publish({ request, user }: ApiContext): ApiEffect { +function publish({ request, user, payload }: ApiContext<"publish">) { return Effect.gen(function* () { const config = yield* ServerConfig; - const publishRequest = yield* readPublishRequest(request); + const publishRequest = yield* normalizePublishRequest(payload); const siteStore = yield* SiteStore; const result = yield* siteStore.publish(publishRequest, user!, config); const url = publishedUrl(contentBaseUrl(request, config), result.project, result.openPath, config); - return jsonResponse({ ...result, url } satisfies PublishResponse, 200); + return { ...result, url }; }); } /** Handles `GET /api/projects`: the authenticated user's own project index. */ -function listProjects({ request, user }: ApiContext): ApiEffect { +function listProjects({ request, user }: ApiContext<"projects-list">) { return Effect.gen(function* () { const config = yield* ServerConfig; const siteStore = yield* SiteStore; const projects = yield* siteStore.listProjects(user!); const contentBase = contentBaseUrl(request, config); - return jsonResponse({ + return { projects: projects.map((project) => projectSummary(project, contentBase, projectRole(project, user, config), config)), - } satisfies ProjectsListResponse, 200); + }; }); } @@ -244,7 +255,7 @@ function listProjects({ request, user }: ApiContext): ApiEffect { * authorization, and URL-to-project resolution stay centralized. The project * subject comes from the query string, so the read gate runs here rather than * in the dispatcher — same mask, same policy. */ -function resolveProjectPath({ request, url, user }: ApiContext): ApiEffect { +function resolveProjectPath({ request, url, user }: ApiContext<"resolve">) { return Effect.gen(function* () { const path = url.searchParams.get("path"); if (path == null || !path.startsWith("/")) { @@ -255,64 +266,64 @@ function resolveProjectPath({ request, url, user }: ApiContext): ApiEffect { const project = projectForRequest(path); const loaded = project == null ? null : yield* siteStore.loadProject(project); const site = yield* requireReadableSite(loaded, user, config); - return jsonResponse({ + return { project: projectSummary(site.record, contentBaseUrl(request, config), projectRole(site.record, user, config), config), - } satisfies ProjectResponse, 200); + }; }); } /** Handles `GET /api/projects/:project`. */ -function projectInfo({ request, user, site }: ApiContext): ApiEffect { +function projectInfo({ request, user, site }: ApiContext<"project-info">) { return Effect.gen(function* () { const config = yield* ServerConfig; - return jsonResponse({ + return { project: projectSummary(site!.record, contentBaseUrl(request, config), projectRole(site!.record, user, config), config), - } satisfies ProjectResponse, 200); + }; }); } /** Handles `GET /api/projects/:project/bundle` for clone/read workflows. */ -function projectBundle({ site }: ApiContext): ApiEffect { +function projectBundle({ site }: ApiContext<"project-bundle">) { return Effect.gen(function* () { const siteStore = yield* SiteStore; const bundle = yield* siteStore.bundle(site!.record.project); if (bundle == null) return yield* Effect.fail(new HttpError({ status: 404, message: "Project not found" })); - return jsonResponse({ bundle }, 200); + return { bundle }; }); } /** Handles `POST /api/projects/:project/unpublish` (SiteStore enforces admin). */ -function unpublishProject({ request, user, site }: ApiContext): ApiEffect { +function unpublishProject({ request, user, site }: ApiContext<"project-unpublish">) { return Effect.gen(function* () { const config = yield* ServerConfig; const siteStore = yield* SiteStore; const record = yield* siteStore.unpublish(site!.record.project, user!, config); - return jsonResponse({ + return { project: projectSummary(record, contentBaseUrl(request, config), projectRole(record, user, config), config), - } satisfies ProjectResponse, 200); + }; }); } /** Handles `POST /api/projects/:project/share` (SiteStore enforces admin). */ -function shareProject({ request, user, site }: ApiContext): ApiEffect { +function shareProject({ request, user, site, payload }: ApiContext<"project-share">) { return Effect.gen(function* () { const config = yield* ServerConfig; - const changes = yield* readShareRequest(request); + const changes = yield* validateShareChanges(payload); const siteStore = yield* SiteStore; const result = yield* siteStore.share(site!.record.project, user!, changes, config); - return jsonResponse({ + return { project: projectSummary(result.record, contentBaseUrl(request, config), projectRole(result.record, user, config), config), warnings: result.warnings, - } satisfies ShareResponse, 200); + }; }); } /** Handles `DELETE /api/projects/:project` (SiteStore enforces owner). */ -function deleteProject({ user, site }: ApiContext): ApiEffect { +function deleteProject({ user, site }: ApiContext<"project-delete">) { return Effect.gen(function* () { const siteStore = yield* SiteStore; yield* siteStore.deleteProject(site!.record.project, user!); - return jsonResponse({ ok: true }, 200); + return { ok: true }; }); } @@ -320,20 +331,70 @@ function deleteProject({ user, site }: ApiContext): ApiEffect { // The registry // --------------------------------------------------------------------------- +/** + * The complete security policy for every contract endpoint. The mapped type + * is the exhaustiveness proof: a new endpoint added to ScratchworkApi will + * not compile until it gets a policy here, and a policy for a removed + * endpoint is a compile error too. + */ +const API_POLICY: { readonly [Name in ScratchworkEndpointName]: ApiPolicy } = { + health: { auth: "optional", minimumRole: null, mutation: false, visibility: "status", handler: health }, + "cli-token-exchange": { + auth: "code-exchange", + minimumRole: null, + mutation: true, + visibility: "session-token", + payloadLimit: { maxBytes: MAX_CLI_TOKEN_BODY_BYTES, message: "Request body is too large" }, + handler: exchangeCliToken, + }, + me: { auth: "optional", minimumRole: null, mutation: false, visibility: "identity", handler: me }, + publish: { + auth: "bearer", + minimumRole: "write", + mutation: true, + visibility: "project-summary", + payloadLimit: { maxBytes: MAX_PUBLISH_BODY_BYTES, message: "Publish body is too large" }, + handler: publish, + }, + "projects-list": { auth: "bearer", minimumRole: null, mutation: false, visibility: "own-projects", handler: listProjects }, + resolve: { auth: "bearer", minimumRole: "read", mutation: false, visibility: "project-summary", handler: resolveProjectPath }, + "project-info": { auth: "bearer", minimumRole: "read", mutation: false, visibility: "project-summary", handler: projectInfo }, + "project-bundle": { auth: "bearer", minimumRole: "read", mutation: false, visibility: "project-content", handler: projectBundle }, + "project-unpublish": { auth: "bearer", minimumRole: "admin", mutation: true, visibility: "project-summary", handler: unpublishProject }, + "project-share": { + auth: "bearer", + minimumRole: "admin", + mutation: true, + visibility: "project-summary", + payloadLimit: { maxBytes: MAX_SHARE_BODY_BYTES, message: "Share body is too large" }, + handler: shareProject, + }, + "project-delete": { auth: "bearer", minimumRole: "owner", mutation: true, visibility: "status", handler: deleteProject }, +}; + +/** The registry: every contract endpoint joined with its policy, in contract + * order. Derived, never hand-listed — the contract is the route inventory. */ +const ROUTES: ReadonlyArray = Object.values(ScratchworkApi.groups).flatMap((group) => + Object.values(group.endpoints).map((endpoint) => { + const name = endpoint.name as ScratchworkEndpointName; + const policy = API_POLICY[name]; + return { + name, + method: endpoint.method, + path: endpoint.path, + auth: policy.auth, + minimumRole: policy.minimumRole, + mutation: policy.mutation, + visibility: policy.visibility, + payloadLimit: policy.payloadLimit, + endpoint: endpoint as unknown as HttpApiEndpoint.HttpApiEndpoint.AnyWithProps, + handler: policy.handler as unknown as RegisteredRoute["handler"], + }; + }), +); + /** Every JSON endpoint this server exposes, with its complete policy. */ -export const API_ROUTES: ReadonlyArray = [ - { name: "health", method: "GET", path: "/health", auth: "optional", minimumRole: null, mutation: false, visibility: "status", handler: health }, - { name: "cli-token-exchange", method: "POST", path: "/auth/cli/token", auth: "code-exchange", minimumRole: null, mutation: true, visibility: "session-token", handler: exchangeCliToken }, - { name: "me", method: "GET", path: "/api/me", auth: "optional", minimumRole: null, mutation: false, visibility: "identity", handler: me }, - { name: "publish", method: "POST", path: "/api/publish", auth: "bearer", minimumRole: "write", mutation: true, visibility: "project-summary", handler: publish }, - { name: "projects-list", method: "GET", path: "/api/projects", auth: "bearer", minimumRole: null, mutation: false, visibility: "own-projects", handler: listProjects }, - { name: "resolve", method: "GET", path: "/api/resolve", auth: "bearer", minimumRole: "read", mutation: false, visibility: "project-summary", handler: resolveProjectPath }, - { name: "project-info", method: "GET", path: "/api/projects/:project", auth: "bearer", minimumRole: "read", mutation: false, visibility: "project-summary", handler: projectInfo }, - { name: "project-bundle", method: "GET", path: "/api/projects/:project/bundle", auth: "bearer", minimumRole: "read", mutation: false, visibility: "project-content", handler: projectBundle }, - { name: "project-unpublish", method: "POST", path: "/api/projects/:project/unpublish", auth: "bearer", minimumRole: "admin", mutation: true, visibility: "project-summary", handler: unpublishProject }, - { name: "project-share", method: "POST", path: "/api/projects/:project/share", auth: "bearer", minimumRole: "admin", mutation: true, visibility: "project-summary", handler: shareProject }, - { name: "project-delete", method: "DELETE", path: "/api/projects/:project", auth: "bearer", minimumRole: "owner", mutation: true, visibility: "status", handler: deleteProject }, -]; +export const API_ROUTES: ReadonlyArray = ROUTES; // --------------------------------------------------------------------------- // Dispatch @@ -355,7 +416,7 @@ export function dispatchApiRoute( ServerConfig | SiteStore | Auth | PrimitiveDb > { return Effect.gen(function* () { - const matches = API_ROUTES + const matches = ROUTES .map((route) => ({ route, params: matchPath(route.path, url.pathname) })) .filter((match) => match.params != null); if (matches.length === 0) { @@ -374,14 +435,15 @@ export function dispatchApiRoute( }); } -/** Applies the declared policy in order — origin, principal, project gate — - * then runs the handler with the resolved context. */ +/** Applies the declared policy in order — origin, principal, project gate, + * payload decode — then runs the handler and encodes its result through the + * endpoint's success schema. */ function runRoute( - route: ApiRoute, + route: RegisteredRoute, project: string | null, request: HttpServerRequest.HttpServerRequest, url: URL, -): ApiEffect { +): Effect.Effect { return Effect.gen(function* () { const config = yield* ServerConfig; yield* rejectCrossOriginApiRequest(request, appBaseUrl(request, config)); @@ -398,7 +460,47 @@ function runRoute( const siteStore = yield* SiteStore; site = yield* requireReadableSite(yield* siteStore.loadProject(project), user, config); } - return yield* route.handler({ request, url, user, site }); + + const payload = Option.isSome(route.endpoint.payloadSchema) && route.payloadLimit != null + ? yield* readEndpointPayload(request, route.endpoint.payloadSchema.value, route.payloadLimit) + : undefined; + + const result = yield* route.handler({ request, url, user, site, payload: payload as never }); + const body = yield* Schema.encodeUnknown(route.endpoint.successSchema as Schema.Schema)(result).pipe( + Effect.mapError((cause) => new HttpError({ status: 500, message: "Could not encode the response", cause })), + ); + return jsonResponse(body, 200); + }); +} + +/** Reads, size-limits, parses, and strictly decodes one request body through + * the contract payload schema. Decoding is deliberately strict — every + * problem is reported and unknown fields are errors — so protocol drift + * surfaces as a clear 400 instead of being silently dropped. */ +function readEndpointPayload( + request: HttpServerRequest.HttpServerRequest, + schema: Schema.Schema.Any, + limit: PayloadLimit, +): Effect.Effect { + return Effect.gen(function* () { + const text = yield* request.text.pipe( + HttpServerRequest.withMaxBodySize(Option.some(limit.maxBytes)), + Effect.mapError((cause) => new HttpError({ status: 413, message: limit.message, cause })), + ); + if (new TextEncoder().encode(text).byteLength > limit.maxBytes) { + return yield* Effect.fail(new HttpError({ status: 413, message: limit.message })); + } + const parsed = yield* Schema.decodeUnknown(Schema.parseJson())(text).pipe( + Effect.mapError(() => new HttpError({ status: 400, message: "Invalid JSON body" })), + ); + return yield* Schema.decodeUnknown(schema as Schema.Schema)(parsed, { + errors: "all", + onExcessProperty: "error", + }).pipe( + Effect.mapError((error) => + new HttpError({ status: 400, message: ParseResult.TreeFormatter.formatErrorSync(error) }), + ), + ); }); } diff --git a/server/core/src/publish-request.ts b/server/core/src/publish-request.ts index c01ea5f..ca8c4e7 100644 --- a/server/core/src/publish-request.ts +++ b/server/core/src/publish-request.ts @@ -1,10 +1,12 @@ -import * as HttpServerRequest from "@effect/platform/HttpServerRequest"; +/* + * Cross-field validation and normalization for POST /api/publish. The wire + * shape is the shared PublishRequestBodySchema (decoded strictly by the + * dispatcher in api-routes.ts); this module applies the server's size policy + * and normalizes the open path. + */ import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as ParseResult from "effect/ParseResult"; -import * as Schema from "effect/Schema"; import { decodedBase64ByteLength } from "../../../shared/src/encoding/base64"; -import { PublishRequestBodySchema, type PublishRequestBody } from "../../../shared/src/publish/api"; +import { type PublishRequestBody } from "../../../shared/src/publish/api"; import { HttpError } from "./http"; /** Maximum accepted request body size (base64-encoded JSON, larger than the content caps). */ @@ -25,46 +27,8 @@ export interface PublishRequest extends Omit { readonly totalBytes: number; } -/** Reads, size-limits, parses, and validates a publish request body. */ -export function readPublishRequest( - request: HttpServerRequest.HttpServerRequest, -): Effect.Effect { - return Effect.gen(function* () { - const text = yield* request.text.pipe( - HttpServerRequest.withMaxBodySize(Option.some(MAX_PUBLISH_BODY_BYTES)), - Effect.mapError((cause) => - new HttpError({ status: 413, message: "Publish body is too large", cause }), - ), - ); - if (new TextEncoder().encode(text).byteLength > MAX_PUBLISH_BODY_BYTES) { - return yield* Effect.fail(new HttpError({ status: 413, message: "Publish body is too large" })); - } - const parsed = yield* Schema.decodeUnknown(Schema.parseJson())(text).pipe( - Effect.mapError(() => new HttpError({ status: 400, message: "Invalid JSON body" })), - ); - return yield* decodePublishRequest(parsed); - }); -} - -/** Decodes an unknown JSON value into a normalized publish request. Decoding is - * deliberately strict — every problem is reported and unknown fields are errors — - * so protocol drift surfaces as a clear 400 instead of being silently dropped. */ -export function decodePublishRequest(value: unknown): Effect.Effect { - return Effect.gen(function* () { - const raw = yield* Schema.decodeUnknown(PublishRequestBodySchema)(value, { - errors: "all", - onExcessProperty: "error", - }).pipe( - Effect.mapError((error) => - new HttpError({ status: 400, message: ParseResult.TreeFormatter.formatErrorSync(error) }), - ), - ); - return yield* normalizePublishRequest(raw); - }); -} - /** Applies cross-field publish validation and computes decoded bundle size. */ -function normalizePublishRequest(raw: PublishRequestBody): Effect.Effect { +export function normalizePublishRequest(raw: PublishRequestBody): Effect.Effect { return Effect.gen(function* () { if (raw.bundle.files.length === 0) { return yield* Effect.fail(new HttpError({ status: 400, message: "Publish bundle must contain files" })); diff --git a/server/core/src/share-request.ts b/server/core/src/share-request.ts index 8b08299..a105558 100644 --- a/server/core/src/share-request.ts +++ b/server/core/src/share-request.ts @@ -1,13 +1,12 @@ /* - * Body validation for POST /api/projects/:project/share. Only the shape is checked - * here; target grammar (email vs @domain) and sharing policy are enforced by - * access.ts and the site store so every access rule lives in one place. + * Cross-field validation for POST /api/projects/:project/share. The wire + * shape is the shared ShareRequestSchema (decoded strictly by the dispatcher + * in api-routes.ts); target grammar (email vs @domain) and sharing policy are + * enforced by access.ts and the site store so every access rule lives in one + * place. */ -import * as HttpServerRequest from "@effect/platform/HttpServerRequest"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as ParseResult from "effect/ParseResult"; -import * as Schema from "effect/Schema"; +import type { ShareRequest } from "../../../shared/src/publish/api"; import { HttpError } from "./http"; import type { ShareChanges } from "./site-store"; @@ -16,43 +15,14 @@ export const MAX_SHARE_BODY_BYTES = 64 * 1024; /** Maximum add + remove targets in one share call. */ export const MAX_SHARE_TARGETS = 100; -const ShareTargetSchema = Schema.String.pipe( - Schema.filter((value) => value.trim() !== "" || "Share targets must be non-empty"), -); - -const RawShareRequestSchema = Schema.Struct({ - role: Schema.optional(Schema.Literal("read", "write", "admin")), - add: Schema.optional(Schema.Array(ShareTargetSchema)), - remove: Schema.optional(Schema.Array(ShareTargetSchema)), -}); - -/** Reads, size-limits, parses, and validates a share request body. */ -export function readShareRequest( - request: HttpServerRequest.HttpServerRequest, -): Effect.Effect { - return Effect.gen(function* () { - const text = yield* request.text.pipe( - HttpServerRequest.withMaxBodySize(Option.some(MAX_SHARE_BODY_BYTES)), - Effect.mapError((cause) => new HttpError({ status: 413, message: "Share body is too large", cause })), - ); - const parsed = yield* Schema.decodeUnknown(Schema.parseJson())(text).pipe( - Effect.mapError(() => new HttpError({ status: 400, message: "Invalid JSON body" })), - ); - const raw = yield* Schema.decodeUnknown(RawShareRequestSchema)(parsed, { - errors: "all", - onExcessProperty: "error", - }).pipe( - Effect.mapError((error) => - new HttpError({ status: 400, message: ParseResult.TreeFormatter.formatErrorSync(error) }), - ), - ); - const changes: ShareChanges = { role: raw.role ?? "read", add: raw.add ?? [], remove: raw.remove ?? [] }; - if (changes.add.length + changes.remove.length === 0) { - return yield* Effect.fail(new HttpError({ status: 400, message: "Share request must add or remove at least one target" })); - } - if (changes.add.length + changes.remove.length > MAX_SHARE_TARGETS) { - return yield* Effect.fail(new HttpError({ status: 400, message: `Share request exceeds ${MAX_SHARE_TARGETS} targets` })); - } - return changes; - }); +/** Normalizes and cross-field-validates a decoded share request body. */ +export function validateShareChanges(raw: ShareRequest): Effect.Effect { + const changes: ShareChanges = { role: raw.role ?? "read", add: raw.add ?? [], remove: raw.remove ?? [] }; + if (changes.add.length + changes.remove.length === 0) { + return Effect.fail(new HttpError({ status: 400, message: "Share request must add or remove at least one target" })); + } + if (changes.add.length + changes.remove.length > MAX_SHARE_TARGETS) { + return Effect.fail(new HttpError({ status: 400, message: `Share request exceeds ${MAX_SHARE_TARGETS} targets` })); + } + return Effect.succeed(changes); } diff --git a/server/core/test/publish-request.test.ts b/server/core/test/publish-request.test.ts index e001c28..2641596 100644 --- a/server/core/test/publish-request.test.ts +++ b/server/core/test/publish-request.test.ts @@ -1,9 +1,19 @@ import { describe, expect, test } from "bun:test"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; -import { decodePublishRequest, MAX_PUBLISH_FILE_BYTES, MAX_PUBLISH_FILES, MAX_PUBLISH_TOTAL_BYTES } from "../src/publish-request"; +import * as Schema from "effect/Schema"; +import { PublishRequestBodySchema } from "../../../shared/src/publish/api"; +import { MAX_PUBLISH_FILE_BYTES, MAX_PUBLISH_FILES, MAX_PUBLISH_TOTAL_BYTES, normalizePublishRequest } from "../src/publish-request"; import { bundle } from "./helpers"; +/** The production decode path for a publish body: the dispatcher's strict + * contract-schema decode (see readEndpointPayload in api-routes.ts) followed + * by the server's cross-field normalization. */ +const decodePublishRequest = (value: unknown) => + Schema.decodeUnknown(PublishRequestBodySchema)(value, { errors: "all", onExcessProperty: "error" }).pipe( + Effect.flatMap(normalizePublishRequest), + ); + describe("decodePublishRequest", () => { test("decodes a valid request", async () => { const request = await Effect.runPromise(decodePublishRequest({ diff --git a/shared/src/publish/api.ts b/shared/src/publish/api.ts index 0788fb9..35cc615 100644 --- a/shared/src/publish/api.ts +++ b/shared/src/publish/api.ts @@ -1,17 +1,33 @@ /* - * The CLI↔server JSON API contract, as Effect Schemas. Both sides derive - * their types and decoding from these definitions: the server decodes the - * publish request strictly (errors: "all", onExcessProperty: "error") and - * types its responses with the exported types so drift is caught at compile - * time, while the CLI decodes responses with the default (tolerant) options - * so a newer server adding fields never breaks an older CLI. + * The CLI↔server JSON API contract, defined once as an `@effect/platform` + * `HttpApi` (ScratchworkApi, at the bottom of this file). Every JSON endpoint + * is declared here with its method, path, request payload, and success + * schema; the CLI derives its HTTP client from the contract object + * (cli/src/api.ts) and the server derives its route registry, request + * decoding, and response encoding from the same object + * (server/core/src/api-routes.ts) — so the two sides cannot drift. + * + * Decoding conventions: the server decodes request payloads strictly + * (errors: "all", onExcessProperty: "error") so protocol drift surfaces as a + * clear 400, while the CLI decodes responses with the default (tolerant) + * options so a newer server adding fields never breaks an older CLI. */ +import * as HttpApi from "@effect/platform/HttpApi"; +import * as HttpApiEndpoint from "@effect/platform/HttpApiEndpoint"; +import * as HttpApiGroup from "@effect/platform/HttpApiGroup"; +import * as HttpApiSchema from "@effect/platform/HttpApiSchema"; import * as Schema from "effect/Schema"; import { isSafeProjectIdentifier } from "../site/identifiers"; import { PublishBundleSchema } from "./bundle"; export { PublishBundleSchema }; +/** A safe project identifier, as validated everywhere a project name crosses + * the wire (publish bodies and `/api/projects/:project` path segments). */ +export const ProjectIdentifierSchema = Schema.String.pipe( + Schema.filter((project) => isSafeProjectIdentifier(project) || "Invalid project"), +); + /** The JSON body of `POST /api/publish`. `project` is optional at the protocol * level — a random-naming server mints a name when none is sent; `isPublic` is * the public/private toggle (omitted preserves an existing project's setting, @@ -20,7 +36,7 @@ export { PublishBundleSchema }; export const PublishRequestBodySchema = Schema.Struct({ bundle: PublishBundleSchema, openPath: Schema.optional(Schema.String), - project: Schema.optional(Schema.String.pipe(Schema.filter((project) => isSafeProjectIdentifier(project) || "Invalid project"))), + project: Schema.optional(ProjectIdentifierSchema), isPublic: Schema.optional(Schema.Boolean), }); @@ -131,3 +147,122 @@ export const ShareResponseSchema = Schema.Struct({ /** The decoded share envelope. */ export type ShareResponse = typeof ShareResponseSchema.Type; + +/** One share target: an email address or an @domain group. Target grammar and + * sharing policy are enforced server-side so every access rule lives in one + * place; the wire contract only rejects blank strings. */ +const ShareTargetSchema = Schema.String.pipe( + Schema.filter((value) => value.trim() !== "" || "Share targets must be non-empty"), +); + +/** The JSON body of `POST /api/projects/:project/share`: grant `add` targets + * the given role (default read), and strip every role from `remove` targets. */ +export const ShareRequestSchema = Schema.Struct({ + role: Schema.optional(Schema.Literal("read", "write", "admin")), + add: Schema.optional(Schema.Array(ShareTargetSchema)), + remove: Schema.optional(Schema.Array(ShareTargetSchema)), +}); + +/** The decoded share request body. */ +export type ShareRequest = typeof ShareRequestSchema.Type; + +/** The response of `GET /api/me`: the caller's own authentication state. + * Mirrors the server's AuthUser, including the optional profile fields. */ +export const MeResponseSchema = Schema.Struct({ + authenticated: Schema.Boolean, + user: Schema.NullOr(Schema.Struct({ + id: Schema.String, + email: Schema.String, + name: Schema.optional(Schema.String), + picture: Schema.optional(Schema.String), + })), +}); + +/** The decoded identity echo. */ +export type MeResponse = typeof MeResponseSchema.Type; + +/** The bare acknowledgment returned by `GET /health` and the delete API. */ +export const OkResponseSchema = Schema.Struct({ ok: Schema.Boolean }); + +/** The decoded acknowledgment. */ +export type OkResponse = typeof OkResponseSchema.Type; + +/** The envelope returned by `GET /api/projects/:project/bundle` (clone). */ +export const ProjectBundleResponseSchema = Schema.Struct({ + bundle: PublishBundleSchema, +}); + +/** The decoded bundle envelope. */ +export type ProjectBundleResponse = typeof ProjectBundleResponseSchema.Type; + +// --------------------------------------------------------------------------- +// The contract object +// --------------------------------------------------------------------------- + +/** The `:project` path parameter of the per-project endpoints. */ +const projectParam = HttpApiSchema.param("project", ProjectIdentifierSchema); + +/** + * Every JSON endpoint of the scratchwork server, in one top-level group. + * Endpoint names double as the server's route-policy registry keys + * (invariant 4), so each name here must have a policy entry there — the + * registry's mapped type enforces it at compile time. + * + * The contract deliberately declares no error schemas: every non-2xx response + * carries the ApiErrorBodySchema envelope regardless of status, and the CLI + * reads it from the raw response so unknown statuses degrade gracefully. + */ +const CliApiGroup = HttpApiGroup.make("cli", { topLevel: true }) + .add(HttpApiEndpoint.get("health", "/health").addSuccess(OkResponseSchema)) + .add( + HttpApiEndpoint.post("cli-token-exchange", CLI_TOKEN_EXCHANGE_PATH) + .setPayload(CliTokenRequestSchema) + .addSuccess(CliTokenResponseSchema), + ) + .add(HttpApiEndpoint.get("me", "/api/me").addSuccess(MeResponseSchema)) + .add( + HttpApiEndpoint.post("publish", "/api/publish") + .setPayload(PublishRequestBodySchema) + .addSuccess(PublishResponseSchema), + ) + .add(HttpApiEndpoint.get("projects-list", "/api/projects").addSuccess(ProjectsListResponseSchema)) + .add( + HttpApiEndpoint.get("resolve", "/api/resolve") + .setUrlParams(Schema.Struct({ path: Schema.String })) + .addSuccess(ProjectResponseSchema), + ) + .add(HttpApiEndpoint.get("project-info")`/api/projects/${projectParam}`.addSuccess(ProjectResponseSchema)) + .add(HttpApiEndpoint.get("project-bundle")`/api/projects/${projectParam}/bundle`.addSuccess(ProjectBundleResponseSchema)) + .add(HttpApiEndpoint.post("project-unpublish")`/api/projects/${projectParam}/unpublish`.addSuccess(ProjectResponseSchema)) + .add( + HttpApiEndpoint.post("project-share")`/api/projects/${projectParam}/share` + .setPayload(ShareRequestSchema) + .addSuccess(ShareResponseSchema), + ) + .add(HttpApiEndpoint.del("project-delete")`/api/projects/${projectParam}`.addSuccess(OkResponseSchema)); + +/** The CLI↔server JSON API contract: the one object both sides derive from. */ +export const ScratchworkApi = HttpApi.make("scratchwork").add(CliApiGroup); + +/** Union of the contract's endpoint definitions (for type-level derivation). */ +export type ScratchworkApiEndpoints = HttpApiGroup.HttpApiGroup.Endpoints; + +/** Union of the contract's endpoint names — the server's registry keys. */ +export type ScratchworkEndpointName = HttpApiEndpoint.HttpApiEndpoint.Name; + +/** The contract endpoint named `Name`. */ +export type ScratchworkEndpoint = HttpApiEndpoint.HttpApiEndpoint.WithName< + ScratchworkApiEndpoints, + Name +>; + +/** The declared success (response) type of the endpoint named `Name`. */ +export type EndpointSuccess = HttpApiEndpoint.HttpApiEndpoint.Success< + ScratchworkEndpoint +>; + +/** The declared request payload type of the endpoint named `Name` (`never` + * for endpoints without a body). */ +export type EndpointPayload = HttpApiEndpoint.HttpApiEndpoint.Payload< + ScratchworkEndpoint +>; diff --git a/shared/src/site/default-renderer.generated.js b/shared/src/site/default-renderer.generated.js index 0159918..6e752f2 100644 --- a/shared/src/site/default-renderer.generated.js +++ b/shared/src/site/default-renderer.generated.js @@ -1,4 +1,4 @@ // AUTO-GENERATED by renderer/build.js — do not edit. -export const defaultRendererSourceHash = "8e50903e9c9e19a802bd7c6b33b17bb40bff5f191a30b187845e446419691b61"; +export const defaultRendererSourceHash = "67ac94fe85f68eb4448c9aa641ff5d49be3986d4b5ce67c6675c8f6474cdf2d4"; export const defaultRendererHtml = "\n\n\n \n \n \n \n\n \n \n \n \n \n \n\n
\n\n \n \n \n\n"; export default defaultRendererHtml;