From 19a86d4b22685ea45fb35796e324a789880ad981 Mon Sep 17 00:00:00 2001 From: Peter Koomen Date: Mon, 6 Jul 2026 14:50:46 -0700 Subject: [PATCH 01/13] Add Cloudflare Access auth mode with CLI token relay SCRATCHWORK_AUTH=cloudflare-access delegates authentication to a Cloudflare Access application: the server verifies the edge-injected Cf-Access-Jwt-Assertion (RS256 vs team JWKS, issuer, AUD tag, expiry) and uses the asserted email as the identity. /auth/login converts the assertion into the CLI bearer token and relays the verified Access JWT to the CLI loopback as cf_token; API requests also accept the relayed JWT via a cf-access-token header, verified identically. Co-Authored-By: Claude Fable 5 --- server/core/src/auth.ts | 169 ++++++++++- server/core/src/cloudflare-jwt.ts | 82 ++++++ server/core/src/config.ts | 107 ++++++- server/core/src/google-jwt.ts | 131 +-------- server/core/src/index.ts | 4 +- server/core/src/jwt-rs256.ts | 140 +++++++++ server/core/test/app.test.ts | 5 + server/core/test/auth.test.ts | 287 ++++++++++++++++++- server/core/test/cloudflare-jwt.test.ts | 94 ++++++ server/core/test/google-jwt.test.ts | 45 +-- server/core/test/helpers.ts | 1 + server/core/test/jwt-helpers.ts | 58 ++++ server/core/test/site-store.test.ts | 1 + server/deploy-cloudflare/src/deploy.ts | 2 + server/deploy-cloudflare/test/worker.test.ts | 16 ++ server/deploy-local/src/run.ts | 2 + server/scripts/server-settings.test.ts | 24 ++ server/scripts/server-settings.ts | 26 +- 18 files changed, 991 insertions(+), 203 deletions(-) create mode 100644 server/core/src/cloudflare-jwt.ts create mode 100644 server/core/src/jwt-rs256.ts create mode 100644 server/core/test/cloudflare-jwt.test.ts create mode 100644 server/core/test/jwt-helpers.ts diff --git a/server/core/src/auth.ts b/server/core/src/auth.ts index 8f6061b..e593f7e 100644 --- a/server/core/src/auth.ts +++ b/server/core/src/auth.ts @@ -1,8 +1,10 @@ /** - * Google-OAuth auth service and the three signed HMAC token kinds it mints: - * session tokens (browser cookie or CLI bearer, session TTL), OAuth state tokens - * (10-minute, browser-bound), and project-access tokens ("handoff": ~60s, - * query-string form; "cookie": session-length redeemed form). + * The auth service — one implementation per auth mode (built-in Google OAuth, or + * Cloudflare Access asserting identity via the Cf-Access-Jwt-Assertion header) — and + * the three signed HMAC token kinds it mints: session tokens (browser cookie or CLI + * bearer, session TTL), OAuth state tokens (10-minute, browser-bound), and + * project-access tokens ("handoff": ~60s, query-string form; "cookie": session-length + * redeemed form). */ import * as HttpServerRequest from "@effect/platform/HttpServerRequest"; import * as HttpServerResponse from "@effect/platform/HttpServerResponse"; @@ -15,7 +17,8 @@ import { base64UrlToBytes, bytesToBase64Url } from "../../../shared/src/encoding import { errorMessage } from "../../../shared/src/util/errors"; import { parseJson } from "../../../shared/src/util/json"; import { accessGroupMatches } from "./access"; -import { ServerConfig, type AuthConfig } from "./config"; +import { verifyCloudflareAccessToken } from "./cloudflare-jwt"; +import { ServerConfig, type AuthConfig, type CloudflareAccessAuthConfig, type OAuthAuthConfig } from "./config"; import { clearSessionCookie, cookieToken, @@ -58,7 +61,7 @@ const AuthUserSchema = Schema.Struct({ /** Payload of a session token (browser cookie or CLI bearer). */ const SessionPayloadSchema = Schema.Struct({ version: Schema.Literal(SESSION_VERSION), - provider: Schema.Literal("google"), + provider: Schema.Literal("google", "cloudflare-access"), user: AuthUserSchema, issuedAt: Schema.Number, expiresAt: Schema.Number, @@ -151,14 +154,19 @@ export interface AuthShape { /** Service tag for the auth service. */ export class Auth extends Context.Tag("@scratchwork/server/Auth")() {} -/** Provides the Google OAuth auth service from server config. */ +/** Provides the configured auth mode's service from server config. */ export const AuthLive: Layer.Layer = Layer.effect( Auth, Effect.map(ServerConfig, (config) => makeAuth(config.auth)), ); -/** Creates the auth service implementation over Google OAuth. */ +/** Creates the auth service implementation for the configured mode. */ export function makeAuth(config: AuthConfig): AuthShape { + return config.mode === "cloudflare-access" ? makeCloudflareAccessAuth(config) : makeGoogleAuth(config); +} + +/** Creates the auth service implementation over built-in Google OAuth. */ +function makeGoogleAuth(config: OAuthAuthConfig): AuthShape { return Auth.of({ currentUser: (request) => Effect.gen(function* () { @@ -259,6 +267,145 @@ export function makeAuth(config: AuthConfig): AuthShape { }, }), + ...projectAccessTokenMethods(config), + }); +} + +/** The Cloudflare Access request header carrying the edge-verified identity assertion. */ +const CF_ACCESS_JWT_HEADER = "cf-access-jwt-assertion"; +/** The client-supplied header the CLI sends its relayed Access JWT in. Cloudflare's edge + * accepts it as an Access credential; the server accepts it too so requests that reach + * the origin without passing the edge (grey-clouded origin, local testing) still verify. + * Verification is identical to the assertion header, so this adds no trust. */ +const CF_ACCESS_TOKEN_HEADER = "cf-access-token"; + +/** The raw Access JWT presented on a request: the edge-injected assertion header first, + * then the CLI's relayed header. Null when neither is present. */ +function presentedAccessToken(request: HttpServerRequest.HttpServerRequest): string | null { + for (const header of [CF_ACCESS_JWT_HEADER, CF_ACCESS_TOKEN_HEADER]) { + const value = request.headers[header]; + if (value != null && value !== "") return value; + } + return null; +} + +/** + * Creates the auth service implementation over Cloudflare Access. The edge authenticates + * every request before it reaches the server and injects a signed assertion header; the + * server verifies it against the team's JWKS and the application AUD tag, so a directly + * reached origin cannot be fooled by a forged header. Sessions minted at /auth/login keep + * the CLI bearer flow working — the login redirect also relays the verified Access JWT so + * the CLI can pass the edge on API requests — and there is no OAuth redirect dance and no + * session cookie. + */ +function makeCloudflareAccessAuth(config: CloudflareAccessAuthConfig): AuthShape { + /** The identity asserted by the Access header: null when the header is absent, an + * AuthError when it is present but does not verify. The allow-list is applied here so + * every entry point shares the same gate. */ + const assertedUser = ( + request: HttpServerRequest.HttpServerRequest, + ): Effect.Effect => + Effect.gen(function* () { + const token = presentedAccessToken(request); + if (token == null) return null; + const claims = yield* verifyCloudflareAccessToken(token, { + teamDomain: config.teamDomain, + audience: config.audience, + }).pipe( + Effect.mapError((cause) => new AuthError({ status: 401, message: cause.message, cause })), + ); + const email = claims.email.toLowerCase(); + // sub is Cloudflare's stable user UUID; tokens without one fall back to the email. + const user: AuthUser = { + id: typeof claims.sub === "string" && claims.sub !== "" ? claims.sub : email, + email, + }; + return allowedUser(user, config) ? user : null; + }); + + return Auth.of({ + currentUser: (request) => + Effect.gen(function* () { + const token = bearerToken(request) ?? cookieToken(request); + if (token != null) { + const user = yield* verifySessionToken(token, config).pipe(Effect.orElseSucceed(() => null)); + if (user != null) return user; + } + return yield* assertedUser(request).pipe(Effect.orElseSucceed(() => null)); + }), + + requireApiUser: (request) => + Effect.gen(function* () { + const token = bearerToken(request); + const sessionUser = token == null ? null : yield* verifySessionToken(token, config); + if (sessionUser != null) return sessionUser; + const user = yield* assertedUser(request); + if (user == null) { + return yield* Effect.fail(new AuthError({ status: 401, message: "Authentication required" })); + } + return user; + }), + + // Cloudflare already authenticated the browser before this request arrived, so login + // just converts the asserted identity into a redirect — with a bearer token for the + // CLI loopback, or straight back into the app for a browser. + login: (request, url, baseUrl) => + Effect.gen(function* () { + const accessToken = presentedAccessToken(request); + if (accessToken == null) { + return yield* Effect.fail( + new AuthError({ + status: 401, + message: + "Cloudflare Access did not authenticate this request (no Cf-Access-Jwt-Assertion header). This server expects to run behind a Cloudflare Access application.", + }), + ); + } + const user = yield* assertedUser(request); + if (user == null) { + return yield* Effect.fail(new AuthError({ status: 403, message: "Account is not allowed on this server" })); + } + + const cliRedirect = safeCliRedirect(url.searchParams.get("cli_redirect")); + if (cliRedirect != null) { + const token = yield* createSessionToken(user, config); + const redirectUrl = new URL(cliRedirect); + redirectUrl.searchParams.set("token", token); + redirectUrl.searchParams.set("server", baseUrl); + redirectUrl.searchParams.set("email", user.email); + // Relay the verified Access JWT so the CLI can present it back (as + // cf-access-token) and pass Cloudflare's edge on API requests. It rides the + // loopback query string exactly like the bearer token above — same exposure. + redirectUrl.searchParams.set("cf_token", accessToken); + return HttpServerResponse.redirect(redirectUrl, { status: 302 }); + } + return HttpServerResponse.redirect(safeReturnTo(url.searchParams.get("returnTo")) ?? "/", { status: 302 }); + }), + + callback: () => + Effect.fail(new AuthError({ status: 404, message: "This server uses Cloudflare Access; there is no OAuth callback" })), + + // Cloudflare's edge handles /cdn-cgi/access/logout on the protected domain and ends + // the Access session; the server never sees that request. Clear the scratchwork + // session cookie too so a stale one cannot outlive the Access session. + logout: (baseUrl) => + HttpServerResponse.redirect("/cdn-cgi/access/logout", { + status: 302, + headers: { + "set-cookie": clearSessionCookie(baseUrl), + }, + }), + + ...projectAccessTokenMethods(config), + }); +} + +/** The project-access token methods every auth mode shares: HMAC tokens signed with the + * session secret, carrying the viewer's email through the app-to-content-host handoff. */ +function projectAccessTokenMethods( + config: AuthConfig, +): Pick { + return { issueProjectAccessToken: (project, user, use) => signValue( { @@ -287,7 +434,7 @@ export function makeAuth(config: AuthConfig): AuthShape { const user = { id: payload.email, email: payload.email }; return allowedUser(user, config) ? user : null; }), - }); + }; } /** Signs a portable session token for browser cookies and CLI bearer auth. */ @@ -299,7 +446,7 @@ export function createSessionToken( return signValue( { version: SESSION_VERSION, - provider: "google", + provider: config.mode === "cloudflare-access" ? "cloudflare-access" : "google", user, issuedAt, expiresAt: issuedAt + config.sessionTtlSeconds, @@ -326,7 +473,7 @@ function exchangeGoogleCode( code: string, redirectUri: string, nonce: string, - config: AuthConfig, + config: OAuthAuthConfig, ): Effect.Effect { return Effect.gen(function* () { const { ok, json } = yield* Effect.tryPromise({ diff --git a/server/core/src/cloudflare-jwt.ts b/server/core/src/cloudflare-jwt.ts new file mode 100644 index 0000000..32cb0eb --- /dev/null +++ b/server/core/src/cloudflare-jwt.ts @@ -0,0 +1,82 @@ +/** + * Verifies Cloudflare Access application tokens (the Cf-Access-Jwt-Assertion header the + * edge injects after authenticating a user): RS256 signature against the team's JWKS via + * the shared machinery in jwt-rs256.ts, plus issuer/audience/expiry/email claim checks. + * + * Only identity assertions carrying an email are accepted; Access service tokens assert a + * client ID (common_name) instead of a user and are rejected, since every scratchwork + * authorization decision is keyed by email. + */ +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import { errorMessage } from "../../../shared/src/util/errors"; +import { CLOCK_SKEW_SECONDS, verifyRs256Jwt } from "./jwt-rs256"; + +/** Raised when an Access token fails signature or claim validation. */ +export class CloudflareJwtError extends Data.TaggedError("CloudflareJwtError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** The Cloudflare Access application-token claims the server reads. */ +export interface CloudflareAccessClaims { + readonly iss: string; + readonly aud: string | ReadonlyArray; + readonly sub: string; + readonly email: string; + readonly exp: number; + readonly iat?: number; + readonly nbf?: number; + /** Set on service-token assertions instead of email; those are rejected. */ + readonly common_name?: string; +} + +/** The JWKS endpoint Cloudflare serves for one Access team origin. */ +export function cloudflareJwksUrl(teamDomain: string): string { + return `${teamDomain}/cdn-cgi/access/certs`; +} + +/** Verifies a Cloudflare Access application token signature and required claims. */ +export function verifyCloudflareAccessToken( + token: string, + options: { + /** Team origin the token must be issued by, like "https://myteam.cloudflareaccess.com". */ + readonly teamDomain: string; + /** Audience (AUD) tag of the Access application protecting this server. */ + readonly audience: string; + readonly jwksUrl?: string; + readonly nowSeconds?: number; + }, +): Effect.Effect { + return Effect.tryPromise({ + try: async () => { + const payload = (await verifyRs256Jwt(token, options.jwksUrl ?? cloudflareJwksUrl(options.teamDomain))) as unknown as CloudflareAccessClaims; + validateClaims(payload, options.teamDomain, options.audience, options.nowSeconds ?? epochSeconds()); + return payload; + }, + catch: (cause) => new CloudflareJwtError({ message: errorMessage(cause), cause }), + }); +} + +/** Validates issuer, audience, time, and email claims. */ +function validateClaims( + claims: CloudflareAccessClaims, + teamDomain: string, + audience: string, + now: number, +): void { + if (claims.iss !== teamDomain) throw new Error("Invalid Access token issuer"); + const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; + if (!audiences.includes(audience)) throw new Error("Invalid Access token audience"); + if (typeof claims.exp !== "number" || claims.exp < now - CLOCK_SKEW_SECONDS) throw new Error("Expired Access token"); + if (typeof claims.nbf === "number" && claims.nbf > now + CLOCK_SKEW_SECONDS) throw new Error("Access token not active yet"); + if (typeof claims.iat === "number" && claims.iat > now + CLOCK_SKEW_SECONDS) throw new Error("Access token issued in the future"); + if (typeof claims.email !== "string" || claims.email === "") { + throw new Error("Access token asserts no email (service tokens are not supported)"); + } +} + +/** Returns the current Unix timestamp in seconds. */ +function epochSeconds(): number { + return Math.floor(Date.now() / 1000); +} diff --git a/server/core/src/config.ts b/server/core/src/config.ts index 0e1a991..a43b2c7 100644 --- a/server/core/src/config.ts +++ b/server/core/src/config.ts @@ -33,15 +33,33 @@ export interface ServerConfigShape { readonly auth: AuthConfig; } -/** Google OAuth and session-signing settings. Auth cannot be disabled. */ -export interface AuthConfig { - readonly clientId: string; - readonly clientSecret: string; +/** Session-signing and allow-list settings shared by every auth mode. */ +export interface AuthConfigCommon { readonly sessionSecret: string; readonly allowedUsers: AccessGroup; readonly sessionTtlSeconds: number; } +/** Built-in Google OAuth: the server runs the login flow itself. */ +export interface OAuthAuthConfig extends AuthConfigCommon { + readonly mode: "oauth"; + readonly clientId: string; + readonly clientSecret: string; +} + +/** Cloudflare Access: the server sits behind an Access application that authenticates + * users at the edge and injects a signed Cf-Access-Jwt-Assertion header. */ +export interface CloudflareAccessAuthConfig extends AuthConfigCommon { + /** Team origin the assertions are issued by, like "https://myteam.cloudflareaccess.com". */ + readonly teamDomain: string; + /** Audience (AUD) tag of the Access application protecting this server. */ + readonly audience: string; + readonly mode: "cloudflare-access"; +} + +/** Auth settings. Auth cannot be disabled. */ +export type AuthConfig = OAuthAuthConfig | CloudflareAccessAuthConfig; + /** Service tag for server configuration. */ export class ServerConfig extends Context.Tag("@scratchwork/server/Config")< ServerConfig, @@ -162,17 +180,26 @@ function readHomepage( }); } -/** Parses required OAuth settings from environment variables. Auth cannot be disabled. */ +/** Parses auth settings from environment variables. Auth cannot be disabled: a server + * either runs built-in Google OAuth or sits behind Cloudflare Access. */ function readAuthConfig(env: EnvVars): Effect.Effect { return Effect.gen(function* () { const authMode = (env.SCRATCHWORK_AUTH ?? "").toLowerCase(); + if (authMode !== "" && authMode !== "oauth" && authMode !== "cloudflare-access") { + return yield* Effect.fail(invalidValue("SCRATCHWORK_AUTH", authMode, '"oauth" or "cloudflare-access", or leave it unset for oauth')); + } + if (authMode === "cloudflare-access") return yield* readCloudflareAccessConfig(env); + return yield* readOAuthConfig(env); + }); +} + +/** Parses required Google OAuth settings from environment variables. */ +function readOAuthConfig(env: EnvVars): Effect.Effect { + return Effect.gen(function* () { const clientId = env.SCRATCHWORK_GOOGLE_CLIENT_ID ?? env.GOOGLE_CLIENT_ID; const clientSecret = env.SCRATCHWORK_GOOGLE_CLIENT_SECRET ?? env.GOOGLE_CLIENT_SECRET; const sessionSecret = env.SCRATCHWORK_SESSION_SECRET; - if (authMode !== "" && authMode !== "oauth") { - return yield* Effect.fail(invalidValue("SCRATCHWORK_AUTH", authMode, '"oauth" (the only supported mode), or leave it unset')); - } if (!clientId || !clientSecret || !sessionSecret) { const missing = [ clientId ? null : "SCRATCHWORK_GOOGLE_CLIENT_ID", @@ -185,6 +212,56 @@ function readAuthConfig(env: EnvVars): 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 sessionSecret = env.SCRATCHWORK_SESSION_SECRET; + + if (teamDomainValue == null || audience == null || !sessionSecret) { + const missing = [ + teamDomainValue != null ? null : "SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN", + audience != null ? null : "SCRATCHWORK_CF_ACCESS_AUD", + sessionSecret ? null : "SCRATCHWORK_SESSION_SECRET", + ].filter((name) => name != null); + return yield* Effect.fail( + new ServerConfigError({ + message: `Cloudflare Access mode requires ${missing.join(", ")}: copy the team domain and the application Audience (AUD) tag from the Cloudflare Zero Trust dashboard, and generate a session secret with "openssl rand -hex 32".`, + }), + ); + } + + const teamDomain = normalizeCfTeamDomain(teamDomainValue); + if (teamDomain == null) { + return yield* Effect.fail( + invalidValue("SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN", teamDomainValue, 'a Cloudflare Access team domain, like "myteam" or "myteam.cloudflareaccess.com"'), + ); + } + + return { + mode: "cloudflare-access", + teamDomain, + audience, + ...(yield* readCommonAuthConfig(env, sessionSecret)), + } as const; + }); +} + +/** Parses the auth settings every mode shares: the session-signing secret (validated + * for length), the allow-list, and the session lifetime. */ +function readCommonAuthConfig(env: EnvVars, sessionSecret: string): Effect.Effect { + return Effect.gen(function* () { if (new TextEncoder().encode(sessionSecret).byteLength < 32) { return yield* Effect.fail( new ServerConfigError({ @@ -192,10 +269,7 @@ function readAuthConfig(env: EnvVars): Effect.Effect; -} - -/** One imported verification key with its cache expiry. */ -interface CachedKey { - readonly key: CryptoKey; - readonly expiresAt: number; -} - -/** Process-global JWKS key cache, keyed `jwksUrl:kid` and shared across requests. */ -const keyCache = new Map(); - /** Verifies a Google ID token signature and required claims. */ export function verifyGoogleIdToken( token: string, @@ -77,27 +43,7 @@ export function verifyGoogleIdToken( ): Effect.Effect { return Effect.tryPromise({ try: async () => { - const parts = token.split("."); - if (parts.length !== 3) throw new Error("ID token must have 3 parts"); - const [encodedHeader, encodedPayload, encodedSignature] = parts; - const header = decodeJwtJson(encodedHeader); - if (header.alg !== "RS256") throw new Error("Unsupported ID token algorithm"); - if (typeof header.kid !== "string" || header.kid === "") throw new Error("ID token is missing kid"); - if (header.crit != null) throw new Error("ID token uses unsupported critical headers"); - - const payload = decodeJwtJson(encodedPayload); - const signature = base64UrlToBytes(encodedSignature); - if (signature == null) throw new Error("Invalid ID token signature encoding"); - - const key = await getJwksKey(header.kid, options.jwksUrl ?? GOOGLE_JWKS_URL); - const ok = await crypto.subtle.verify( - "RSASSA-PKCS1-v1_5", - key, - toArrayBuffer(signature), - toArrayBuffer(new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`)), - ); - if (!ok) throw new Error("Invalid ID token signature"); - + const payload = (await verifyRs256Jwt(token, options.jwksUrl ?? GOOGLE_JWKS_URL)) as unknown as GoogleIdTokenClaims; validateClaims(payload, options.clientId, options.expectedNonce, options.nowSeconds ?? epochSeconds()); return payload; }, @@ -105,63 +51,6 @@ export function verifyGoogleIdToken( }); } -/** Decodes one base64url JWT part as JSON. */ -function decodeJwtJson(value: string): A { - const bytes = base64UrlToBytes(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"); - return parsed as A; -} - -/** Finds a cached Google signing key, refreshing JWKS when needed. */ -async function getJwksKey(kid: string, jwksUrl: string): Promise { - const cached = keyCache.get(cacheKey(jwksUrl, kid)); - if (cached != null && cached.expiresAt > Date.now()) return cached.key; - - await refreshJwks(jwksUrl); - const refreshed = keyCache.get(cacheKey(jwksUrl, kid)); - if (refreshed == null || refreshed.expiresAt <= Date.now()) throw new Error("Unknown Google signing key"); - return refreshed.key; -} - -/** Fetches Google's JWKS and imports supported RSA verification keys. */ -async function refreshJwks(jwksUrl: string): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - try { - const response = await fetch(jwksUrl, { signal: controller.signal }); - if (!response.ok) throw new Error(`Could not fetch Google JWKS: ${response.status}`); - const body = (await response.json().catch(() => null)) as JwksResponse | null; - if (body == null || !Array.isArray(body.keys)) throw new Error("Invalid Google JWKS response"); - const expiresAt = Date.now() + jwksMaxAgeSeconds(response.headers.get("cache-control")) * 1000; - evictJwks(jwksUrl); - await Promise.all( - body.keys.map(async (jwk) => { - if (typeof jwk.kid !== "string" || jwk.kid === "" || jwk.kty !== "RSA") return; - const key = await crypto.subtle.importKey( - "jwk", - jwk, - { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, - false, - ["verify"], - ); - keyCache.set(cacheKey(jwksUrl, jwk.kid), { key, expiresAt }); - }), - ); - } finally { - clearTimeout(timeout); - } -} - -/** Removes cached keys for one JWKS URL before storing a refreshed set. */ -function evictJwks(jwksUrl: string): void { - const prefix = `${jwksUrl}:`; - for (const key of keyCache.keys()) { - if (key.startsWith(prefix)) keyCache.delete(key); - } -} - /** Validates issuer, audience, time, email, and nonce claims. */ function validateClaims( claims: GoogleIdTokenClaims, @@ -184,18 +73,6 @@ function validateClaims( if (expectedNonce != null && claims.nonce !== expectedNonce) throw new Error("Invalid ID token nonce"); } -/** Parses JWKS cache lifetime and clamps it to sane bounds. */ -function jwksMaxAgeSeconds(cacheControl: string | null): number { - const match = /(?:^|,)\s*max-age=(\d+)/i.exec(cacheControl ?? ""); - const seconds = match == null ? MIN_CACHE_SECONDS : Number(match[1]); - return Math.min(Math.max(seconds, MIN_CACHE_SECONDS), MAX_CACHE_SECONDS); -} - -/** Builds the process-local cache key for one JWKS key ID. */ -function cacheKey(jwksUrl: string, kid: string): string { - return `${jwksUrl}:${kid}`; -} - /** Returns the current Unix timestamp in seconds. */ function epochSeconds(): number { return Math.floor(Date.now() / 1000); diff --git a/server/core/src/index.ts b/server/core/src/index.ts index dcd06ae..ebc18c9 100644 --- a/server/core/src/index.ts +++ b/server/core/src/index.ts @@ -14,11 +14,13 @@ export { ServerConfig, ServerConfigError, type AuthConfig, + type CloudflareAccessAuthConfig, type EnvVars, + type OAuthAuthConfig, type ServerConfigShape, } from "./config"; -// Google OAuth auth service and signed session tokens. +// The auth service (Google OAuth or Cloudflare Access) and signed session tokens. export { Auth, AuthError, AuthLive, createSessionToken, makeAuth, type AuthShape, type AuthUser } from "./auth"; // Access-group expressions ("public" | "private" | emails/@domains) and identifier helpers. diff --git a/server/core/src/jwt-rs256.ts b/server/core/src/jwt-rs256.ts new file mode 100644 index 0000000..496fcd8 --- /dev/null +++ b/server/core/src/jwt-rs256.ts @@ -0,0 +1,140 @@ +/** + * Provider-neutral RS256 JWT machinery shared by google-jwt.ts and cloudflare-jwt.ts: + * compact-JWT decoding, header checks, JWKS fetch/cache, and signature verification. + * Claim validation (issuer, audience, expiry, email) stays in each provider module. + * + * This module is a deliberate Promise boundary, not an unfinished Effect migration: + * verification is plain async/await (raw fetch + Web Crypto) wrapped exactly once by + * Effect.tryPromise in each provider's verify function. `keyCache` is intentionally + * process-global: JWKS refreshes are idempotent, so concurrent cold-start misses at + * worst duplicate one fetch. + */ +import { base64UrlToBytes } from "../../../shared/src/encoding/base64"; +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; +const FETCH_TIMEOUT_MS = 5_000; +const MIN_CACHE_SECONDS = 60; +const MAX_CACHE_SECONDS = 60 * 60 * 24; + +/** The JWT header fields checked before verification. */ +interface JwtHeader { + readonly alg: string; + readonly kid: string; + readonly typ?: string; + readonly crit?: unknown; +} + +/** A JWKS endpoint response shape. */ +interface JwksResponse { + readonly keys?: ReadonlyArray; +} + +/** One imported verification key with its cache expiry. */ +interface CachedKey { + readonly key: CryptoKey; + readonly expiresAt: number; +} + +/** Process-global JWKS key cache, keyed `jwksUrl:kid` and shared across requests. */ +const keyCache = new Map(); + +/** + * Verifies a compact RS256 JWT against the given JWKS endpoint and returns its decoded + * payload. Checks the header (algorithm, kid, no critical extensions) and the signature + * only; the caller validates the claims. Throws plain Errors on any failure. + */ +export async function verifyRs256Jwt(token: string, jwksUrl: string): Promise> { + const parts = token.split("."); + if (parts.length !== 3) throw new Error("Token must have 3 parts"); + const [encodedHeader, encodedPayload, encodedSignature] = parts; + const header = decodeJwtJson(encodedHeader); + if (header.alg !== "RS256") throw new Error("Unsupported token algorithm"); + if (typeof header.kid !== "string" || header.kid === "") throw new Error("Token is missing kid"); + if (header.crit != null) throw new Error("Token uses unsupported critical headers"); + + const payload = decodeJwtJson>(encodedPayload); + const signature = base64UrlToBytes(encodedSignature); + if (signature == null) throw new Error("Invalid token signature encoding"); + + const key = await getJwksKey(header.kid, jwksUrl); + const ok = await crypto.subtle.verify( + "RSASSA-PKCS1-v1_5", + key, + toArrayBuffer(signature), + toArrayBuffer(new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`)), + ); + if (!ok) throw new Error("Invalid token signature"); + return payload; +} + +/** Decodes one base64url JWT part as JSON. */ +function decodeJwtJson(value: string): A { + const bytes = base64UrlToBytes(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"); + return parsed as A; +} + +/** Finds a cached signing key, refreshing the JWKS when needed. */ +async function getJwksKey(kid: string, jwksUrl: string): Promise { + const cached = keyCache.get(cacheKey(jwksUrl, kid)); + if (cached != null && cached.expiresAt > Date.now()) return cached.key; + + await refreshJwks(jwksUrl); + const refreshed = keyCache.get(cacheKey(jwksUrl, kid)); + if (refreshed == null || refreshed.expiresAt <= Date.now()) throw new Error("Unknown signing key"); + return refreshed.key; +} + +/** Fetches one JWKS document and imports supported RSA verification keys. */ +async function refreshJwks(jwksUrl: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const response = await fetch(jwksUrl, { signal: controller.signal }); + if (!response.ok) throw new Error(`Could not fetch JWKS: ${response.status}`); + const body = (await response.json().catch(() => null)) as JwksResponse | null; + if (body == null || !Array.isArray(body.keys)) throw new Error("Invalid JWKS response"); + const expiresAt = Date.now() + jwksMaxAgeSeconds(response.headers.get("cache-control")) * 1000; + evictJwks(jwksUrl); + await Promise.all( + body.keys.map(async (jwk) => { + if (typeof jwk.kid !== "string" || jwk.kid === "" || jwk.kty !== "RSA") return; + const key = await crypto.subtle.importKey( + "jwk", + jwk, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ); + keyCache.set(cacheKey(jwksUrl, jwk.kid), { key, expiresAt }); + }), + ); + } finally { + clearTimeout(timeout); + } +} + +/** Removes cached keys for one JWKS URL before storing a refreshed set. */ +function evictJwks(jwksUrl: string): void { + const prefix = `${jwksUrl}:`; + for (const key of keyCache.keys()) { + if (key.startsWith(prefix)) keyCache.delete(key); + } +} + +/** Parses JWKS cache lifetime and clamps it to sane bounds. */ +function jwksMaxAgeSeconds(cacheControl: string | null): number { + const match = /(?:^|,)\s*max-age=(\d+)/i.exec(cacheControl ?? ""); + const seconds = match == null ? MIN_CACHE_SECONDS : Number(match[1]); + return Math.min(Math.max(seconds, MIN_CACHE_SECONDS), MAX_CACHE_SECONDS); +} + +/** Builds the process-local cache key for one JWKS key ID. */ +function cacheKey(jwksUrl: string, kid: string): string { + return `${jwksUrl}:${kid}`; +} diff --git a/server/core/test/app.test.ts b/server/core/test/app.test.ts index d3c5b2d..59e9aee 100644 --- a/server/core/test/app.test.ts +++ b/server/core/test/app.test.ts @@ -736,6 +736,7 @@ describe("server app", () => { config: { appUrl: "https://app.scratch.test", auth: { + mode: "oauth", clientId: "client-id", clientSecret: "client-secret", sessionSecret: "session-secret-session-secret-32-bytes", @@ -803,6 +804,7 @@ describe("server app", () => { test("authenticates private content through a handoff token and path-scoped cookie", async () => { const authConfig = { + mode: "oauth", clientId: "client-id", clientSecret: "client-secret", sessionSecret: "session-secret-session-secret-32-bytes", @@ -886,6 +888,7 @@ describe("server app", () => { test("rejects cross-project and unauthenticated access to private content", async () => { const authConfig = { + mode: "oauth", clientId: "client-id", clientSecret: "client-secret", sessionSecret: "session-secret-session-secret-32-bytes", @@ -1044,6 +1047,7 @@ describe("server app", () => { const handler = await appHandler({ config: { auth: { + mode: "oauth", clientId: "client-id", clientSecret: "client-secret", sessionSecret: "session-secret-session-secret-32-bytes", @@ -1171,6 +1175,7 @@ describe("server homepage", () => { test("gates a private homepage behind the handoff flow with a /-scoped cookie", async () => { const authConfig = { + mode: "oauth", clientId: "client-id", clientSecret: "client-secret", sessionSecret: "session-secret-session-secret-32-bytes", diff --git a/server/core/test/auth.test.ts b/server/core/test/auth.test.ts index a42be23..8db7866 100644 --- a/server/core/test/auth.test.ts +++ b/server/core/test/auth.test.ts @@ -1,8 +1,10 @@ import * as HttpServerRequest from "@effect/platform/HttpServerRequest"; -import { describe, expect, test } from "bun:test"; +import * as HttpServerResponse from "@effect/platform/HttpServerResponse"; +import { afterEach, describe, expect, test } from "bun:test"; import * as Effect from "effect/Effect"; import { createSessionToken, makeAuth, type AuthUser } from "../src/auth"; -import { readServerConfig, type AuthConfig } from "../src/config"; +import { readServerConfig, type AuthConfig, type CloudflareAccessAuthConfig } from "../src/config"; +import { jwksFetch, makeKeyPair, nowSeconds, signJwt, type TestKeyPair } from "./jwt-helpers"; const user: AuthUser = { id: "google-user-1", @@ -11,6 +13,7 @@ const user: AuthUser = { }; const googleConfig: AuthConfig = { + mode: "oauth", clientId: "google-client-id", clientSecret: "google-client-secret", sessionSecret: "session-secret-session-secret-32-bytes", @@ -67,6 +70,69 @@ describe("Auth", () => { expect(await Effect.runPromise(auth.verifyProjectAccessToken(token, "site", "handoff"))).toBeNull(); }); + test("OAuth login redirects to Google and never relays a cf_token", async () => { + const auth = makeAuth(googleConfig); + const response = await Effect.runPromise(auth.login( + request({}), + new URL("https://app.scratch.test/auth/login?cli_redirect=http%3A%2F%2F127.0.0.1%3A5555%2Fcallback"), + "https://app.scratch.test", + )); + + const location = new URL(HttpServerResponse.toWeb(response).headers.get("location") ?? "https://invalid"); + expect(location.origin).toBe("https://accounts.google.com"); + expect(location.searchParams.get("cf_token")).toBeNull(); + }); + + test("OAuth callback redirects the CLI loopback without a cf_token", async () => { + const originalFetch = globalThis.fetch; + try { + const auth = makeAuth(googleConfig); + const baseUrl = "https://app.scratch.test"; + const loginResponse = HttpServerResponse.toWeb(await Effect.runPromise(auth.login( + request({}), + new URL(`${baseUrl}/auth/login?cli_redirect=http%3A%2F%2F127.0.0.1%3A5555%2Fcallback`), + baseUrl, + ))); + const authorizeUrl = new URL(loginResponse.headers.get("location") ?? "https://invalid"); + const state = authorizeUrl.searchParams.get("state") ?? ""; + const nonce = authorizeUrl.searchParams.get("nonce") ?? ""; + + // Google's side of the exchange: the token endpoint returns an ID token signed by + // a test key served from Google's JWKS URL. + const keyPair = await makeKeyPair(); + const idToken = await signJwt(keyPair.privateKey, { + iss: "https://accounts.google.com", + aud: googleConfig.mode === "oauth" ? googleConfig.clientId : "", + sub: "google-user-1", + email: "founder@example.com", + email_verified: true, + exp: nowSeconds() + 600, + nonce, + }); + globalThis.fetch = (async (input: Parameters[0]) => { + const url = String(input instanceof Request ? input.url : input); + if (url.startsWith("https://oauth2.googleapis.com/token")) { + return Response.json({ id_token: idToken }); + } + return jwksFetch(keyPair.publicJwk)(input); + }) as typeof fetch; + + const response = await Effect.runPromise(auth.callback( + request({ cookie: `__Host-scratchwork_oauth_state=${encodeURIComponent(state)}` }), + new URL(`${baseUrl}/auth/callback/google?code=auth-code&state=${encodeURIComponent(state)}`), + baseUrl, + )); + + const location = new URL(HttpServerResponse.toWeb(response).headers.get("location") ?? "https://invalid"); + expect(location.origin).toBe("http://127.0.0.1:5555"); + expect(location.searchParams.get("email")).toBe("founder@example.com"); + expect(location.searchParams.get("token")).not.toBeNull(); + expect(location.searchParams.get("cf_token")).toBeNull(); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("rejects old-format project-access tokens as invalid, not as a crash", async () => { const auth = makeAuth(googleConfig); // A token in the retired workspace-era shape (projectKey/routePath) signed with the @@ -90,6 +156,179 @@ describe("Auth", () => { }); }); +// Each key pair gets its own team domain because the production JWKS cache is +// process-global and keyed by JWKS URL, which the auth service derives from the team. +const cfTeamDomain = "https://auth-test.cloudflareaccess.com"; + +const cloudflareConfig: CloudflareAccessAuthConfig = { + mode: "cloudflare-access", + teamDomain: cfTeamDomain, + audience: "aud-tag-1", + sessionSecret: "session-secret-session-secret-32-bytes", + allowedUsers: "public", + sessionTtlSeconds: 60, +}; + +describe("Auth (cloudflare-access)", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + // One signing key for the whole suite: the JWKS cache is process-global and keyed by + // the team's JWKS URL, so every token for cfTeamDomain must come from the same key. + let teamKeyPair: TestKeyPair | undefined; + + /** Points fetch at the team's JWKS and signs an Access assertion with its key. */ + async function accessAssertion(claims: Record = {}): Promise<{ token: string; keyPair: TestKeyPair }> { + const keyPair = (teamKeyPair ??= await makeKeyPair()); + globalThis.fetch = jwksFetch(keyPair.publicJwk); + const token = await signJwt(keyPair.privateKey, { + iss: cfTeamDomain, + aud: [cloudflareConfig.audience], + sub: "cf-user-1", + email: "Founder@Example.com", + exp: nowSeconds() + 600, + iat: nowSeconds(), + type: "app", + ...claims, + }); + return { token, keyPair }; + } + + test("authenticates browser and API requests from the Access assertion header", async () => { + const { token } = await accessAssertion(); + const auth = makeAuth(cloudflareConfig); + + const currentUser = await Effect.runPromise(auth.currentUser(request({ "cf-access-jwt-assertion": token }))); + expect(currentUser?.email).toBe("founder@example.com"); + expect(currentUser?.id).toBe("cf-user-1"); + + const apiUser = await Effect.runPromise(auth.requireApiUser(request({ "cf-access-jwt-assertion": token }))); + expect(apiUser.email).toBe("founder@example.com"); + }); + + test("applies the allow-list to asserted identities", async () => { + const { token } = await accessAssertion(); + const auth = makeAuth({ ...cloudflareConfig, allowedUsers: "@yc.com" }); + + expect(await Effect.runPromise(auth.currentUser(request({ "cf-access-jwt-assertion": token })))).toBeNull(); + await expect( + Effect.runPromise(auth.requireApiUser(request({ "cf-access-jwt-assertion": token }))), + ).rejects.toThrow("Authentication required"); + }); + + test("requires a credential for API requests", async () => { + const auth = makeAuth(cloudflareConfig); + await expect(Effect.runPromise(auth.requireApiUser(request({})))).rejects.toThrow("Authentication required"); + }); + + test("still accepts scratchwork bearer session tokens", async () => { + const token = await Effect.runPromise(createSessionToken(user, cloudflareConfig)); + const auth = makeAuth(cloudflareConfig); + + const apiUser = await Effect.runPromise(auth.requireApiUser(request({ authorization: `Bearer ${token}` }))); + expect(apiUser.email).toBe("founder@example.com"); + }); + + test("login redirects the CLI loopback with a working bearer token", async () => { + const { token } = await accessAssertion(); + const auth = makeAuth(cloudflareConfig); + + const response = await Effect.runPromise(auth.login( + request({ "cf-access-jwt-assertion": token }), + new URL("https://app.scratch.test/auth/login?cli_redirect=http%3A%2F%2F127.0.0.1%3A5555%2Fcallback"), + "https://app.scratch.test", + )); + + const location = new URL(HttpServerResponse.toWeb(response).headers.get("location") ?? "https://invalid"); + expect(location.origin).toBe("http://127.0.0.1:5555"); + expect(location.searchParams.get("email")).toBe("founder@example.com"); + expect(location.searchParams.get("server")).toBe("https://app.scratch.test"); + // The verified Access JWT is relayed so the CLI can pass the edge on API requests. + expect(location.searchParams.get("cf_token")).toBe(token); + + const bearer = location.searchParams.get("token") ?? ""; + const apiUser = await Effect.runPromise(auth.requireApiUser(request({ authorization: `Bearer ${bearer}` }))); + expect(apiUser.email).toBe("founder@example.com"); + }); + + test("browser login without a CLI redirect relays nothing", async () => { + const { token } = await accessAssertion(); + const auth = makeAuth(cloudflareConfig); + + const response = await Effect.runPromise(auth.login( + request({ "cf-access-jwt-assertion": token }), + new URL("https://app.scratch.test/auth/login?returnTo=%2Fhome"), + "https://app.scratch.test", + )); + + expect(HttpServerResponse.toWeb(response).headers.get("location")).toBe("/home"); + }); + + test("accepts the relayed JWT from the cf-access-token header", async () => { + const { token } = await accessAssertion(); + const auth = makeAuth(cloudflareConfig); + + const currentUser = await Effect.runPromise(auth.currentUser(request({ "cf-access-token": token }))); + expect(currentUser?.email).toBe("founder@example.com"); + + const apiUser = await Effect.runPromise(auth.requireApiUser(request({ "cf-access-token": token }))); + expect(apiUser.email).toBe("founder@example.com"); + }); + + test("the edge-injected assertion wins over cf-access-token when both are present", async () => { + const { token } = await accessAssertion(); + const auth = makeAuth(cloudflareConfig); + + // A garbage relayed header cannot displace a valid assertion... + const apiUser = await Effect.runPromise(auth.requireApiUser( + request({ "cf-access-jwt-assertion": token, "cf-access-token": "garbage" }), + )); + expect(apiUser.email).toBe("founder@example.com"); + + // ...and a valid relayed header never rescues a bad assertion: the edge-verified + // header is authoritative when present. + expect(await Effect.runPromise(auth.currentUser( + request({ "cf-access-jwt-assertion": "garbage", "cf-access-token": token }), + ))).toBeNull(); + }); + + test("rejects an invalid cf-access-token", async () => { + await accessAssertion(); // primes the JWKS mock for the verification attempt + const auth = makeAuth(cloudflareConfig); + + expect(await Effect.runPromise(auth.currentUser(request({ "cf-access-token": "garbage" })))).toBeNull(); + await expect( + Effect.runPromise(auth.requireApiUser(request({ "cf-access-token": "garbage" }))), + ).rejects.toThrow(); + }); + + test("login without an Access assertion fails loudly", async () => { + const auth = makeAuth(cloudflareConfig); + await expect(Effect.runPromise(auth.login( + request({}), + new URL("https://app.scratch.test/auth/login"), + "https://app.scratch.test", + ))).rejects.toThrow("Cloudflare Access did not authenticate this request"); + }); + + test("there is no OAuth callback", async () => { + const auth = makeAuth(cloudflareConfig); + await expect(Effect.runPromise(auth.callback( + request({}), + new URL("https://app.scratch.test/auth/callback/google?code=x&state=y"), + "https://app.scratch.test", + ))).rejects.toThrow("no OAuth callback"); + }); + + test("logout redirects to Cloudflare's edge logout endpoint", () => { + const auth = makeAuth(cloudflareConfig); + const response = HttpServerResponse.toWeb(auth.logout("https://app.scratch.test")); + expect(response.headers.get("location")).toBe("/cdn-cgi/access/logout"); + }); +}); + describe("readServerConfig", () => { test("reads OAuth settings from environment", async () => { const config = await Effect.runPromise( @@ -105,7 +344,7 @@ describe("readServerConfig", () => { expect(config.auth.allowedUsers).toBe("@example.com,@yc.com"); }); - test("rejects auth modes other than oauth", async () => { + test("rejects unknown auth modes", async () => { await expect( Effect.runPromise( readServerConfig({ @@ -115,7 +354,7 @@ describe("readServerConfig", () => { SCRATCHWORK_SESSION_SECRET: "session-secret-session-secret-32-bytes", }), ), - ).rejects.toThrow('Invalid SCRATCHWORK_AUTH "google"'); + ).rejects.toThrow('Invalid SCRATCHWORK_AUTH "google": expected "oauth" or "cloudflare-access"'); }); test("fails without OAuth credentials", async () => { @@ -124,6 +363,46 @@ describe("readServerConfig", () => { ); }); + test("reads Cloudflare Access settings and normalizes the team domain", async () => { + const config = await Effect.runPromise( + readServerConfig({ + SCRATCHWORK_AUTH: "cloudflare-access", + SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN: "myteam", + SCRATCHWORK_CF_ACCESS_AUD: "aud-tag-1", + SCRATCHWORK_SESSION_SECRET: "session-secret-session-secret-32-bytes", + SCRATCHWORK_ALLOWED_USERS: "@example.com", + }), + ); + + expect(config.auth.mode).toBe("cloudflare-access"); + if (config.auth.mode !== "cloudflare-access") throw new Error("unreachable"); + expect(config.auth.teamDomain).toBe("https://myteam.cloudflareaccess.com"); + expect(config.auth.audience).toBe("aud-tag-1"); + expect(config.auth.allowedUsers).toBe("@example.com"); + + // Full and bare forms of the team domain normalize the same way. + for (const value of ["myteam.cloudflareaccess.com", "https://myteam.cloudflareaccess.com"]) { + const alternate = await Effect.runPromise( + readServerConfig({ + SCRATCHWORK_AUTH: "cloudflare-access", + SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN: value, + SCRATCHWORK_CF_ACCESS_AUD: "aud-tag-1", + SCRATCHWORK_SESSION_SECRET: "session-secret-session-secret-32-bytes", + }), + ); + if (alternate.auth.mode !== "cloudflare-access") throw new Error("unreachable"); + expect(alternate.auth.teamDomain).toBe("https://myteam.cloudflareaccess.com"); + } + }); + + test("fails without the Cloudflare Access settings, without demanding OAuth credentials", async () => { + await expect( + Effect.runPromise(readServerConfig({ SCRATCHWORK_AUTH: "cloudflare-access" })), + ).rejects.toThrow( + "Cloudflare Access mode requires SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN, SCRATCHWORK_CF_ACCESS_AUD, SCRATCHWORK_SESSION_SECRET", + ); + }); + test("defaults to user-set project names", async () => { const config = await Effect.runPromise( readServerConfig({ diff --git a/server/core/test/cloudflare-jwt.test.ts b/server/core/test/cloudflare-jwt.test.ts new file mode 100644 index 0000000..bf51cb1 --- /dev/null +++ b/server/core/test/cloudflare-jwt.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import * as Effect from "effect/Effect"; +import { bytesToBase64Url } from "../../../shared/src/encoding/base64"; +import { verifyCloudflareAccessToken } from "../src/cloudflare-jwt"; +import { jwksFetch, makeKeyPair, nowSeconds as now, signJwt } from "./jwt-helpers"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +const TEAM_DOMAIN = "https://myteam.cloudflareaccess.com"; +const AUDIENCE = "aud-tag-1"; + +/** Baseline claims of a valid Access application token; Cloudflare sends aud as an array. */ +function validClaims(): Record { + return { + iss: TEAM_DOMAIN, + aud: [AUDIENCE], + sub: "cf-user-uuid-1", + email: "founder@example.com", + exp: now() + 600, + iat: now(), + type: "app", + }; +} + +describe("verifyCloudflareAccessToken", () => { + test("accepts a valid Access application token", async () => { + const keyPair = await makeKeyPair(); + globalThis.fetch = jwksFetch(keyPair.publicJwk); + const token = await signJwt(keyPair.privateKey, validClaims()); + + const claims = await Effect.runPromise(verifyCloudflareAccessToken(token, { + teamDomain: TEAM_DOMAIN, + audience: AUDIENCE, + jwksUrl: "https://cf-jwks-1.test/certs", + })); + + expect(claims.email).toBe("founder@example.com"); + expect(claims.sub).toBe("cf-user-uuid-1"); + }); + + test("rejects wrong audiences, wrong issuers, and expired tokens", async () => { + const keyPair = await makeKeyPair(); + globalThis.fetch = jwksFetch(keyPair.publicJwk); + const options = { teamDomain: TEAM_DOMAIN, audience: AUDIENCE, jwksUrl: "https://cf-jwks-2.test/certs" }; + + const wrongAud = await signJwt(keyPair.privateKey, { ...validClaims(), aud: ["another-application"] }); + await expect(Effect.runPromise(verifyCloudflareAccessToken(wrongAud, options))).rejects.toThrow("audience"); + + const wrongIssuer = await signJwt(keyPair.privateKey, { ...validClaims(), iss: "https://other.cloudflareaccess.com" }); + await expect(Effect.runPromise(verifyCloudflareAccessToken(wrongIssuer, options))).rejects.toThrow("issuer"); + + const expired = await signJwt(keyPair.privateKey, { ...validClaims(), exp: now() - 3600 }); + await expect(Effect.runPromise(verifyCloudflareAccessToken(expired, options))).rejects.toThrow("Expired"); + }); + + test("rejects service tokens, which assert a client ID instead of an email", async () => { + const keyPair = await makeKeyPair(); + globalThis.fetch = jwksFetch(keyPair.publicJwk); + const serviceToken = await signJwt(keyPair.privateKey, { + ...validClaims(), + email: undefined, + sub: "", + common_name: "service-token-client-id", + }); + + await expect(Effect.runPromise(verifyCloudflareAccessToken(serviceToken, { + teamDomain: TEAM_DOMAIN, + audience: AUDIENCE, + jwksUrl: "https://cf-jwks-3.test/certs", + }))).rejects.toThrow("service tokens are not supported"); + }); + + test("rejects tampered payloads", async () => { + const keyPair = await makeKeyPair(); + globalThis.fetch = jwksFetch(keyPair.publicJwk); + const token = await signJwt(keyPair.privateKey, validClaims()); + + const [header, , signature] = token.split("."); + const tamperedPayload = bytesToBase64Url(new TextEncoder().encode(JSON.stringify({ + ...validClaims(), + email: "attacker@example.com", + }))); + + await expect(Effect.runPromise(verifyCloudflareAccessToken(`${header}.${tamperedPayload}.${signature}`, { + teamDomain: TEAM_DOMAIN, + audience: AUDIENCE, + jwksUrl: "https://cf-jwks-4.test/certs", + }))).rejects.toThrow("signature"); + }); +}); diff --git a/server/core/test/google-jwt.test.ts b/server/core/test/google-jwt.test.ts index 1ebed84..0dcf66d 100644 --- a/server/core/test/google-jwt.test.ts +++ b/server/core/test/google-jwt.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test"; import * as Effect from "effect/Effect"; import { bytesToBase64Url } from "../../../shared/src/encoding/base64"; -import { toArrayBuffer } from "../../../shared/src/encoding/bytes"; import { verifyGoogleIdToken } from "../src/google-jwt"; +import { jwksFetch, makeKeyPair, nowSeconds as now, signJwt } from "./jwt-helpers"; const originalFetch = globalThis.fetch; @@ -72,46 +72,3 @@ describe("verifyGoogleIdToken", () => { }); }); -/** Creates a test RSA key pair and public JWK. */ -async function makeKeyPair() { - const pair = await crypto.subtle.generateKey( - { - name: "RSASSA-PKCS1-v1_5", - modulusLength: 2048, - publicExponent: new Uint8Array([1, 0, 1]), - hash: "SHA-256", - }, - true, - ["sign", "verify"], - ) as CryptoKeyPair; - const publicJwk = await crypto.subtle.exportKey("jwk", pair.publicKey); - return { privateKey: pair.privateKey, publicJwk: { ...publicJwk, kid: "kid-1", alg: "RS256", use: "sig" } }; -} - -/** Signs a test JWT with the generated RSA private key. */ -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 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))}`; -} - -/** Returns a fetch mock that serves one JWKS document. */ -function jwksFetch(publicJwk: JsonWebKey & { readonly kid: string }): typeof fetch { - return (async () => new Response(JSON.stringify({ keys: [publicJwk] }), { - headers: { - "cache-control": "max-age=60", - "content-type": "application/json", - }, - })) as unknown as typeof fetch; -} - -/** Returns the current Unix timestamp in seconds for test claims. */ -function now(): number { - return Math.floor(Date.now() / 1000); -} diff --git a/server/core/test/helpers.ts b/server/core/test/helpers.ts index 6cd39d6..a3045dc 100644 --- a/server/core/test/helpers.ts +++ b/server/core/test/helpers.ts @@ -45,6 +45,7 @@ export async function appHandler(options: { usersCanSetProjectNames: true, defaultVisibility: "public", auth: { + mode: "oauth", clientId: "test-client-id", clientSecret: "test-client-secret", sessionSecret: "test-session-secret-test-session-secret", diff --git a/server/core/test/jwt-helpers.ts b/server/core/test/jwt-helpers.ts new file mode 100644 index 0000000..e6d0adb --- /dev/null +++ b/server/core/test/jwt-helpers.ts @@ -0,0 +1,58 @@ +/** + * Shared JWT test fixtures: a throwaway RSA key pair, a compact RS256-JWT signer, and a + * fetch mock serving the matching JWKS document. Used by the google-jwt, cloudflare-jwt, + * 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 { toArrayBuffer } from "../../../shared/src/encoding/bytes"; + +/** A generated RSA signing key with its public JWKS entry. */ +export interface TestKeyPair { + readonly privateKey: CryptoKey; + readonly publicJwk: JsonWebKey & { readonly kid: string }; +} + +/** Creates a test RSA key pair and public JWK. */ +export async function makeKeyPair(): Promise { + const pair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ) as CryptoKeyPair; + const publicJwk = await crypto.subtle.exportKey("jwk", pair.publicKey); + return { privateKey: pair.privateKey, publicJwk: { ...publicJwk, kid: "kid-1", alg: "RS256", use: "sig" } }; +} + +/** 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 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))}`; +} + +/** Returns a fetch mock that serves one JWKS document. */ +export function jwksFetch(publicJwk: JsonWebKey & { readonly kid: string }): typeof fetch { + return (async () => new Response(JSON.stringify({ keys: [publicJwk] }), { + headers: { + "cache-control": "max-age=60", + "content-type": "application/json", + }, + })) as unknown as typeof fetch; +} + +/** Returns the current Unix timestamp in seconds for test claims. */ +export function nowSeconds(): number { + return Math.floor(Date.now() / 1000); +} diff --git a/server/core/test/site-store.test.ts b/server/core/test/site-store.test.ts index 7a9f659..68c162a 100644 --- a/server/core/test/site-store.test.ts +++ b/server/core/test/site-store.test.ts @@ -42,6 +42,7 @@ function config(maxVisibility: string): ServerConfigShape { usersCanSetProjectNames: true, defaultVisibility: "private", auth: { + mode: "oauth", clientId: "client-id", clientSecret: "client-secret", sessionSecret: "test-session-secret-test-session-secret", diff --git a/server/deploy-cloudflare/src/deploy.ts b/server/deploy-cloudflare/src/deploy.ts index c5ae4f9..026a487 100644 --- a/server/deploy-cloudflare/src/deploy.ts +++ b/server/deploy-cloudflare/src/deploy.ts @@ -219,6 +219,8 @@ async function writeConfig( const vars: Record = {}; copyEnv(vars, env, "SCRATCHWORK_AUTH"); copyEnv(vars, env, "SCRATCHWORK_GOOGLE_CLIENT_ID"); + copyEnv(vars, env, "SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN"); + copyEnv(vars, env, "SCRATCHWORK_CF_ACCESS_AUD"); copyEnv(vars, env, "SCRATCHWORK_AUTH_ALLOWED_EMAILS"); copyEnv(vars, env, "SCRATCHWORK_AUTH_ALLOWED_DOMAINS"); copyEnv(vars, env, "SCRATCHWORK_ALLOWED_USERS"); diff --git a/server/deploy-cloudflare/test/worker.test.ts b/server/deploy-cloudflare/test/worker.test.ts index 6389d16..3d724f3 100644 --- a/server/deploy-cloudflare/test/worker.test.ts +++ b/server/deploy-cloudflare/test/worker.test.ts @@ -52,6 +52,22 @@ describe("worker fetch", () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ ok: true }); }); + + test("a Cloudflare Access worker serves /health without OAuth credentials", async () => { + const env = { + ...bindings, + SCRATCHWORK_AUTH: "cloudflare-access", + SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN: "myteam", + SCRATCHWORK_CF_ACCESS_AUD: "aud-tag-1", + SCRATCHWORK_SESSION_SECRET: "test-session-secret-test-session-secret", + }; + const response = await worker.fetch(new Request("https://scratch.test/health"), env as never, { + waitUntil: () => {}, + passThroughOnException: () => {}, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + }); }); describe("envVarsFromCloudflare", () => { diff --git a/server/deploy-local/src/run.ts b/server/deploy-local/src/run.ts index c6cefbd..b5171a5 100644 --- a/server/deploy-local/src/run.ts +++ b/server/deploy-local/src/run.ts @@ -107,6 +107,8 @@ function serverSettingsEnv(server: ScratchworkServerConfig, processEnv: EnvVars) }; set("SCRATCHWORK_AUTH", server.auth); set("SCRATCHWORK_GOOGLE_CLIENT_ID", server.googleClientId); + set("SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN", server.cfAccessTeamDomain); + set("SCRATCHWORK_CF_ACCESS_AUD", server.cfAccessAud); set("SCRATCHWORK_AUTH_ALLOWED_EMAILS", server.authAllowedEmails); set("SCRATCHWORK_AUTH_ALLOWED_DOMAINS", server.authAllowedDomains); set("SCRATCHWORK_AUTH_SESSION_SECONDS", server.authSessionSeconds == null ? undefined : String(server.authSessionSeconds)); diff --git a/server/scripts/server-settings.test.ts b/server/scripts/server-settings.test.ts index 5823256..f41b8a3 100644 --- a/server/scripts/server-settings.test.ts +++ b/server/scripts/server-settings.test.ts @@ -36,6 +36,14 @@ describe("serverConfigEnv", () => { SCRATCHWORK_HOMEPAGE_PROJECT: "home", }); }); + + test("maps the Cloudflare Access settings onto their environment variables", () => { + expect(serverConfigEnv({ auth: "cloudflare-access", cfAccessTeamDomain: "myteam", cfAccessAud: "aud-tag-1" }, {})).toEqual({ + SCRATCHWORK_AUTH: "cloudflare-access", + SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN: "myteam", + SCRATCHWORK_CF_ACCESS_AUD: "aud-tag-1", + }); + }); }); describe("validateDeploymentConfig", () => { @@ -66,6 +74,22 @@ describe("validateDeploymentConfig", () => { .toThrow("SCRATCHWORK_SESSION_SECRET is required"); }); + test("accepts a Cloudflare Access config without OAuth credentials", () => { + const cfEnv = { + SCRATCHWORK_AUTH: "cloudflare-access", + SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN: "myteam", + SCRATCHWORK_CF_ACCESS_AUD: "aud-tag-1", + SCRATCHWORK_SESSION_SECRET: "test-session-secret-test-session-secret", + SCRATCHWORK_APP_URL: "https://app.example", + SCRATCHWORK_CONTENT_URL: "https://pages.example", + }; + expect(() => validateDeploymentConfig(cfEnv, "Test")).not.toThrow(); + expect(() => validateDeploymentConfig({ ...cfEnv, SCRATCHWORK_CF_ACCESS_AUD: undefined }, "Test")) + .toThrow("SCRATCHWORK_CF_ACCESS_AUD is required"); + expect(() => validateDeploymentConfig({ ...cfEnv, SCRATCHWORK_SESSION_SECRET: undefined }, "Test")) + .toThrow("SCRATCHWORK_SESSION_SECRET is required"); + }); + test("validates the homepage settings", () => { const homepage = { SCRATCHWORK_HOMEPAGE_DOMAINS: "example.com,www.example.com", diff --git a/server/scripts/server-settings.ts b/server/scripts/server-settings.ts index fdf61e8..fbfc49e 100644 --- a/server/scripts/server-settings.ts +++ b/server/scripts/server-settings.ts @@ -11,8 +11,12 @@ import type { DeployEnv } from "./env"; /** The `server` section of a deploy project's config, mapped onto SCRATCHWORK_* env vars. */ export interface ScratchworkServerConfig { - readonly auth?: "oauth"; + readonly auth?: "oauth" | "cloudflare-access"; readonly googleClientId?: string; + /** Cloudflare Access team domain, like "myteam" or "myteam.cloudflareaccess.com". */ + readonly cfAccessTeamDomain?: string; + /** Audience (AUD) tag of the Cloudflare Access application protecting this server. */ + readonly cfAccessAud?: string; readonly authAllowedEmails?: string; readonly authAllowedDomains?: string; readonly authSessionSeconds?: number; @@ -67,6 +71,8 @@ export function serverConfigEnv(config: ScratchworkServerConfig, resolved: Resol const env: DeployEnv = {}; if (config.auth != null) env.SCRATCHWORK_AUTH = config.auth; if (config.googleClientId != null) env.SCRATCHWORK_GOOGLE_CLIENT_ID = config.googleClientId; + if (config.cfAccessTeamDomain != null) env.SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN = config.cfAccessTeamDomain; + if (config.cfAccessAud != null) env.SCRATCHWORK_CF_ACCESS_AUD = config.cfAccessAud; if (config.authAllowedEmails != null) env.SCRATCHWORK_AUTH_ALLOWED_EMAILS = config.authAllowedEmails; if (config.authAllowedDomains != null) env.SCRATCHWORK_AUTH_ALLOWED_DOMAINS = config.authAllowedDomains; if (config.authSessionSeconds != null) env.SCRATCHWORK_AUTH_SESSION_SECONDS = String(config.authSessionSeconds); @@ -97,16 +103,26 @@ export function homepagePublishHint( return `publish the homepage with: scratchwork publish --server ${server} --project ${config.homepageProject} --visibility public`; } -/** Validates required OAuth secrets before a deploy. Auth cannot be disabled. */ +/** Validates required auth settings before a deploy. Auth cannot be disabled. */ export function validateDeploymentAuth(env: DeployEnv, platform: string): void { const authMode = (env.SCRATCHWORK_AUTH ?? "").toLowerCase(); - if (authMode !== "" && authMode !== "oauth") { - throw new Error(`Invalid SCRATCHWORK_AUTH "${env.SCRATCHWORK_AUTH}": expected "oauth" (the only supported mode), or leave it unset`); + if (authMode !== "" && authMode !== "oauth" && authMode !== "cloudflare-access") { + throw new Error(`Invalid SCRATCHWORK_AUTH "${env.SCRATCHWORK_AUTH}": expected "oauth" or "cloudflare-access", or leave it unset for oauth`); + } + if (authMode === "cloudflare-access") { + for (const key of ["SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN", "SCRATCHWORK_CF_ACCESS_AUD", "SCRATCHWORK_SESSION_SECRET"]) { + if (!env[key]) { + throw new Error( + `${key} is required for Cloudflare Access auth: copy the team domain and application Audience (AUD) tag from the Cloudflare Zero Trust dashboard, and generate a session secret with "openssl rand -hex 32".`, + ); + } + } + return; } for (const key of ["SCRATCHWORK_GOOGLE_CLIENT_ID", "SCRATCHWORK_GOOGLE_CLIENT_SECRET", "SCRATCHWORK_SESSION_SECRET"]) { if (!env[key]) { throw new Error( - `${key} is required: ${platform} deploys always use OAuth. Create OAuth credentials at https://console.cloud.google.com/apis/credentials and generate a session secret with "openssl rand -hex 32".`, + `${key} is required: ${platform} deploys use OAuth unless SCRATCHWORK_AUTH is "cloudflare-access". Create OAuth credentials at https://console.cloud.google.com/apis/credentials and generate a session secret with "openssl rand -hex 32".`, ); } } From 5fe292ccd637015ccfa01e52e9633bdc83f8e36a Mon Sep 17 00:00:00 2001 From: Peter Koomen Date: Mon, 6 Jul 2026 14:50:58 -0700 Subject: [PATCH 02/13] CLI: pass Cloudflare Access-protected edges on API requests Store the cf_token relayed by a cloudflare-access server at login in auth.json and send it back as a cf-access-token header on every API request, so publish/share/etc. pass the edge with no Access bypass policy. SCRATCHWORK_CF_ACCESS_CLIENT_ID/SECRET attach Access service-token headers for CI. Requests the edge blocks anyway (403 + cf-mitigated, or an Access login page where JSON was expected) fail with a clear re-login prompt instead of dumping HTML. Co-Authored-By: Claude Fable 5 --- cli/src/api.ts | 65 +++++++++++++++-- cli/src/auth.ts | 25 ++++++- cli/src/commands/login.ts | 2 +- cli/src/commands/publish.ts | 2 +- cli/test/api.test.js | 141 ++++++++++++++++++++++++++++++++++++ cli/test/auth.test.js | 68 ++++++++++++++++- 6 files changed, 293 insertions(+), 10 deletions(-) create mode 100644 cli/test/api.test.js diff --git a/cli/src/api.ts b/cli/src/api.ts index 369e286..0e2653d 100644 --- a/cli/src/api.ts +++ b/cli/src/api.ts @@ -13,7 +13,7 @@ import * as HttpClientRequest from "@effect/platform/HttpClientRequest"; import type * as Path from "@effect/platform/Path"; import * as Effect from "effect/Effect"; import { isRecord, parseJson } from "../../shared/src/util/json"; -import { readAuthToken, serverApiUrl } from "./auth"; +import { readAuthToken, readCfToken, serverApiUrl } from "./auth"; import { CliError, errorMessage } from "./errors"; /** A completed API exchange: HTTP status plus the raw and JSON-decoded body. */ @@ -40,21 +40,30 @@ export interface ResolvedProjectRef { /** * 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. Requests are - * interruption-safe: Ctrl-C aborts the in-flight request. + * 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. */ export function apiRequest( context: string, url: URL | string, options: ApiRequestOptions = {}, -): Effect.Effect { +): Effect.Effect { 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, + "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.", + ); + } return { status: response.status, ok: response.status >= 200 && response.status < 300, @@ -74,7 +83,7 @@ export function apiJson( context: string, url: URL | string, options: ApiRequestOptions = {}, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const response = yield* apiRequest(context, url, options); if (!response.ok) return yield* apiFail(context, apiErrorText(response)); @@ -82,6 +91,52 @@ export function apiJson( }); } +/** + * 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 + * 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. + */ +function attachCloudflareAccess( + request: HttpClientRequest.HttpClientRequest, + url: URL | string, +): Effect.Effect { + return Effect.gen(function* () { + let result = request; + const cfToken = yield* readCfToken(new URL(url).origin); + 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; + if (clientId != null && clientId !== "" && clientSecret != null && clientSecret !== "") { + result = HttpClientRequest.setHeaders(result, { + "CF-Access-Client-Id": clientId, + "CF-Access-Client-Secret": clientSecret, + }); + } + return result; + }); +} + +/** 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). + */ +function edgeBlocked( + status: number, + headers: Readonly>, + text: string, +): boolean { + if (status === 403 && headers["cf-mitigated"] != null) return true; + 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 — diff --git a/cli/src/auth.ts b/cli/src/auth.ts index 3ba5029..8b001e2 100644 --- a/cli/src/auth.ts +++ b/cli/src/auth.ts @@ -18,10 +18,13 @@ import { CliError, errorMessage } from "./errors"; const AUTH_FILE = "auth.json"; const DEFAULT_APP_SUBDOMAIN = "app"; -/** One stored login: the bearer token for a server plus who/when it was issued. */ +/** 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; } @@ -36,6 +39,7 @@ export interface LoginCallback { readonly token: string; readonly email?: string; readonly server?: string; + readonly cfToken?: string; } /** @@ -58,11 +62,27 @@ export function readAuthToken( }); } +/** Looks up the stored Cloudflare Access JWT for a server, with the same origin + * fallbacks as readAuthToken. Undefined for servers that did not relay one. */ +export function readCfToken( + server: string, +): Effect.Effect { + return Effect.gen(function* () { + const auth = yield* readAuthFile(); + for (const candidate of candidateServers(server)) { + const cfToken = auth.servers[candidate]?.cfToken; + if (cfToken != null) return cfToken; + } + return undefined; + }); +} + /** Saves a bearer token for a server, preserving tokens stored for other servers. */ export function writeAuthToken( server: string, token: string, email: string | undefined, + cfToken?: string, ): Effect.Effect { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -76,6 +96,7 @@ export function writeAuthToken( [server]: { token, email, + cfToken, updatedAt: new Date().toISOString(), }, }, @@ -128,6 +149,7 @@ export function decodeLoginCallback(url: URL): LoginCallback | null { token, email: nonEmpty(url.searchParams.get("email") ?? undefined), server: nonEmpty(url.searchParams.get("server") ?? undefined), + cfToken: nonEmpty(url.searchParams.get("cf_token") ?? undefined), }; } @@ -200,6 +222,7 @@ function isAuthFile(value: unknown): value is AuthFile { 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; } diff --git a/cli/src/commands/login.ts b/cli/src/commands/login.ts index 46dc913..d2da0dd 100644 --- a/cli/src/commands/login.ts +++ b/cli/src/commands/login.ts @@ -31,7 +31,7 @@ export function runLogin( try: () => normalizeServerUrl(result.server ?? server), catch: () => new CliError({ code: 1, message: `scratchwork login: server returned an invalid server URL: ${result.server}` }), }); - yield* writeAuthToken(authenticatedServer, result.token, result.email); + yield* writeAuthToken(authenticatedServer, result.token, result.email, result.cfToken); yield* Console.log(`Authenticated ${result.email ?? "user"} for ${authenticatedServer}`); }); } diff --git a/cli/src/commands/publish.ts b/cli/src/commands/publish.ts index e7c6c0b..8e64c6c 100644 --- a/cli/src/commands/publish.ts +++ b/cli/src/commands/publish.ts @@ -187,7 +187,7 @@ function postPublish( readonly visibility?: string; }, authToken: string | undefined, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const response = yield* apiRequest("scratchwork publish", serverApiUrl(server, "/api/publish"), { method: "POST", diff --git a/cli/test/api.test.js b/cli/test/api.test.js new file mode 100644 index 0000000..1c59a07 --- /dev/null +++ b/cli/test/api.test.js @@ -0,0 +1,141 @@ +/* + * 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. + */ +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +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 { writeAuthToken } from "../src/auth"; + +const TestLayer = Layer.mergeAll(FetchHttpClient.layer, BunContext.layer); +const run = (effect) => Effect.runPromise(Effect.provide(effect, TestLayer)); + +let home; +let previousHome; +beforeAll(() => { + home = mkdtempSync(join(tmpdir(), "scratchwork-api-test-")); + previousHome = process.env.SCRATCHWORK_HOME; + process.env.SCRATCHWORK_HOME = home; +}); +afterAll(() => { + if (previousHome == null) delete process.env.SCRATCHWORK_HOME; + else process.env.SCRATCHWORK_HOME = previousHome; + rmSync(home, { recursive: true, force: true }); +}); + +/** Serves one canned response for the duration of a test. */ +function withServer(handler, use) { + const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: handler }); + const origin = `http://127.0.0.1:${server.port}`; + return Promise.resolve(use(origin)).finally(() => server.stop(true)); +} + +describe("apiRequest 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 () => { + await withServer( + (request) => Response.json({ headers: Object.fromEntries(request.headers) }), + async (origin) => { + 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"); + }, + ); + }); + + 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(); + }, + ); + }); + + test("sends service-token headers when both env vars are set", async () => { + 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"); + }, + ); + }); + + 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(); + }, + ); + }); +}); + +describe("apiRequest Cloudflare edge-block detection", () => { + test("a 403 tagged cf-mitigated fails with the re-auth hint", async () => { + await withServer( + () => new Response("Forbidden", { status: 403, headers: { "cf-mitigated": "challenge" } }), + async (origin) => { + await expect(run(apiRequest("scratchwork publish", `${origin}/api/publish`, {}))) + .rejects.toThrow("Cloudflare Access blocked this request. Run `scratchwork login` again"); + }, + ); + }); + + test("an Access login page where JSON was expected fails with the re-auth hint", async () => { + const loginPage = "Sign inmyteam.cloudflareaccess.com"; + 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"); + }, + ); + }); + + test("an ordinary 403 from the server is returned for the caller to interpret", 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" }); + }, + ); + }); + + test("an ordinary HTML error page without Access markers is not misread as a block", async () => { + await withServer( + () => new Response("502 Bad Gatewayoops", { + status: 502, + headers: { "content-type": "text/html" }, + }), + async (origin) => { + const response = await run(apiRequest("scratchwork test", `${origin}/api/me`, {})); + expect(response.status).toBe(502); + }, + ); + }); +}); diff --git a/cli/test/auth.test.js b/cli/test/auth.test.js index 5f4876a..b5245ba 100644 --- a/cli/test/auth.test.js +++ b/cli/test/auth.test.js @@ -1,5 +1,10 @@ -import { describe, expect, test } from "bun:test"; -import { normalizeServerUrl } from "../src/auth"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as BunContext from "@effect/platform-bun/BunContext"; +import * as Effect from "effect/Effect"; +import { decodeLoginCallback, normalizeServerUrl, readAuthToken, readCfToken, writeAuthToken } from "../src/auth"; describe("normalizeServerUrl", () => { test("defaults bare public hosts to the https app subdomain", () => { @@ -19,3 +24,62 @@ describe("normalizeServerUrl", () => { expect(normalizeServerUrl("http://localhost:3001/")).toBe("http://localhost:3001"); }); }); + +describe("decodeLoginCallback", () => { + test("reads the relayed cf_token when the server sends one", () => { + const url = new URL("http://127.0.0.1:5555/callback?token=bearer-1&email=a%40b.co&server=https%3A%2F%2Fapp.b.co&cf_token=cf-jwt-1"); + expect(decodeLoginCallback(url)).toEqual({ + token: "bearer-1", + email: "a@b.co", + server: "https://app.b.co", + cfToken: "cf-jwt-1", + }); + }); + + test("leaves cfToken unset when the server does not relay one", () => { + const url = new URL("http://127.0.0.1:5555/callback?token=bearer-1"); + const decoded = decodeLoginCallback(url); + expect(decoded?.token).toBe("bearer-1"); + expect(decoded?.cfToken).toBeUndefined(); + }); +}); + +describe("auth.json cfToken storage", () => { + let home; + let previousHome; + beforeAll(() => { + home = mkdtempSync(join(tmpdir(), "scratchwork-auth-test-")); + previousHome = process.env.SCRATCHWORK_HOME; + process.env.SCRATCHWORK_HOME = home; + }); + afterAll(() => { + if (previousHome == null) delete process.env.SCRATCHWORK_HOME; + else process.env.SCRATCHWORK_HOME = previousHome; + rmSync(home, { recursive: true, force: true }); + }); + + const run = (effect) => Effect.runPromise(Effect.provide(effect, BunContext.layer)); + + test("round-trips the cfToken alongside the bearer token", async () => { + await run(writeAuthToken("https://app.cf.example", "bearer-1", "a@b.co", "cf-jwt-1")); + expect(await run(readAuthToken("https://app.cf.example"))).toBe("bearer-1"); + expect(await run(readCfToken("https://app.cf.example"))).toBe("cf-jwt-1"); + // The content-host origin fallback finds it too, like readAuthToken. + expect(await run(readCfToken("https://pages.cf.example"))).toBe("cf-jwt-1"); + }); + + test("a login without a cfToken stores none, and old auth files still read", async () => { + await run(writeAuthToken("https://app.plain.example", "bearer-2", "a@b.co")); + expect(await run(readAuthToken("https://app.plain.example"))).toBe("bearer-2"); + expect(await run(readCfToken("https://app.plain.example"))).toBeUndefined(); + + // A file written by an older CLI (no cfToken fields anywhere) is untouched by the + // reader and never treated as corrupt. + writeFileSync(join(home, "auth.json"), JSON.stringify({ + version: 1, + servers: { "https://app.old.example": { token: "bearer-3", updatedAt: "2026-01-01T00:00:00.000Z" } }, + })); + expect(await run(readAuthToken("https://app.old.example"))).toBe("bearer-3"); + expect(await run(readCfToken("https://app.old.example"))).toBeUndefined(); + }); +}); From 627c84af8d6ff7fd594f9d214e63067d5a2442bb Mon Sep 17 00:00:00 2001 From: Peter Koomen Date: Mon, 6 Jul 2026 14:50:58 -0700 Subject: [PATCH 03/13] Document Cloudflare Access CLI auth The /api/* Access bypass policy is now an optional fallback for older CLIs; document the token relay, service-token env vars, and the Access session-duration recommendation. Co-Authored-By: Claude Fable 5 --- notes/cloudflare-access.md | 152 +++++++++++++++++++++++++++++++++++++ notes/spec.md | 19 ++++- server/README.md | 27 ++++++- 3 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 notes/cloudflare-access.md diff --git a/notes/cloudflare-access.md b/notes/cloudflare-access.md new file mode 100644 index 0000000..ba4b8fa --- /dev/null +++ b/notes/cloudflare-access.md @@ -0,0 +1,152 @@ +# Cloudflare Access: CLI auth without Access-app configuration + +## Status + +**Implemented (2026-07-06), including the plan below.** Login relays the verified Access +JWT to the CLI as `cf_token`; the CLI stores it in auth.json and sends it back as +`cf-access-token` on API requests (which both Cloudflare's edge and the server accept); +`SCRATCHWORK_CF_ACCESS_CLIENT_ID`/`SECRET` attach service-token headers for CI; edge +blocks fail with a re-auth prompt. The `/api/*` bypass policy is now optional, documented +as a fallback for older CLIs. Remaining manual step: verify against a real +Access-protected deploy. + +The server-side `cloudflare-access` auth mode is implemented (see `server/README.md` +"Cloudflare Access"): `SCRATCHWORK_AUTH=cloudflare-access` makes the server verify the +`Cf-Access-Jwt-Assertion` header Cloudflare injects — RS256 signature against the team +JWKS, issuer, AUD tag, expiry — and use the asserted email as the identity. Browser +auth is transparent; `scratchwork login` works because `/auth/login` converts the +browser's verified assertion into the CLI's bearer token. + +The gap: the CLI's **API requests** (`publish`, `share`, ...) carry only the bearer +token, so Cloudflare's edge blocks them before they reach the server. Today the README +tells admins to work around this with an Access bypass policy for `/api/*` or by having +users authenticate with `cloudflared`. This plan removes that requirement. + +## Prior art + +`~/git/scratch/scratch` (the earlier scratch implementation) solved this; its approach +is the model, with corrections: + +1. **CF JWT relay**: during CLI login, its server reads the browser's + `Cf-Access-Jwt-Assertion` and passes it to the CLI loopback callback as `cf_token` + (`server/src/routes/app/ui.ts:114`). The CLI stores it and sends it back as a + `cf-access-token` header on every API request (`cli/src/cloud/request.ts:199`). + Cloudflare's edge accepts that header as an Access credential, so CLI requests pass + the edge with **zero Access-application configuration**. +2. **Service tokens** for CI: stored CF-Access-Client-Id/Secret headers; the edge + validates them and lets requests through. +3. **Edge-block detection**: 403 + `cf-mitigated` header, or HTML with Cloudflare + Access markers where JSON was expected, triggers a friendly re-auth prompt + (`cli/src/config/cf-access.ts:111-147`). + +One mistake there we must not copy: its JWT verification checks the issuer but **not +the audience** (`server/src/lib/cloudflare-access.ts:41`), so a JWT minted for any +other Access application in the same team validates. Our `cloudflare-jwt.ts` enforces +the AUD tag; keep that in every new acceptance path. + +## Plan + +### 1. Server: relay the CF JWT to the CLI at login + +`server/core/src/auth.ts`, `makeCloudflareAccessAuth` → `login`: + +- When `cli_redirect` is present (already validated loopback-only by + `safeCliRedirect`), add `cf_token=` to the + redirect alongside the existing `token`/`server`/`email` params. +- Relay the raw header only after `assertedUser` verified it (it already has, by the + time login mints the bearer token). +- The token rides a loopback query string exactly like the existing bearer token — + same exposure class, no new precedent. + +### 2. Server: accept the JWT from the `cf-access-token` header + +`server/core/src/auth.ts`, `assertedUser`: read `cf-access-jwt-assertion` first, then +fall back to `cf-access-token`. Verification is identical (signature, issuer, AUD, +expiry — `verifyCloudflareAccessToken`), so this adds no trust; it only matters when a +request reaches the origin without passing through Cloudflare (grey-clouded origin, +local testing, migration). When the request does go through the edge, Cloudflare +validates `cf-access-token` itself and injects `Cf-Access-Jwt-Assertion` anyway. + +Non-goal: reading the `CF_Authorization` cookie (the scratch repo does). Browser +requests always come through the edge, which injects the header; the cookie path adds +surface without a use case here. + +### 3. CLI: store and send the CF token + +- `cli/src/auth.ts`: + - `AuthRecord` gains optional `cfToken` (auth.json stays `version: 1`; the field is + optional, so old files and old CLIs are unaffected). + - `LoginCallback` and `decodeLoginCallback` read `cf_token`. + - `writeAuthToken` persists it; add `readCfToken(server)` using the same + `candidateServers` origin fallback as `readAuthToken`. +- `cli/src/commands/login.ts`: pass `cfToken` through to `writeAuthToken`. +- `cli/src/api.ts`: `apiRequest` resolves the CF token itself from the request URL's + origin (via `readCfToken`) and sets the `cf-access-token` header when one is stored. + `readAuthToken` keeps returning the bearer string, so its ~11 call sites across + `commands/publish.ts` and `commands/projects.ts` are untouched; the only ripple is + that `apiRequest`/`apiJson` now require `FileSystem | Path` in their Effect context, + which every caller already provides. + +### 4. CLI: detect Cloudflare edge blocks and fail helpfully + +In `apiRequest` (`cli/src/api.ts`), before generic error handling: + +- **Denied**: status 403 with a `cf-mitigated` response header → CliError: + "Cloudflare Access blocked this request. Run `scratchwork login` again (your Access + session may have expired), or set SCRATCHWORK_CF_ACCESS_CLIENT_ID/SECRET for + automation." +- **Login page**: response body is HTML containing Access markers + (`cloudflareaccess`, `CF_Authorization`, ...) where JSON was expected — covers the + 302-to-login-page case after redirect following. Hook this into both the non-ok + branch and the JSON-parse-failure branch, mirroring + `scratch/cli/src/cloud/request.ts:264,296`. +- No automatic retry loop (the scratch repo prompts interactively mid-request; our + CLI's commands are non-interactive by design) — one clear error with the fix is + enough. + +### 5. CLI: service tokens for CI/headless + +Env vars only (no new credential file): + +- `SCRATCHWORK_CF_ACCESS_CLIENT_ID` / `SCRATCHWORK_CF_ACCESS_CLIENT_SECRET`, attached + by `apiRequest` as `CF-Access-Client-Id` / `CF-Access-Client-Secret` headers when + both are set. +- No server change needed: the edge validates the service token and lets the request + through; the service-token JWT it injects has no email, but `requireApiUser` checks + the bearer token first, so the bearer still identifies the user. (Server-side + service-token identities remain unsupported — authorization is email-based.) + +### 6. Docs + +- `server/README.md` Cloudflare Access section: the `/api/*` bypass policy becomes + optional ("if you prefer not to relay Access tokens" / older CLIs) instead of + required; document the env vars and the Access session-duration recommendation. +- `notes/spec.md` "Authenticating": mention the relay and the `cf-access-token` + header. + +## Caveats and decisions + +- **CF token lifetime**: the relayed JWT expires with the Access application's session + duration (default 24h, configurable to weeks) — much shorter than the 30-day bearer + token. When it expires the edge blocks the CLI, which now fails with "run + `scratchwork login` again" (step 4). Recommend a long Access session duration in the + README; service tokens are the answer for automation. +- **No refresh path**: we deliberately don't try to refresh the CF token outside a + browser (that is Cloudflare's login flow, not ours). Re-login is the refresh. +- **Compatibility**: old CLI + new server ignores `cf_token` (still needs the bypass + policy, as today). New CLI + old server sees no `cf_token` and behaves exactly as + today. New CLI + oauth server: no `cf_token`, nothing attached. +- **AUD check stays** in every path that verifies an Access JWT (see "Prior art"). + +## Testing + +- `server/core/test/auth.test.ts`: login relay — CF-mode login with `cli_redirect` + includes `cf_token` equal to the presented assertion; oauth-mode login never does. + `cf-access-token` header accepted by `currentUser`/`requireApiUser`; + `cf-access-jwt-assertion` wins when both are present; invalid values still rejected. +- `cli/test`: `decodeLoginCallback` with/without `cf_token`; auth.json round-trip with + `cfToken` and old-file compatibility; `apiRequest` attaches `cf-access-token` and + service-token headers; edge-block detection on a fake 403 + `cf-mitigated` and on an + HTML body. +- Manual: local server in CF mode (JWT via test JWKS) exercising login → publish with + the relayed token; a real Access-protected deploy for the edge behavior. diff --git a/notes/spec.md b/notes/spec.md index ac68cf4..ef93174 100644 --- a/notes/spec.md +++ b/notes/spec.md @@ -74,10 +74,21 @@ export const server = { homepageDomains: ["example.com", "www.example.com"], homepageProject: "home", - // Authentication method. "oauth" is the only supported option; auth cannot be disabled. - // Every server requires OAuth credentials, and every project has an owner. + // Authentication method: "oauth" (built-in Google OAuth, the default) or + // "cloudflare-access" (the server runs behind a Cloudflare Access application that + // authenticates users at the edge). Auth cannot be disabled; every project has an owner. auth: "oauth", + // Cloudflare Access settings, required when auth is "cloudflare-access": the team + // domain ("myteam" or "myteam.cloudflareaccess.com") and the Access application's + // Audience (AUD) tag, both from the Cloudflare Zero Trust dashboard. The server + // verifies the Cf-Access-Jwt-Assertion header Cloudflare injects — signature against + // the team's public keys, issuer, audience, expiry — and uses the asserted email as + // the user identity. sessionSecret is still required (CLI bearer tokens and the + // private-content handoff). Service tokens are not supported. + // cfAccessTeamDomain: "myteam", + // cfAccessAud: "...", + // Optional login/API restrictions, in the standard group syntax. // Defaults to "public" unless a deploy target sets a tighter value. allowedUsers: "@example.com", @@ -356,9 +367,9 @@ scratchwork info [--server text] [--project text] [] ### Authenticating -Users authenticate their CLI using OAuth in the browser. The CLI stores the returned bearer session token in `~/.scratchwork/auth.json` and sends it to the API as a bearer token. A separate long-lived API-token/dashboard flow is out of scope for the current implementation. +Users authenticate their CLI using the browser (`scratchwork login`). The CLI stores the returned bearer session token in `~/.scratchwork/auth.json` and sends it to the API as a bearer token. A separate long-lived API-token/dashboard flow is out of scope for the current implementation. -Users authenticate to the app. domain using google oauth. +Users authenticate to the app. domain using google oauth, or — on a `cloudflare-access` server — are authenticated by Cloudflare at the edge before requests reach the server. In that mode the browser flow has no `/auth/callback` round-trip: `/auth/login` reads the verified Access assertion directly (which also keeps the CLI loopback flow working), and API requests accept either a scratchwork bearer token or a valid Access JWT (`Cf-Access-Jwt-Assertion`, or the CLI's relayed `cf-access-token`, verified identically). During CLI login the server relays the browser's verified Access JWT to the loopback callback as `cf_token`; the CLI stores it alongside the bearer token and sends it back as a `cf-access-token` header on every API request, which Cloudflare's edge accepts as an Access credential — so CLI requests pass an Access-protected edge without extra Access configuration. For CI, `SCRATCHWORK_CF_ACCESS_CLIENT_ID`/`SCRATCHWORK_CF_ACCESS_CLIENT_SECRET` attach Access service-token headers that satisfy the edge (identity still comes from the bearer token). A request the edge blocks anyway (expired Access session) fails with a clear prompt to run `scratchwork login` again. ### Accessing a server diff --git a/server/README.md b/server/README.md index 78f63b3..8866eba 100644 --- a/server/README.md +++ b/server/README.md @@ -21,10 +21,10 @@ By default the server listens on `43118` and stores published bundles under `.sc ## Google OAuth -Every server requires OAuth — auth cannot be disabled. Configure Google OAuth credentials (including for local development): +Every server requires auth — it cannot be disabled. The default mode is built-in Google OAuth; servers running behind Cloudflare Access can use that instead (see below). Configure Google OAuth credentials (including for local development): ```sh -SCRATCHWORK_AUTH=oauth # optional; "oauth" is the only supported mode +SCRATCHWORK_AUTH=oauth # optional; "oauth" is the default mode SCRATCHWORK_GOOGLE_CLIENT_ID=... SCRATCHWORK_GOOGLE_CLIENT_SECRET=... SCRATCHWORK_SESSION_SECRET=use-at-least-32-random-bytes @@ -88,6 +88,29 @@ scratchwork login --server https://your-scratchwork-server.example scratchwork publish index.html ``` +## Cloudflare Access + +When the server's domains are served through Cloudflare with a [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/policies/access/) application in front of them, the server can delegate authentication to Access instead of running OAuth itself: + +```sh +SCRATCHWORK_AUTH=cloudflare-access +SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN=myteam # or myteam.cloudflareaccess.com +SCRATCHWORK_CF_ACCESS_AUD=... # the Access application's Audience (AUD) tag +SCRATCHWORK_SESSION_SECRET=use-at-least-32-random-bytes +``` + +Both values come from the Cloudflare Zero Trust dashboard (the AUD tag is on the Access application's overview page). No Google credentials are needed. `SCRATCHWORK_SESSION_SECRET` is still required: it signs the CLI bearer tokens and the private-content handoff tokens. + +Cloudflare authenticates every user at the edge and injects a signed JWT into each request (`Cf-Access-Jwt-Assertion`); the server verifies it against the team's public keys and the AUD tag, and uses the asserted email as the user identity. `SCRATCHWORK_ALLOWED_USERS` still applies on top of the Access policy. Browser login is transparent (there is no `/auth/callback` round-trip), and `/auth/logout` redirects to Cloudflare's `/cdn-cgi/access/logout`. + +Things to know when setting up the Access application: + +- Cover the app domain (and any private content domains) with the Access application. Do **not** put a domain that serves public projects behind Access, or anonymous visitors will be blocked at the edge before the server can serve them. +- `scratchwork login` works unchanged: the browser passes Access, and the server converts the asserted identity into the CLI's bearer token. The login redirect also relays the browser's verified Access JWT; the CLI stores it and sends it back as a `cf-access-token` header on every API request, which Cloudflare's edge accepts as an Access credential — so CLI requests pass the edge with no extra Access configuration. +- The relayed JWT expires with the Access application's **session duration** (default 24 hours) — much shorter than the CLI's bearer token. When it expires, CLI commands fail with a prompt to run `scratchwork login` again; configure a longer session duration on the Access application to keep re-logins rare. +- For CI and headless automation, create an Access [service token](https://developers.cloudflare.com/cloudflare-one/identity/service-tokens/) and set `SCRATCHWORK_CF_ACCESS_CLIENT_ID` and `SCRATCHWORK_CF_ACCESS_CLIENT_SECRET` in the environment; the CLI attaches them as `CF-Access-Client-Id`/`CF-Access-Client-Secret` headers so requests pass the edge. Service tokens only satisfy the edge — the server still identifies the user by the bearer token, so the machine must have a stored login. +- Older CLIs that predate the token relay cannot pass the edge on API calls. For those, either add an Access bypass policy for `/api/*` (safe: the server still requires its own bearer token there), or have CLI users authenticate to Access themselves (e.g. with `cloudflared`) — the server accepts a valid `Cf-Access-Jwt-Assertion` (or `cf-access-token`) header on API calls. + ## AWS Deploy to AWS Lambda + S3 via the placeholder `deploy/generic-aws` project (kept around in case we invest more in AWS deploy capabilities): From d5d3f54a3a33ce723c914f75c14d0ddfb31219bb Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Tue, 7 Jul 2026 09:04:59 -0700 Subject: [PATCH 04/13] Add local CLI activation and Markdown fixture --- README.md | 10 ++ cli/activate-scratchwork-alias | 16 ++ examples/cli-example/index.md | 46 ------ .../components/Counter.js | 0 .../components/Highlight.js | 0 examples/local-dev-project/index.md | 151 ++++++++++++++++++ .../local-dev-project/scratchwork-logo.svg | 1 + renderer/src/render.js | 6 +- renderer/test/render.test.js | 4 +- shared/src/site/default-renderer.generated.js | 4 +- 10 files changed, 186 insertions(+), 52 deletions(-) create mode 100644 cli/activate-scratchwork-alias delete mode 100644 examples/cli-example/index.md rename examples/{cli-example => local-dev-project}/components/Counter.js (100%) rename examples/{cli-example => local-dev-project}/components/Highlight.js (100%) create mode 100644 examples/local-dev-project/index.md create mode 100644 examples/local-dev-project/scratchwork-logo.svg diff --git a/README.md b/README.md index f1e2323..f49fa9c 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,16 @@ scratchwork dev page.md scratchwork dev page.html ``` +When developing the CLI itself, activate this checkout once in each terminal +session: + +```sh +source ./cli/activate-scratchwork-alias +``` + +After that, `scratchwork` runs `cli/src/index.ts` from this checkout, even after +changing to another directory. + ## Working with Markdown Scratchwork renders Markdown with an embedded default renderer, and Markdown files can reference React components from nearby component files. See [`docs/index.md`](docs/index.md) for live examples. diff --git a/cli/activate-scratchwork-alias b/cli/activate-scratchwork-alias new file mode 100644 index 0000000..875d6ca --- /dev/null +++ b/cli/activate-scratchwork-alias @@ -0,0 +1,16 @@ +# Source this file to run the checkout's CLI as `scratchwork` in this shell. + +if [ -n "${ZSH_VERSION:-}" ]; then + SCRATCHWORK_DEV_ROOT="${${(%):-%N}:A:h:h}" +elif [ -n "${BASH_VERSION:-}" ]; then + SCRATCHWORK_DEV_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" +else + echo "scratchwork: cli/activate-scratchwork-alias supports zsh and bash" >&2 + return 1 +fi + +scratchwork() { + command bun "$SCRATCHWORK_DEV_ROOT/cli/src/index.ts" "$@" +} + +echo "scratchwork: activated $SCRATCHWORK_DEV_ROOT/cli/src/index.ts" diff --git a/examples/cli-example/index.md b/examples/cli-example/index.md deleted file mode 100644 index 3440373..0000000 --- a/examples/cli-example/index.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -# Page metadata — edit freely. -title: "My Scratchwork project" -description: "A static page authored in Markdown" -author: "Me" -lang: "en" ---- - -# My Scratchwork project - -Welcome! This page was written by `scratchwork example`. It's plain Markdown — -edit `index.md` and the page hot-reloads while `scratchwork dev` is running. - -## Markdown, the usual way - -Write **bold**, _italic_, `inline code`, [links](https://scratchwork.dev), and -lists: - -- Add a list item -- Then another -- Reorder them however you like - -```js -// Fenced code blocks are syntax-highlighted. -function hello(name) { - return `Hello, ${name}!`; -} -``` - -## React components, inline - -Scratchwork renders Markdown through a template that lets you drop React -components right into the prose. Each embedded `` maps to a file in -`components/`, in this case `components/Tag.js`. - -Components render inline, like this: - -
- -
- -You can also build small formatting components like this -highlighter for anything Markdown can't express on its own. - -Open `components/Counter.js` to see how a component is defined, then add your -own. diff --git a/examples/cli-example/components/Counter.js b/examples/local-dev-project/components/Counter.js similarity index 100% rename from examples/cli-example/components/Counter.js rename to examples/local-dev-project/components/Counter.js diff --git a/examples/cli-example/components/Highlight.js b/examples/local-dev-project/components/Highlight.js similarity index 100% rename from examples/cli-example/components/Highlight.js rename to examples/local-dev-project/components/Highlight.js diff --git a/examples/local-dev-project/index.md b/examples/local-dev-project/index.md new file mode 100644 index 0000000..79a0ea6 --- /dev/null +++ b/examples/local-dev-project/index.md @@ -0,0 +1,151 @@ +--- +# Page metadata — edit freely. +title: "Markdown feature spectrum" +description: "A visual fixture for Scratchwork's supported Markdown" +author: "Scratchwork" +lang: "en" +keywords: ["markdown", "components", "test fixture"] +--- + +[![Scratchwork](scratchwork-logo.svg "Scratchwork")](https://scratchwork.dev) + +# Markdown feature spectrum + +This page exercises Scratchwork's supported Markdown and component embedding. +Edit `index.md` and it hot-reloads while `scratchwork dev` is running. + +## React components, inline + +Scratchwork maps each embedded component to a JavaScript file in `components/`. +The existing stateful counter remains part of this fixture: + +
+ +
+ +Components can also wrap Markdown content. Here is +text rendered by the existing Highlight component. + +Open `components/Counter.js` and `components/Highlight.js` to see how the +components are defined. + + + +Underlined heading level one +============================ + +Underlined heading level two +---------------------------- + +### ATX heading level three + +#### ATX heading level four + +##### ATX heading level five + +###### ATX heading level six + +## Inline formatting + +Plain text can contain *asterisk emphasis*, _underscore emphasis_, **bold**, +***bold italic***, ~~strikethrough~~, and `inline code`. A multi-backtick span +can contain a backtick: ``const marker = `code`;``. + +Escaped punctuation stays literal: \*not italic\*, \[not a link\], and \# not a +heading. This line ends with a backslash.\ +So this text starts after a hard break. This one does too.\ +It also starts after a hard break. + +## Links + +Try an [inline link](https://scratchwork.dev "Scratchwork"), a [reference +link][scratchwork], an autolink , and a bare URL: +https://example.org/docs. + +## Lists + +- An unordered item +- An item with lazy + continuation text + - A nested unordered item + - Another nested item +- A final item + +An ordered list with an explicit starting number: + +3. An ordered list starting at three +4. Its second item + 1. A nested ordered item + 2. Another nested ordered item + +Task list items: + +- [x] Completed task +- [ ] Open task +- Plain list item + +Fenced code nested inside a list: + +- A list item containing fenced code: + + ```js + const insideAList = true; + ``` + +- The next list item + +## Blockquotes + +> A blockquote can contain **formatting** and a [link][scratchwork]. +> +> > It can also contain a nested blockquote. +> +> - And a list +> - With multiple items + +## Code blocks + +```js +// Backtick fence with syntax highlighting. +function hello(name) { + return `Hello, ${name}!`; +} +``` + +~~~python +# Tilde fences work too. +def hello(name): + return f"Hello, {name}!" +~~~ + +An indented code block follows: + + const indented = "four spaces"; + console.log(indented); + +## Tables + +| Left aligned | Center aligned | Right aligned | +| :----------- | :------------: | ------------: | +| Alpha | Beta | 10 | +| Escaped \| pipe | `code | pipe` | 20 | + +## Horizontal rule + +Thematic sections can be separated with a horizontal rule. + +--- + +The content continues after the rule. + +## Raw HTML + +Inline HTML includes Ctrl + K and an explicit
line +break. + +
+ This block is rendered from raw HTML with nested markup and + inline styles. +
+ +[scratchwork]: https://scratchwork.dev "Scratchwork home" diff --git a/examples/local-dev-project/scratchwork-logo.svg b/examples/local-dev-project/scratchwork-logo.svg new file mode 100644 index 0000000..4044a98 --- /dev/null +++ b/examples/local-dev-project/scratchwork-logo.svg @@ -0,0 +1 @@ +Scratchwork diff --git a/renderer/src/render.js b/renderer/src/render.js index af17769..9c00540 100644 --- a/renderer/src/render.js +++ b/renderer/src/render.js @@ -55,9 +55,11 @@ const linkDef = (ctx, ref) => (ctx.linkDefs || {})[ref.toLowerCase()]; // deep, so wikipedia.org/wiki/Bracket_(disambiguation) survives), a title, // and a link label (escapes and complete inline images allowed, so the badge // pattern [![alt](img)](href) is just a link whose label is an image). -const DEST = /((?:[^()\s]|\([^()\s]*\))+)/.source; +const DEST_BODY = /(?:[^()\s]|\([^()\s]*\))+/.source; +const DEST = `(${DEST_BODY})`; const TITLE = /(?:\s+"([^"]*)")?/.source; -const LABEL = /((?:\\.|!\[[^\]]*\]\([^()\s]*\)|[^[\]\\])+)/.source; +const INLINE_IMAGE = `!\\[[^\\]]*\\]\\(${DEST_BODY}(?:\\s+"[^"]*")?\\)`; +const LABEL = `((?:\\\\.|${INLINE_IMAGE}|[^\\[\\]\\\\])+)`; // When a [ref] has no definition, render the brackets literally but still // parse the inline markdown between them. diff --git a/renderer/test/render.test.js b/renderer/test/render.test.js index ab2941c..dcf5f38 100644 --- a/renderer/test/render.test.js +++ b/renderer/test/render.test.js @@ -76,9 +76,9 @@ describe("inline markdown", () => { }); test("badge pattern: image inside a link", () => { - const html = render("[![CI](https://img.shields.io/b.svg)](https://ci.example.com)"); + const html = render('[![CI](https://img.shields.io/b.svg "Build status")](https://ci.example.com)'); expect(html).toContain('
{ diff --git a/shared/src/site/default-renderer.generated.js b/shared/src/site/default-renderer.generated.js index 010d895..1aa580b 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 = "2a1c29d4837b9384c6329c73e1995f1d92fd258dc858246e11a80af048b64352"; -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 const defaultRendererSourceHash = "a0aafa173e528e5eca1d182e3f12b2a0b07e01913bbb0b338d54c9e3cda5ade9"; +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; From ad456830de1f9dd2b49501a32a610274d37980d6 Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Tue, 7 Jul 2026 09:34:37 -0700 Subject: [PATCH 05/13] Add local Cloudflare worker deployments --- .gitignore | 2 + README.md | 7 + bun.lock | 187 +++++++++++++++- deploy/cf-access/README.md | 23 ++ deploy/cf-access/local.ts | 24 ++ deploy/cf-access/package.json | 17 ++ deploy/cf-access/tsconfig.json | 25 +++ deploy/sndbx.sh/README.md | 24 +- deploy/sndbx.sh/cloudflare-config.ts | 21 ++ deploy/sndbx.sh/deploy.ts | 24 +- deploy/sndbx.sh/local.ts | 11 +- deploy/sndbx.sh/package.json | 3 +- deploy/sndbx.sh/server-config.ts | 2 +- deploy/sndbx.sh/tsconfig.json | 1 + package.json | 4 +- server/README.md | 27 ++- server/core/src/auth.ts | 1 + server/core/src/cloudflare-jwt.ts | 9 +- server/core/src/config.ts | 43 +++- server/core/src/jwt-rs256.ts | 51 ++++- server/core/test/auth.test.ts | 11 + server/core/test/cloudflare-jwt.test.ts | 16 ++ server/deploy-cloudflare/README.md | 81 +++++++ server/deploy-cloudflare/package.json | 5 +- server/deploy-cloudflare/scripts/dev.ts | 7 + .../deploy-cloudflare/scripts/smoke-local.ts | 49 +++++ server/deploy-cloudflare/src/deploy.ts | 207 +++++++++++++++++- server/deploy-cloudflare/src/index.ts | 3 + server/deploy-cloudflare/src/local-worker.ts | 102 +++++++++ server/deploy-cloudflare/test/worker.test.ts | 34 +++ 30 files changed, 960 insertions(+), 61 deletions(-) create mode 100644 deploy/cf-access/README.md create mode 100644 deploy/cf-access/local.ts create mode 100644 deploy/cf-access/package.json create mode 100644 deploy/cf-access/tsconfig.json create mode 100644 deploy/sndbx.sh/cloudflare-config.ts create mode 100644 server/deploy-cloudflare/README.md create mode 100644 server/deploy-cloudflare/scripts/dev.ts create mode 100644 server/deploy-cloudflare/scripts/smoke-local.ts create mode 100644 server/deploy-cloudflare/src/local-worker.ts diff --git a/.gitignore b/.gitignore index bfd8b42..303899e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ dist/ # deploy project's directory. .scratchwork-data/ .scratchwork-local-data/ +.scratchwork-cloudflare-data/ +.wrangler/ diff --git a/README.md b/README.md index f49fa9c..cb7be9f 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,13 @@ Run the publishing server locally: bun run local:local-dev ``` +To run the actual Cloudflare Worker with persistent local R2 and D1 simulations (and +an optional locally signed Cloudflare Access identity), see +[`server/deploy-cloudflare/README.md`](server/deploy-cloudflare/README.md). + +The ready-made Access test deployment is `bun run local:cf-access`; the sndbx.sh +project's production Worker configuration runs locally with `bun run local:sndbx.sh`. + Then publish a directory or file: ```sh diff --git a/bun.lock b/bun.lock index 5568a92..c044b97 100644 --- a/bun.lock +++ b/bun.lock @@ -29,6 +29,17 @@ "typescript": "^6.0.3", }, }, + "deploy/cf-access": { + "name": "@scratchwork/deploy-cf-access-local", + "version": "0.1.0", + "dependencies": { + "@scratchwork/server-deploy-cloudflare": "workspace:*", + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^6.0.3", + }, + }, "deploy/generic-aws": { "name": "@scratchwork/deploy-generic-aws", "version": "0.1.0", @@ -57,7 +68,6 @@ "version": "0.1.0", "dependencies": { "@scratchwork/server-deploy-cloudflare": "workspace:*", - "@scratchwork/server-deploy-local": "workspace:*", }, "devDependencies": { "@types/bun": "^1.3.14", @@ -116,6 +126,7 @@ "devDependencies": { "@types/bun": "^1.3.14", "typescript": "^6.0.3", + "wrangler": "^4.107.0", }, }, "server/deploy-local": { @@ -196,6 +207,22 @@ "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260701.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Zd9Y1bah6DwwBN2RW8vJohffQrIUazb8UXnqSNecOxM+jJLhUuvv5IOG8dbHcV83TyZAubea6gsQXo2yH1lDdw=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260701.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yBLsjS1qCWqFyCY37qRUrYfzHHvMGvjh8zRKJ6MvUivYDhkZTzqduppK38FoqYvayLJ5KbcxH7zo5rkxGqbsaA=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260701.1", "", { "os": "linux", "cpu": "x64" }, "sha512-vMfqSIMfoo4xmZXEuUVqLpSFS921YKjiR9q7kDXPi6Vld1PK74UHg9LZuBavT2KSyemHUCTpj9y/4JSYOEyQbQ=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260701.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-HRfwbKU2pK44V2NhoM0+iH0JJSj7nQ9Wv13ifIiGYCmTtDL8/zKtEhX7kQ3D4Vy/Cpjhttl0FkfqXj1aqLDPPg=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260701.1", "", { "os": "win32", "cpu": "x64" }, "sha512-ngxCiIN9s/fM2o1IBMD0o1/mcXrv2NJVdyznh51UH8sQuvrTrXvV2nM0Uj/qU2wMwF6prgNBcdcd7AZeZGiBQA=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + "@effect/cli": ["@effect/cli@0.75.2", "", { "dependencies": { "ini": "^4.1.3", "toml": "^3.0.0", "yaml": "^2.5.0" }, "peerDependencies": { "@effect/platform": "^0.96.1", "@effect/printer": "^0.49.0", "@effect/printer-ansi": "^0.49.0", "effect": "^3.21.2" } }, "sha512-+cMn/ZtFB+jEvgB6phrTT6XyPkIUnmX4G0nUSln7yj3lwyiHQ578Iwr8nHeU1CKEtlh4Xt+mYc0yiT912xu7YA=="], "@effect/cluster": ["@effect/cluster@0.59.0", "", { "dependencies": { "kubernetes-types": "^1.30.0" }, "peerDependencies": { "@effect/platform": "^0.96.1", "@effect/rpc": "^0.75.1", "@effect/sql": "^0.51.1", "@effect/workflow": "^0.18.2", "effect": "^3.21.2" } }, "sha512-rxtJ0zlcdDDtn9wau0z/PFfwPP2FsYPB2/tSK4tT8DkTYQmACJ5AwRwZm3Ea83iL/gv3df63lRe94oMiS8bFfg=="], @@ -220,6 +247,8 @@ "@effect/workflow": ["@effect/workflow@0.18.2", "", { "peerDependencies": { "@effect/experimental": "^0.60.0", "@effect/platform": "^0.96.1", "@effect/rpc": "^0.75.1", "effect": "^3.21.2" } }, "sha512-2av0TexhxHnapQa3eLn6TkniokUFRZKf8dlWlLqbODMpXAJ3mm+DOHWM0ozMGkg9K/+zGCtiL3dMqJyJ8r7G8g=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], @@ -254,10 +283,16 @@ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], @@ -266,6 +301,62 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], @@ -306,6 +397,14 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + + "@scratchwork/deploy-cf-access-local": ["@scratchwork/deploy-cf-access-local@workspace:deploy/cf-access"], + "@scratchwork/deploy-generic-aws": ["@scratchwork/deploy-generic-aws@workspace:deploy/generic-aws"], "@scratchwork/deploy-local-dev": ["@scratchwork/deploy-local-dev@workspace:deploy/local-dev"], @@ -320,6 +419,8 @@ "@scratchwork/server-deploy-local": ["@scratchwork/server-deploy-local@workspace:server/deploy-local"], + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + "@smithy/core": ["@smithy/core@3.26.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g=="], "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.2", "", { "dependencies": { "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug=="], @@ -338,6 +439,8 @@ "@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@speed-highlight/core": ["@speed-highlight/core@1.2.17", "", {}, "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@types/aws-lambda": ["@types/aws-lambda@8.10.162", "", {}, "sha512-Fn658grtLOci1oxi1391vvDWJRKNGWRSqfxRkmN/Iy3c0tQH1USMKEXcPYHLvope+ZgTFocx9FRQJx1muBL6qw=="], @@ -346,20 +449,28 @@ "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "effect": ["effect@3.21.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-B89v/xSgPbl1J2Ai2u18jxq3odpFauU1rC6/eSs4FeNHi72kwKdJp12VGigvRV2lK+kRnx+OOz41XV8guZd4gQ=="], + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "htm": ["htm@3.1.1", "", {}, "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ=="], "ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], @@ -370,10 +481,14 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "miniflare": ["miniflare@4.20260701.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260701.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-L6eAAi6IKtyb/7J6L+YsH2vb1yBrJWKRXI293JYDiMl70+6nncdAgigex58w6WBd+CwvdMsqOyNyGs95Op5gWQ=="], + "mnemonist": ["mnemonist@0.38.3", "", { "dependencies": { "obliterator": "^1.6.1" } }, "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw=="], "msgpackr": ["msgpackr@1.12.1", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ=="], @@ -388,6 +503,10 @@ "obliterator": ["obliterator@1.6.1", "", {}, "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig=="], + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], @@ -404,20 +523,38 @@ "scratchwork-renderer": ["scratchwork-renderer@workspace:renderer"], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + "uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], + "workerd": ["workerd@1.20260701.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260701.1", "@cloudflare/workerd-darwin-arm64": "1.20260701.1", "@cloudflare/workerd-linux-64": "1.20260701.1", "@cloudflare/workerd-linux-arm64": "1.20260701.1", "@cloudflare/workerd-windows-64": "1.20260701.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-uF813NG09JwNRRUfJ0zBomyTslSPM810dMj9LVvkQ7RAkLrQLzAlPU8Xh/3dIqZDo2bfd7tChbf2PtqLRARRJQ=="], + + "wrangler": ["wrangler@4.107.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260701.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260701.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260701.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-fw69ThymNitZ0oIEBU2yNeq3kK59UKz/jyA3udwRrQIAIsxX57q5qLOpPTN7qc5t8n9pnUeofe0uxtMuhQZW8w=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + "@aws-sdk/dynamodb-codec/@aws-sdk/core": ["@aws-sdk/core@3.974.25", "", { "dependencies": { "@aws-sdk/types": "^3.973.14", "@aws-sdk/xml-builder": "^3.972.32", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.28.0", "@smithy/signature-v4": "^5.6.0", "@smithy/types": "^4.15.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-fJFkx6u6wCqGMV/v6EAxiwa2UzEukbvr1hNPv4MrD3yj4IFz011jZg42/eSTOP/u5kJ0tlILqEjCWtT8GiKZvA=="], "@aws-sdk/dynamodb-codec/@smithy/core": ["@smithy/core@3.28.0", "", { "dependencies": { "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-N/LoLG8pZ1zv5cIWpdF6vmSjtZtXKK9G0OqT5yYCOZU+CzPq1+nYA95VoKJBGWRScs7YbMugZ7lZx8Fj1vdHoA=="], @@ -426,10 +563,58 @@ "@aws-sdk/middleware-endpoint-discovery/@smithy/core": ["@smithy/core@3.28.0", "", { "dependencies": { "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-N/LoLG8pZ1zv5cIWpdF6vmSjtZtXKK9G0OqT5yYCOZU+CzPq1+nYA95VoKJBGWRScs7YbMugZ7lZx8Fj1vdHoA=="], + "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=="], + "@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=="], "@aws-sdk/dynamodb-codec/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.6.0", "", { "dependencies": { "@smithy/core": "^3.28.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-IkPHQdbyoebSwBCuMTzJ/2oIhKVqiZZAZxQYSlpDZqq/WhJUpmdgbHvP7ItddxsPzcDUJeI0V4PNMSNtlZ0aqA=="], + + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], } } diff --git a/deploy/cf-access/README.md b/deploy/cf-access/README.md new file mode 100644 index 0000000..0cd31b0 --- /dev/null +++ b/deploy/cf-access/README.md @@ -0,0 +1,23 @@ +# Local Cloudflare Access deploy + +Local-only Scratchwork deployment for testing the Cloudflare Worker, R2, D1, and +Cloudflare Access authentication together. It has no remote `deploy` command and +needs no Cloudflare account or OAuth credentials. + +From the repository root: + +```sh +bun run local:cf-access +``` + +It listens on `http://localhost:8787` and signs Access assertions for +`developer@example.com`. Select another identity with: + +```sh +SCRATCHWORK_LOCAL_CF_ACCESS_EMAIL=alice@example.com bun run local:cf-access +``` + +R2 and D1 data persist under `deploy/cf-access/.scratchwork-cloudflare-data/`. +Remove that ignored directory for an empty environment. This simulates an +already-authenticated Access session and its signed identity assertion, not the +Cloudflare policy engine or an identity provider's login UI. diff --git a/deploy/cf-access/local.ts b/deploy/cf-access/local.ts new file mode 100644 index 0000000..c0eb7e1 --- /dev/null +++ b/deploy/cf-access/local.ts @@ -0,0 +1,24 @@ +import { + runLocalCloudflareServer, + type CloudflareDeployServerConfig, +} from "@scratchwork/server-deploy-cloudflare"; + +/** Local-only Worker deployment for exercising Cloudflare Access authentication with + * persistent R2 and D1 state. There is deliberately no remote deploy command. */ +const config = { + server: { + auth: "cloudflare-access", + authSessionSeconds: 2_592_000, + allowedUsers: "public", + maxVisibility: "public", + usersCanSetProjectNames: true, + defaultVisibility: "private", + }, + deploy: { + workerName: "scratchwork-cf-access-local", + r2Bucket: "scratchwork-cf-access-local", + d1Database: "scratchwork-cf-access-local-projects", + }, +} satisfies CloudflareDeployServerConfig; + +await runLocalCloudflareServer(config, { simulateAccess: true }); diff --git a/deploy/cf-access/package.json b/deploy/cf-access/package.json new file mode 100644 index 0000000..e1f8162 --- /dev/null +++ b/deploy/cf-access/package.json @@ -0,0 +1,17 @@ +{ + "name": "@scratchwork/deploy-cf-access-local", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "local": "bun local.ts", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@scratchwork/server-deploy-cloudflare": "workspace:*" + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^6.0.3" + } +} diff --git a/deploy/cf-access/tsconfig.json b/deploy/cf-access/tsconfig.json new file mode 100644 index 0000000..10e2e7d --- /dev/null +++ b/deploy/cf-access/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "allowArbitraryExtensions": true, + "allowImportingTsExtensions": true, + "allowJs": true, + "baseUrl": ".", + "ignoreDeprecations": "6.0", + "lib": ["ESNext", "DOM"], + "module": "Preserve", + "moduleDetection": "force", + "moduleResolution": "bundler", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "types": ["bun"], + "verbatimModuleSyntax": true + }, + "include": [ + "local.ts", + "../../server/core/src/assets.d.ts", + "../../shared/src/**/*.ts", + "../../shared/**/*.js" + ] +} diff --git a/deploy/sndbx.sh/README.md b/deploy/sndbx.sh/README.md index 80828a3..ffd2d73 100644 --- a/deploy/sndbx.sh/README.md +++ b/deploy/sndbx.sh/README.md @@ -16,9 +16,10 @@ cp deploy/sndbx.sh/.env.example deploy/sndbx.sh/.env ``` The server settings (domains, auth policy, visibility rules) live in -`server-config.ts` and are shared by the Cloudflare deploy and the local run; -`deploy.ts` adds the Cloudflare-specific bindings. Secrets are read from `.env` -in this directory and the shell environment. +`server-config.ts`. `cloudflare-config.ts` adds the Worker, R2, D1, and route +configuration; both the remote deploy and local Wrangler run consume that same +complete config. Secrets are read from `.env` in this directory and the shell +environment. It binds the Worker with routes for `app.sndbx.sh/*` (app/API/auth), `pages.sndbx.sh/*` (published content), and the home domains `sndbx.sh/*` and `www.sndbx.sh/*`, which serve the homepage project `www` (`homepageDomains` / @@ -44,27 +45,26 @@ https://app.sndbx.sh/auth/callback/google ## Local run -Run the sndbx.sh server settings on a local server (local file storage, -in-memory database, no Cloudflare access needed): +Run the sndbx.sh Worker with Wrangler's persistent local R2 and D1 bindings: ```sh bun run local:sndbx.sh # from the repo root, or `bun run local` here ``` Because the config declares separate app and content domains, the local run -mirrors that split on one port: the app/API on `http://localhost:43118`, -published content on `http://pages.localhost:43118`, and the homepage project -on `http://home.localhost:43118` (`*.localhost` names are loopback per RFC +mirrors that split on one port: the app/API on `http://localhost:8787`, +published content on `http://pages.localhost:8787`, and the homepage project +on `http://home.localhost:8787` (`*.localhost` names are loopback per RFC 6761; browsers and macOS resolve them without setup). The app stays on plain `localhost` because Google OAuth accepts it as an http redirect -URI. Set `PORT` to change the port, and `SCRATCHWORK_STORAGE_DIR` to relocate -storage (default `.scratchwork-local-data` in this directory). Any -`SCRATCHWORK_*` environment variable overrides the shared config. +URI. Set `PORT` to change the port. Wrangler stores both local R2 and D1 state +under `.scratchwork-cloudflare-data` in this directory; remove it for a clean +environment. The remote route entries are ignored by the local runtime. The same OAuth secrets are required as for the Cloudflare deploy; Bun loads them from `.env` in this directory. To complete a browser login locally, add a second redirect URI to the Google OAuth client: ```txt -http://localhost:43118/auth/callback/google +http://localhost:8787/auth/callback/google ``` diff --git a/deploy/sndbx.sh/cloudflare-config.ts b/deploy/sndbx.sh/cloudflare-config.ts new file mode 100644 index 0000000..c0d2107 --- /dev/null +++ b/deploy/sndbx.sh/cloudflare-config.ts @@ -0,0 +1,21 @@ +import type { CloudflareDeployServerConfig } from "@scratchwork/server-deploy-cloudflare"; +import { server } from "./server-config"; + +/** The complete sndbx.sh Cloudflare configuration, shared by remote deploys and the + * local Wrangler Worker runtime. Routes are ignored locally; binding names are not. */ +export const config = { + server, + + deploy: { + workerName: "scratchwork", + r2Bucket: "scratchwork-sndbx-sh", + d1Database: "scratchwork-sndbx-sh-projects", + routes: [ + { pattern: "sndbx.sh/*" }, + { pattern: "www.sndbx.sh/*" }, + { pattern: "app.sndbx.sh/*" }, + { pattern: "pages.sndbx.sh/*" }, + ], + zoneName: "sndbx.sh", + }, +} satisfies CloudflareDeployServerConfig; diff --git a/deploy/sndbx.sh/deploy.ts b/deploy/sndbx.sh/deploy.ts index 8cca65b..94f6992 100644 --- a/deploy/sndbx.sh/deploy.ts +++ b/deploy/sndbx.sh/deploy.ts @@ -1,25 +1,5 @@ -import { - deployServer, - type CloudflareDeployServerConfig, -} from "@scratchwork/server-deploy-cloudflare"; -import { server } from "./server-config"; - -const config = { - server, - - deploy: { - workerName: "scratchwork", - r2Bucket: "scratchwork-sndbx-sh", - d1Database: "scratchwork-sndbx-sh-projects", - routes: [ - { pattern: "sndbx.sh/*" }, - { pattern: "www.sndbx.sh/*" }, - { pattern: "app.sndbx.sh/*" }, - { pattern: "pages.sndbx.sh/*" }, - ], - zoneName: "sndbx.sh", - }, -} satisfies CloudflareDeployServerConfig; +import { deployServer } from "@scratchwork/server-deploy-cloudflare"; +import { config } from "./cloudflare-config"; const result = await deployServer(config, { envFile: ".env" }); diff --git a/deploy/sndbx.sh/local.ts b/deploy/sndbx.sh/local.ts index 22ef722..739856f 100644 --- a/deploy/sndbx.sh/local.ts +++ b/deploy/sndbx.sh/local.ts @@ -1,7 +1,6 @@ -import { runLocalServer } from "@scratchwork/server-deploy-local"; -import { server } from "./server-config"; +import { runLocalCloudflareServer } from "@scratchwork/server-deploy-cloudflare"; +import { config } from "./cloudflare-config"; -// Runs the sndbx.sh server settings locally: app on http://localhost:, published -// content on http://pages.localhost: (mirroring the app./pages. domain split), -// local file storage, in-memory database. -runLocalServer({ server }); +// Runs the same Worker and binding names used in production, backed by Wrangler's +// persistent local R2 and D1 implementations. +await runLocalCloudflareServer(config, { envFile: ".env" }); diff --git a/deploy/sndbx.sh/package.json b/deploy/sndbx.sh/package.json index 56a4069..2ffb53d 100644 --- a/deploy/sndbx.sh/package.json +++ b/deploy/sndbx.sh/package.json @@ -9,8 +9,7 @@ "typecheck": "tsc -p tsconfig.json" }, "dependencies": { - "@scratchwork/server-deploy-cloudflare": "workspace:*", - "@scratchwork/server-deploy-local": "workspace:*" + "@scratchwork/server-deploy-cloudflare": "workspace:*" }, "devDependencies": { "@types/bun": "^1.3.14", diff --git a/deploy/sndbx.sh/server-config.ts b/deploy/sndbx.sh/server-config.ts index a136eea..b276301 100644 --- a/deploy/sndbx.sh/server-config.ts +++ b/deploy/sndbx.sh/server-config.ts @@ -1,6 +1,6 @@ import type { ScratchworkServerConfig } from "@scratchwork/server-deploy-cloudflare"; -/** sndbx.sh server settings, shared by the Cloudflare deploy and the local run. */ +/** sndbx.sh server settings, shared by the remote and local Cloudflare Worker runs. */ export const server = { appDomain: "app.sndbx.sh", contentDomain: "pages.sndbx.sh", diff --git a/deploy/sndbx.sh/tsconfig.json b/deploy/sndbx.sh/tsconfig.json index 0860dc6..fd04b7e 100644 --- a/deploy/sndbx.sh/tsconfig.json +++ b/deploy/sndbx.sh/tsconfig.json @@ -19,6 +19,7 @@ "include": [ "deploy.ts", "local.ts", + "cloudflare-config.ts", "server-config.ts", "../../server/core/src/assets.d.ts", "../../shared/src/**/*.ts", diff --git a/package.json b/package.json index aaa8156..0b31abf 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,14 @@ "deploy:generic-aws": "cd deploy/generic-aws && bun run deploy", "deploy:sndbx.sh": "cd deploy/sndbx.sh && bun run deploy", "local:generic-aws": "cd deploy/generic-aws && bun run local", + "local:cloudflare": "cd server/deploy-cloudflare && bun run dev", + "local:cf-access": "cd deploy/cf-access && bun run local", "local:local-dev": "cd deploy/local-dev && bun run local", "local:sndbx.sh": "cd deploy/sndbx.sh && bun run local", "dev:renderer": "cd renderer && bun run dev", "lint": "cd cli && bun run lint", "test": "cd renderer && bun test && cd ../cli && bun run test", - "typecheck": "cd cli && bun run typecheck && cd ../server && bun run typecheck && cd ../deploy/generic-aws && bun run typecheck && cd ../local-dev && bun run typecheck && cd ../sndbx.sh && bun run typecheck" + "typecheck": "cd cli && bun run typecheck && cd ../server && bun run typecheck && cd ../deploy/generic-aws && bun run typecheck && cd ../local-dev && bun run typecheck && cd ../sndbx.sh && bun run typecheck && cd ../cf-access && bun run typecheck" }, "dependencies": { "@effect/platform": "0.96.2", diff --git a/server/README.md b/server/README.md index 8866eba..1839939 100644 --- a/server/README.md +++ b/server/README.md @@ -17,7 +17,30 @@ These packages are libraries. Actual deployments live as projects under `deploy/ bun run local:local-dev ``` -By default the server listens on `43118` and stores published bundles under `.scratchwork-local-data/`. Every deploy project can also run its own server config locally via `deploy-local`'s `runLocalServer` (for example `bun run local:sndbx.sh`) — see `server/deploy-local/README.md`. +By default the generic local server listens on `43118` and stores published bundles under `.scratchwork-local-data/`. Deploy projects can choose the local adapter matching their platform: `bun run local:sndbx.sh` now runs its production Worker configuration with Wrangler-backed R2 and D1, while the generic AWS project uses `deploy-local`'s `runLocalServer` — see the deploy project's README. + +To exercise the actual Cloudflare Worker adapter with locally simulated R2 and D1 +bindings instead, run: + +```sh +bun run local:cloudflare +``` + +Wrangler persists those resources under `.scratchwork-cloudflare-data/`. To simulate +an already-authenticated Cloudflare Access edge as well, set a local identity: + +```sh +SCRATCHWORK_LOCAL_CF_ACCESS_EMAIL=developer@example.com bun run local:cloudflare +``` + +See `server/deploy-cloudflare/README.md` for the deploy-project API and the exact +Access behavior that is simulated. + +For a ready-made local-only Access deployment, run: + +```sh +bun run local:cf-access +``` ## Google OAuth @@ -148,7 +171,7 @@ Deploy to Cloudflare Workers + R2 via a deploy project, such as `deploy/sndbx.sh bun run deploy:sndbx.sh ``` -The deploy command uses the `wrangler` CLI credentials in your environment. It creates the R2 bucket if needed, writes a generated Wrangler config under `server/deploy-cloudflare/dist/`, and deploys the Worker. +The deploy command uses the `wrangler` CLI credentials in your environment. It creates the R2 bucket if needed, writes a generated Wrangler config under `server/deploy-cloudflare/dist/`, and deploys the Worker. The package pins Wrangler as a development dependency, so neither deployment nor local development requires a separate global install. Optional environment variables: diff --git a/server/core/src/auth.ts b/server/core/src/auth.ts index e593f7e..4fefd14 100644 --- a/server/core/src/auth.ts +++ b/server/core/src/auth.ts @@ -311,6 +311,7 @@ function makeCloudflareAccessAuth(config: CloudflareAccessAuthConfig): AuthShape const claims = yield* verifyCloudflareAccessToken(token, { teamDomain: config.teamDomain, audience: config.audience, + jwks: config.localJwks, }).pipe( Effect.mapError((cause) => new AuthError({ status: 401, message: cause.message, cause })), ); diff --git a/server/core/src/cloudflare-jwt.ts b/server/core/src/cloudflare-jwt.ts index 32cb0eb..fb618b3 100644 --- a/server/core/src/cloudflare-jwt.ts +++ b/server/core/src/cloudflare-jwt.ts @@ -10,7 +10,7 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import { errorMessage } from "../../../shared/src/util/errors"; -import { CLOCK_SKEW_SECONDS, verifyRs256Jwt } from "./jwt-rs256"; +import { CLOCK_SKEW_SECONDS, verifyRs256Jwt, verifyRs256JwtWithJwks } from "./jwt-rs256"; /** Raised when an Access token fails signature or claim validation. */ export class CloudflareJwtError extends Data.TaggedError("CloudflareJwtError")<{ @@ -45,12 +45,17 @@ export function verifyCloudflareAccessToken( /** Audience (AUD) tag of the Access application protecting this server. */ readonly audience: string; readonly jwksUrl?: string; + /** Preloaded public keys for an offline local Access simulation. Production uses + * the team JWKS URL and never sets this. */ + readonly jwks?: ReadonlyArray; readonly nowSeconds?: number; }, ): Effect.Effect { return Effect.tryPromise({ try: async () => { - const payload = (await verifyRs256Jwt(token, options.jwksUrl ?? cloudflareJwksUrl(options.teamDomain))) as unknown as CloudflareAccessClaims; + const payload = (await (options.jwks == null + ? verifyRs256Jwt(token, options.jwksUrl ?? cloudflareJwksUrl(options.teamDomain)) + : verifyRs256JwtWithJwks(token, options.jwks))) as unknown as CloudflareAccessClaims; validateClaims(payload, options.teamDomain, options.audience, options.nowSeconds ?? epochSeconds()); return payload; }, diff --git a/server/core/src/config.ts b/server/core/src/config.ts index a43b2c7..7682d98 100644 --- a/server/core/src/config.ts +++ b/server/core/src/config.ts @@ -54,6 +54,8 @@ export interface CloudflareAccessAuthConfig extends AuthConfigCommon { readonly teamDomain: string; /** Audience (AUD) tag of the Access application protecting this server. */ readonly audience: string; + /** Public signing keys supplied only by the offline local Access simulator. */ + readonly localJwks?: ReadonlyArray; readonly mode: "cloudflare-access"; } @@ -112,7 +114,7 @@ export function readServerConfig( shareAllowedDomains: yield* readDomainSet(env.SCRATCHWORK_SHARE_ALLOWED_DOMAINS, "SCRATCHWORK_SHARE_ALLOWED_DOMAINS"), usersCanSetProjectNames: yield* readBoolean(env.SCRATCHWORK_USERS_CAN_SET_PROJECT_NAMES, true, "SCRATCHWORK_USERS_CAN_SET_PROJECT_NAMES"), defaultVisibility: yield* readBinaryVisibility(env.SCRATCHWORK_DEFAULT_VISIBILITY, "private", "SCRATCHWORK_DEFAULT_VISIBILITY"), - auth: yield* readAuthConfig(env), + auth: yield* readAuthConfig(env, appUrl), }; }); } @@ -182,13 +184,13 @@ function readHomepage( /** Parses auth settings from environment variables. Auth cannot be disabled: a server * either runs built-in Google OAuth or sits behind Cloudflare Access. */ -function readAuthConfig(env: EnvVars): Effect.Effect { +function readAuthConfig(env: EnvVars, appUrl: string | undefined): Effect.Effect { return Effect.gen(function* () { const authMode = (env.SCRATCHWORK_AUTH ?? "").toLowerCase(); if (authMode !== "" && authMode !== "oauth" && authMode !== "cloudflare-access") { return yield* Effect.fail(invalidValue("SCRATCHWORK_AUTH", authMode, '"oauth" or "cloudflare-access", or leave it unset for oauth')); } - if (authMode === "cloudflare-access") return yield* readCloudflareAccessConfig(env); + if (authMode === "cloudflare-access") return yield* readCloudflareAccessConfig(env, appUrl); return yield* readOAuthConfig(env); }); } @@ -223,7 +225,7 @@ function readOAuthConfig(env: EnvVars): Effect.Effect { +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()); @@ -253,11 +255,44 @@ function readCloudflareAccessConfig(env: EnvVars): Effect.Effect, ServerConfigError> { + if (value == null || value === "") return Effect.succeed({}); + if (appUrl == null || !isLoopbackHost(new URL(appUrl).hostname)) { + return Effect.fail( + new ServerConfigError({ + message: "SCRATCHWORK_LOCAL_CF_ACCESS_JWKS is accepted only when SCRATCHWORK_APP_URL uses a loopback host", + }), + ); + } + 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( + 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 * for length), the allow-list, and the session lifetime. */ function readCommonAuthConfig(env: EnvVars, sessionSecret: string): Effect.Effect { diff --git a/server/core/src/jwt-rs256.ts b/server/core/src/jwt-rs256.ts index 496fcd8..26ddc0c 100644 --- a/server/core/src/jwt-rs256.ts +++ b/server/core/src/jwt-rs256.ts @@ -47,6 +47,42 @@ const keyCache = new Map(); * only; the caller validates the claims. Throws plain Errors on any failure. */ export async function verifyRs256Jwt(token: string, jwksUrl: string): Promise> { + const decoded = decodeRs256Jwt(token); + const key = await getJwksKey(decoded.header.kid, jwksUrl); + await verifySignature(decoded, key); + return decoded.payload; +} + +/** Verifies a compact RS256 JWT against an already-loaded JWKS document. This is used + * by local platform simulators, where fetching a provider-owned JWKS endpoint would + * defeat an otherwise fully offline development environment. */ +export async function verifyRs256JwtWithJwks( + token: string, + keys: ReadonlyArray, +): Promise> { + const decoded = decodeRs256Jwt(token); + const jwk = keys.find((candidate) => candidate.kid === decoded.header.kid && candidate.kty === "RSA"); + if (jwk == null) throw new Error("Unknown signing key"); + const key = await crypto.subtle.importKey( + "jwk", + jwk, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ); + await verifySignature(decoded, key); + return decoded.payload; +} + +interface DecodedRs256Jwt { + readonly header: JwtHeader; + readonly payload: Record; + readonly signed: Uint8Array; + readonly signature: Uint8Array; +} + +/** Parses and validates the provider-neutral parts of one RS256 token. */ +function decodeRs256Jwt(token: string): DecodedRs256Jwt { const parts = token.split("."); if (parts.length !== 3) throw new Error("Token must have 3 parts"); const [encodedHeader, encodedPayload, encodedSignature] = parts; @@ -58,16 +94,23 @@ export async function verifyRs256Jwt(token: string, jwksUrl: string): Promise>(encodedPayload); const signature = base64UrlToBytes(encodedSignature); if (signature == null) throw new Error("Invalid token signature encoding"); + return { + header, + payload, + signed: new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`), + signature, + }; +} - const key = await getJwksKey(header.kid, jwksUrl); +/** Checks one decoded token signature with an imported RSA verification key. */ +async function verifySignature(decoded: DecodedRs256Jwt, key: CryptoKey): Promise { const ok = await crypto.subtle.verify( "RSASSA-PKCS1-v1_5", key, - toArrayBuffer(signature), - toArrayBuffer(new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`)), + toArrayBuffer(decoded.signature), + toArrayBuffer(decoded.signed), ); if (!ok) throw new Error("Invalid token signature"); - return payload; } /** Decodes one base64url JWT part as JSON. */ diff --git a/server/core/test/auth.test.ts b/server/core/test/auth.test.ts index 8db7866..8e6c393 100644 --- a/server/core/test/auth.test.ts +++ b/server/core/test/auth.test.ts @@ -395,6 +395,17 @@ describe("readServerConfig", () => { } }); + test("never accepts local Access signing keys on a non-loopback app URL", async () => { + 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: "session-secret-session-secret-32-bytes", + SCRATCHWORK_APP_URL: "https://app.example.com", + SCRATCHWORK_LOCAL_CF_ACCESS_JWKS: JSON.stringify({ keys: [{ kty: "RSA", kid: "local" }] }), + }))).rejects.toThrow("only when SCRATCHWORK_APP_URL uses a loopback host"); + }); + test("fails without the Cloudflare Access settings, without demanding OAuth credentials", async () => { await expect( Effect.runPromise(readServerConfig({ SCRATCHWORK_AUTH: "cloudflare-access" })), diff --git a/server/core/test/cloudflare-jwt.test.ts b/server/core/test/cloudflare-jwt.test.ts index bf51cb1..c044c89 100644 --- a/server/core/test/cloudflare-jwt.test.ts +++ b/server/core/test/cloudflare-jwt.test.ts @@ -42,6 +42,22 @@ describe("verifyCloudflareAccessToken", () => { expect(claims.sub).toBe("cf-user-uuid-1"); }); + test("accepts a valid token against a preloaded local JWKS without fetching", async () => { + const keyPair = await makeKeyPair(); + globalThis.fetch = (async () => { + throw new Error("local verification must not fetch"); + }) as unknown as typeof fetch; + const token = await signJwt(keyPair.privateKey, validClaims()); + + const claims = await Effect.runPromise(verifyCloudflareAccessToken(token, { + teamDomain: TEAM_DOMAIN, + audience: AUDIENCE, + jwks: [keyPair.publicJwk], + })); + + expect(claims.email).toBe("founder@example.com"); + }); + test("rejects wrong audiences, wrong issuers, and expired tokens", async () => { const keyPair = await makeKeyPair(); globalThis.fetch = jwksFetch(keyPair.publicJwk); diff --git a/server/deploy-cloudflare/README.md b/server/deploy-cloudflare/README.md new file mode 100644 index 0000000..2c17020 --- /dev/null +++ b/server/deploy-cloudflare/README.md @@ -0,0 +1,81 @@ +# Cloudflare deploy package + +Deploys the Scratchwork server as a Cloudflare Worker backed by R2 and D1. It can +also run that exact Worker locally through Wrangler/workerd, with persistent local R2 +and D1 bindings. + +## Local Cloudflare runtime + +From the repository root, start the generic configuration with: + +```sh +bun run local:cloudflare +``` + +The normal auth configuration is still required. For an offline run that also +simulates Cloudflare Access, select a local identity instead: + +```sh +SCRATCHWORK_LOCAL_CF_ACCESS_EMAIL=developer@example.com bun run local:cloudflare +``` + +The server listens at `http://localhost:8787`. R2 and D1 data persist under +`.scratchwork-cloudflare-data/`; remove that ignored directory when you need a clean +environment. Override the defaults with `PORT` and `SCRATCHWORK_LOCAL_CF_ACCESS_EMAIL`. +The launcher writes secret bindings to the ignored `dist/.dev.vars` file so Wrangler +does not display them as ordinary configuration variables. +With the Access simulator running, a second terminal can exercise all three emulated +pieces in one login/publish/read smoke test: + +```sh +cd server/deploy-cloudflare +bun run smoke:local +``` + +Deploy projects can use their production server and binding names locally: + +```ts +import { + runLocalCloudflareServer, + type CloudflareDeployServerConfig, +} from "@scratchwork/server-deploy-cloudflare"; + +const config = { + server: { + appDomain: "app.example.com", + contentDomain: "pages.example.com", + auth: "cloudflare-access", + }, + deploy: { + workerName: "scratchwork-example", + r2Bucket: "scratchwork-example", + d1Database: "scratchwork-example-projects", + }, +} satisfies CloudflareDeployServerConfig; + +await runLocalCloudflareServer(config, { + envFile: ".env", + simulateAccess: { email: "developer@example.com" }, +}); +``` + +`simulateAccess: true` uses `developer@example.com`. Set it to `false` to test the +server's missing-assertion response even when the environment variable is present. + +Wrangler runs the Worker under the same `workerd` runtime used by Cloudflare and +creates local R2 and D1 implementations. The Access edge itself is not part of +Wrangler, so the package adds a local-only wrapper: it generates a throwaway RSA key, +issues a short-lived `Cf-Access-Jwt-Assertion` for the selected email, and lets the +production auth code verify its signature, issuer, audience, and expiry. It simulates +an already-authenticated Access session; it does not reproduce an identity provider's +login UI or Cloudflare policy engine. + +## Deploy + +```ts +import { deployServer } from "@scratchwork/server-deploy-cloudflare"; + +await deployServer(config, { envFile: ".env" }); +``` + +See the repository's `server/README.md` for all server and deployment settings. diff --git a/server/deploy-cloudflare/package.json b/server/deploy-cloudflare/package.json index 3940177..98b7a43 100644 --- a/server/deploy-cloudflare/package.json +++ b/server/deploy-cloudflare/package.json @@ -12,7 +12,9 @@ }, "scripts": { "check": "bun run typecheck && bun test", + "dev": "bun scripts/dev.ts", "deploy": "bun scripts/deploy.ts", + "smoke:local": "bun scripts/smoke-local.ts", "typecheck": "tsc -p tsconfig.json" }, "dependencies": { @@ -22,6 +24,7 @@ }, "devDependencies": { "@types/bun": "^1.3.14", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "wrangler": "^4.107.0" } } diff --git a/server/deploy-cloudflare/scripts/dev.ts b/server/deploy-cloudflare/scripts/dev.ts new file mode 100644 index 0000000..945095d --- /dev/null +++ b/server/deploy-cloudflare/scripts/dev.ts @@ -0,0 +1,7 @@ +#!/usr/bin/env bun +import { runLocalCloudflareServer } from "../src/deploy"; + +await runLocalCloudflareServer({}, { + argv: Bun.argv.slice(2), + loadPackageEnvFiles: true, +}); diff --git a/server/deploy-cloudflare/scripts/smoke-local.ts b/server/deploy-cloudflare/scripts/smoke-local.ts new file mode 100644 index 0000000..9f5a1df --- /dev/null +++ b/server/deploy-cloudflare/scripts/smoke-local.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env bun +/** Manual smoke test for a running local Cloudflare server. It exercises the simulated + * Access login, D1-backed project metadata, and R2-backed file storage. */ +const origin = process.env.SCRATCHWORK_LOCAL_ORIGIN ?? "http://localhost:8787"; +const project = process.env.SCRATCHWORK_LOCAL_SMOKE_PROJECT ?? "cloudflare-local-smoke"; + +const login = await fetch( + `${origin}/auth/login?cli_redirect=${encodeURIComponent("http://127.0.0.1:49123/callback")}`, + { redirect: "manual" }, +); +const location = login.headers.get("location"); +if (location == null) throw new Error(`Local Access login failed: ${login.status} ${await login.text()}`); +const callback = new URL(location); +const token = callback.searchParams.get("token"); +const cfToken = callback.searchParams.get("cf_token"); +if (token == null || cfToken == null) throw new Error("Local Access login did not return both CLI tokens"); + +const marker = `R2 + D1 + Access work (${Date.now()})`; +const publish = await fetch(`${origin}/api/publish`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "cf-access-token": cfToken, + "content-type": "application/json", + }, + body: JSON.stringify({ + bundle: { + version: 1, + files: [{ path: "index.html", contentBase64: btoa(`

${marker}

`) }], + }, + openPath: "/", + project, + visibility: "public", + }), +}); +const published = await publish.json() as { readonly project?: string; readonly error?: string }; +if (!publish.ok) throw new Error(`Local publish failed: ${publish.status} ${JSON.stringify(published)}`); + +const site = await fetch(`${origin}/${project}/`); +const html = await site.text(); +if (!site.ok || !html.includes(marker)) throw new Error(`Local site read failed: ${site.status} ${html}`); + +console.log(JSON.stringify({ + login: login.status, + publish: publish.status, + site: site.status, + project: published.project, + email: callback.searchParams.get("email"), +}, null, 2)); diff --git a/server/deploy-cloudflare/src/deploy.ts b/server/deploy-cloudflare/src/deploy.ts index 026a487..6228512 100644 --- a/server/deploy-cloudflare/src/deploy.ts +++ b/server/deploy-cloudflare/src/deploy.ts @@ -5,7 +5,7 @@ * Promise-based script code, not Effect: it runs once on a developer's machine. */ import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { copyEnv, definedEnv, loadDeployEnv, type DeployEnv } from "../../scripts/env"; import { createRunner } from "../../scripts/proc"; @@ -64,6 +64,25 @@ export interface CloudflareDeployServerConfig { readonly deploy?: CloudflareDeployConfig; } +/** One identity for the local Cloudflare Access edge simulator. */ +export interface CloudflareLocalAccessConfig { + /** Email asserted by the simulated Access application. */ + readonly email?: string; +} + +/** Options for running the Worker and its R2/D1 bindings entirely locally. */ +export interface CloudflareLocalOptions extends DeployServerOptions { + /** Local listen port. Defaults to 8787. */ + readonly port?: number; + /** Local listen address. Defaults to 127.0.0.1. */ + readonly ip?: string; + /** Wrangler state directory. Defaults to .scratchwork-cloudflare-data in the caller's directory. */ + readonly persistTo?: string; + /** Enable an edge-style Access assertion. `true` uses developer@example.com; an + * object can select the email. SCRATCHWORK_LOCAL_CF_ACCESS_EMAIL is another opt-in. */ + readonly simulateAccess?: boolean | CloudflareLocalAccessConfig; +} + /** What deployServer reports back after a successful deploy. */ export interface CloudflareDeployResult { readonly workerName: string; @@ -96,6 +115,13 @@ const root = dirname(dirname(fileURLToPath(import.meta.url))); const dist = join(root, "dist"); const workerPath = join(dist, "worker.js"); const configPath = join(dist, "wrangler.jsonc"); +const localWorkerPath = join(dist, "local-worker.js"); +const localConfigPath = join(dist, "wrangler.local.jsonc"); +const localDevVarsPath = join(dist, ".dev.vars"); +const LOCAL_D1_ID = "00000000-0000-0000-0000-000000000001"; +const LOCAL_ACCESS_TEAM = "scratchwork-local.cloudflareaccess.com"; +const LOCAL_ACCESS_AUDIENCE = "scratchwork-local"; +const LOCAL_SESSION_SECRET = "scratchwork-local-session-secret-not-for-production"; /** Deploys the Scratchwork server as a Cloudflare Worker. */ export async function deployServer( @@ -150,6 +176,121 @@ export async function deployServer( }; } +/** Runs the production Worker entry point under Wrangler's local workerd runtime, with + * persistent local R2 and D1 bindings. Cloudflare Access can optionally be simulated by + * a local-only wrapper that injects a correctly signed assertion before each request. */ +export async function runLocalCloudflareServer( + config: CloudflareDeployServerConfig = {}, + options: CloudflareLocalOptions = {}, +): Promise { + const callerDirectory = process.cwd(); + const loadedEnv = await loadDeployEnv({ + packageRoot: root, + argv: deployArgv(options), + processEnv: options.processEnv ?? process.env, + loadDefaultEnvFiles: options.loadPackageEnvFiles === true, + explicitEnvRoots: options.loadPackageEnvFiles === true ? undefined : [callerDirectory], + }); + const serverConfig = config.server ?? {}; + const resolvedDeploy = resolveDeployConfig(config.deploy ?? {}, loadedEnv.env); + const port = localPort(options.port, loadedEnv.env.PORT ?? loadedEnv.env.SCRATCHWORK_PORT); + const appUrl = `http://localhost:${port}`; + const splitHosts = serverConfig.appDomain != null + && serverConfig.contentDomain != null + && serverConfig.appDomain !== serverConfig.contentDomain; + const localServer: ResolvedScratchworkServerConfig = { + appUrl, + contentUrl: splitHosts ? `http://pages.localhost:${port}` : appUrl, + }; + const localServerEnv = serverConfigEnv(serverConfig, localServer); + if (serverConfig.homepageProject != null) { + localServerEnv.SCRATCHWORK_HOMEPAGE_DOMAINS = `http://home.localhost:${port}`; + } + const env: DeployEnv = { + ...loadedEnv.env, + ...localServerEnv, + ...deployConfigEnv(resolvedDeploy), + }; + + const access = localAccessSimulation(options.simulateAccess, env.SCRATCHWORK_LOCAL_CF_ACCESS_EMAIL); + if (access != null) Object.assign(env, await localAccessEnv(access.email, env.SCRATCHWORK_SESSION_SECRET)); + + await mkdir(dist, { recursive: true }); + validateDeploymentConfig(env, "local Cloudflare"); + const source = access == null ? "src/worker.ts" : "src/local-worker.ts"; + const output = access == null ? workerPath : localWorkerPath; + await createRunner(definedEnv(env)).run( + "bun", + ["build", source, "--target=browser", "--format=esm", `--outfile=${output}`], + { cwd: root }, + ); + + await writeLocalDevVars(env); + const localVars: Record = {}; + copyEnv(localVars, env, "SCRATCHWORK_LOCAL_CF_ACCESS_EMAIL"); + await writeConfig( + { ...resolvedDeploy, d1DatabaseId: LOCAL_D1_ID }, + localServer, + env, + [], + { + outputPath: localConfigPath, + main: access == null ? "worker.js" : "local-worker.js", + extraVars: localVars, + }, + ); + + const persistTo = resolve(callerDirectory, options.persistTo ?? ".scratchwork-cloudflare-data"); + console.log([ + "scratchwork local Cloudflare deploy", + `app ${localServer.appUrl}`, + `content ${localServer.contentUrl}`, + ...(serverConfig.homepageProject == null ? [] : [`home http://home.localhost:${port} (project "${serverConfig.homepageProject}")`]), + `storage R2 + D1 (${persistTo})`, + ...(access == null ? [] : [`access ${access.email}`]), + ].join("\n")); + + await createRunner(definedEnv(env)).run( + "wrangler", + [ + "dev", + "--config", + localConfigPath, + "--local", + "--persist-to", + persistTo, + "--ip", + options.ip ?? "127.0.0.1", + "--port", + String(port), + ], + { cwd: root }, + ); +} + +/** Writes local secrets in Wrangler's dev-only secret file instead of ordinary vars. + * Wrangler prints vars in its startup binding table; .dev.vars values stay hidden. */ +async function writeLocalDevVars(env: DeployEnv): Promise { + const lines: Array = []; + for (const key of [ + "SCRATCHWORK_GOOGLE_CLIENT_SECRET", + "SCRATCHWORK_SESSION_SECRET", + "SCRATCHWORK_LOCAL_CF_ACCESS_PRIVATE_JWK", + "SCRATCHWORK_LOCAL_CF_ACCESS_JWKS", + ]) { + const value = env[key]; + if (value == null || value === "") continue; + // The generated JWK documents contain many double quotes. Wrangler's dotenv + // parser preserves backslashes inside a JSON-stringified value, so use a + // single-quoted dotenv value for these machine-generated JSON documents. + const encoded = key.endsWith("_JWK") || key.endsWith("_JWKS") + ? `'${value}'` + : JSON.stringify(value); + lines.push(`${key}=${encoded}`); + } + await writeFile(localDevVarsPath, `${lines.join("\n")}\n`); +} + /** Applies env-var and default fallbacks to the Cloudflare deploy settings. */ function resolveDeployConfig(config: CloudflareDeployConfig, env: DeployEnv): ResolvedCloudflareDeployConfig { const customDomain = optional(config.customDomain) ?? optional(env.SCRATCHWORK_CLOUDFLARE_CUSTOM_DOMAIN); @@ -215,6 +356,11 @@ async function writeConfig( server: ResolvedScratchworkServerConfig, env: DeployEnv, routes: ReadonlyArray>, + options: { + readonly outputPath?: string; + readonly main?: string; + readonly extraVars?: Readonly>; + } = {}, ): Promise { const vars: Record = {}; copyEnv(vars, env, "SCRATCHWORK_AUTH"); @@ -233,12 +379,13 @@ async function writeConfig( copyEnv(vars, env, "SCRATCHWORK_HOMEPAGE_PROJECT"); if (server.appUrl != null && server.appUrl !== "") vars.SCRATCHWORK_APP_URL = server.appUrl; if (server.contentUrl != null && server.contentUrl !== "") vars.SCRATCHWORK_CONTENT_URL = server.contentUrl; + Object.assign(vars, options.extraVars); await writeFile( - configPath, + options.outputPath ?? configPath, JSON.stringify( { name: config.workerName, - main: "worker.js", + main: options.main ?? "worker.js", compatibility_date: config.compatibilityDate, r2_buckets: [ { @@ -262,6 +409,60 @@ async function writeConfig( ); } +/** Resolves and validates the local listen port. */ +function localPort(configured: number | undefined, fromEnv: string | undefined): number { + const value = configured ?? (fromEnv == null || fromEnv === "" ? 8787 : Number(fromEnv)); + if (!Number.isInteger(value) || value < 1 || value > 65_535) { + throw new Error(`Invalid local Cloudflare port "${configured ?? fromEnv}": expected an integer between 1 and 65535`); + } + return value; +} + +/** Decides whether Access simulation was requested through code or the environment. */ +function localAccessSimulation( + configured: boolean | CloudflareLocalAccessConfig | undefined, + envEmail: string | undefined, +): { readonly email: string } | null { + if (configured === false) return null; + const requested = configured === true || typeof configured === "object" || (envEmail != null && envEmail !== ""); + if (!requested) return null; + const email = (typeof configured === "object" ? configured.email : undefined) ?? envEmail ?? "developer@example.com"; + const normalized = email.trim().toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) { + throw new Error(`Invalid local Cloudflare Access email "${email}"`); + } + return { email: normalized }; +} + +/** Generates the throwaway key material and config values used only by the local Access + * wrapper. The private key is written to dist/wrangler.local.jsonc, which is ignored. */ +async function localAccessEnv(email: string, sessionSecret: string | undefined): Promise { + const pair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ) as CryptoKeyPair; + const [privateJwk, publicJwk] = await Promise.all([ + crypto.subtle.exportKey("jwk", pair.privateKey), + crypto.subtle.exportKey("jwk", pair.publicKey), + ]); + const keyMetadata = { kid: "scratchwork-local-access", alg: "RS256", use: "sig" } as const; + return { + SCRATCHWORK_AUTH: "cloudflare-access", + SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN: LOCAL_ACCESS_TEAM, + SCRATCHWORK_CF_ACCESS_AUD: LOCAL_ACCESS_AUDIENCE, + SCRATCHWORK_SESSION_SECRET: sessionSecret ?? LOCAL_SESSION_SECRET, + SCRATCHWORK_LOCAL_CF_ACCESS_EMAIL: email, + SCRATCHWORK_LOCAL_CF_ACCESS_PRIVATE_JWK: JSON.stringify({ ...privateJwk, ...keyMetadata }), + SCRATCHWORK_LOCAL_CF_ACCESS_JWKS: JSON.stringify({ keys: [{ ...publicJwk, ...keyMetadata }] }), + }; +} + /** Builds optional custom-domain and route entries for Wrangler. */ function cloudflareRoutes(config: ResolvedCloudflareDeployConfig): ReadonlyArray> { if (config.routes != null) { diff --git a/server/deploy-cloudflare/src/index.ts b/server/deploy-cloudflare/src/index.ts index e65bd5b..50edfe5 100644 --- a/server/deploy-cloudflare/src/index.ts +++ b/server/deploy-cloudflare/src/index.ts @@ -1,9 +1,12 @@ export { deployServer, + runLocalCloudflareServer, type CloudflareDeployConfig, type CloudflareDeployOptions, type CloudflareDeployResult, type CloudflareDeployServerConfig, + type CloudflareLocalAccessConfig, + type CloudflareLocalOptions, type CloudflareR2BucketConfig, type CloudflareRouteConfig, type ScratchworkServerConfig, diff --git a/server/deploy-cloudflare/src/local-worker.ts b/server/deploy-cloudflare/src/local-worker.ts new file mode 100644 index 0000000..da2b8c7 --- /dev/null +++ b/server/deploy-cloudflare/src/local-worker.ts @@ -0,0 +1,102 @@ +/** + * Local-only Worker entry point that simulates the identity assertion Cloudflare Access + * 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 { toArrayBuffer } from "../../../shared/src/encoding/bytes"; +import worker from "./worker"; + +interface LocalAccessEnv extends Record { + readonly SCRATCHWORK_LOCAL_CF_ACCESS_PRIVATE_JWK: string; + readonly SCRATCHWORK_LOCAL_CF_ACCESS_EMAIL: string; + readonly SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN: string; + readonly SCRATCHWORK_CF_ACCESS_AUD: string; +} + +interface ExecutionContextBinding { + readonly waitUntil: (promise: Promise) => void; + readonly passThroughOnException: () => void; +} + +const KID = "scratchwork-local-access"; +const importedKeys = new Map>(); + +export default { + async fetch(request: Request, env: LocalAccessEnv, context: ExecutionContextBinding): Promise { + try { + // In production this endpoint belongs to Cloudflare's edge. Locally there is no + // persistent Access browser session to clear, so returning home models the next + // request beginning a fresh simulated session. + if (new URL(request.url).pathname === "/cdn-cgi/access/logout") { + return Response.redirect(new URL("/", request.url), 302); + } + const assertion = await issueLocalAccessAssertion(env); + const headers = new Headers(request.headers); + // Access owns this header at the edge. Always overwrite a client-supplied value. + headers.set("Cf-Access-Jwt-Assertion", assertion); + return worker.fetch(new Request(request, { headers }), env as never, context); + } catch (error) { + console.error("scratchwork local Access simulator: unhandled error", error); + return new Response(JSON.stringify({ error: "Local Cloudflare Access simulation failed" }), { + status: 500, + headers: { "content-type": "application/json; charset=utf-8" }, + }); + } + }, +}; + +/** Issues one short-lived, Cloudflare-shaped application token for the configured local + * identity. The production verifier still checks its RS256 signature and all claims. */ +export async function issueLocalAccessAssertion(env: LocalAccessEnv, now = epochSeconds()): Promise { + const header = encodeJson({ alg: "RS256", kid: KID, typ: "JWT" }); + const payload = encodeJson({ + iss: normalizedTeamDomain(env.SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN), + aud: [env.SCRATCHWORK_CF_ACCESS_AUD], + sub: `local:${env.SCRATCHWORK_LOCAL_CF_ACCESS_EMAIL.toLowerCase()}`, + email: env.SCRATCHWORK_LOCAL_CF_ACCESS_EMAIL.toLowerCase(), + iat: now, + nbf: now, + exp: now + 60 * 60, + type: "app", + }); + const signed = `${header}.${payload}`; + const key = await localPrivateKey(env.SCRATCHWORK_LOCAL_CF_ACCESS_PRIVATE_JWK); + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + key, + toArrayBuffer(new TextEncoder().encode(signed)), + ); + return `${signed}.${bytesToBase64Url(new Uint8Array(signature))}`; +} + +/** Imports and caches the generated private key for the life of the local isolate. */ +function localPrivateKey(value: string): Promise { + const cached = importedKeys.get(value); + if (cached != null) return cached; + const imported = Promise.resolve().then(() => + crypto.subtle.importKey( + "jwk", + JSON.parse(value) as JsonWebKey, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["sign"], + ) + ); + importedKeys.set(value, imported); + return imported; +} + +/** Mirrors server config's normalization for the generated JWT issuer. */ +function normalizedTeamDomain(value: string): string { + const host = value.toLowerCase().replace(/^https:\/\//, "").replace(/[/?#].*$/, ""); + return `https://${host.includes(".") ? host : `${host}.cloudflareaccess.com`}`; +} + +function encodeJson(value: unknown): string { + return bytesToBase64Url(new TextEncoder().encode(JSON.stringify(value))); +} + +function epochSeconds(): number { + return Math.floor(Date.now() / 1000); +} diff --git a/server/deploy-cloudflare/test/worker.test.ts b/server/deploy-cloudflare/test/worker.test.ts index 3d724f3..0efa719 100644 --- a/server/deploy-cloudflare/test/worker.test.ts +++ b/server/deploy-cloudflare/test/worker.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { makeKeyPair } from "../../core/test/jwt-helpers"; +import localWorker from "../src/local-worker"; import worker, { envVarsFromCloudflare } from "../src/worker"; /** Minimal R2/D1 binding stubs; requests that die in config never reach them. */ @@ -70,6 +72,38 @@ describe("worker fetch", () => { }); }); +describe("local Cloudflare Access worker", () => { + test("injects a signed Access assertion that the production auth path verifies", async () => { + const keyPair = await makeKeyPair(); + const privateJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey); + const keyMetadata = { kid: "scratchwork-local-access", alg: "RS256", use: "sig" } as const; + const env = { + ...bindings, + SCRATCHWORK_AUTH: "cloudflare-access", + SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN: "scratchwork-local.cloudflareaccess.com", + SCRATCHWORK_CF_ACCESS_AUD: "scratchwork-local", + SCRATCHWORK_APP_URL: "http://localhost:8787", + SCRATCHWORK_SESSION_SECRET: "test-session-secret-test-session-secret", + SCRATCHWORK_LOCAL_CF_ACCESS_EMAIL: "Developer@Example.com", + SCRATCHWORK_LOCAL_CF_ACCESS_PRIVATE_JWK: JSON.stringify({ ...privateJwk, ...keyMetadata }), + SCRATCHWORK_LOCAL_CF_ACCESS_JWKS: JSON.stringify({ + keys: [{ ...keyPair.publicJwk, ...keyMetadata }], + }), + }; + + const response = await localWorker.fetch( + new Request("http://localhost:8787/health", { + headers: { "cf-access-jwt-assertion": "client-cannot-forge-this" }, + }), + env, + { waitUntil: () => {}, passThroughOnException: () => {} }, + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + }); +}); + describe("envVarsFromCloudflare", () => { test("copies string server env vars and excludes bindings", () => { const vars = envVarsFromCloudflare({ From 173defdef77c1c53700f5e6b93e984e8cc938404 Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Tue, 7 Jul 2026 19:03:18 -0700 Subject: [PATCH 06/13] update docs page --- docs/index.md | 87 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 55 insertions(+), 32 deletions(-) diff --git a/docs/index.md b/docs/index.md index f1ec0bb..f723c64 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,51 +1,65 @@ --- # Page metadata -title: "Scratchwork" # Page title and og:title -description: "Share coding agent artifacts" # Meta description and og:description -keywords: ["MDX", "static site", "React", "Bun", "markdown"] # Meta keywords -author: "Scratchwork" # Meta author -lang: "en" # HTML lang attribute +title: "Scratchwork" # Page title and og:title +description: "Share your agent artifacts" # Meta description and og:description +keywords: ["MDX", "static site", "React", "Bun", "markdown"] # Meta keywords +author: "Scratchwork" # Meta author +lang: "en" # HTML lang attribute --- Scratchwork -Scratchwork is a local development tool for static HTML and Markdown artifacts created by your coding agent. +Scratchwork is a tool for sharing static websites with your colleagues. -Specifically, Scratchwork is: +It's designed for sharing agent artifacts like HTML and markdown files, but it's also useful for writing, product specs, mocks, and demos. -1. A CLI for serving static websites locally with hot reload -2. A Markdown renderer that supports React components -3. Shared routing/rendering logic for the future server +Publish your work publicly to share it with the world, or privately to share it with friends and teammates. ## Quick start -Install `scratchwork`, then start the local development server: +Just ask your agent: + +``` +Create a simple "hello world" website and publish it to scratchwork.dev. Give everyone with an email @example.com permission to read it. +``` + +## Slow start + +Install the Scratchwork CLI with ```sh curl -fsSL https://scratchwork.dev/install.sh | bash - -scratchwork --version -scratchwork dev [path] ``` -Publishing and hosted sharing are being rebuilt. +Ask your agent to create an html or markdown file, or use a Scratchwork example project: -## Local development +```sh +scratchwork example +``` + +Preview your work locally: ```sh -# Serve the current directory scratchwork dev +``` + +Publish it privately -# Serve a Markdown file -scratchwork dev page.md +```sh +scratchwork publish --server scratchwork.dev --visibility private +``` + +Give your all of your teammates read access: -# Serve an HTML file -scratchwork dev page.html +```sh +scratchwork share @example.com --role read ``` -## Working with Markdown +## Working with Markdown and React + +HTML is great for reading, but it's a terrible medium for writing. Scratchwork renders Markdown with an embedded default renderer. It's not magic; you can see (and edit) it with `scratchwork template`. -Scratchwork renders Markdown with an embedded default renderer. Markdown files can reference React components from nearby component files, which is useful for interactive demos like this: +Your markdown files can reference React components defined in `./components/`, which is useful for interactive demos like this:
@@ -53,24 +67,33 @@ Scratchwork renders Markdown with an embedded default renderer. Markdown files c ...or building custom formatting components like this highlighter. -To use this page as a starting point for your project, run: +## Publishing on scratchwork.dev + +For now, you can publish projects (<5mb) on [scratchwork.dev](https://scratchwork.dev) for free. However, published projects are deleted after 48 hours. + +If you'd like to be able to pay for a hosted option, upvote [this Github issue](https://github.com/koomen/scratchwork) (TODO: create an issue) + +## Hosting your own server + +Scratchwork is open source and can be hosted anywhere. To configure your own server, use: ```sh -# Write example Markdown and React content -scratchwork example [path] +# Configure for Cloudflare (a worker using R2 and D1) +npm create scratchwork-server-cloudflare ``` -To copy the default Markdown renderer into your project, run: +swap `cloudflare` for your favorite serverless platform: `aws`, `vercel`, `railway`, or `smolmachines`. + +Run your server locally with ```sh -# Write the default renderer to index.html -scratchwork template [file] +node local.ts ``` -To customize Markdown rendering, add an `index.html` renderer file at or above the Markdown file and start it with Scratchwork's identifying comment: +and deploy it with -```html - +```sh +node deploy.ts ``` From 6355b8227a15dfca150cb94aa823cc80ade162ce Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Tue, 7 Jul 2026 19:16:43 -0700 Subject: [PATCH 07/13] Fall back to the Access assertion when a bearer token fails verification In cloudflare-access mode, requireApiUser propagated a bearer-signature failure (e.g. a token signed with a rotated session secret) instead of trying the request's Access assertion, locking the CLI out until manual re-login even though it carried a valid credential. Treat an unverifiable bearer as absent, matching currentUser. Co-Authored-By: Claude Fable 5 --- server/core/src/auth.ts | 8 +++++++- server/core/test/auth.test.ts | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/server/core/src/auth.ts b/server/core/src/auth.ts index 4fefd14..8f4ac26 100644 --- a/server/core/src/auth.ts +++ b/server/core/src/auth.ts @@ -337,8 +337,14 @@ function makeCloudflareAccessAuth(config: CloudflareAccessAuthConfig): AuthShape requireApiUser: (request) => Effect.gen(function* () { + // A bearer that fails verification (e.g. signed with a rotated secret) is treated + // as absent rather than fatal: the Access assertion is an independent credential + // and may still authenticate the request. const token = bearerToken(request); - const sessionUser = token == null ? null : yield* verifySessionToken(token, config); + const sessionUser = + token == null + ? null + : yield* verifySessionToken(token, config).pipe(Effect.orElseSucceed(() => null)); if (sessionUser != null) return sessionUser; const user = yield* assertedUser(request); if (user == null) { diff --git a/server/core/test/auth.test.ts b/server/core/test/auth.test.ts index 8e6c393..2fd420f 100644 --- a/server/core/test/auth.test.ts +++ b/server/core/test/auth.test.ts @@ -231,6 +231,21 @@ describe("Auth (cloudflare-access)", () => { expect(apiUser.email).toBe("founder@example.com"); }); + test("a stale bearer token falls back to a valid Access assertion", async () => { + // A bearer signed with a rotated secret must not lock out a request that also + // carries a verifiable Access assertion. + const staleBearer = await Effect.runPromise( + createSessionToken(user, { ...cloudflareConfig, sessionSecret: "rotated-secret-rotated-secret-32-bytes" }), + ); + const { token } = await accessAssertion(); + const auth = makeAuth(cloudflareConfig); + + const apiUser = await Effect.runPromise( + auth.requireApiUser(request({ authorization: `Bearer ${staleBearer}`, "cf-access-jwt-assertion": token })), + ); + expect(apiUser.email).toBe("founder@example.com"); + }); + test("login redirects the CLI loopback with a working bearer token", async () => { const { token } = await accessAssertion(); const auth = makeAuth(cloudflareConfig); From 563297c0b4835b7e6736266d71a5fd72312abef4 Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Tue, 7 Jul 2026 19:16:43 -0700 Subject: [PATCH 08/13] Add code-review notes for the cloudflare-access branch Co-Authored-By: Claude Fable 5 --- notes/cloudflare-access-review.md | 146 ++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 notes/cloudflare-access-review.md diff --git a/notes/cloudflare-access-review.md b/notes/cloudflare-access-review.md new file mode 100644 index 0000000..d6f12fc --- /dev/null +++ b/notes/cloudflare-access-review.md @@ -0,0 +1,146 @@ +# Code review: cloudflare-access branch + +Review of the five commits on `cloudflare-access` vs `origin/main` (~2,600 lines across 57 +files): the Cloudflare Access auth mode, the CLI token relay, and the local Cloudflare +worker deployments. + +**Overall**: a well-built branch. The JWT verification is careful (alg/kid/crit checks, +clock skew, service-token rejection, key caching), the local Access simulator is cleanly +isolated from production entry points, and the test suite passes (298/0). Findings are +ranked most-severe first: 1–2 are worth fixing before merge, 3–8 deserve a decision, +9–10 are polish. + +## 1. `requireApiUser` hard-fails on an invalid bearer instead of falling back to a valid Access assertion — FIXED + +Fixed 2026-07-07: `verifySessionToken` is now wrapped in `Effect.orElseSucceed(() => null)` +in the cloudflare-access `requireApiUser`, matching `currentUser`; regression test added +("a stale bearer token falls back to a valid Access assertion"). + +`server/core/src/auth.ts:341` — in cloudflare-access mode, `requireApiUser` propagates a +bearer-signature verification failure instead of falling through to the Access assertion, +unlike `currentUser` (line 330–335), which wraps `verifySessionToken` in `orElseSucceed`. + +Failure: operator rotates `SCRATCHWORK_SESSION_SECRET`. The CLI still sends its stale +bearer token plus a fresh, valid `cf-access-token` header. `verifySessionToken` fails with +`AuthError` before `assertedUser(request)` is ever tried, so every API call 401s even +though the same request *without* the bearer would authenticate. Browser requests via +`currentUser` keep working; the CLI is locked out until manual re-login. (Expired tokens +return null and fall through correctly — only signature-invalid tokens hit this.) + +## 2. Relayed Access JWT is never found for path-mounted servers + +`cli/src/api.ts:108` — `readCfToken` is keyed by bare URL origin, but auth.json records +are keyed by `normalizeServerUrl` output, which preserves a path prefix. + +Failure: user logs into a path-mounted server (`scratchwork login --server +https://host/scratchwork` — a shape `serverApiUrl` explicitly supports) behind Cloudflare +Access. `writeAuthToken` stores the cfToken under `"https://host/scratchwork"`, but +`attachCloudflareAccess` looks up `readCfToken("https://host")` and `candidateServers` +never yields the path-prefixed key — so `cf-access-token` is never attached, every request +is edge-blocked, and the CLI loops on "re-run scratchwork login" immediately after a +successful login. + +## 3. Service-token secret is sent to every origin + +`cli/src/api.ts:113` — the `SCRATCHWORK_CF_ACCESS_CLIENT_ID/SECRET` headers are attached +to every `apiRequest` regardless of target origin. + +Failure: CI sets the service-token env vars for its corp server; any command addressed at +a different server (`scratchwork publish --server https://other.example`) sends +`CF-Access-Client-Id` and `CF-Access-Client-Secret` to that third party. The stored +cfToken is origin-scoped, but the service token is not — scope it to a configured origin +(e.g. `SCRATCHWORK_CF_ACCESS_SERVER`) or at least to the logged-in server. + +## 4. Unknown-kid tokens trigger an unauthenticated JWKS fetch per request + +`server/core/src/jwt-rs256.ts:130` — `getJwksKey` performs a network JWKS refresh on +every unknown-kid token with no negative caching or cooldown, and in cloudflare-access +mode this path is reachable by unauthenticated requests. + +Failure: `assertedUser` verifies any client-supplied `cf-access-token` header before +authentication. An attacker sends well-formed RS256 JWTs with random `kid` values: each +request misses the cache and triggers a full fetch of the team JWKS URL (5s timeout, +whole-set key re-import) — one outbound subrequest per attacker request, burning Worker +subrequest limits and hammering the JWKS endpoint. Add a short negative-result cooldown +per jwksUrl. + +## 5. Edge blocks bypass publish's auto-login retry + +`cli/src/api.ts:61` — a Cloudflare edge block becomes a plain `CliError`, so publish's +`PublishAuthRequired` auto-login retry never fires for Access-protected servers. + +Failure: user with an expired (or absent) Access session runs `scratchwork publish`: the +edge returns the Access login page, `apiRequest` fails with a generic `CliError` instead +of the 401 → `PublishAuthRequired` path (`cli/src/commands/publish.ts:82`), so the +automatic runLogin-and-retry flow that oauth servers get is skipped and the command aborts +telling the user to log in manually. + +## 6. Local Cloudflare run inverts env-var precedence + +`server/deploy-cloudflare/src/deploy.ts:211` — `runLocalCloudflareServer` spreads +config-derived vars after `loadedEnv.env`, inverting the established precedence where +shell `SCRATCHWORK_*` vars override the deploy project's server config +(`server/deploy-local/src/run.ts:105` uses `processEnv[key] ?? value`). + +Failure: developer runs `SCRATCHWORK_ALLOWED_USERS=@myco.com bun run local:sndbx.sh`: +server-config.ts's `allowedUsers` silently wins because `localServerEnv` is spread after +`loadedEnv.env`, so the local server accepts logins from any account despite the explicit +shell override — no warning, and the two local runners now disagree on precedence. + +## 7. Local sndbx.sh run hard-requires a `.env` file + +`deploy/sndbx.sh/local.ts:6` — the local run passes `envFile: ".env"`, which reaches +`readExplicitEnvFile`, and that throws when the file is absent. + +Failure: developer with all secrets exported in their shell but no `deploy/sndbx.sh/.env` +file runs `bun run local:sndbx.sh`: `loadDeployEnv` throws `Env file not found: .env` +(`server/scripts/env.ts:112`) and the server never starts, where the previous +`runLocalServer` path worked from shell env alone. + +## 8. WAF challenges are misclassified as Access blocks + +`cli/src/api.ts:136` — `edgeBlocked` treats any 403 carrying a `cf-mitigated` header as a +Cloudflare Access block, but `cf-mitigated` is also set by Cloudflare WAF/bot challenges +on servers with no Access application. + +Failure: a server behind ordinary Cloudflare gets a WAF bot challenge: 403 with +`cf-mitigated: challenge`. `apiRequest` converts this into a hard `CliError` advising +"run scratchwork login again" and setting Access service-token vars — advice that cannot +fix a WAF challenge — and callers never see the response to interpret the 403 themselves. + +## 9. Renderer: empty-destination image inside a link label no longer parses as a link + +`renderer/src/render.js:61` — `INLINE_IMAGE`'s destination changed from the old +`[^()\s]*` (empty allowed) to `DEST_BODY` with a `+` quantifier. + +Failure: markdown `[![CI]()](https://ci.example.com)` (badge with a placeholder image +URL): origin/main rendered a working link wrapping the literal image text; the new +renderer emits literal `[![CI]()](` plus an autolinked URL and `)`, silently breaking the +link. + +## 10. Duplicated team-domain normalization and kid constant in the local simulator + +`server/deploy-cloudflare/src/local-worker.ts:91` — `normalizedTeamDomain` hand-copies +config.ts's private `normalizeCfTeamDomain` (its docstring admits it "mirrors" it), and +the simulator kid string `"scratchwork-local-access"` is independently hardcoded here and +in `server/deploy-cloudflare/src/deploy.ts:454`. + +Cost: the simulator's signed `iss` claim must byte-match what +`server/core/src/config.ts:325` computes for the verifier; the copies already diverge +(config validates via `safeDomain` and can return null; the mirror silently produces an +invalid origin). The next normalization change lands in one file and every locally +simulated request fails with "Invalid Access token issuer" — export +`normalizeCfTeamDomain` from server-core (local-worker already imports server core) and +share the kid constant. + +## Verification notes + +Findings 1, 2, 5, 6, 7, 9 are confirmed directly against the code (each failure path +traced by hand); 3, 4, 8 are plausible-by-construction (real reachable states, severity +depends on deployment shape); 10 was independently flagged by three finder angles. One +candidate was refuted during verification: a suspected missing `await` on `worker.fetch` +in local-worker.ts is harmless because the production worker's `fetch` wraps its body in +`try { return await … } catch` and its promise never rejects. + +Review run 2026-07-07 against `origin/main...HEAD` (`ad45683`), 8 finder angles plus a +recall-biased verify pass; full test suite passing at time of review (298 pass, 0 fail). From 7596985716c661c689d1cbf7ca396a687a2d402e Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Wed, 8 Jul 2026 17:10:08 -1000 Subject: [PATCH 09/13] Deploy through the Cloudflare SDK and rename the sndbx.sh deploy project Replace the wrangler CLI shell-outs in deployServer with the official cloudflare SDK: R2 bucket and D1 database provisioning, the multipart worker upload with R2/D1/plain-text bindings, zone routes and custom domains, the workers.dev subdomain, and secrets all go through the REST API. Deploys now authenticate with CLOUDFLARE_API_TOKEN (plus CLOUDFLARE_ACCOUNT_ID when the token sees several accounts) instead of wrangler credentials; the local runtime still runs through wrangler dev, which remains the only way to run workerd locally. Rename deploy/sndbx.sh to deploy/cloudflare-vanilla to match the other deploy projects' flavor naming (compare deploy/cf-access), and document the exact token permissions in its .env.example. Co-Authored-By: Claude Fable 5 --- README.md | 8 +- bun.lock | 105 +++++- deploy/cloudflare-vanilla/.env.example | 26 ++ .../README.md | 9 +- .../cloudflare-config.ts | 0 .../deploy.ts | 0 .../{sndbx.sh => cloudflare-vanilla}/local.ts | 0 .../package.json | 2 +- .../server-config.ts | 0 .../tsconfig.json | 0 deploy/generic-aws/README.md | 2 +- deploy/local-dev/README.md | 2 +- deploy/local-dev/local.ts | 2 +- deploy/sndbx.sh/.env.example | 11 - package.json | 6 +- server/README.md | 18 +- server/deploy-cloudflare/README.md | 6 + server/deploy-cloudflare/package.json | 1 + server/deploy-cloudflare/src/deploy.ts | 323 ++++++++++++------ server/deploy-local/README.md | 2 +- server/scripts/env.test.ts | 4 +- 21 files changed, 375 insertions(+), 152 deletions(-) create mode 100644 deploy/cloudflare-vanilla/.env.example rename deploy/{sndbx.sh => cloudflare-vanilla}/README.md (88%) rename deploy/{sndbx.sh => cloudflare-vanilla}/cloudflare-config.ts (100%) rename deploy/{sndbx.sh => cloudflare-vanilla}/deploy.ts (100%) rename deploy/{sndbx.sh => cloudflare-vanilla}/local.ts (100%) rename deploy/{sndbx.sh => cloudflare-vanilla}/package.json (87%) rename deploy/{sndbx.sh => cloudflare-vanilla}/server-config.ts (100%) rename deploy/{sndbx.sh => cloudflare-vanilla}/tsconfig.json (100%) delete mode 100644 deploy/sndbx.sh/.env.example diff --git a/README.md b/README.md index cb7be9f..0603ddc 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ an optional locally signed Cloudflare Access identity), see [`server/deploy-cloudflare/README.md`](server/deploy-cloudflare/README.md). The ready-made Access test deployment is `bun run local:cf-access`; the sndbx.sh -project's production Worker configuration runs locally with `bun run local:sndbx.sh`. +project's production Worker configuration runs locally with `bun run local:cloudflare-vanilla`. Then publish a directory or file: @@ -115,7 +115,7 @@ scratchwork revoke alice@example.com Deployments live as projects under `deploy/`, one per domain, each deployable with one command: ```sh -bun run deploy:sndbx.sh +bun run deploy:cloudflare-vanilla ``` Cloud runtime dependencies live in `server/deploy-aws` and `server/deploy-cloudflare`. See `server/README.md` for cloud setup details. @@ -123,8 +123,8 @@ Cloud runtime dependencies live in `server/deploy-aws` and `server/deploy-cloudf Deploy secrets load from the project's `.env`: ```sh -cp deploy/sndbx.sh/.env.example deploy/sndbx.sh/.env -bun run deploy:sndbx.sh +cp deploy/cloudflare-vanilla/.env.example deploy/cloudflare-vanilla/.env +bun run deploy:cloudflare-vanilla ``` --- diff --git a/bun.lock b/bun.lock index c044b97..e98cdb9 100644 --- a/bun.lock +++ b/bun.lock @@ -40,22 +40,22 @@ "typescript": "^6.0.3", }, }, - "deploy/generic-aws": { - "name": "@scratchwork/deploy-generic-aws", + "deploy/cloudflare-vanilla": { + "name": "@scratchwork/deploy-cloudflare-vanilla", "version": "0.1.0", "dependencies": { - "@scratchwork/server-deploy-aws": "workspace:*", - "@scratchwork/server-deploy-local": "workspace:*", + "@scratchwork/server-deploy-cloudflare": "workspace:*", }, "devDependencies": { "@types/bun": "^1.3.14", "typescript": "^6.0.3", }, }, - "deploy/local-dev": { - "name": "@scratchwork/deploy-local-dev", + "deploy/generic-aws": { + "name": "@scratchwork/deploy-generic-aws", "version": "0.1.0", "dependencies": { + "@scratchwork/server-deploy-aws": "workspace:*", "@scratchwork/server-deploy-local": "workspace:*", }, "devDependencies": { @@ -63,11 +63,11 @@ "typescript": "^6.0.3", }, }, - "deploy/sndbx.sh": { - "name": "@scratchwork/deploy-sndbx-sh", + "deploy/local-dev": { + "name": "@scratchwork/deploy-local-dev", "version": "0.1.0", "dependencies": { - "@scratchwork/server-deploy-cloudflare": "workspace:*", + "@scratchwork/server-deploy-local": "workspace:*", }, "devDependencies": { "@types/bun": "^1.3.14", @@ -121,6 +121,7 @@ "dependencies": { "@effect/platform": "0.96.2", "@scratchwork/server-core": "workspace:*", + "cloudflare": "^6.5.0", "effect": "3.21.4", }, "devDependencies": { @@ -405,12 +406,12 @@ "@scratchwork/deploy-cf-access-local": ["@scratchwork/deploy-cf-access-local@workspace:deploy/cf-access"], + "@scratchwork/deploy-cloudflare-vanilla": ["@scratchwork/deploy-cloudflare-vanilla@workspace:deploy/cloudflare-vanilla"], + "@scratchwork/deploy-generic-aws": ["@scratchwork/deploy-generic-aws@workspace:deploy/generic-aws"], "@scratchwork/deploy-local-dev": ["@scratchwork/deploy-local-dev@workspace:deploy/local-dev"], - "@scratchwork/deploy-sndbx-sh": ["@scratchwork/deploy-sndbx-sh@workspace:deploy/sndbx.sh"], - "@scratchwork/server-core": ["@scratchwork/server-core@workspace:server/core"], "@scratchwork/server-deploy-aws": ["@scratchwork/server-deploy-aws@workspace:server/deploy-aws"], @@ -447,7 +448,15 @@ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + "@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + + "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], @@ -455,24 +464,66 @@ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "cloudflare": ["cloudflare@6.5.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-PS3rQM9AZrYK/nuoQ3q1Je9kpQ1FIC6aUiNuRGybDGHJ21W7zfqXG6ubVhkD8xDpeFFdWQXEBFh92raVfkIGXg=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "effect": ["effect@3.21.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-B89v/xSgPbl1J2Ai2u18jxq3odpFauU1rC6/eSs4FeNHi72kwKdJp12VGigvRV2lK+kRnx+OOz41XV8guZd4gQ=="], "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], + + "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], + + "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=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "htm": ["htm@3.1.1", "", {}, "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ=="], + "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], + "ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -487,10 +538,18 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "miniflare": ["miniflare@4.20260701.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260701.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-L6eAAi6IKtyb/7J6L+YsH2vb1yBrJWKRXI293JYDiMl70+6nncdAgigex58w6WBd+CwvdMsqOyNyGs95Op5gWQ=="], "mnemonist": ["mnemonist@0.38.3", "", { "dependencies": { "obliterator": "^1.6.1" } }, "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "msgpackr": ["msgpackr@1.12.1", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ=="], "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], @@ -499,6 +558,10 @@ "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], "obliterator": ["obliterator@1.6.1", "", {}, "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig=="], @@ -531,18 +594,26 @@ "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], - "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], "uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], + "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "workerd": ["workerd@1.20260701.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260701.1", "@cloudflare/workerd-darwin-arm64": "1.20260701.1", "@cloudflare/workerd-linux-64": "1.20260701.1", "@cloudflare/workerd-linux-arm64": "1.20260701.1", "@cloudflare/workerd-windows-64": "1.20260701.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-uF813NG09JwNRRUfJ0zBomyTslSPM810dMj9LVvkQ7RAkLrQLzAlPU8Xh/3dIqZDo2bfd7tChbf2PtqLRARRJQ=="], "wrangler": ["wrangler@4.107.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260701.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260701.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260701.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-fw69ThymNitZ0oIEBU2yNeq3kK59UKz/jyA3udwRrQIAIsxX57q5qLOpPTN7qc5t8n9pnUeofe0uxtMuhQZW8w=="], @@ -563,6 +634,10 @@ "@aws-sdk/middleware-endpoint-discovery/@smithy/core": ["@smithy/core@3.28.0", "", { "dependencies": { "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-N/LoLG8pZ1zv5cIWpdF6vmSjtZtXKK9G0OqT5yYCOZU+CzPq1+nYA95VoKJBGWRScs7YbMugZ7lZx8Fj1vdHoA=="], + "@types/node-fetch/@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + + "bun-types/@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + "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=="], "@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=="], @@ -571,6 +646,10 @@ "@aws-sdk/dynamodb-codec/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.6.0", "", { "dependencies": { "@smithy/core": "^3.28.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-IkPHQdbyoebSwBCuMTzJ/2oIhKVqiZZAZxQYSlpDZqq/WhJUpmdgbHvP7ItddxsPzcDUJeI0V4PNMSNtlZ0aqA=="], + "@types/node-fetch/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "bun-types/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], diff --git a/deploy/cloudflare-vanilla/.env.example b/deploy/cloudflare-vanilla/.env.example new file mode 100644 index 0000000..558c900 --- /dev/null +++ b/deploy/cloudflare-vanilla/.env.example @@ -0,0 +1,26 @@ +# Local secrets for sndbx.sh deploys. +# The non-secret Cloudflare/domain settings live in cloudflare-config.ts. +# These values are required here unless provided by the shell environment. + +# API token for deploys, which go through the Cloudflare REST API. Create a +# custom token (dash.cloudflare.com -> My Profile -> API Tokens) with: +# Account | Workers Scripts | Edit - upload the Worker, secrets, workers.dev +# Account | Workers R2 Storage | Edit - create the R2 bucket +# Account | D1 | Edit - find or create the D1 database +# Zone | Zone | Read - resolve the sndbx.sh zone ID +# Zone | Workers Routes | Edit - point the sndbx.sh routes at the Worker +# Account | Account Settings | Read - only if CLOUDFLARE_ACCOUNT_ID is unset +# The zone permissions can be scoped to just the sndbx.sh zone. The token is +# not needed for `bun run local`. +CLOUDFLARE_API_TOKEN= + +# Only required when the token can see more than one Cloudflare account. +CLOUDFLARE_ACCOUNT_ID= + +SCRATCHWORK_GOOGLE_CLIENT_ID= +SCRATCHWORK_GOOGLE_CLIENT_SECRET= + +# Private signing key for browser and CLI sessions. Keep this stable across +# deploys; changing it logs everyone out. Generate one with: +# openssl rand -base64 48 +SCRATCHWORK_SESSION_SECRET= diff --git a/deploy/sndbx.sh/README.md b/deploy/cloudflare-vanilla/README.md similarity index 88% rename from deploy/sndbx.sh/README.md rename to deploy/cloudflare-vanilla/README.md index ffd2d73..27efff8 100644 --- a/deploy/sndbx.sh/README.md +++ b/deploy/cloudflare-vanilla/README.md @@ -1,18 +1,19 @@ # sndbx.sh Cloudflare deploy This deploy instance is a Bun project that uses the shared Cloudflare Worker -deploy package. +deploy package to serve the sndbx.sh domain, without Cloudflare Access in +front of it (hence "vanilla" — compare `deploy/cf-access`). Deploy from the repo root: ```sh -bun run deploy:sndbx.sh +bun run deploy:cloudflare-vanilla ``` Start local secrets from the template: ```sh -cp deploy/sndbx.sh/.env.example deploy/sndbx.sh/.env +cp deploy/cloudflare-vanilla/.env.example deploy/cloudflare-vanilla/.env ``` The server settings (domains, auth policy, visibility rules) live in @@ -48,7 +49,7 @@ https://app.sndbx.sh/auth/callback/google Run the sndbx.sh Worker with Wrangler's persistent local R2 and D1 bindings: ```sh -bun run local:sndbx.sh # from the repo root, or `bun run local` here +bun run local:cloudflare-vanilla # from the repo root, or `bun run local` here ``` Because the config declares separate app and content domains, the local run diff --git a/deploy/sndbx.sh/cloudflare-config.ts b/deploy/cloudflare-vanilla/cloudflare-config.ts similarity index 100% rename from deploy/sndbx.sh/cloudflare-config.ts rename to deploy/cloudflare-vanilla/cloudflare-config.ts diff --git a/deploy/sndbx.sh/deploy.ts b/deploy/cloudflare-vanilla/deploy.ts similarity index 100% rename from deploy/sndbx.sh/deploy.ts rename to deploy/cloudflare-vanilla/deploy.ts diff --git a/deploy/sndbx.sh/local.ts b/deploy/cloudflare-vanilla/local.ts similarity index 100% rename from deploy/sndbx.sh/local.ts rename to deploy/cloudflare-vanilla/local.ts diff --git a/deploy/sndbx.sh/package.json b/deploy/cloudflare-vanilla/package.json similarity index 87% rename from deploy/sndbx.sh/package.json rename to deploy/cloudflare-vanilla/package.json index 2ffb53d..94299a0 100644 --- a/deploy/sndbx.sh/package.json +++ b/deploy/cloudflare-vanilla/package.json @@ -1,5 +1,5 @@ { - "name": "@scratchwork/deploy-sndbx-sh", + "name": "@scratchwork/deploy-cloudflare-vanilla", "version": "0.1.0", "private": true, "type": "module", diff --git a/deploy/sndbx.sh/server-config.ts b/deploy/cloudflare-vanilla/server-config.ts similarity index 100% rename from deploy/sndbx.sh/server-config.ts rename to deploy/cloudflare-vanilla/server-config.ts diff --git a/deploy/sndbx.sh/tsconfig.json b/deploy/cloudflare-vanilla/tsconfig.json similarity index 100% rename from deploy/sndbx.sh/tsconfig.json rename to deploy/cloudflare-vanilla/tsconfig.json diff --git a/deploy/generic-aws/README.md b/deploy/generic-aws/README.md index 7d00e49..d3637fd 100644 --- a/deploy/generic-aws/README.md +++ b/deploy/generic-aws/README.md @@ -3,7 +3,7 @@ > **Placeholder.** This project is only here in case we want to spend more time > developing our AWS deploy capabilities. It is not attached to a domain and is > not part of any production setup — real deployments live in sibling projects -> like `deploy/sndbx.sh`. +> like `deploy/cloudflare-vanilla`. Deploys the Scratchwork server as an AWS Lambda Function URL backed by S3 and DynamoDB, using the `@scratchwork/server-deploy-aws` adapter with generic, diff --git a/deploy/local-dev/README.md b/deploy/local-dev/README.md index 1fe7eb9..5ed3c32 100644 --- a/deploy/local-dev/README.md +++ b/deploy/local-dev/README.md @@ -25,4 +25,4 @@ SCRATCHWORK_STORAGE_DIR=/tmp/scratchwork-local-storage ``` To run a real deploy's server config locally instead, use that project's local -run (for example `bun run local:sndbx.sh`). +run (for example `bun run local:cloudflare-vanilla`). diff --git a/deploy/local-dev/local.ts b/deploy/local-dev/local.ts index e5b91d4..2139f85 100644 --- a/deploy/local-dev/local.ts +++ b/deploy/local-dev/local.ts @@ -1,7 +1,7 @@ import { runLocalServer } from "@scratchwork/server-deploy-local"; // Generic local development server: local file storage, in-memory database, no -// cloud counterpart. Domain deploys live in sibling projects such as deploy/sndbx.sh. +// cloud counterpart. Domain deploys live in sibling projects such as deploy/cloudflare-vanilla. runLocalServer({ server: { usersCanSetProjectNames: true, diff --git a/deploy/sndbx.sh/.env.example b/deploy/sndbx.sh/.env.example deleted file mode 100644 index e345f97..0000000 --- a/deploy/sndbx.sh/.env.example +++ /dev/null @@ -1,11 +0,0 @@ -# Local secrets for sndbx.sh deploys. -# The non-secret Cloudflare/domain settings live in deploy.ts. -# These values are required here unless provided by the shell environment. - -SCRATCHWORK_GOOGLE_CLIENT_ID= -SCRATCHWORK_GOOGLE_CLIENT_SECRET= - -# Private signing key for browser and CLI sessions. Keep this stable across -# deploys; changing it logs everyone out. Generate one with: -# openssl rand -base64 48 -SCRATCHWORK_SESSION_SECRET= diff --git a/package.json b/package.json index 0b31abf..f2a03bd 100644 --- a/package.json +++ b/package.json @@ -18,16 +18,16 @@ "build:renderer": "cd renderer && bun install && bun run build", "check": "cd cli && bun run check && cd ../server && bun run check", "deploy:generic-aws": "cd deploy/generic-aws && bun run deploy", - "deploy:sndbx.sh": "cd deploy/sndbx.sh && bun run deploy", + "deploy:cloudflare-vanilla": "cd deploy/cloudflare-vanilla && bun run deploy", "local:generic-aws": "cd deploy/generic-aws && bun run local", "local:cloudflare": "cd server/deploy-cloudflare && bun run dev", "local:cf-access": "cd deploy/cf-access && bun run local", "local:local-dev": "cd deploy/local-dev && bun run local", - "local:sndbx.sh": "cd deploy/sndbx.sh && bun run local", + "local:cloudflare-vanilla": "cd deploy/cloudflare-vanilla && bun run local", "dev:renderer": "cd renderer && bun run dev", "lint": "cd cli && bun run lint", "test": "cd renderer && bun test && cd ../cli && bun run test", - "typecheck": "cd cli && bun run typecheck && cd ../server && bun run typecheck && cd ../deploy/generic-aws && bun run typecheck && cd ../local-dev && bun run typecheck && cd ../sndbx.sh && bun run typecheck && cd ../cf-access && bun run typecheck" + "typecheck": "cd cli && bun run typecheck && cd ../server && bun run typecheck && cd ../deploy/generic-aws && bun run typecheck && cd ../local-dev && bun run typecheck && cd ../cloudflare-vanilla && bun run typecheck && cd ../cf-access && bun run typecheck" }, "dependencies": { "@effect/platform": "0.96.2", diff --git a/server/README.md b/server/README.md index 1839939..8fca1b3 100644 --- a/server/README.md +++ b/server/README.md @@ -9,7 +9,7 @@ The server is split into four packages: 3. `deploy-cloudflare`: Cloudflare Worker adapter backed by R2 4. `deploy-local`: local Bun adapter backed by local files and an in-memory database -These packages are libraries. Actual deployments live as projects under `deploy/` — one per domain the Scratchwork project owns (currently `deploy/sndbx.sh`), plus `deploy/local-dev` (local-only development server) and `deploy/generic-aws` (a placeholder AWS deploy). +These packages are libraries. Actual deployments live as projects under `deploy/` — one per domain the Scratchwork project owns (currently `deploy/cloudflare-vanilla`), plus `deploy/local-dev` (local-only development server) and `deploy/generic-aws` (a placeholder AWS deploy). ## Local @@ -17,7 +17,7 @@ These packages are libraries. Actual deployments live as projects under `deploy/ bun run local:local-dev ``` -By default the generic local server listens on `43118` and stores published bundles under `.scratchwork-local-data/`. Deploy projects can choose the local adapter matching their platform: `bun run local:sndbx.sh` now runs its production Worker configuration with Wrangler-backed R2 and D1, while the generic AWS project uses `deploy-local`'s `runLocalServer` — see the deploy project's README. +By default the generic local server listens on `43118` and stores published bundles under `.scratchwork-local-data/`. Deploy projects can choose the local adapter matching their platform: `bun run local:cloudflare-vanilla` now runs its production Worker configuration with Wrangler-backed R2 and D1, while the generic AWS project uses `deploy-local`'s `runLocalServer` — see the deploy project's README. To exercise the actual Cloudflare Worker adapter with locally simulated R2 and D1 bindings instead, run: @@ -56,14 +56,14 @@ SCRATCHWORK_SESSION_SECRET=use-at-least-32-random-bytes Deploy projects load environment values from files and the shell. Precedence is: 1. Shell environment -2. The project's `.env`, such as `deploy/sndbx.sh/.env` +2. The project's `.env`, such as `deploy/cloudflare-vanilla/.env` 3. Built-in defaults Start from the project's example file, then deploy: ```sh -cp deploy/sndbx.sh/.env.example deploy/sndbx.sh/.env -bun run deploy:sndbx.sh +cp deploy/cloudflare-vanilla/.env.example deploy/cloudflare-vanilla/.env +bun run deploy:cloudflare-vanilla ``` Configure your Google OAuth app with this redirect URI: @@ -165,13 +165,13 @@ SCRATCHWORK_SESSION_SECRET=use-at-least-32-random-bytes ## Cloudflare -Deploy to Cloudflare Workers + R2 via a deploy project, such as `deploy/sndbx.sh`: +Deploy to Cloudflare Workers + R2 via a deploy project, such as `deploy/cloudflare-vanilla`: ```sh -bun run deploy:sndbx.sh +bun run deploy:cloudflare-vanilla ``` -The deploy command uses the `wrangler` CLI credentials in your environment. It creates the R2 bucket if needed, writes a generated Wrangler config under `server/deploy-cloudflare/dist/`, and deploys the Worker. The package pins Wrangler as a development dependency, so neither deployment nor local development requires a separate global install. +The deploy command talks to the Cloudflare REST API through the official `cloudflare` SDK, authenticated by `CLOUDFLARE_API_TOKEN` in the environment or an env file (also set `CLOUDFLARE_ACCOUNT_ID` when the token can see more than one account). It creates the R2 bucket and D1 database if needed, uploads the Worker with its bindings, and applies routes and custom domains. A Wrangler-format config is still written under `server/deploy-cloudflare/dist/` as a record of the deploy. Local development runs through the pinned Wrangler development dependency, so it requires no separate global install. Optional environment variables: @@ -186,6 +186,6 @@ SCRATCHWORK_GOOGLE_CLIENT_SECRET=... SCRATCHWORK_SESSION_SECRET=use-at-least-32-random-bytes ``` -Cloudflare deploy writes non-secret auth values into the generated Wrangler config and uploads `SCRATCHWORK_GOOGLE_CLIENT_SECRET` plus `SCRATCHWORK_SESSION_SECRET` with `wrangler secret put`. AWS deploy sends `SCRATCHWORK_*` values to Lambda environment variables. +Cloudflare deploy attaches non-secret auth values to the Worker as plain-text bindings and uploads `SCRATCHWORK_GOOGLE_CLIENT_SECRET` plus `SCRATCHWORK_SESSION_SECRET` through the Workers secrets API. AWS deploy sends `SCRATCHWORK_*` values to Lambda environment variables. Set `SCRATCHWORK_APP_URL` and `SCRATCHWORK_CONTENT_URL` (or the `appDomain`/`contentDomain` config values) behind a custom domain or proxy so publish responses return the public URL. Use the same value for both on a single-host server. diff --git a/server/deploy-cloudflare/README.md b/server/deploy-cloudflare/README.md index 2c17020..e2caaae 100644 --- a/server/deploy-cloudflare/README.md +++ b/server/deploy-cloudflare/README.md @@ -78,4 +78,10 @@ import { deployServer } from "@scratchwork/server-deploy-cloudflare"; await deployServer(config, { envFile: ".env" }); ``` +Deploys call the Cloudflare REST API through the official `cloudflare` SDK, so they +need `CLOUDFLARE_API_TOKEN` in the environment or an env file. Set +`CLOUDFLARE_ACCOUNT_ID` as well when the token can see more than one account. The +token needs permission to manage Workers scripts, R2 buckets, D1 databases, and — +when routes or custom domains are configured — the zone's Workers routes. + See the repository's `server/README.md` for all server and deployment settings. diff --git a/server/deploy-cloudflare/package.json b/server/deploy-cloudflare/package.json index 98b7a43..916aa24 100644 --- a/server/deploy-cloudflare/package.json +++ b/server/deploy-cloudflare/package.json @@ -20,6 +20,7 @@ "dependencies": { "@effect/platform": "0.96.2", "@scratchwork/server-core": "workspace:*", + "cloudflare": "^6.5.0", "effect": "3.21.4" }, "devDependencies": { diff --git a/server/deploy-cloudflare/src/deploy.ts b/server/deploy-cloudflare/src/deploy.ts index 6228512..fbcdf25 100644 --- a/server/deploy-cloudflare/src/deploy.ts +++ b/server/deploy-cloudflare/src/deploy.ts @@ -1,10 +1,13 @@ /** * Deploys the Scratchwork server as a Cloudflare Worker backed by R2 + D1, by building - * the worker bundle, generating a Wrangler config under dist/, and shelling out to the - * `wrangler` CLI. Like all deploy tooling under server/, this is deliberately plain - * Promise-based script code, not Effect: it runs once on a developer's machine. + * the worker bundle and driving the Cloudflare REST API through the official + * `cloudflare` SDK. The local runtime still shells out to `wrangler dev`, which is the + * only way to run workerd locally. Like all deploy tooling under server/, this is + * deliberately plain Promise-based script code, not Effect: it runs once on a + * developer's machine. */ -import { mkdir, writeFile } from "node:fs/promises"; +import Cloudflare, { APIError, toFile } from "cloudflare"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { copyEnv, definedEnv, loadDeployEnv, type DeployEnv } from "../../scripts/env"; @@ -110,7 +113,15 @@ interface ResolvedCloudflareDeployConfig { readonly skipDatabaseCreate: boolean; } -/** Build artifacts written under dist/ for Wrangler to consume. */ +/** One resolved route in Wrangler's config shape, also used to drive the routes API. */ +type CloudflareRouteEntry = { + readonly pattern: string; + readonly custom_domain?: boolean; + readonly zone_name?: string; +}; + +/** Build artifacts written under dist/: the worker bundles uploaded by deploys, plus + * the Wrangler configs consumed by the local runtime and kept as deploy records. */ const root = dirname(dirname(fileURLToPath(import.meta.url))); const dist = join(root, "dist"); const workerPath = join(dist, "worker.js"); @@ -155,13 +166,19 @@ export async function deployServer( validateDeploymentConfig(env, "Cloudflare"); await run("bun", ["build", "src/worker.ts", "--target=browser", "--format=esm", `--outfile=${workerPath}`], { cwd: root }); const routes = cloudflareRoutes(resolvedDeploy); - await ensureBucket(resolvedDeploy, run); - const databaseId = await ensureDatabase(resolvedDeploy, run); + const { client, accountId } = await createCloudflareClient(env); + await ensureBucket(client, accountId, resolvedDeploy); + const databaseId = await ensureDatabase(client, accountId, resolvedDeploy); const deployWithDatabase = { ...resolvedDeploy, d1DatabaseId: databaseId }; - await writeConfig(deployWithDatabase, resolvedServer, env, routes); - await run("wrangler", ["deploy", "--config", configPath, "--no-bundle"], { cwd: root }); - await putSecret(commandEnv, env, "SCRATCHWORK_GOOGLE_CLIENT_SECRET"); - await putSecret(commandEnv, env, "SCRATCHWORK_SESSION_SECRET"); + const vars = workerVars(resolvedServer, env); + await writeConfig(deployWithDatabase, vars, routes); + await uploadWorker(client, accountId, deployWithDatabase, databaseId, vars); + console.log(`Uploaded worker ${resolvedDeploy.workerName} (R2 ${resolvedDeploy.bucketName}, D1 ${resolvedDeploy.d1DatabaseName})`); + await applyRoutes(client, accountId, resolvedDeploy.workerName, routes); + if (routes.length > 0) console.log(`Routed ${routes.map((route) => route.pattern).join(", ")}`); + await setWorkersDevSubdomain(client, accountId, resolvedDeploy.workerName, routes.length === 0); + await putSecret(client, accountId, resolvedDeploy.workerName, env, "SCRATCHWORK_GOOGLE_CLIENT_SECRET"); + await putSecret(client, accountId, resolvedDeploy.workerName, env, "SCRATCHWORK_SESSION_SECRET"); const homepageHint = homepagePublishHint(serverConfig, resolvedServer); if (homepageHint != null) console.log(homepageHint); @@ -230,13 +247,11 @@ export async function runLocalCloudflareServer( copyEnv(localVars, env, "SCRATCHWORK_LOCAL_CF_ACCESS_EMAIL"); await writeConfig( { ...resolvedDeploy, d1DatabaseId: LOCAL_D1_ID }, - localServer, - env, + workerVars(localServer, env, localVars), [], { outputPath: localConfigPath, main: access == null ? "worker.js" : "local-worker.js", - extraVars: localVars, }, ); @@ -350,18 +365,12 @@ function deployConfigEnv(resolved: ResolvedCloudflareDeployConfig): DeployEnv { return env; } -/** Writes the generated Wrangler config consumed by the deploy command. */ -async function writeConfig( - config: ResolvedCloudflareDeployConfig, +/** Collects the non-secret configuration variables passed to the Worker. */ +function workerVars( server: ResolvedScratchworkServerConfig, env: DeployEnv, - routes: ReadonlyArray>, - options: { - readonly outputPath?: string; - readonly main?: string; - readonly extraVars?: Readonly>; - } = {}, -): Promise { + extraVars?: Readonly>, +): Record { const vars: Record = {}; copyEnv(vars, env, "SCRATCHWORK_AUTH"); copyEnv(vars, env, "SCRATCHWORK_GOOGLE_CLIENT_ID"); @@ -379,7 +388,22 @@ async function writeConfig( copyEnv(vars, env, "SCRATCHWORK_HOMEPAGE_PROJECT"); if (server.appUrl != null && server.appUrl !== "") vars.SCRATCHWORK_APP_URL = server.appUrl; if (server.contentUrl != null && server.contentUrl !== "") vars.SCRATCHWORK_CONTENT_URL = server.contentUrl; - Object.assign(vars, options.extraVars); + Object.assign(vars, extraVars); + return vars; +} + +/** Writes a Wrangler-format config under dist/. Remote deploys go through the API and + * only keep this file as a record of what was deployed; the local runtime's config is + * what `wrangler dev` actually reads. */ +async function writeConfig( + config: ResolvedCloudflareDeployConfig, + vars: Readonly>, + routes: ReadonlyArray, + options: { + readonly outputPath?: string; + readonly main?: string; + } = {}, +): Promise { await writeFile( options.outputPath ?? configPath, JSON.stringify( @@ -463,114 +487,211 @@ async function localAccessEnv(email: string, sessionSecret: string | undefined): }; } -/** Builds optional custom-domain and route entries for Wrangler. */ -function cloudflareRoutes(config: ResolvedCloudflareDeployConfig): ReadonlyArray> { +/** Builds optional custom-domain and route entries in Wrangler's config shape. */ +function cloudflareRoutes(config: ResolvedCloudflareDeployConfig): ReadonlyArray { if (config.routes != null) { - return config.routes.map((route) => ({ - pattern: route.pattern, - ...(route.customDomain === true ? { custom_domain: true } : {}), - ...zoneFor(route.pattern, route.zoneName ?? config.zoneName), - })); + return config.routes.map((route) => routeEntry(route.pattern, route.customDomain === true, route.zoneName ?? config.zoneName)); } - const routes: Array> = []; + const routes: Array = []; if (config.customDomain != null) { - routes.push({ pattern: config.customDomain, custom_domain: true, ...zoneFor(config.customDomain, config.zoneName) }); + routes.push(routeEntry(config.customDomain, true, config.zoneName)); } if (config.route != null) { - routes.push({ pattern: config.route, ...zoneFor(config.route, config.zoneName) }); + routes.push(routeEntry(config.route, false, config.zoneName)); } return routes; } +/** Builds one route entry with its zone name resolved. */ +function routeEntry(pattern: string, customDomain: boolean, zoneName: string | undefined): CloudflareRouteEntry { + const zone = zoneFor(pattern, zoneName); + return { + pattern, + ...(customDomain ? { custom_domain: true } : {}), + ...(zone == null ? {} : { zone_name: zone }), + }; +} + /** Infers the Cloudflare zone name from a route pattern unless configured. */ -function zoneFor(pattern: string, configured: string | undefined): Record { - if (configured != null && configured.trim() !== "") return { zone_name: configured }; +function zoneFor(pattern: string, configured: string | undefined): string | undefined { + if (configured != null && configured.trim() !== "") return configured; const host = pattern.replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/^\*\./, ""); const labels = host.split(".").filter(Boolean); - return labels.length >= 2 ? { zone_name: labels.slice(-2).join(".") } : {}; + return labels.length >= 2 ? labels.slice(-2).join(".") : undefined; } -/** Uploads one configured secret to Wrangler without printing its value. */ -async function putSecret(commandEnv: Record, env: DeployEnv, key: string): Promise { - const value = env[key]; - if (value == null || value === "") return; - const proc = Bun.spawn(["wrangler", "secret", "put", key, "--config", configPath], { - cwd: root, - env: commandEnv, - stdin: "pipe", - stdout: "inherit", - stderr: "inherit", - }); - proc.stdin.write(value); - proc.stdin.end(); - const exitCode = await proc.exited; - if (exitCode !== 0) throw new Error(`wrangler secret put ${key} failed with exit code ${exitCode}`); +/** Builds an authenticated API client and resolves the Cloudflare account to deploy to. */ +async function createCloudflareClient(env: DeployEnv): Promise<{ client: Cloudflare; accountId: string }> { + const apiToken = optional(env.CLOUDFLARE_API_TOKEN); + const apiKey = optional(env.CLOUDFLARE_API_KEY); + const apiEmail = optional(env.CLOUDFLARE_EMAIL); + if (apiToken == null && (apiKey == null || apiEmail == null)) { + throw new Error( + "Cloudflare deploys need CLOUDFLARE_API_TOKEN (or CLOUDFLARE_API_KEY and CLOUDFLARE_EMAIL) in the environment or an env file", + ); + } + const client = new Cloudflare({ apiToken: apiToken ?? null, apiKey: apiKey ?? null, apiEmail: apiEmail ?? null }); + + const configured = optional(env.CLOUDFLARE_ACCOUNT_ID); + if (configured != null) return { client, accountId: configured }; + const accounts: Array<{ id: string; name: string }> = []; + for await (const account of client.accounts.list()) { + accounts.push({ id: account.id, name: account.name }); + } + if (accounts.length === 1) return { client, accountId: accounts[0].id }; + const names = accounts.map((account) => `${account.name} (${account.id})`).join(", "); + throw new Error( + accounts.length === 0 + ? "The Cloudflare API token has no visible accounts; set CLOUDFLARE_ACCOUNT_ID" + : `The Cloudflare API token can see multiple accounts; set CLOUDFLARE_ACCOUNT_ID to one of: ${names}`, + ); } -/** Creates the configured R2 bucket unless bucket creation is explicitly skipped. */ -async function ensureBucket( +/** Uploads the built worker bundle with its R2, D1, and configuration bindings. The + * upload replaces all bindings, so secrets set by earlier deploys are explicitly kept. */ +async function uploadWorker( + client: Cloudflare, + accountId: string, config: ResolvedCloudflareDeployConfig, - run: ReturnType["run"], + databaseId: string, + vars: Readonly>, ): Promise { - if (config.skipBucketCreate) return; - const result = await run("wrangler", ["r2", "bucket", "create", config.bucketName], { - allowFailure: true, - capture: true, - cwd: root, + const bundle = await readFile(workerPath); + await client.workers.scripts.update(config.workerName, { + account_id: accountId, + metadata: { + main_module: "worker.js", + compatibility_date: config.compatibilityDate, + bindings: [ + { type: "r2_bucket", name: config.bucketBinding, bucket_name: config.bucketName }, + { type: "d1", name: config.d1DatabaseBinding, database_id: databaseId }, + ...Object.entries(vars).map(([name, text]) => ({ type: "plain_text" as const, name, text })), + ], + keep_bindings: ["secret_text", "secret_key"], + }, + files: [await toFile(bundle, "worker.js", { type: "application/javascript+module" })], }); - if (result.ok || alreadyExists(result.stderr) || alreadyExists(result.stdout)) return; - process.stderr.write(result.stderr || result.stdout); - throw new Error(`Could not create R2 bucket ${config.bucketName}`); } -/** Creates or finds the configured D1 database and returns its Wrangler database ID. */ -async function ensureDatabase( - config: ResolvedCloudflareDeployConfig, - run: ReturnType["run"], -): Promise { - if (config.d1DatabaseId != null) return config.d1DatabaseId; +/** Points the configured custom domains and zone routes at the deployed Worker. */ +async function applyRoutes( + client: Cloudflare, + accountId: string, + workerName: string, + routes: ReadonlyArray, +): Promise { + const zones = new Map(); + const zoneId = async (entry: CloudflareRouteEntry): Promise => { + if (entry.zone_name == null) { + throw new Error(`Cannot infer the Cloudflare zone for route "${entry.pattern}"; set zoneName`); + } + const cached = zones.get(entry.zone_name); + if (cached != null) return cached; + for await (const zone of client.zones.list({ name: entry.zone_name })) { + if (zone.name === entry.zone_name) { + zones.set(entry.zone_name, zone.id); + return zone.id; + } + } + throw new Error(`Cloudflare zone "${entry.zone_name}" not found for route "${entry.pattern}"`); + }; - const listed = await run("wrangler", ["d1", "list", "--json"], { - allowFailure: true, - capture: true, - cwd: root, - }); - const existing = listed.ok ? databaseIdFromList(listed.stdout, config.d1DatabaseName) : undefined; - if (existing != null) return existing; - if (config.skipDatabaseCreate) return undefined; + for (const entry of routes) { + if (entry.custom_domain === true) { + await client.workers.domains.update({ + account_id: accountId, + hostname: entry.pattern, + service: workerName, + environment: "production", + zone_id: await zoneId(entry), + }); + continue; + } + const zone = await zoneId(entry); + let existing: { id: string; script?: string } | undefined; + for await (const route of client.workers.routes.list({ zone_id: zone })) { + if (route.pattern === entry.pattern) { + existing = route; + break; + } + } + if (existing == null) { + await client.workers.routes.create({ zone_id: zone, pattern: entry.pattern, script: workerName }); + } else if (existing.script !== workerName) { + await client.workers.routes.update(existing.id, { zone_id: zone, pattern: entry.pattern, script: workerName }); + } + } +} - const created = await run("wrangler", ["d1", "create", config.d1DatabaseName], { - capture: true, - cwd: root, +/** Matches Wrangler's workers.dev behavior: the subdomain serves the Worker only when + * no routes or custom domains are configured. */ +async function setWorkersDevSubdomain( + client: Cloudflare, + accountId: string, + workerName: string, + enabled: boolean, +): Promise { + await client.workers.scripts.subdomain.create(workerName, { + account_id: accountId, + enabled, + previews_enabled: enabled, }); - return databaseIdFromText(created.stdout) ?? databaseIdFromText(created.stderr); } -/** Detects Wrangler's resource-already-exists message. */ -function alreadyExists(value: string): boolean { - return value.toLowerCase().includes("already exists"); +/** Uploads one configured secret through the API without printing its value. */ +async function putSecret( + client: Cloudflare, + accountId: string, + workerName: string, + env: DeployEnv, + key: string, +): Promise { + const value = env[key]; + if (value == null || value === "") return; + await client.workers.scripts.secrets.update(workerName, { + account_id: accountId, + name: key, + text: value, + type: "secret_text", + }); } -/** Finds the database ID for a name in `wrangler d1 list --json` output. */ -function databaseIdFromList(text: string, name: string): string | undefined { +/** Creates the configured R2 bucket unless bucket creation is explicitly skipped. */ +async function ensureBucket(client: Cloudflare, accountId: string, config: ResolvedCloudflareDeployConfig): Promise { + if (config.skipBucketCreate) return; try { - const parsed = JSON.parse(text) as unknown; - if (!Array.isArray(parsed)) return undefined; - for (const item of parsed) { - if (typeof item !== "object" || item == null) continue; - const record = item as Record; - if (record.name !== name) continue; - const id = record.uuid ?? record.id ?? record.database_id; - return typeof id === "string" && id !== "" ? id : undefined; + await client.r2.buckets.create({ account_id: accountId, name: config.bucketName }); + } catch (error) { + if (alreadyExists(error)) return; + throw error; + } +} + +/** Creates or finds the configured D1 database and returns its database ID. */ +async function ensureDatabase(client: Cloudflare, accountId: string, config: ResolvedCloudflareDeployConfig): Promise { + if (config.d1DatabaseId != null) return config.d1DatabaseId; + + for await (const database of client.d1.database.list({ account_id: accountId, name: config.d1DatabaseName })) { + if (database.name === config.d1DatabaseName && database.uuid != null && database.uuid !== "") { + return database.uuid; } - return undefined; - } catch { - return undefined; } + if (config.skipDatabaseCreate) { + throw new Error( + `D1 database ${config.d1DatabaseName} not found and creation is disabled; set SCRATCHWORK_D1_DATABASE_ID`, + ); + } + + const created = await client.d1.database.create({ account_id: accountId, name: config.d1DatabaseName }); + if (created.uuid == null || created.uuid === "") { + throw new Error(`Cloudflare did not return a database ID for D1 database ${config.d1DatabaseName}`); + } + return created.uuid; } -/** Extracts the first UUID in Wrangler's `d1 create` output. */ -function databaseIdFromText(text: string): string | undefined { - return /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i.exec(text)?.[1]; +/** Detects Cloudflare's resource-already-exists API error. */ +function alreadyExists(error: unknown): boolean { + return error instanceof APIError + && error.errors.some((item) => item.code === 10004 || /already exists/i.test(item.message ?? "")); } diff --git a/server/deploy-local/README.md b/server/deploy-local/README.md index 031763e..cca1bb7 100644 --- a/server/deploy-local/README.md +++ b/server/deploy-local/README.md @@ -34,7 +34,7 @@ SCRATCHWORK_CONTENT_URL=http://localhost:43118 The package exports `runLocalServer`, which accepts the `server` section of a Cloudflare/AWS deploy config and runs it locally (environment variables still win over config values). Deploy projects use it to share one config module -between their cloud deploy and a local run — see `deploy/sndbx.sh/local.ts`: +between their cloud deploy and a local run — see `deploy/cloudflare-vanilla/local.ts`: ```ts import { runLocalServer } from "@scratchwork/server-deploy-local"; diff --git a/server/scripts/env.test.ts b/server/scripts/env.test.ts index 6aef774..4af906a 100644 --- a/server/scripts/env.test.ts +++ b/server/scripts/env.test.ts @@ -33,7 +33,7 @@ describe("loadDeployEnv", () => { test("can isolate explicit env loading to caller roots", async () => { const root = await mkdtemp(join(tmpdir(), "scratchwork-env-")); const packageRoot = join(root, "server", "deploy-cloudflare"); - const deployRoot = join(root, "deploy", "sndbx.sh"); + const deployRoot = join(root, "deploy", "cloudflare-vanilla"); try { await mkdir(packageRoot, { recursive: true }); await mkdir(deployRoot, { recursive: true }); @@ -61,7 +61,7 @@ describe("loadDeployEnv", () => { test("does not fall back to server env when caller env is missing", async () => { const root = await mkdtemp(join(tmpdir(), "scratchwork-env-")); const packageRoot = join(root, "server", "deploy-cloudflare"); - const deployRoot = join(root, "deploy", "sndbx.sh"); + const deployRoot = join(root, "deploy", "cloudflare-vanilla"); try { await mkdir(packageRoot, { recursive: true }); await mkdir(deployRoot, { recursive: true }); From c8eddc94ca99d7299a49f8bf0898d484bf11a6be Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Wed, 8 Jul 2026 17:27:21 -1000 Subject: [PATCH 10/13] Replace the dual-use visibility concept with explicit policy names "Visibility" meant two different things: a full access-group ceiling in server config (maxVisibility) and a binary public/private toggle everywhere else. Make every setting say exactly what it does: - Server: allowPublicProjects (bool), allowedShareDomains, publicByDefault (bool) replace maxVisibility, shareAllowedDomains, defaultVisibility. Retired SCRATCHWORK_* variables fail startup with a pointer to their replacement so policy is never silently dropped. - Project: a boolean isPublic replaces the visibility string in the stored record (now version 5; v4 records migrate at load), the publish wire format, API responses, and .scratchwork.json. - CLI: --public/--private flags replace --visibility. allowedShareDomains is now also enforced at role-resolution time, so tightening it locks down existing grants the way the maxVisibility subset check used to. Setting share domains no longer implicitly forbids public publishes; that is allowPublicProjects' job. accessGroupIsSubset had no remaining callers and is removed. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- cli/src/commands/projects.ts | 6 +- cli/src/commands/publish.ts | 20 +-- cli/src/help.ts | 14 +- cli/src/index.ts | 14 +- cli/src/project-config.ts | 10 +- cli/src/types.ts | 5 +- cli/test/e2e.test.js | 20 +-- cli/test/help.test.js | 2 +- deploy/cf-access/local.ts | 4 +- deploy/cloudflare-vanilla/README.md | 4 +- deploy/cloudflare-vanilla/server-config.ts | 4 +- deploy/local-dev/local.ts | 2 +- docs/index.md | 2 +- notes/spec.md | 62 ++++---- server/core/src/access.ts | 25 +-- server/core/src/app.ts | 12 +- server/core/src/config.ts | 48 +++--- server/core/src/index.ts | 1 - server/core/src/publish-request.ts | 24 +-- server/core/src/site-records.ts | 38 +++-- server/core/src/site-store.ts | 108 +++++++------ server/core/test/app.test.ts | 149 ++++++++---------- server/core/test/helpers.ts | 6 +- server/core/test/publish-request.test.ts | 16 +- server/core/test/site-store.test.ts | 127 ++++++++------- .../deploy-cloudflare/scripts/smoke-local.ts | 2 +- server/deploy-cloudflare/src/deploy.ts | 6 +- server/deploy-cloudflare/test/worker.test.ts | 4 +- server/deploy-local/README.md | 2 +- server/deploy-local/src/run.ts | 6 +- server/scripts/server-settings.test.ts | 21 ++- server/scripts/server-settings.ts | 19 ++- 33 files changed, 411 insertions(+), 374 deletions(-) diff --git a/README.md b/README.md index 0603ddc..239c6fd 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ scratchwork login --server http://localhost:43118 scratchwork publish index.html ``` -The server stores immutable file blobs in object storage and mutable project metadata in its database. The CLI saves `server`, `project`, `visibility`, and the latest URL in `.scratchwork.json` so the next `scratchwork publish` updates the same project. +The server stores immutable file blobs in object storage and mutable project metadata in its database. The CLI saves `server`, `project`, `isPublic`, and the latest URL in `.scratchwork.json` so the next `scratchwork publish` updates the same project. Share a published project with specific accounts or a whole domain — as readers, writers (can publish updates), or admins (can also manage sharing) — or take access away again: diff --git a/cli/src/commands/projects.ts b/cli/src/commands/projects.ts index c02f8a3..8a3f538 100644 --- a/cli/src/commands/projects.ts +++ b/cli/src/commands/projects.ts @@ -25,7 +25,7 @@ import { runPublish, SKIPPED_DIRECTORIES, type PublishServices } from "./publish /** Project metadata as returned by /api/projects. */ interface ApiProject { readonly project: string; - readonly visibility: string; + readonly isPublic: boolean; readonly url?: string; readonly updatedAt: string; } @@ -59,7 +59,7 @@ export function runProjects( return; } yield* Console.log(projects.map((project) => - `${project.project}\t${project.visibility}\t${project.url ?? `/${project.project}/`}`, + `${project.project}\t${project.isPublic ? "public" : "private"}\t${project.url ?? `/${project.project}/`}`, ).join("\n")); }); } @@ -76,7 +76,7 @@ export function runInfo( }); } -/** Runs `scratchwork unpublish`: sets a project's visibility to private. */ +/** Runs `scratchwork unpublish`: makes a project private and clears every grant. */ export function runUnpublish( config: ProjectRefConfig, ): Effect.Effect { diff --git a/cli/src/commands/publish.ts b/cli/src/commands/publish.ts index 8e64c6c..c3b1c24 100644 --- a/cli/src/commands/publish.ts +++ b/cli/src/commands/publish.ts @@ -41,7 +41,7 @@ export type PublishServices = CommandExecutor | FileSystem.FileSystem | HttpClie * random-naming server it is how the CLI learns the assigned name. */ interface PublishResponse { readonly project: string; - readonly visibility: string; + readonly isPublic: boolean; readonly openPath: string; readonly url: string; } @@ -67,16 +67,16 @@ export function runPublish( const authToken = yield* readAuthToken(server); const project = yield* resolveProjectName(config, projectConfig, target); const nameSource = target.file ?? (yield* basename(target.root)); - // An omitted visibility lets the server preserve an existing project's visibility - // or apply its default; an omitted project lets a random-naming server mint one. - const visibility = nonEmpty(config.visibility) ?? nonEmpty(projectConfig?.visibility); + // An omitted isPublic lets the server preserve an existing project's setting or + // apply its default; an omitted project lets a random-naming server mint one. + const isPublic = config.isPublic ?? projectConfig?.isPublic; const bundle = yield* createBundle(target.root); const body = { bundle, openPath: target.openPath, project, - visibility, + isPublic, }; const response = yield* postPublish(server, body, authToken).pipe( Effect.catchIf((error) => error instanceof PublishAuthRequired, () => @@ -184,7 +184,7 @@ function postPublish( readonly bundle: PublishBundle; readonly openPath: string; readonly project?: string; - readonly visibility?: string; + readonly isPublic?: boolean; }, authToken: string | undefined, ): Effect.Effect { @@ -230,7 +230,7 @@ function writeMetadata( return writeProjectConfig(root, { server, project: response.project, - visibility: response.visibility, + isPublic: response.isPublic, url: response.url, updatedAt: new Date().toISOString(), }).pipe( @@ -260,7 +260,7 @@ function printResult( ...(sentProject != null && sentProject !== response.project ? [` note server assigned project name "${response.project}"`] : []), - ` access ${response.visibility}`, + ` access ${response.isPublic ? "public" : "private"}`, ` files ${bundle.files.length} (${formatBytes(bytes)})`, ...(saved ? [` saved ${PROJECT_CONFIG_FILE}\n`] : [""]), ].join("\n"), @@ -272,7 +272,7 @@ function decodePublishResponse(value: unknown): PublishResponse | null { if (!isRecord(value)) return null; if ( typeof value.project !== "string" || - typeof value.visibility !== "string" || + typeof value.isPublic !== "boolean" || typeof value.openPath !== "string" || typeof value.url !== "string" ) { @@ -280,7 +280,7 @@ function decodePublishResponse(value: unknown): PublishResponse | null { } return { project: value.project, - visibility: value.visibility, + isPublic: value.isPublic, openPath: value.openPath, url: value.url, }; diff --git a/cli/src/help.ts b/cli/src/help.ts index 585985e..68e2794 100644 --- a/cli/src/help.ts +++ b/cli/src/help.ts @@ -77,10 +77,10 @@ const EXTRAS: Readonly> = { "If the server returns 401, the CLI starts scratchwork login and retries.", ], examples: [ - "scratchwork publish . --server sndbx.sh --visibility public", + "scratchwork publish . --server sndbx.sh --public", "scratchwork publish notes.md --server sndbx.sh", "scratchwork publish docs --server https://app.sndbx.sh --project hello-world", - "scratchwork publish --visibility private", + "scratchwork publish --private", ], }, login: { @@ -115,9 +115,9 @@ const EXTRAS: Readonly> = { }, share: { notes: [ - "Roles: read (view), write (read + publish updates), admin (write + manage sharing, visibility, and unpublish). The project creator is the owner (admin + delete); ownership cannot be granted.", + "Roles: read (view), write (read + publish updates), admin (write + manage sharing, the public/private toggle, and unpublish). The project creator is the owner (admin + delete); ownership cannot be granted.", "Sharing sets the targets' role — a target holding another role is moved, and other grants are kept.", - "Requires admin access. Grants are independent of the public/private visibility toggle and work on public projects too.", + "Requires admin access. Grants are independent of the public/private toggle and work on public projects too.", ], examples: [ "scratchwork share alice@example.com", @@ -128,7 +128,7 @@ const EXTRAS: Readonly> = { revoke: { notes: [ "Strips every role (read, write, admin) the exact email/@domain targets hold.", - "Warns when a revoked address still has access through a remaining domain grant, public visibility, or ownership.", + "Warns when a revoked address still has access through a remaining domain grant, the project being public, or ownership.", ], examples: [ "scratchwork revoke alice@example.com", @@ -136,7 +136,7 @@ const EXTRAS: Readonly> = { ], }, unpublish: { - notes: ["This does not delete files or project metadata; it resets the project to owner-only, setting visibility to private and clearing every share grant."], + notes: ["This does not delete files or project metadata; it resets the project to owner-only, making it private and clearing every share grant."], examples: [ "scratchwork unpublish https://pages.sndbx.sh/hello-world/", "scratchwork unpublish --server sndbx.sh --project hello-world", @@ -287,7 +287,7 @@ function renderRootHelp(version: string, commands: ReadonlyArray): "Examples:", formatExamples([ "scratchwork dev", - "scratchwork publish . --server sndbx.sh --visibility public", + "scratchwork publish . --server sndbx.sh --public", "scratchwork projects --server sndbx.sh", "scratchwork help publish", ]), diff --git a/cli/src/index.ts b/cli/src/index.ts index 3ba2b75..bcc1946 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -80,9 +80,17 @@ const publishCommand = Command.make( path: pathArg("path", ".", "File or directory to publish. Default: current directory. Directories are uploaded recursively, excluding .git, node_modules, and .scratchwork-data."), server: textOption("server", "url", "Scratchwork app server, such as sndbx.sh or https://app.sndbx.sh. Required on first publish; later publishes read it from .scratchwork.json."), project: textOption("project", "name", "Project name for the published URL. Default: saved config, the directory name, or the file name without its extension. Servers in random-naming mode assign a name on first publish."), - visibility: textOption("visibility", "scope", "Visibility toggle: private or public. Default: saved config, the project's current visibility, or the server default. Grant per-account or per-domain access with scratchwork share."), + isPublicFlag: Options.boolean("public").pipe( + Options.withDescription("Make the project readable by everyone. Default: saved config, the project's current setting, or the server default. Grant per-account or per-domain access with scratchwork share."), + ), + isPrivateFlag: Options.boolean("private").pipe( + Options.withDescription("Make the project readable only by its owner and share grants."), + ), }, - runPublish, + ({ path, server, project, isPublicFlag, isPrivateFlag }) => + isPublicFlag && isPrivateFlag + ? Effect.fail(new CliError({ code: 1, message: "scratchwork publish: pass at most one of --public and --private" })) + : runPublish({ path, server, project, isPublic: isPublicFlag ? true : isPrivateFlag ? false : undefined }), ).pipe(Command.withDescription("Publish a static Scratchwork project to a server")); const projectRefOptions = { @@ -141,7 +149,7 @@ const shareCommand = Command.make( ...shareOptions("grant access"), role: Options.choice("role", ["read", "write", "admin"]).pipe( Options.withDefault("read" as const), - Options.withDescription("Permission level to assign: read (view the project), write (read + publish updates), or admin (write + manage sharing, visibility, and unpublish). Default: read. Sharing again with a different role moves the target; ownership stays with the project creator."), + Options.withDescription("Permission level to assign: read (view the project), write (read + publish updates), or admin (write + manage sharing, the public/private toggle, and unpublish). Default: read. Sharing again with a different role moves the target; ownership stays with the project creator."), ), }, runShare, diff --git a/cli/src/project-config.ts b/cli/src/project-config.ts index 6bc17a9..468200e 100644 --- a/cli/src/project-config.ts +++ b/cli/src/project-config.ts @@ -23,7 +23,7 @@ export const PROJECT_CONFIG_FILE = ".scratchwork.json"; export interface ProjectConfigFile { readonly server?: string; readonly project?: string; - readonly visibility?: string; + readonly isPublic?: boolean; readonly url?: string; readonly updatedAt?: string; } @@ -187,13 +187,15 @@ function parseProjectUrl(value: string | undefined): { readonly server: string; } /** Validates and narrows parsed JSON into a ProjectConfigFile, dropping unknown fields. - * Workspace-era fields never reach here — rejectLegacyConfig fails on them first. */ + * Workspace-era fields never reach here — rejectLegacyConfig fails on them first. A + * retired string `visibility` field is dropped too: omitting isPublic is safe because + * the server then preserves the project's current setting. */ function decodeProjectConfig(value: unknown): ProjectConfigFile | null { if (!isRecord(value)) return null; - const config: Record = {}; + const config: Record = {}; if (typeof value.server === "string") config.server = value.server; if (typeof value.project === "string") config.project = value.project; - if (typeof value.visibility === "string") config.visibility = value.visibility; + if (typeof value.isPublic === "boolean") config.isPublic = value.isPublic; if (typeof value.url === "string") config.url = value.url; if (typeof value.updatedAt === "string") config.updatedAt = value.updatedAt; return config; diff --git a/cli/src/types.ts b/cli/src/types.ts index 56eefe7..5f0f67f 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -21,12 +21,13 @@ export interface TemplateConfig { readonly file: string; } -/** `scratchwork publish` options. */ +/** `scratchwork publish` options. `isPublic` stays undefined when neither --public nor + * --private is passed, letting saved config or the server decide. */ export interface PublishConfig { readonly path: string; readonly server?: string; readonly project?: string; - readonly visibility?: string; + readonly isPublic?: boolean; } /** `scratchwork login` options. */ diff --git a/cli/test/e2e.test.js b/cli/test/e2e.test.js index 2a193a5..2a7777b 100644 --- a/cli/test/e2e.test.js +++ b/cli/test/e2e.test.js @@ -660,7 +660,7 @@ describe("scratchwork publish", () => { publishBody = await request.json(); return Response.json({ project: publishBody.project, - visibility: publishBody.visibility, + isPublic: publishBody.isPublic ?? true, openPath: publishBody.openPath, url: `${serverUrl}/${publishBody.project}/`, }); @@ -693,7 +693,7 @@ describe("scratchwork publish", () => { { server: serverUrl, project: "site", - visibility: "public", + isPublic: true, url: `${serverUrl}/site/`, updatedAt: "2026-06-29T00:00:00.000Z", }, @@ -712,7 +712,7 @@ describe("scratchwork publish", () => { expect(authorization).toBe(`Bearer ${authToken}`); expect("workspace" in publishBody).toBe(false); expect(publishBody.project).toBe("site"); - expect(publishBody.visibility).toBe("public"); + expect(publishBody.isPublic).toBe(true); expect(publishBody.bundle.files.map((file) => file.path)).toEqual(["index.html"]); } finally { server.stop(true); @@ -731,7 +731,7 @@ describe("scratchwork project commands", () => { const shareBodies = []; const project = { project: "site", - visibility: "public", + isPublic: true, url: `${serverUrl}/site/`, updatedAt: "2026-06-29T00:00:00.000Z", }; @@ -747,7 +747,7 @@ describe("scratchwork project commands", () => { } if (url.pathname === "/api/projects/site" && request.method === "GET") return Response.json({ project }); if (url.pathname === "/api/projects/site/unpublish" && request.method === "POST") { - return Response.json({ project: { ...project, visibility: "private" } }); + return Response.json({ project: { ...project, isPublic: false } }); } if (url.pathname === "/api/projects/site/share" && request.method === "POST") { const body = await request.json(); @@ -774,7 +774,7 @@ describe("scratchwork project commands", () => { try { expect((await runCli(["projects", "--server", serverUrl], dir)).stdout).toContain(`site\tpublic\t${serverUrl}/site/`); expect((await runCli(["info", "--server", serverUrl, "--project", "site"], dir)).stdout).toContain('"project": "site"'); - expect((await runCli(["unpublish", "--server", serverUrl, "--project", "site"], dir)).stdout).toContain('"visibility": "private"'); + expect((await runCli(["unpublish", "--server", serverUrl, "--project", "site"], dir)).stdout).toContain('"isPublic": false'); expect((await runCli(["share", "alice@example.com", "@example.com", "--server", serverUrl, "--project", "site"], dir)).stdout) .toContain('"alice@example.com"'); expect((await runCli(["share", "--role", "write", "bob@example.com", "--server", serverUrl, "--project", "site"], dir)).code) @@ -795,7 +795,7 @@ describe("scratchwork project commands", () => { expect(noTargets.stderr).toContain("pass at least one email address or @domain group"); expect((await runCli(["delete", "--server", serverUrl, "--project", "site"], dir)).stdout).toContain("Deleted site"); expect((await runCli(["info", `${serverUrl}/site/`], dir)).stdout).toContain('"project": "site"'); - expect((await runCli(["unpublish", `${serverUrl}/site/`], dir)).stdout).toContain('"visibility": "private"'); + expect((await runCli(["unpublish", `${serverUrl}/site/`], dir)).stdout).toContain('"isPublic": false'); expect((await runCli(["delete", `${serverUrl}/site/`], dir)).stdout).toContain("Deleted site"); expect((await runCli(["clone", `${serverUrl}/site/`], dir)).stdout).toContain("Cloned site"); expect(readFileSync(join(dir, "site", "index.html"), "utf8")).toBe("

cloned

"); @@ -935,7 +935,7 @@ describe("scratchwork stream", () => { publishes.push(await request.json()); return Response.json({ project: "site", - visibility: "public", + isPublic: true, openPath: "/", url: `${serverUrl}/site/`, }); @@ -998,7 +998,7 @@ describe("publish and auth safety", () => { publishBody = await request.json(); return Response.json({ project: publishBody.project, - visibility: "public", + isPublic: true, openPath: "/", url: `${serverUrl}/${publishBody.project}/`, }); @@ -1074,7 +1074,7 @@ describe("project naming", () => { const project = assignedName ?? body.project; return Response.json({ project, - visibility: "public", + isPublic: true, openPath: body.openPath ?? "/", url: `${serverUrl}/${project}/`, }); diff --git a/cli/test/help.test.js b/cli/test/help.test.js index 967724f..acd3028 100644 --- a/cli/test/help.test.js +++ b/cli/test/help.test.js @@ -81,7 +81,7 @@ describe("CLI help", () => { expect(after.code).toBe(0); expect(before.stdout).toBe(after.stdout); expect(before.stdout).toContain("scratchwork publish [path] [--server ]"); - expect(before.stdout).toContain("--visibility "); + expect(before.stdout).toContain("--public"); }); test("treats a bare help token after the command as a positional argument", async () => { diff --git a/deploy/cf-access/local.ts b/deploy/cf-access/local.ts index c0eb7e1..c7ff340 100644 --- a/deploy/cf-access/local.ts +++ b/deploy/cf-access/local.ts @@ -10,9 +10,9 @@ const config = { auth: "cloudflare-access", authSessionSeconds: 2_592_000, allowedUsers: "public", - maxVisibility: "public", + allowPublicProjects: true, usersCanSetProjectNames: true, - defaultVisibility: "private", + publicByDefault: false, }, deploy: { workerName: "scratchwork-cf-access-local", diff --git a/deploy/cloudflare-vanilla/README.md b/deploy/cloudflare-vanilla/README.md index 27efff8..d1f16b4 100644 --- a/deploy/cloudflare-vanilla/README.md +++ b/deploy/cloudflare-vanilla/README.md @@ -16,7 +16,7 @@ Start local secrets from the template: cp deploy/cloudflare-vanilla/.env.example deploy/cloudflare-vanilla/.env ``` -The server settings (domains, auth policy, visibility rules) live in +The server settings (domains, auth policy, sharing rules) live in `server-config.ts`. `cloudflare-config.ts` adds the Worker, R2, D1, and route configuration; both the remote deploy and local Wrangler run consume that same complete config. Secrets are read from `.env` in this directory and the shell @@ -31,7 +31,7 @@ Cloudflare route assignment for those hostnames. The homepage is an ordinary project: after a fresh deploy, publish it with ```sh -scratchwork publish --server https://app.sndbx.sh --project www --visibility public +scratchwork publish --server https://app.sndbx.sh --project www --public ``` (the deploy output prints this command). Until then, `sndbx.sh` serves a diff --git a/deploy/cloudflare-vanilla/server-config.ts b/deploy/cloudflare-vanilla/server-config.ts index b276301..1d33d8e 100644 --- a/deploy/cloudflare-vanilla/server-config.ts +++ b/deploy/cloudflare-vanilla/server-config.ts @@ -9,7 +9,7 @@ export const server = { auth: "oauth", authSessionSeconds: 2_592_000, allowedUsers: "public", - maxVisibility: "public", + allowPublicProjects: true, usersCanSetProjectNames: true, - defaultVisibility: "private", + publicByDefault: false, } satisfies ScratchworkServerConfig; diff --git a/deploy/local-dev/local.ts b/deploy/local-dev/local.ts index 2139f85..226f08f 100644 --- a/deploy/local-dev/local.ts +++ b/deploy/local-dev/local.ts @@ -5,6 +5,6 @@ import { runLocalServer } from "@scratchwork/server-deploy-local"; runLocalServer({ server: { usersCanSetProjectNames: true, - defaultVisibility: "public", + publicByDefault: true, }, }); diff --git a/docs/index.md b/docs/index.md index f723c64..8f7e964 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,7 +46,7 @@ scratchwork dev Publish it privately ```sh -scratchwork publish --server scratchwork.dev --visibility private +scratchwork publish --server scratchwork.dev --private ``` Give your all of your teammates read access: diff --git a/notes/spec.md b/notes/spec.md index ef93174..615ff1b 100644 --- a/notes/spec.md +++ b/notes/spec.md @@ -18,7 +18,7 @@ A **group** is the access expression used everywhere access is configured: - `user@example.com` means one specific authenticated user can access it. - `user@x.com,@acme.com` means any matching email or domain can access it. -A project has a **visibility** toggle — `public` or `private` — controlling whether everyone can read the published project, plus three **grant groups** (read, write, admin) naming the specific emails and `@domains` that hold each role. Server-level settings (`allowedUsers`, `maxVisibility`) use the full group syntax. +A project has an **isPublic** toggle controlling whether everyone can read the published project, plus three **grant groups** (read, write, admin) naming the specific emails and `@domains` that hold each role. The server-level `allowedUsers` setting uses the full group syntax; the other policy settings are explicit scalars — `allowPublicProjects` (boolean), `allowedShareDomains` (domain list), `publicByDefault` (boolean). A server may designate one project as its **homepage** — the project served on the server's home domains, typically the naked domain and `www`. The homepage is an ordinary project: it is published, updated, and access-controlled exactly like any other project. Only the way requests reach it differs. See "Server homepage" below. @@ -37,13 +37,13 @@ A project config looks like this: { "server": "example.com", "project": "hello-world", - "visibility": "private", + "isPublic": false, "url": "https://pages.example.com/hello-world/", "updatedAt": "2026-07-04T00:00:00.000Z" } ``` -`server` and `project` are the portable identity fields. `visibility` is the next publish default. `url` and `updatedAt` are server-assigned output fields saved by the CLI for display and convenience; they are not required to identify the project. A config still carrying the retired `workspace` or `routePath` fields is a hard error: the CLI names the stale field and asks for the file to be fixed or deleted, then republished. +`server` and `project` are the portable identity fields. `isPublic` is the next publish default. `url` and `updatedAt` are server-assigned output fields saved by the CLI for display and convenience; they are not required to identify the project. A config still carrying the retired `workspace` or `routePath` fields is a hard error: the CLI names the stale field and asks for the file to be fixed or deleted, then republished. ## Server Config @@ -94,12 +94,12 @@ export const server = { allowedUsers: "@example.com", authSessionSeconds: 2_592_000, - // Caps project visibility. A project cannot be published more broadly than this group. - maxVisibility: "@example.com", + // false: no project on this server may be public. Default: true. + allowPublicProjects: true, - // If shareAllowedDomains is set, users can only share published content with users on these + // If allowedShareDomains is set, users can only share published content with users on these // domains. If it is not set, there are no restrictions on who users can share with. - shareAllowedDomains: undefined, + allowedShareDomains: undefined, // How new projects get their globally unique, server-wide name. // true (default) - publishers choose names; the first publish of a name claims it @@ -110,8 +110,8 @@ export const server = { // for future features such as gh/g and auth-provider names) are rejected either way. usersCanSetProjectNames: true, - // Server fallback when the CLI does not send visibility. - defaultVisibility: "private", + // Server fallback when the CLI does not say public or private. Default: false. + publicByDefault: false, } satisfies ScratchworkServerConfig; ``` @@ -208,11 +208,11 @@ Cloudflare deploys also require Cloudflare credentials, and AWS deploys require Scratchwork uses the same group syntax for project-level and server-level access. -Project-level access has two parts: a `visibility` toggle that is only ever `"public"` or `"private"`, and per-role grant lists managed through the share API. In API responses they appear as: +Project-level access has two parts: a boolean `isPublic` toggle, and per-role grant lists managed through the share API. In API responses they appear as: ```json { - "visibility": "private", + "isPublic": false, "permissions": { "read": ["alice@example.com"], "write": ["@team.example.com"], @@ -221,20 +221,20 @@ Project-level access has two parts: a `visibility` toggle that is only ever `"pu } ``` -`permissions` names other users' emails, so it is included only for callers with admin access; everyone else sees just the visibility toggle. +`permissions` names other users' emails, so it is included only for callers with admin access; everyone else sees just the public/private toggle. Every user holds one effective role per project: `none < read < write < admin < owner`. Each level implies the ones below it: - `read` — view the published site, `info`, `clone`. -- `write` — read, plus publish new revisions (`publish`, `stream`). Writers cannot change visibility. -- `admin` — write, plus manage sharing (`share`/`revoke`), change visibility, and `unpublish`. +- `write` — read, plus publish new revisions (`publish`, `stream`). Writers cannot flip the public/private toggle. +- `admin` — write, plus manage sharing (`share`/`revoke`), flip the public/private toggle, and `unpublish`. - `owner` — admin, plus `delete`. The owner is fixed at creation and always retains full access; ownership cannot be granted or transferred (yet). -The project record stores the visibility toggle plus three grant groups, one per grantable role — `readers`, `writers`, and `admins` — the groups using the email/@domain syntax. A viewer has read access when the project is public or any grant group names them; grants are independent of the toggle, so read grants persist while a project is temporarily public. (Records written before this split stored read grants inside `visibility`; they migrate to `readers` on first load.) +The project record stores the `isPublic` toggle plus three grant groups, one per grantable role — `readers`, `writers`, and `admins` — the groups using the email/@domain syntax. A viewer has read access when the project is public or any grant group names them; grants are independent of the toggle, so read grants persist while a project is temporarily public. (Records written before this split stored read grants inside a `visibility` string; they migrate to `readers` on first load, and the retired string toggle migrates onto `isPublic`.) -The visibility toggle is set at publish time (`--visibility public|private`); the grant lists are edited with `scratchwork share --role ` / `scratchwork revoke` (`POST /api/projects/:project/share` with `{"role": ..., "add": [...], "remove": [...]}`). Sharing assigns the role — a target holding a different role is moved, never duplicated — and revoking strips every role. Grants are validated against `maxVisibility` and `shareAllowedDomains`; revokes are not, so tightening server policy never blocks revocation. `unpublish` resets a project to owner-only: visibility private and every grant cleared. A revoke response warns when the removed address still has access (through a remaining domain grant, public visibility, or ownership). +The toggle is set at publish time (`--public` / `--private`); the grant lists are edited with `scratchwork share --role ` / `scratchwork revoke` (`POST /api/projects/:project/share` with `{"role": ..., "add": [...], "remove": [...]}`). Sharing assigns the role — a target holding a different role is moved, never duplicated — and revoking strips every role. Grants are validated against `allowedShareDomains`; revokes are not, so tightening server policy never blocks revocation. `unpublish` resets a project to owner-only: private and every grant cleared. A revoke response warns when the removed address still has access (through a remaining domain grant, the project being public, or ownership). -`allowedUsers` gates app/API login. `maxVisibility` caps project visibility, so a project cannot be more public than the server allows; the ceiling gates every granted role (a group outside the ceiling stops conferring access), never the owner. For example, if `maxVisibility` is `"@example.com"`, a project cannot be published as `public`. +`allowedUsers` gates app/API login. `allowPublicProjects: false` forbids public projects: public publishes are rejected, and existing public projects read as private while the setting is off. `allowedShareDomains` bounds every granted role (a group outside the allowed domains stops conferring access). Neither setting ever gates the owner. API responses report the caller's own role as `access`; the grant lists themselves are returned only to admins and the owner, since they name other users' emails. @@ -263,7 +263,7 @@ This keeps routing deterministic — on any given host, a request path still res The homepage project also remains addressable at its normal content route (`pages.example.com//`). When the published project is the configured `homepageProject`, the publish response and the saved project config report the canonical home origin as the project `url`. -Access control is unchanged: the homepage project has an owner, a visibility toggle, and grant groups, checked on every request under the server's `maxVisibility` ceiling. A non-public homepage runs the standard project-access handoff, with the access cookie scoped to `/` on the home origin. Because the home origin is separate from the content origin, homepage JavaScript does not share an origin with projects on the content domain, so the same-origin exposures described under Security do not extend across the two hosts. Most servers will want the homepage published as `public`. +Access control is unchanged: the homepage project has an owner, a public/private toggle, and grant groups, checked on every request under the server's policy settings. A non-public homepage runs the standard project-access handoff, with the access cookie scoped to `/` on the home origin. Because the home origin is separate from the content origin, homepage JavaScript does not share an origin with projects on the content domain, so the same-origin exposures described under Security do not extend across the two hosts. Most servers will want the homepage published as `public`. ### Publishing the homepage @@ -272,7 +272,7 @@ There is no deploy-time publishing step. Deploys provision infrastructure only; ```sh cd homepage/ scratchwork publish --server https://app.example.com \ - --project home --visibility public + --project home --public ``` Two affordances make this easy to get right: @@ -308,8 +308,9 @@ scratchwork me # publish this project # server must be specified unless it is found in .scratchwork.json in the current # directory or a parent directory -# visibility defaults to the project config, then user default, then interactive prompt -# in non-interactive contexts with no user default, visibility defaults to private +# the public/private setting defaults to the project config, then the server's +# publicByDefault (omitting both --public and --private preserves an existing +# project's setting) # project name defaults, highest precedence first: --project, the publish root's # .scratchwork.json, the directory name for a directory target, or the file name # minus its final extension for a file target (notes.md -> notes); if nothing @@ -319,7 +320,7 @@ scratchwork me # if the server is specified (either in the args or project config) and the cli is not logged # in to that server, scratchwork login is automatically run first # if the server is not specified, this command should error out -scratchwork publish [--server text] [--project text] [--visibility ] [] +scratchwork publish [--server text] [--project text] [--public | --private] [] # The following commands reference a project on the server. The project may be identified in one # of three ways: @@ -329,7 +330,8 @@ scratchwork publish [--server text] [--project text] [--visibility ] ... # Revoke every role the exact email/@domain targets hold (the CLI warns when a -# revoked address still has access through a remaining domain grant, public -# visibility, or ownership) +# revoked address still has access through a remaining domain grant, the project +# being public, or ownership) scratchwork revoke [--server text] [--project text] [] ... -# Unpublish a given project: reset it to owner-only (visibility private, all -# share grants cleared) +# Unpublish a given project: reset it to owner-only (private, all share +# grants cleared) scratchwork unpublish [--server text] [--project text] [] # Delete a given project (releases its name) @@ -373,7 +375,7 @@ Users authenticate to the app. domain using google oauth, or — on a `cloudflar ### Accessing a server -A server uses `allowedUsers` to limit who can authenticate to the app/API. It uses `maxVisibility` to limit how widely any project on that server can be shared. Both settings use the standard group syntax. +A server uses `allowedUsers` (standard group syntax) to limit who can authenticate to the app/API. It uses `allowPublicProjects` to decide whether any project may be public, and `allowedShareDomains` to limit which domains share grants may name. ### Accessing content @@ -381,9 +383,9 @@ The server exposes an API on the `app.` subdomain, and serves published projects Published pages are served with normal, unrestrictive policies — no `Content-Security-Policy: sandbox` — so published JavaScript behaves like an ordinary static site. Isolation from the API and the login session comes from the host split alone: the `app.` session cookie is host-bound and never visible to `pages.`. -To view a non-public project in the browser, a viewer needs a _project access cookie_ for that project. When a user requests a non-public project at its clean URL (`pages.example.com//...`), the content host redirects them to `app.example.com/auth/project?route=&returnTo=`, where they authenticate if needed via the `app.`-scoped session cookie. If they hold at least read access (public visibility, a grant naming their email or domain, or ownership) under the server `maxVisibility` ceiling, `app.` mints a **handoff token** — an HMAC-signed, ~60-second, single-purpose token bound to the project name and the viewer email — and redirects back to the content URL with the token in a reserved query parameter (`?_scratchwork_handoff=...`). The content host redeems it: it re-signs the same claims as a longer-lived **cookie token** (`authSessionSeconds`, matching the app session) and sets it as an `HttpOnly; Secure; SameSite=Lax` cookie whose `Path` matches where the redeeming host serves the project — `/` on the content host, `/` on a home origin — then immediately redirects to the clean URL. The token never stays in the address bar, so the URL a viewer shares never carries a credential; a recipient who follows it just runs the same handoff under their own identity. An invalid or expired handoff token redirects to the clean URL, which re-runs the handoff. +To view a non-public project in the browser, a viewer needs a _project access cookie_ for that project. When a user requests a non-public project at its clean URL (`pages.example.com//...`), the content host redirects them to `app.example.com/auth/project?route=&returnTo=`, where they authenticate if needed via the `app.`-scoped session cookie. If they hold at least read access (the project being public, a grant naming their email or domain, or ownership) under server policy, `app.` mints a **handoff token** — an HMAC-signed, ~60-second, single-purpose token bound to the project name and the viewer email — and redirects back to the content URL with the token in a reserved query parameter (`?_scratchwork_handoff=...`). The content host redeems it: it re-signs the same claims as a longer-lived **cookie token** (`authSessionSeconds`, matching the app session) and sets it as an `HttpOnly; Secure; SameSite=Lax` cookie whose `Path` matches where the redeeming host serves the project — `/` on the content host, `/` on a home origin — then immediately redirects to the clean URL. The token never stays in the address bar, so the URL a viewer shares never carries a credential; a recipient who follows it just runs the same handoff under their own identity. An invalid or expired handoff token redirects to the clean URL, which re-runs the handoff. -Every private-content request re-verifies the cookie signature and re-checks project access (the visibility toggle, grant groups, and `maxVisibility`) against the cookie's email, so revoking access (unpublish, revoke, tightened policy) takes effect immediately despite the long-lived cookie. The redirect dance only repeats when the cookie expires or access changes. Because the cookie is minted by the content host for its own hostname, the flow does not depend on `app.` and `pages.` sharing a registrable domain. +Every private-content request re-verifies the cookie signature and re-checks project access (the public/private toggle, grant groups, and server policy) against the cookie's email, so revoking access (unpublish, revoke, tightened policy) takes effect immediately despite the long-lived cookie. The redirect dance only repeats when the cookie expires or access changes. Because the cookie is minted by the content host for its own hostname, the flow does not depend on `app.` and `pages.` sharing a registrable domain. Without the sandbox, every project on `pages.` shares one web origin, so the server — not the browser — must keep one project's JavaScript from reading another project's private content with the viewer's ambient cookies. Private-content responses set `Referrer-Policy: same-origin`, and the content host rejects any private **subresource** request (`Sec-Fetch-Dest` present and not `document`: fetch/XHR, ``, `" }), openPath: "/", project: "docs", - visibility: "public", + isPublic: true, }))) as { project: string }; const html = await handler(new Request(`https://scratch.test/${published.project}/`)); @@ -101,13 +101,13 @@ describe("server app", () => { expect(svg.headers.get("content-security-policy")).toBeNull(); }); - test("republishing without visibility preserves the project's visibility", async () => { - const handler = await appHandler({ auth: testAuth(user), config: { defaultVisibility: "private" } }); + test("republishing without isPublic preserves the project's setting", async () => { + const handler = await appHandler({ auth: testAuth(user), config: { publicByDefault: false } }); const first = await handler(post("/api/publish", { bundle: bundle({ "index.html": "v1" }), openPath: "/", project: "site", - visibility: "public", + isPublic: true, })); expect(first.status).toBe(200); @@ -117,7 +117,7 @@ describe("server app", () => { project: "site", })); expect(second.status).toBe(200); - expect(((await json(second)) as { visibility: string }).visibility).toBe("public"); + expect(((await json(second)) as { isPublic: boolean }).isPublic).toBe(true); const html = await handler(new Request("https://scratch.test/site/")); expect(html.status).toBe(200); @@ -131,7 +131,7 @@ describe("server app", () => { bundle: bundle({ "index.html": "hello" }), openPath: "/", project, - visibility: "public", + isPublic: true, })); expect(response.status).toBe(400); expect(await response.text()).toContain(`Project name is reserved: ${project}`); @@ -143,7 +143,7 @@ describe("server app", () => { const response = await handler(post("/api/publish", { bundle: bundle({ "index.html": "hello" }), openPath: "/", - visibility: "public", + isPublic: true, })); expect(response.status).toBe(400); expect(await response.text()).toContain("project name is required (pass --project)"); @@ -155,7 +155,7 @@ describe("server app", () => { bundle: bundle({ "index.html": "hello" }), openPath: "/", project: "Docs", - visibility: "public", + isPublic: true, })); expect(response.status).toBe(400); expect(await response.text()).toContain("Invalid project"); @@ -168,7 +168,7 @@ describe("server app", () => { openPath: "/", workspace: "demo", project: "site", - visibility: "public", + isPublic: true, })); expect(response.status).toBe(400); expect(await response.text()).toContain("workspace"); @@ -188,7 +188,7 @@ describe("server app", () => { bundle: bundle({ "index.html": "mine" }), openPath: "/", project: "site", - visibility: "public", + isPublic: true, })); expect(first.status).toBe(200); @@ -196,7 +196,7 @@ describe("server app", () => { bundle: bundle({ "index.html": "theirs" }), openPath: "/", project: "site", - visibility: "public", + isPublic: true, })); expect(taken.status).toBe(409); expect((await json(taken) as { error: string }).error).toBe(TAKEN("site")); @@ -206,7 +206,7 @@ describe("server app", () => { bundle: bundle({ "index.html": "mine v2" }), openPath: "/", project: "site", - visibility: "public", + isPublic: true, })); expect(republish.status).toBe(200); }); @@ -217,17 +217,17 @@ describe("server app", () => { bundle: bundle({ "index.html": "hello" }), openPath: "/", project: "site", - visibility: "private", + isPublic: false, })); expect(publish.status).toBe(200); const share = await handler(post("/api/projects/site/share", { add: ["Alice@Example.com", "@team.example.com"] })); expect(share.status).toBe(200); const shared = await json(share) as { - project: { visibility: string; permissions: { read: string[]; write: string[]; admin: string[] } }; + project: { isPublic: boolean; permissions: { read: string[]; write: string[]; admin: string[] } }; warnings: string[]; }; - expect(shared.project.visibility).toBe("private"); + expect(shared.project.isPublic).toBe(false); expect(shared.project.permissions).toEqual({ read: ["alice@example.com", "@team.example.com"], write: [], admin: [] }); expect(shared.warnings).toEqual([]); @@ -241,8 +241,8 @@ describe("server app", () => { .toEqual(["@team.example.com"]); const last = await handler(post("/api/projects/site/share", { remove: ["@team.example.com"] })); - const cleared = await json(last) as { project: { visibility: string; permissions: { read: string[] } } }; - expect(cleared.project.visibility).toBe("private"); + const cleared = await json(last) as { project: { isPublic: boolean; permissions: { read: string[] } } }; + expect(cleared.project.isPublic).toBe(false); expect(cleared.project.permissions.read).toEqual([]); }); @@ -252,7 +252,7 @@ describe("server app", () => { bundle: bundle({ "index.html": "hello" }), openPath: "/", project: "site", - visibility: "private", + isPublic: false, })); await handler(post("/api/projects/site/share", { add: ["alice@corp.example.com", "@corp.example.com"] })); @@ -274,15 +274,15 @@ describe("server app", () => { bundle: bundle({ "index.html": "hello" }), openPath: "/", project: "site", - visibility: "private", + isPublic: false, })); const write = await handler(post("/api/projects/site/share", { add: ["alice@example.com"], role: "write" })); expect(write.status).toBe(200); const written = await json(write) as { - project: { visibility: string; permissions: { read: string[]; write: string[]; admin: string[] } }; + project: { isPublic: boolean; permissions: { read: string[]; write: string[]; admin: string[] } }; }; - expect(written.project.visibility).toBe("private"); + expect(written.project.isPublic).toBe(false); expect(written.project.permissions).toEqual({ read: [], write: ["alice@example.com"], admin: [] }); // Re-sharing with a different role moves the target, never duplicates it. @@ -316,7 +316,7 @@ describe("server app", () => { bundle: bundle({ "index.html": "v1" }), openPath: "/", project: "site", - visibility: "private", + isPublic: false, })); await ownerHandler(post("/api/projects/site/share", { add: ["writer@example.com"], role: "write" })); await ownerHandler(post("/api/projects/site/share", { add: ["admin@example.com"], role: "admin" })); @@ -334,7 +334,7 @@ describe("server app", () => { bundle: bundle({ "index.html": "v3" }), openPath: "/", project: "site", - visibility: "public", + isPublic: true, })); expect(escalate.status).toBe(403); expect(await escalate.text()).toContain("admin access"); @@ -351,7 +351,7 @@ describe("server app", () => { bundle: bundle({ "index.html": "v4" }), openPath: "/", project: "site", - visibility: "public", + isPublic: true, })); expect(adminPublish.status).toBe(200); const adminUnpublish = await adminHandler(post("/api/projects/site/unpublish", {})); @@ -370,17 +370,17 @@ describe("server app", () => { bundle: bundle({ "index.html": "hello" }), openPath: "/", project: "site", - visibility: "public", + isPublic: true, })); await handler(post("/api/projects/site/share", { add: ["alice@example.com"], role: "write" })); const revoke = await handler(post("/api/projects/site/share", { remove: ["alice@example.com"] })); expect(revoke.status).toBe(200); const body = await json(revoke) as { - project: { visibility: string; permissions: { write: string[] } }; + project: { isPublic: boolean; permissions: { write: string[] } }; warnings: string[]; }; - expect(body.project.visibility).toBe("public"); + expect(body.project.isPublic).toBe(true); expect(body.project.permissions.write).toEqual([]); // The write role is gone, but the site is still publicly readable. expect(body.warnings).toEqual(["alice@example.com still has read access because the project is public"]); @@ -396,20 +396,20 @@ describe("server app", () => { bundle: bundle({ "index.html": "hello" }), openPath: "/", project: "site", - visibility: "private", + isPublic: false, })); await ownerHandler(post("/api/projects/site/share", { add: ["reader@example.com"] })); const ownerInfo = await json(await ownerHandler(new Request("https://scratch.test/api/projects/site"))) as { project: Record; }; - expect(ownerInfo.project.visibility).toBe("private"); + expect(ownerInfo.project.isPublic).toBe(false); expect(ownerInfo.project.permissions).toEqual({ read: ["reader@example.com"], write: [], admin: [] }); const readerInfo = await json(await readerHandler(new Request("https://scratch.test/api/projects/site"))) as { project: Record; }; - expect(readerInfo.project.visibility).toBe("private"); + expect(readerInfo.project.isPublic).toBe(false); expect("permissions" in readerInfo.project).toBe(false); }); @@ -419,7 +419,7 @@ describe("server app", () => { bundle: bundle({ "index.html": "hello" }), openPath: "/", project: "site", - visibility: "public", + isPublic: true, })); await handler(post("/api/projects/site/share", { add: ["alice@example.com"] })); await handler(post("/api/projects/site/share", { add: ["bob@example.com"], role: "write" })); @@ -427,9 +427,9 @@ describe("server app", () => { const unpublish = await handler(post("/api/projects/site/unpublish", {})); expect(unpublish.status).toBe(200); const body = await json(unpublish) as { - project: { visibility: string; permissions: { read: string[]; write: string[]; admin: string[] } }; + project: { isPublic: boolean; permissions: { read: string[]; write: string[]; admin: string[] } }; }; - expect(body.project.visibility).toBe("private"); + expect(body.project.isPublic).toBe(false); expect(body.project.permissions).toEqual({ read: [], write: [], admin: [] }); }); @@ -439,18 +439,18 @@ describe("server app", () => { bundle: bundle({ "index.html": "hello" }), openPath: "/", project: "site", - visibility: "public", + isPublic: true, })); // The grant is stored alongside the public toggle, so alice keeps read access // when the project later goes private. const response = await handler(post("/api/projects/site/share", { add: ["alice@example.com"] })); expect(response.status).toBe(200); - const body = await json(response) as { project: { visibility: string; permissions: { read: string[] } } }; - expect(body.project.visibility).toBe("public"); + const body = await json(response) as { project: { isPublic: boolean; permissions: { read: string[] } } }; + expect(body.project.isPublic).toBe(true); expect(body.project.permissions.read).toEqual(["alice@example.com"]); }); - test("publish rejects grant-list visibilities in favor of share", async () => { + test("publish rejects the retired visibility field", async () => { const handler = await appHandler({ auth: testAuth(user) }); const response = await handler(post("/api/publish", { bundle: bundle({ "index.html": "hello" }), @@ -459,7 +459,7 @@ describe("server app", () => { visibility: "alice@example.com", })); expect(response.status).toBe(400); - expect(await response.text()).toContain("scratchwork share"); + expect(await response.text()).toContain("visibility"); }); test("share is owner-only and 404s for missing projects", async () => { @@ -475,7 +475,7 @@ describe("server app", () => { bundle: bundle({ "index.html": "hello" }), openPath: "/", project: "site", - visibility: "private", + isPublic: false, })); const forbidden = await otherHandler(post("/api/projects/site/share", { add: ["other@example.com"] })); @@ -495,7 +495,7 @@ describe("server app", () => { bundle: bundle({ "index.html": "hello" }), openPath: "/", project: "site", - visibility: "private", + isPublic: false, })); const empty = await handler(post("/api/projects/site/share", {})); @@ -517,20 +517,20 @@ describe("server app", () => { bundle: bundle({ "index.html": "hello" }), openPath: "/", project: "site", - visibility: "private", + isPublic: false, })); await openHandler(post("/api/projects/site/share", { add: ["alice@old.example.com", "bob@old.example.com"] })); - // The same server, after tightening shareAllowedDomains. + // The same server, after tightening allowedShareDomains. const restricted = await appHandler({ db, storage, auth: testAuth(user), - config: { shareAllowedDomains: new Set(["example.com"]) }, + config: { allowedShareDomains: new Set(["example.com"]) }, }); const grant = await restricted(post("/api/projects/site/share", { add: ["carol@old.example.com"] })); expect(grant.status).toBe(403); - expect(await grant.text()).toContain("shareAllowedDomains"); + expect(await grant.text()).toContain("allowedShareDomains"); // Revoking still works even though the remaining grants predate the policy. const revoke = await restricted(post("/api/projects/site/share", { remove: ["alice@old.example.com"] })); @@ -545,7 +545,7 @@ describe("server app", () => { bundle: bundle({ "index.html": "hello" }), openPath: "/", project: "site", - visibility: "private", + isPublic: false, }))) as { project: string }; expect(published.project).toBe("site"); @@ -568,7 +568,7 @@ describe("server app", () => { const published = await json(await handler(post("/api/publish", { bundle: bundle({ "index.html": "hello" }), openPath: "/", - visibility: "public", + isPublic: true, }))) as { project: string; url: string }; expect(published.project).toMatch(/^[a-z2-9]{10}$/); @@ -583,14 +583,14 @@ describe("server app", () => { const first = await json(await handler(post("/api/publish", { bundle: bundle({ "index.html": "v1" }), openPath: "/", - visibility: "public", + isPublic: true, }))) as { project: string }; const second = await json(await handler(post("/api/publish", { bundle: bundle({ "index.html": "v2" }), openPath: "/", project: first.project, - visibility: "public", + isPublic: true, }))) as { project: string }; expect(second.project).toBe(first.project); @@ -604,7 +604,7 @@ describe("server app", () => { bundle: bundle({ "index.html": "hello" }), openPath: "/", project: "my-notes", - visibility: "public", + isPublic: true, }))) as { project: string }; expect(published.project).toMatch(/^[a-z2-9]{10}$/); @@ -626,14 +626,14 @@ describe("server app", () => { const first = await json(await ownerHandler(post("/api/publish", { bundle: bundle({ "index.html": "mine" }), openPath: "/", - visibility: "public", + isPublic: true, }))) as { project: string }; const taken = await otherHandler(post("/api/publish", { bundle: bundle({ "index.html": "theirs" }), openPath: "/", project: first.project, - visibility: "public", + isPublic: true, })); expect(taken.status).toBe(409); expect((await json(taken) as { error: string }).error).toBe(TAKEN(first.project)); @@ -646,7 +646,7 @@ describe("server app", () => { bundle: bundle({ "index.html": "hello" }), openPath: "/", project: `site-${String(index).padStart(3, "0")}`, - visibility: "public", + isPublic: true, })); expect(published.status).toBe(200); } @@ -669,28 +669,17 @@ describe("server app", () => { expect(response.status).toBe(401); }); - test("rejects visibility above the server ceiling", async () => { - const handler = await appHandler({ auth: testAuth(user), config: { maxVisibility: "@example.com" } }); + test("rejects public publishes when the server forbids public projects", async () => { + const handler = await appHandler({ auth: testAuth(user), config: { allowPublicProjects: false } }); const response = await handler(post("/api/publish", { bundle: bundle({ "index.html": "hello" }), openPath: "/", project: "site", - visibility: "public", - })); - - expect(response.status).toBe(403); - }); - - test("rejects public visibility when shareAllowedDomains is set", async () => { - const handler = await appHandler({ auth: testAuth(user), config: { shareAllowedDomains: new Set(["example.com"]) } }); - const response = await handler(post("/api/publish", { - bundle: bundle({ "index.html": "hello" }), - openPath: "/", - project: "site", - visibility: "public", + isPublic: true, })); expect(response.status).toBe(403); + expect(await response.text()).toContain("allowPublicProjects"); }); test("rejects garbage project identifiers with client errors, never 500", async () => { @@ -719,7 +708,7 @@ describe("server app", () => { bundle: bundle({ "index.html": "private" }), openPath: "/", project: "secret", - visibility: "private", + isPublic: false, })); expect(publish.status).toBe(200); @@ -782,7 +771,7 @@ describe("server app", () => { }), openPath: "/", project: "secret", - visibility: "private", + isPublic: false, })); expect(publish.status).toBe(200); @@ -824,7 +813,7 @@ describe("server app", () => { bundle: bundle({ "index.html": "private page body", "data.json": "{\"secret\":true}" }), openPath: "/", project: "secret", - visibility: "private", + isPublic: false, }, token)); expect(publish.status).toBe(200); @@ -909,7 +898,7 @@ describe("server app", () => { bundle: bundle({ "index.html": `${project} body`, "data.json": "{}" }), openPath: "/", project, - visibility: "private", + isPublic: false, }, token)); expect(publish.status).toBe(200); } @@ -973,7 +962,7 @@ describe("server app", () => { bundle: bundle({ "index.html": "private" }), openPath: "/", project: "secret", - visibility: "private", + isPublic: false, })); expect(publish.status).toBe(200); @@ -1103,7 +1092,7 @@ describe("server homepage", () => { }), openPath: "/", project: "www", - visibility: "public", + isPublic: true, })); expect(publish.status).toBe(200); // The publish response reports the canonical home origin, not the content route. @@ -1139,7 +1128,7 @@ describe("server homepage", () => { test("answers home domains with setup instructions until the homepage is published", async () => { const handler = await appHandler({ auth: testAuth(user), config: homepageConfig }); - const command = "scratchwork publish --server https://app.scratch.test --project www --visibility public"; + const command = "scratchwork publish --server https://app.scratch.test --project www --public"; const plain = await handler(homeRequest("https://scratch.test/")); expect(plain.status).toBe(404); @@ -1157,7 +1146,7 @@ describe("server homepage", () => { bundle: bundle({ "index.html": "home", "api/index.html": "unreachable", "health": "unreachable" }), openPath: "/", project: "www", - visibility: "public", + isPublic: true, })); expect(publish.status).toBe(200); @@ -1189,7 +1178,7 @@ describe("server homepage", () => { bundle: bundle({ "index.html": "private homepage", "data.json": "{}" }), openPath: "/", project: "www", - visibility: "private", + isPublic: false, }, token)); expect(publish.status).toBe(200); @@ -1245,7 +1234,7 @@ describe("server homepage", () => { bundle: bundle({ "index.html": "home" }), openPath: "/", project: "www", - visibility: "public", + isPublic: true, })); expect(publish.status).toBe(200); diff --git a/server/core/test/helpers.ts b/server/core/test/helpers.ts index a3045dc..c8f6dfb 100644 --- a/server/core/test/helpers.ts +++ b/server/core/test/helpers.ts @@ -40,10 +40,10 @@ export async function appHandler(options: { port: 3001, appUrl: "https://scratch.test", contentUrl: "https://scratch.test", - maxVisibility: "public", - shareAllowedDomains: new Set(), + allowPublicProjects: true, + allowedShareDomains: new Set(), usersCanSetProjectNames: true, - defaultVisibility: "public", + publicByDefault: true, auth: { mode: "oauth", clientId: "test-client-id", diff --git a/server/core/test/publish-request.test.ts b/server/core/test/publish-request.test.ts index 420d695..18988ef 100644 --- a/server/core/test/publish-request.test.ts +++ b/server/core/test/publish-request.test.ts @@ -10,12 +10,12 @@ describe("decodePublishRequest", () => { bundle: bundle({ "index.html": "hello" }), openPath: "//docs///", project: "site_docs", - visibility: "Public", + isPublic: true, })); expect(request.openPath).toBe("/docs/"); expect(request.project).toBe("site_docs"); - expect(request.visibility).toBe("public"); + expect(request.isPublic).toBe(true); expect(request.totalBytes).toBe(5); }); @@ -54,7 +54,7 @@ describe("decodePublishRequest", () => { }))).rejects.toThrow("Invalid openPath"); }); - test("validates project and visibility", async () => { + test("validates project and isPublic", async () => { await expect(Effect.runPromise(decodePublishRequest({ bundle: bundle({ "index.html": "hello" }), project: "../bad", @@ -66,18 +66,18 @@ describe("decodePublishRequest", () => { project: "Docs", }))).rejects.toThrow("Invalid project"); + // The retired visibility strings are not silently accepted. await expect(Effect.runPromise(decodePublishRequest({ bundle: bundle({ "index.html": "hello" }), project: "site", - visibility: "public,@example.com", - }))).rejects.toThrow("Invalid access group"); + isPublic: "public", + }))).rejects.toThrow("Expected boolean"); - // Grant lists are managed through share, not the visibility toggle. await expect(Effect.runPromise(decodePublishRequest({ bundle: bundle({ "index.html": "hello" }), project: "site", - visibility: "alice@example.com,@example.com", - }))).rejects.toThrow("scratchwork share"); + visibility: "public", + }))).rejects.toThrow("visibility"); }); test("rejects the retired workspace field as an excess property", async () => { diff --git a/server/core/test/site-store.test.ts b/server/core/test/site-store.test.ts index 68c162a..94094b9 100644 --- a/server/core/test/site-store.test.ts +++ b/server/core/test/site-store.test.ts @@ -6,41 +6,58 @@ import { PrimitiveDb, makeMemoryPrimitiveDb, type PrimitiveDbShape } from "../sr import type { PublishRequest } from "../src/publish-request"; import { projectForRequest, routeRest } from "../src/routes"; import * as Schema from "effect/Schema"; -import { SiteRecordSchema, type SiteRecord } from "../src/site-records"; +import { StoredSiteRecordSchema, type SiteRecord, type StoredSiteRecord } from "../src/site-records"; import { SiteStore, SiteStoreLive, SiteStoreError, canReadProject, migrateSiteRecord, projectRole } from "../src/site-store"; import { bundle, memoryStorageLayer } from "./helpers"; const owner = { id: "user-1", email: "founder@example.com" }; const reader = { id: "user-2", email: "reader@example.com" }; -/** Builds a SiteRecord fixture with the given visibility and optional grant groups. */ -function record(visibility: string, groups: { readers?: string; writers?: string; admins?: string } = {}): SiteRecord { +/** The pointer fields shared by the current and legacy record fixtures. */ +const recordCommon = { + project: "site", + owner, + createdAt: "2026-06-29T00:00:00.000Z", + updatedAt: "2026-06-29T00:00:00.000Z", + currentRevisionId: "rev-1", + currentOpenPath: "/", + fileCount: 1, + totalBytes: 10, +} as const; + +/** Builds a SiteRecord fixture with the given public toggle and optional grant groups. */ +function record(isPublic: boolean, groups: { readers?: string; writers?: string; admins?: string } = {}): SiteRecord { return { - version: 4, - project: "site", - visibility, + version: 5, + isPublic, readers: groups.readers ?? "private", writers: groups.writers ?? "private", admins: groups.admins ?? "private", - owner, - createdAt: "2026-06-29T00:00:00.000Z", - updatedAt: "2026-06-29T00:00:00.000Z", - currentRevisionId: "rev-1", - currentOpenPath: "/", - fileCount: 1, - totalBytes: 10, + ...recordCommon, + }; +} + +/** Builds a retired version-4 record fixture, whose toggle was a visibility string. */ +function v4Record(visibility: string): StoredSiteRecord { + return { + version: 4, + visibility, + readers: "private", + writers: "private", + admins: "private", + ...recordCommon, }; } -/** Builds a ServerConfigShape fixture with the given visibility ceiling. */ -function config(maxVisibility: string): ServerConfigShape { +/** Builds a ServerConfigShape fixture with optional policy overrides. */ +function config(policy: { allowPublicProjects?: boolean; allowedShareDomains?: ReadonlySet } = {}): ServerConfigShape { return { port: 3001, homepageUrls: [], - maxVisibility, - shareAllowedDomains: new Set(), + allowPublicProjects: policy.allowPublicProjects ?? true, + allowedShareDomains: policy.allowedShareDomains ?? new Set(), usersCanSetProjectNames: true, - defaultVisibility: "private", + publicByDefault: false, auth: { mode: "oauth", clientId: "client-id", @@ -58,7 +75,7 @@ function request(project: string | undefined): PublishRequest { bundle: bundle({ "index.html": "hello" }), openPath: "/", project, - visibility: "public", + isPublic: true, totalBytes: 5, } as PublishRequest; } @@ -81,26 +98,26 @@ function run( } describe("canReadProject", () => { - test("owner can read their project when maxVisibility tightens below stored visibility", () => { - expect(canReadProject(record("public"), owner, config("@example.com"))).toBe(true); + test("owner can read their public project even after allowPublicProjects turns off", () => { + expect(canReadProject(record(true), owner, config({ allowPublicProjects: false }))).toBe(true); }); - test("non-owner readers are still gated by the maxVisibility ceiling", () => { - expect(canReadProject(record("public"), reader, config("@example.com"))).toBe(false); - expect(canReadProject(record("public"), null, config("@example.com"))).toBe(false); + test("non-owner readers of a public project are gated by allowPublicProjects", () => { + expect(canReadProject(record(true), reader, config({ allowPublicProjects: false }))).toBe(false); + expect(canReadProject(record(true), null, config({ allowPublicProjects: false }))).toBe(false); }); - test("matching readers can read within the ceiling", () => { - expect(canReadProject(record("public"), reader, config("public"))).toBe(true); - expect(canReadProject(record("public"), null, config("public"))).toBe(true); - expect(canReadProject(record("private"), reader, config("public"))).toBe(false); + test("public projects read for everyone while the server allows them", () => { + expect(canReadProject(record(true), reader, config())).toBe(true); + expect(canReadProject(record(true), null, config())).toBe(true); + expect(canReadProject(record(false), reader, config())).toBe(false); }); }); describe("projectRole", () => { test("grades owner, admin, write, read, and none", () => { - const shared = record("private", { readers: "reader@example.com", writers: "writer@example.com", admins: "admin@example.com" }); - const serverConfig = config("public"); + const shared = record(false, { readers: "reader@example.com", writers: "writer@example.com", admins: "admin@example.com" }); + const serverConfig = config(); expect(projectRole(shared, owner, serverConfig)).toBe("owner"); expect(projectRole(shared, { id: "u-a", email: "admin@example.com" }, serverConfig)).toBe("admin"); expect(projectRole(shared, { id: "u-w", email: "writer@example.com" }, serverConfig)).toBe("write"); @@ -109,21 +126,21 @@ describe("projectRole", () => { expect(projectRole(shared, null, serverConfig)).toBe("none"); }); - test("public visibility confers read on everyone, including anonymous viewers", () => { - const open = record("public"); - expect(projectRole(open, reader, config("public"))).toBe("read"); - expect(projectRole(open, null, config("public"))).toBe("read"); + test("a public project confers read on everyone, including anonymous viewers", () => { + const open = record(true); + expect(projectRole(open, reader, config())).toBe("read"); + expect(projectRole(open, null, config())).toBe("read"); }); test("domain grants confer write and admin", () => { - const shared = record("private", { writers: "@team.example.com", admins: "@ops.example.com" }); - expect(projectRole(shared, { id: "u-1", email: "dev@team.example.com" }, config("public"))).toBe("write"); - expect(projectRole(shared, { id: "u-2", email: "sre@ops.example.com" }, config("public"))).toBe("admin"); + const shared = record(false, { writers: "@team.example.com", admins: "@ops.example.com" }); + expect(projectRole(shared, { id: "u-1", email: "dev@team.example.com" }, config())).toBe("write"); + expect(projectRole(shared, { id: "u-2", email: "sre@ops.example.com" }, config())).toBe("admin"); }); - test("the maxVisibility ceiling gates every granted role, never the owner", () => { - const shared = record("public", { writers: "writer@example.com", admins: "admin@example.com" }); - const tightened = config("@allowed.example.com"); + test("tightened server policy gates every granted role, never the owner", () => { + const shared = record(true, { writers: "writer@example.com", admins: "admin@example.com" }); + const tightened = config({ allowPublicProjects: false, allowedShareDomains: new Set(["allowed.example.com"]) }); expect(projectRole(shared, owner, tightened)).toBe("owner"); expect(projectRole(shared, { id: "u-a", email: "admin@example.com" }, tightened)).toBe("none"); expect(projectRole(shared, { id: "u-w", email: "writer@example.com" }, tightened)).toBe("none"); @@ -132,8 +149,8 @@ describe("projectRole", () => { }); describe("legacy record migration", () => { - test("records written before roles decode with private grant groups", () => { - const decoded = Schema.decodeUnknownSync(SiteRecordSchema)({ + test("version-4 records decode, defaulting pre-role grant groups to private", () => { + const decoded = Schema.decodeUnknownSync(StoredSiteRecordSchema)({ version: 4, project: "site", visibility: "public", @@ -150,13 +167,15 @@ describe("legacy record migration", () => { expect(decoded.admins).toBe("private"); }); - test("a legacy group visibility migrates into the readers grant list", () => { - const legacy = migrateSiteRecord(record("alice@example.com,@example.com")); - expect(legacy.visibility).toBe("private"); + test("version-4 visibility strings migrate onto the isPublic toggle", () => { + expect(migrateSiteRecord(v4Record("public"))).toEqual(record(true)); + expect(migrateSiteRecord(v4Record("private"))).toEqual(record(false)); + // A pre-role-split grant list moves to readers with the project private. + const legacy = migrateSiteRecord(v4Record("alice@example.com,@example.com")); + expect(legacy.isPublic).toBe(false); expect(legacy.readers).toBe("alice@example.com,@example.com"); - // Binary visibilities pass through untouched. - expect(migrateSiteRecord(record("public"))).toEqual(record("public")); - expect(migrateSiteRecord(record("private"))).toEqual(record("private")); + // Current records pass through untouched. + expect(migrateSiteRecord(record(true))).toEqual(record(true)); }); }); @@ -177,9 +196,9 @@ describe("project name claims", () => { }, }; - await run(db, (store) => store.publish(request("site"), owner, config("public"))); + await run(db, (store) => store.publish(request("site"), owner, config())); const failed = await run(db, (store) => - store.publish(request("site"), reader, config("public")).pipe(Effect.flip), + store.publish(request("site"), reader, config()).pipe(Effect.flip), ) as SiteStoreError; expect(failed.status).toBe(409); @@ -191,14 +210,14 @@ describe("project name claims", () => { test("owner updates in place; another owner gets the canonical 409", async () => { const db = makeMemoryPrimitiveDb(); - const created = await run(db, (store) => store.publish(request("site"), owner, config("public"))); + const created = await run(db, (store) => store.publish(request("site"), owner, config())); expect(created.project).toBe("site"); - const updated = await run(db, (store) => store.publish(request("site"), owner, config("public"))); + const updated = await run(db, (store) => store.publish(request("site"), owner, config())); expect(updated.project).toBe("site"); const denied = await run(db, (store) => - store.publish(request("site"), reader, config("public")).pipe(Effect.flip), + store.publish(request("site"), reader, config()).pipe(Effect.flip), ) as SiteStoreError; expect(denied.status).toBe(409); expect(denied.message).toContain("already taken"); @@ -206,7 +225,7 @@ describe("project name claims", () => { test("random naming mints a slug, republishes by slug, and retries collisions", async () => { const db = makeMemoryPrimitiveDb(); - const randomConfig = { ...config("public"), usersCanSetProjectNames: false }; + const randomConfig = { ...config(), usersCanSetProjectNames: false }; const created = await run(db, (store) => store.publish(request(undefined), owner, randomConfig)); expect(created.project).toMatch(/^[a-z2-9]{10}$/); diff --git a/server/deploy-cloudflare/scripts/smoke-local.ts b/server/deploy-cloudflare/scripts/smoke-local.ts index 9f5a1df..4388a84 100644 --- a/server/deploy-cloudflare/scripts/smoke-local.ts +++ b/server/deploy-cloudflare/scripts/smoke-local.ts @@ -30,7 +30,7 @@ const publish = await fetch(`${origin}/api/publish`, { }, openPath: "/", project, - visibility: "public", + isPublic: true, }), }); const published = await publish.json() as { readonly project?: string; readonly error?: string }; diff --git a/server/deploy-cloudflare/src/deploy.ts b/server/deploy-cloudflare/src/deploy.ts index fbcdf25..0dd9c2b 100644 --- a/server/deploy-cloudflare/src/deploy.ts +++ b/server/deploy-cloudflare/src/deploy.ts @@ -380,10 +380,10 @@ function workerVars( copyEnv(vars, env, "SCRATCHWORK_AUTH_ALLOWED_DOMAINS"); copyEnv(vars, env, "SCRATCHWORK_ALLOWED_USERS"); copyEnv(vars, env, "SCRATCHWORK_AUTH_SESSION_SECONDS"); - copyEnv(vars, env, "SCRATCHWORK_MAX_VISIBILITY"); - copyEnv(vars, env, "SCRATCHWORK_SHARE_ALLOWED_DOMAINS"); + copyEnv(vars, env, "SCRATCHWORK_ALLOW_PUBLIC_PROJECTS"); + copyEnv(vars, env, "SCRATCHWORK_ALLOWED_SHARE_DOMAINS"); copyEnv(vars, env, "SCRATCHWORK_USERS_CAN_SET_PROJECT_NAMES"); - copyEnv(vars, env, "SCRATCHWORK_DEFAULT_VISIBILITY"); + copyEnv(vars, env, "SCRATCHWORK_PUBLIC_BY_DEFAULT"); copyEnv(vars, env, "SCRATCHWORK_HOMEPAGE_DOMAINS"); copyEnv(vars, env, "SCRATCHWORK_HOMEPAGE_PROJECT"); if (server.appUrl != null && server.appUrl !== "") vars.SCRATCHWORK_APP_URL = server.appUrl; diff --git a/server/deploy-cloudflare/test/worker.test.ts b/server/deploy-cloudflare/test/worker.test.ts index 0efa719..be4987c 100644 --- a/server/deploy-cloudflare/test/worker.test.ts +++ b/server/deploy-cloudflare/test/worker.test.ts @@ -28,7 +28,7 @@ describe("worker fetch", () => { SCRATCHWORK_GOOGLE_CLIENT_ID: "client-id", SCRATCHWORK_GOOGLE_CLIENT_SECRET: "client-secret", SCRATCHWORK_SESSION_SECRET: "test-session-secret-test-session-secret", - SCRATCHWORK_MAX_VISIBILITY: "gmail.com,koomen.org", + SCRATCHWORK_ALLOWED_USERS: "gmail.com,koomen.org", }; const response = await worker.fetch(new Request("https://scratch.test/health"), env as never, { waitUntil: () => {}, @@ -45,7 +45,7 @@ describe("worker fetch", () => { SCRATCHWORK_GOOGLE_CLIENT_ID: "client-id", SCRATCHWORK_GOOGLE_CLIENT_SECRET: "client-secret", SCRATCHWORK_SESSION_SECRET: "test-session-secret-test-session-secret", - SCRATCHWORK_MAX_VISIBILITY: "@gmail.com,@koomen.org", + SCRATCHWORK_ALLOWED_USERS: "@gmail.com,@koomen.org", }; const response = await worker.fetch(new Request("https://scratch.test/health"), env as never, { waitUntil: () => {}, diff --git a/server/deploy-local/README.md b/server/deploy-local/README.md index cca1bb7..8658ea1 100644 --- a/server/deploy-local/README.md +++ b/server/deploy-local/README.md @@ -24,7 +24,7 @@ Useful environment variables: PORT=43118 SCRATCHWORK_STORAGE_DIR=/tmp/scratchwork-local-storage SCRATCHWORK_USERS_CAN_SET_PROJECT_NAMES=true # false: server assigns random names -SCRATCHWORK_DEFAULT_VISIBILITY=public +SCRATCHWORK_PUBLIC_BY_DEFAULT=true SCRATCHWORK_APP_URL=http://localhost:43118 SCRATCHWORK_CONTENT_URL=http://localhost:43118 ``` diff --git a/server/deploy-local/src/run.ts b/server/deploy-local/src/run.ts index b5171a5..a23a586 100644 --- a/server/deploy-local/src/run.ts +++ b/server/deploy-local/src/run.ts @@ -113,10 +113,10 @@ function serverSettingsEnv(server: ScratchworkServerConfig, processEnv: EnvVars) set("SCRATCHWORK_AUTH_ALLOWED_DOMAINS", server.authAllowedDomains); set("SCRATCHWORK_AUTH_SESSION_SECONDS", server.authSessionSeconds == null ? undefined : String(server.authSessionSeconds)); set("SCRATCHWORK_ALLOWED_USERS", server.allowedUsers); - set("SCRATCHWORK_MAX_VISIBILITY", server.maxVisibility); - set("SCRATCHWORK_SHARE_ALLOWED_DOMAINS", server.shareAllowedDomains); + set("SCRATCHWORK_ALLOW_PUBLIC_PROJECTS", server.allowPublicProjects == null ? undefined : String(server.allowPublicProjects)); + set("SCRATCHWORK_ALLOWED_SHARE_DOMAINS", server.allowedShareDomains); set("SCRATCHWORK_USERS_CAN_SET_PROJECT_NAMES", server.usersCanSetProjectNames == null ? undefined : String(server.usersCanSetProjectNames)); - set("SCRATCHWORK_DEFAULT_VISIBILITY", server.defaultVisibility); + set("SCRATCHWORK_PUBLIC_BY_DEFAULT", server.publicByDefault == null ? undefined : String(server.publicByDefault)); set("SCRATCHWORK_HOMEPAGE_PROJECT", server.homepageProject); return env; } diff --git a/server/scripts/server-settings.test.ts b/server/scripts/server-settings.test.ts index f41b8a3..8c0db47 100644 --- a/server/scripts/server-settings.test.ts +++ b/server/scripts/server-settings.test.ts @@ -16,9 +16,9 @@ describe("serverConfigEnv", () => { const config: ScratchworkServerConfig = { auth: "oauth", allowedUsers: "public", - maxVisibility: "public", + allowPublicProjects: true, usersCanSetProjectNames: true, - defaultVisibility: "private", + publicByDefault: false, }; const env = serverConfigEnv(config, { appUrl: "https://app.example", contentUrl: "https://pages.example" }); @@ -56,19 +56,28 @@ describe("validateDeploymentConfig", () => { }; test("accepts a config the server can parse", () => { - expect(() => validateDeploymentConfig({ ...baseEnv, SCRATCHWORK_MAX_VISIBILITY: "@gmail.com,@koomen.org" }, "Test")).not.toThrow(); + expect(() => validateDeploymentConfig({ ...baseEnv, SCRATCHWORK_ALLOWED_USERS: "@gmail.com,@koomen.org" }, "Test")).not.toThrow(); }); test("rejects values the deployed server would crash on at runtime", () => { // Domains without the leading "@" are not valid group terms. - expect(() => validateDeploymentConfig({ ...baseEnv, SCRATCHWORK_MAX_VISIBILITY: "gmail.com,koomen.org" }, "Test")) + expect(() => validateDeploymentConfig({ ...baseEnv, SCRATCHWORK_ALLOWED_USERS: "gmail.com,koomen.org" }, "Test")) .toThrow("Invalid access group"); - expect(() => validateDeploymentConfig({ ...baseEnv, SCRATCHWORK_DEFAULT_VISIBILITY: "@example.com" }, "Test")) - .toThrow('expected "public" or "private"'); + expect(() => validateDeploymentConfig({ ...baseEnv, SCRATCHWORK_PUBLIC_BY_DEFAULT: "@example.com" }, "Test")) + .toThrow('expected "true" or "false"'); expect(() => validateDeploymentConfig({ ...baseEnv, SCRATCHWORK_APP_URL: "not a url" }, "Test")) .toThrow('expected a URL, like "https://example.com"'); }); + test("rejects the retired visibility-era variables", () => { + expect(() => validateDeploymentConfig({ ...baseEnv, SCRATCHWORK_MAX_VISIBILITY: "public" }, "Test")) + .toThrow("SCRATCHWORK_ALLOW_PUBLIC_PROJECTS"); + expect(() => validateDeploymentConfig({ ...baseEnv, SCRATCHWORK_DEFAULT_VISIBILITY: "private" }, "Test")) + .toThrow("SCRATCHWORK_PUBLIC_BY_DEFAULT"); + expect(() => validateDeploymentConfig({ ...baseEnv, SCRATCHWORK_SHARE_ALLOWED_DOMAINS: "example.com" }, "Test")) + .toThrow("SCRATCHWORK_ALLOWED_SHARE_DOMAINS"); + }); + test("still enforces the OAuth requirements", () => { expect(() => validateDeploymentConfig({ ...baseEnv, SCRATCHWORK_SESSION_SECRET: undefined }, "Test")) .toThrow("SCRATCHWORK_SESSION_SECRET is required"); diff --git a/server/scripts/server-settings.ts b/server/scripts/server-settings.ts index fbfc49e..ac773fb 100644 --- a/server/scripts/server-settings.ts +++ b/server/scripts/server-settings.ts @@ -21,8 +21,10 @@ export interface ScratchworkServerConfig { readonly authAllowedDomains?: string; readonly authSessionSeconds?: number; readonly allowedUsers?: string; - readonly maxVisibility?: string; - readonly shareAllowedDomains?: string; + /** false: no project on this server may be public. Default: true. */ + readonly allowPublicProjects?: boolean; + /** When non-empty, share grants must fall inside these domains (comma-separated). */ + readonly allowedShareDomains?: string; readonly appDomain?: string; readonly contentDomain?: string; /** Hostnames served from the homepage project; the first is canonical, the rest 308 to @@ -32,7 +34,8 @@ export interface ScratchworkServerConfig { /** Globally unique name of the project served on the homepage domains. */ readonly homepageProject?: string; readonly usersCanSetProjectNames?: boolean; - readonly defaultVisibility?: string; + /** Whether a publish that does not say public/private creates a public project. Default: false. */ + readonly publicByDefault?: boolean; } /** Options shared by every deployServer entry point. */ @@ -77,10 +80,10 @@ export function serverConfigEnv(config: ScratchworkServerConfig, resolved: Resol if (config.authAllowedDomains != null) env.SCRATCHWORK_AUTH_ALLOWED_DOMAINS = config.authAllowedDomains; if (config.authSessionSeconds != null) env.SCRATCHWORK_AUTH_SESSION_SECONDS = String(config.authSessionSeconds); if (config.allowedUsers != null) env.SCRATCHWORK_ALLOWED_USERS = config.allowedUsers; - if (config.maxVisibility != null) env.SCRATCHWORK_MAX_VISIBILITY = config.maxVisibility; - if (config.shareAllowedDomains != null) env.SCRATCHWORK_SHARE_ALLOWED_DOMAINS = config.shareAllowedDomains; + if (config.allowPublicProjects != null) env.SCRATCHWORK_ALLOW_PUBLIC_PROJECTS = String(config.allowPublicProjects); + if (config.allowedShareDomains != null) env.SCRATCHWORK_ALLOWED_SHARE_DOMAINS = config.allowedShareDomains; if (config.usersCanSetProjectNames != null) env.SCRATCHWORK_USERS_CAN_SET_PROJECT_NAMES = String(config.usersCanSetProjectNames); - if (config.defaultVisibility != null) env.SCRATCHWORK_DEFAULT_VISIBILITY = config.defaultVisibility; + if (config.publicByDefault != null) env.SCRATCHWORK_PUBLIC_BY_DEFAULT = String(config.publicByDefault); if (config.homepageDomains != null && config.homepageDomains.length > 0) { env.SCRATCHWORK_HOMEPAGE_DOMAINS = config.homepageDomains.join(","); } @@ -100,7 +103,7 @@ export function homepagePublishHint( ): string | null { if (config.homepageProject == null) return null; const server = resolved.appUrl ?? ""; - return `publish the homepage with: scratchwork publish --server ${server} --project ${config.homepageProject} --visibility public`; + return `publish the homepage with: scratchwork publish --server ${server} --project ${config.homepageProject} --public`; } /** Validates required auth settings before a deploy. Auth cannot be disabled. */ @@ -130,7 +133,7 @@ export function validateDeploymentAuth(env: DeployEnv, platform: string): void { /** Validates the composed environment before a deploy by parsing it exactly as the * deployed server will at runtime. A value the server would reject (a malformed - * maxVisibility group, a bad URL, a short session secret) must fail the deploy command, + * allowedShareDomains list, a bad URL, a short session secret) must fail the deploy command, * not take the deployed server down on its first request. */ export function validateDeploymentConfig(env: DeployEnv, platform: string): void { validateDeploymentAuth(env, platform); From 48ab77897d5073c10244200b4fc1a90d4778a19b Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Wed, 8 Jul 2026 18:23:55 -1000 Subject: [PATCH 11/13] Turn deploy/cf-access into a full Access-protected deploy project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename deploy/cf-access to deploy/cloudflare-access and give it the same shape as cloudflare-vanilla: shared server-config.ts/cloudflare-config.ts consumed by both deploy.ts and local.ts, plus an .env.example. The remote deploy serves access.sndbx.sh (app) and access-pages.sndbx.sh (content) behind a Cloudflare Access application, with the Worker scratchwork-access and its own R2 bucket and D1 database. Everything is private (allowPublicProjects: false) since Access blocks anonymous visitors at the edge; allowedUsers stays "public" so the Access policy alone decides who gets in. The team domain and AUD tag live in .env because they only exist once the Access application is created. The local run keeps its previous behavior — simulated Access edge, no .env or Cloudflare account needed — now driven by the shared config. The root scripts become local:cloudflare-access / deploy:cloudflare-access, and stale cf-access references in the READMEs are updated. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- bun.lock | 6 +- deploy/cf-access/README.md | 23 ------ deploy/cf-access/local.ts | 24 ------ deploy/cloudflare-access/.env.example | 32 ++++++++ deploy/cloudflare-access/README.md | 74 +++++++++++++++++++ deploy/cloudflare-access/cloudflare-config.ts | 21 ++++++ deploy/cloudflare-access/deploy.ts | 9 +++ deploy/cloudflare-access/local.ts | 8 ++ .../package.json | 3 +- deploy/cloudflare-access/server-config.ts | 16 ++++ .../tsconfig.json | 3 + deploy/cloudflare-vanilla/README.md | 2 +- package.json | 5 +- server/README.md | 4 +- 15 files changed, 175 insertions(+), 57 deletions(-) delete mode 100644 deploy/cf-access/README.md delete mode 100644 deploy/cf-access/local.ts create mode 100644 deploy/cloudflare-access/.env.example create mode 100644 deploy/cloudflare-access/README.md create mode 100644 deploy/cloudflare-access/cloudflare-config.ts create mode 100644 deploy/cloudflare-access/deploy.ts create mode 100644 deploy/cloudflare-access/local.ts rename deploy/{cf-access => cloudflare-access}/package.json (79%) create mode 100644 deploy/cloudflare-access/server-config.ts rename deploy/{cf-access => cloudflare-access}/tsconfig.json (89%) diff --git a/README.md b/README.md index 239c6fd..19ce861 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ To run the actual Cloudflare Worker with persistent local R2 and D1 simulations an optional locally signed Cloudflare Access identity), see [`server/deploy-cloudflare/README.md`](server/deploy-cloudflare/README.md). -The ready-made Access test deployment is `bun run local:cf-access`; the sndbx.sh +The ready-made Access test deployment is `bun run local:cloudflare-access`; the sndbx.sh project's production Worker configuration runs locally with `bun run local:cloudflare-vanilla`. Then publish a directory or file: diff --git a/bun.lock b/bun.lock index e98cdb9..187cba7 100644 --- a/bun.lock +++ b/bun.lock @@ -29,8 +29,8 @@ "typescript": "^6.0.3", }, }, - "deploy/cf-access": { - "name": "@scratchwork/deploy-cf-access-local", + "deploy/cloudflare-access": { + "name": "@scratchwork/deploy-cloudflare-access", "version": "0.1.0", "dependencies": { "@scratchwork/server-deploy-cloudflare": "workspace:*", @@ -404,7 +404,7 @@ "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], - "@scratchwork/deploy-cf-access-local": ["@scratchwork/deploy-cf-access-local@workspace:deploy/cf-access"], + "@scratchwork/deploy-cloudflare-access": ["@scratchwork/deploy-cloudflare-access@workspace:deploy/cloudflare-access"], "@scratchwork/deploy-cloudflare-vanilla": ["@scratchwork/deploy-cloudflare-vanilla@workspace:deploy/cloudflare-vanilla"], diff --git a/deploy/cf-access/README.md b/deploy/cf-access/README.md deleted file mode 100644 index 0cd31b0..0000000 --- a/deploy/cf-access/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Local Cloudflare Access deploy - -Local-only Scratchwork deployment for testing the Cloudflare Worker, R2, D1, and -Cloudflare Access authentication together. It has no remote `deploy` command and -needs no Cloudflare account or OAuth credentials. - -From the repository root: - -```sh -bun run local:cf-access -``` - -It listens on `http://localhost:8787` and signs Access assertions for -`developer@example.com`. Select another identity with: - -```sh -SCRATCHWORK_LOCAL_CF_ACCESS_EMAIL=alice@example.com bun run local:cf-access -``` - -R2 and D1 data persist under `deploy/cf-access/.scratchwork-cloudflare-data/`. -Remove that ignored directory for an empty environment. This simulates an -already-authenticated Access session and its signed identity assertion, not the -Cloudflare policy engine or an identity provider's login UI. diff --git a/deploy/cf-access/local.ts b/deploy/cf-access/local.ts deleted file mode 100644 index c7ff340..0000000 --- a/deploy/cf-access/local.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - runLocalCloudflareServer, - type CloudflareDeployServerConfig, -} from "@scratchwork/server-deploy-cloudflare"; - -/** Local-only Worker deployment for exercising Cloudflare Access authentication with - * persistent R2 and D1 state. There is deliberately no remote deploy command. */ -const config = { - server: { - auth: "cloudflare-access", - authSessionSeconds: 2_592_000, - allowedUsers: "public", - allowPublicProjects: true, - usersCanSetProjectNames: true, - publicByDefault: false, - }, - deploy: { - workerName: "scratchwork-cf-access-local", - r2Bucket: "scratchwork-cf-access-local", - d1Database: "scratchwork-cf-access-local-projects", - }, -} satisfies CloudflareDeployServerConfig; - -await runLocalCloudflareServer(config, { simulateAccess: true }); diff --git a/deploy/cloudflare-access/.env.example b/deploy/cloudflare-access/.env.example new file mode 100644 index 0000000..19d41a0 --- /dev/null +++ b/deploy/cloudflare-access/.env.example @@ -0,0 +1,32 @@ +# Local secrets for the Access-protected sndbx.sh deploy. +# The non-secret Cloudflare/domain settings live in cloudflare-config.ts. +# These values are required here unless provided by the shell environment. +# None of them are needed for `bun run local`, which simulates Access. + +# API token for deploys, which go through the Cloudflare REST API. Create a +# custom token (dash.cloudflare.com -> My Profile -> API Tokens) with: +# Account | Workers Scripts | Edit - upload the Worker, secrets, workers.dev +# Account | Workers R2 Storage | Edit - create the R2 bucket +# Account | D1 | Edit - find or create the D1 database +# Zone | Zone | Read - resolve the sndbx.sh zone ID +# Zone | Workers Routes | Edit - point the access.sndbx.sh routes at the Worker +# Account | Account Settings | Read - only if CLOUDFLARE_ACCOUNT_ID is unset +# The zone permissions can be scoped to just the sndbx.sh zone. +CLOUDFLARE_API_TOKEN= + +# Only required when the token can see more than one Cloudflare account. +CLOUDFLARE_ACCOUNT_ID= + +# The Cloudflare Zero Trust team domain, like "myteam" or +# "myteam.cloudflareaccess.com" (Zero Trust dashboard -> Settings -> Custom Pages). +SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN= + +# Audience (AUD) tag of the Access application covering access.sndbx.sh and +# access-pages.sndbx.sh (Zero Trust dashboard -> Access -> Applications -> +# the application's overview page). See README.md for the application setup. +SCRATCHWORK_CF_ACCESS_AUD= + +# Private signing key for CLI bearer tokens and private-content handoff tokens. +# Keep this stable across deploys; changing it logs everyone out. Generate one with: +# openssl rand -base64 48 +SCRATCHWORK_SESSION_SECRET= diff --git a/deploy/cloudflare-access/README.md b/deploy/cloudflare-access/README.md new file mode 100644 index 0000000..fb1828c --- /dev/null +++ b/deploy/cloudflare-access/README.md @@ -0,0 +1,74 @@ +# Access-protected sndbx.sh Cloudflare deploy + +This deploy instance is a Bun project that uses the shared Cloudflare Worker +deploy package to serve a Scratchwork server behind [Cloudflare +Access](https://developers.cloudflare.com/cloudflare-one/policies/access/), +which authenticates users at the edge instead of the server running OAuth +itself (compare `deploy/cloudflare-vanilla`). + +It binds the Worker `scratchwork-access` with routes for `access.sndbx.sh/*` +(app/API/auth) and `access-pages.sndbx.sh/*` (published content). Everything +is private (`allowPublicProjects: false`): Access blocks anonymous visitors at +the edge, so a public project could never be reached anyway. + +## Access application setup (once) + +In the Cloudflare Zero Trust dashboard, create a self-hosted Access +application that covers **both** hostnames, `access.sndbx.sh` and +`access-pages.sndbx.sh`, with a policy allowing the intended users. Then: + +- Copy the application's Audience (AUD) tag from its overview page into + `SCRATCHWORK_CF_ACCESS_AUD`. +- Copy the team domain into `SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN`. +- Set a long session duration on the application: `scratchwork login` relays + the browser's Access JWT to the CLI, and CLI commands start failing with a + re-login prompt when it expires. + +`SCRATCHWORK_ALLOWED_USERS` still applies on top of the Access policy; this +config leaves it at `public`, so the Access policy alone decides who gets in. +See the "Cloudflare Access" section of `server/README.md` for CLI behavior, +service tokens for CI, and older-CLI fallbacks. + +## Deploy + +Start local secrets from the template, then deploy from the repo root: + +```sh +cp deploy/cloudflare-access/.env.example deploy/cloudflare-access/.env +bun run deploy:cloudflare-access +``` + +The server settings (domains, auth mode, sharing rules) live in +`server-config.ts`. `cloudflare-config.ts` adds the Worker, R2, D1, and route +configuration; both the remote deploy and local Wrangler run consume that same +complete config. Secrets are read from `.env` in this directory and the shell +environment. + +## Local run + +Run the same Worker with Wrangler's persistent local R2 and D1 bindings and a +simulated Access edge — no `.env`, Cloudflare account, or Access application +needed: + +```sh +bun run local:cloudflare-access # from the repo root, or `bun run local` here +``` + +It listens on `http://localhost:8787` (set `PORT` to change) and signs Access +assertions for `developer@example.com`. Select another identity with: + +```sh +SCRATCHWORK_LOCAL_CF_ACCESS_EMAIL=alice@example.com bun run local:cloudflare-access +``` + +Because the config declares separate app and content domains, the local run +mirrors that split on one port: the app/API on `http://localhost:8787` and +published content on `http://pages.localhost:8787` (`*.localhost` names +are loopback per RFC 6761; browsers and macOS resolve them without setup). + +Wrangler stores both local R2 and D1 state under +`.scratchwork-cloudflare-data` in this directory; remove that ignored +directory for an empty environment. The remote route entries are ignored by +the local runtime. This simulates an already-authenticated Access session and +its signed identity assertion, not the Cloudflare policy engine or an identity +provider's login UI. diff --git a/deploy/cloudflare-access/cloudflare-config.ts b/deploy/cloudflare-access/cloudflare-config.ts new file mode 100644 index 0000000..6601f9f --- /dev/null +++ b/deploy/cloudflare-access/cloudflare-config.ts @@ -0,0 +1,21 @@ +import type { CloudflareDeployServerConfig } from "@scratchwork/server-deploy-cloudflare"; +import { server } from "./server-config"; + +/** The complete Access-protected Cloudflare configuration, shared by remote deploys and + * the local Wrangler Worker runtime. Routes are ignored locally; binding names are not. + * Both routed hostnames must be covered by the Cloudflare Access application whose AUD + * tag is configured in `.env` — see README.md. */ +export const config = { + server, + + deploy: { + workerName: "scratchwork-access", + r2Bucket: "scratchwork-access-sndbx-sh", + d1Database: "scratchwork-access-sndbx-sh-projects", + routes: [ + { pattern: "access.sndbx.sh/*" }, + { pattern: "access-pages.sndbx.sh/*" }, + ], + zoneName: "sndbx.sh", + }, +} satisfies CloudflareDeployServerConfig; diff --git a/deploy/cloudflare-access/deploy.ts b/deploy/cloudflare-access/deploy.ts new file mode 100644 index 0000000..94f6992 --- /dev/null +++ b/deploy/cloudflare-access/deploy.ts @@ -0,0 +1,9 @@ +import { deployServer } from "@scratchwork/server-deploy-cloudflare"; +import { config } from "./cloudflare-config"; + +const result = await deployServer(config, { envFile: ".env" }); + +console.log(`scratchwork Cloudflare Worker deployed: ${result.workerName}`); +console.log( + `publish with: scratchwork publish --server https://${config.server.appDomain}`, +); diff --git a/deploy/cloudflare-access/local.ts b/deploy/cloudflare-access/local.ts new file mode 100644 index 0000000..5da2669 --- /dev/null +++ b/deploy/cloudflare-access/local.ts @@ -0,0 +1,8 @@ +import { runLocalCloudflareServer } from "@scratchwork/server-deploy-cloudflare"; +import { config } from "./cloudflare-config"; + +// Runs the same Worker and binding names used in production, backed by Wrangler's +// persistent local R2 and D1 implementations. The Access edge is simulated with +// generated key material, so no `.env`, Cloudflare account, or Access application +// is needed for the local run. +await runLocalCloudflareServer(config, { simulateAccess: true }); diff --git a/deploy/cf-access/package.json b/deploy/cloudflare-access/package.json similarity index 79% rename from deploy/cf-access/package.json rename to deploy/cloudflare-access/package.json index e1f8162..a1afb60 100644 --- a/deploy/cf-access/package.json +++ b/deploy/cloudflare-access/package.json @@ -1,9 +1,10 @@ { - "name": "@scratchwork/deploy-cf-access-local", + "name": "@scratchwork/deploy-cloudflare-access", "version": "0.1.0", "private": true, "type": "module", "scripts": { + "deploy": "bun deploy.ts", "local": "bun local.ts", "typecheck": "tsc -p tsconfig.json" }, diff --git a/deploy/cloudflare-access/server-config.ts b/deploy/cloudflare-access/server-config.ts new file mode 100644 index 0000000..76fb717 --- /dev/null +++ b/deploy/cloudflare-access/server-config.ts @@ -0,0 +1,16 @@ +import type { ScratchworkServerConfig } from "@scratchwork/server-deploy-cloudflare"; + +/** Access-protected server settings, shared by the remote and local Cloudflare Worker + * runs. The Access team domain and application AUD tag are deployment credentials and + * come from `.env` (SCRATCHWORK_CF_ACCESS_TEAM_DOMAIN / SCRATCHWORK_CF_ACCESS_AUD); the + * local simulator generates its own. Everything is private: Cloudflare Access blocks + * anonymous visitors at the edge, so public projects could never be reached anyway. */ +export const server = { + appDomain: "access.sndbx.sh", + contentDomain: "access-pages.sndbx.sh", + auth: "cloudflare-access", + authSessionSeconds: 2_592_000, + allowedUsers: "public", + allowPublicProjects: false, + usersCanSetProjectNames: true, +} satisfies ScratchworkServerConfig; diff --git a/deploy/cf-access/tsconfig.json b/deploy/cloudflare-access/tsconfig.json similarity index 89% rename from deploy/cf-access/tsconfig.json rename to deploy/cloudflare-access/tsconfig.json index 10e2e7d..fd04b7e 100644 --- a/deploy/cf-access/tsconfig.json +++ b/deploy/cloudflare-access/tsconfig.json @@ -17,7 +17,10 @@ "verbatimModuleSyntax": true }, "include": [ + "deploy.ts", "local.ts", + "cloudflare-config.ts", + "server-config.ts", "../../server/core/src/assets.d.ts", "../../shared/src/**/*.ts", "../../shared/**/*.js" diff --git a/deploy/cloudflare-vanilla/README.md b/deploy/cloudflare-vanilla/README.md index d1f16b4..a81511b 100644 --- a/deploy/cloudflare-vanilla/README.md +++ b/deploy/cloudflare-vanilla/README.md @@ -2,7 +2,7 @@ This deploy instance is a Bun project that uses the shared Cloudflare Worker deploy package to serve the sndbx.sh domain, without Cloudflare Access in -front of it (hence "vanilla" — compare `deploy/cf-access`). +front of it (hence "vanilla" — compare `deploy/cloudflare-access`). Deploy from the repo root: diff --git a/package.json b/package.json index f2a03bd..b9f6ad9 100644 --- a/package.json +++ b/package.json @@ -19,15 +19,16 @@ "check": "cd cli && bun run check && cd ../server && bun run check", "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", "local:generic-aws": "cd deploy/generic-aws && bun run local", "local:cloudflare": "cd server/deploy-cloudflare && bun run dev", - "local:cf-access": "cd deploy/cf-access && bun run local", + "local:cloudflare-access": "cd deploy/cloudflare-access && bun run local", "local:local-dev": "cd deploy/local-dev && bun run local", "local:cloudflare-vanilla": "cd deploy/cloudflare-vanilla && bun run local", "dev:renderer": "cd renderer && bun run dev", "lint": "cd cli && bun run lint", "test": "cd renderer && bun test && cd ../cli && bun run test", - "typecheck": "cd cli && bun run typecheck && cd ../server && bun run typecheck && cd ../deploy/generic-aws && bun run typecheck && cd ../local-dev && bun run typecheck && cd ../cloudflare-vanilla && bun run typecheck && cd ../cf-access && bun run typecheck" + "typecheck": "cd cli && bun run typecheck && cd ../server && bun run typecheck && cd ../deploy/generic-aws && bun run typecheck && cd ../local-dev && bun run typecheck && cd ../cloudflare-vanilla && bun run typecheck && cd ../cloudflare-access && bun run typecheck" }, "dependencies": { "@effect/platform": "0.96.2", diff --git a/server/README.md b/server/README.md index 8fca1b3..0026ce2 100644 --- a/server/README.md +++ b/server/README.md @@ -36,10 +36,10 @@ SCRATCHWORK_LOCAL_CF_ACCESS_EMAIL=developer@example.com bun run local:cloudflare See `server/deploy-cloudflare/README.md` for the deploy-project API and the exact Access behavior that is simulated. -For a ready-made local-only Access deployment, run: +For a ready-made local run of the Access-protected deploy project, run: ```sh -bun run local:cf-access +bun run local:cloudflare-access ``` ## Google OAuth From 5f64134517681bf9bd3e2347468fcce424a4e28a Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Wed, 8 Jul 2026 23:42:51 -1000 Subject: [PATCH 12/13] Show the specific auth failure on 401/403 error pages The generic sign-in page swallowed the reason auth failed, so a Cloudflare Access misconfiguration (wrong AUD tag, missing assertion header) rendered the same page as an ordinary signed-out visit and was undiagnosable from the browser. Surface the caught message as the note line under the friendly copy on 401 and 403 pages. This exposes nothing new: the JSON error path already sends these exact messages to API clients for the same statuses. 404 and 5xx pages are unchanged. Co-Authored-By: Claude Fable 5 --- server/core/src/error-pages.ts | 9 ++++- server/core/test/app.test.ts | 67 +++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/server/core/src/error-pages.ts b/server/core/src/error-pages.ts index 73b8e7a..4b6c506 100644 --- a/server/core/src/error-pages.ts +++ b/server/core/src/error-pages.ts @@ -60,14 +60,18 @@ export function errorPageResponse( }); } -/** Maps an error status to friendly page copy. 4xx messages are crafted for clients and - * shown as-is; 5xx details stay out of the page (they can carry internals). */ +/** Maps an error status to friendly page copy. 4xx messages are crafted for clients + * (the JSON error path already sends them verbatim) and shown as-is; on 401/403 pages + * the specific reason appears as the note under the friendly copy, so an auth + * misconfiguration (say, a Cloudflare Access AUD mismatch) is diagnosable from the page + * itself. 5xx details stay out of the page (they can carry internals). */ function genericErrorPage(status: number, message: string): ErrorPage { if (status === 401) { return { status, title: "Sign-in required", message: "You need to sign in to view this page.", + note: message === "" ? undefined : message, }; } if (status === 403) { @@ -75,6 +79,7 @@ function genericErrorPage(status: number, message: string): ErrorPage { status, title: "Access denied", message: "You don't have access to this page.", + note: message === "" ? undefined : message, }; } if (status === 404) { diff --git a/server/core/test/app.test.ts b/server/core/test/app.test.ts index 16bd2a1..13c50d9 100644 --- a/server/core/test/app.test.ts +++ b/server/core/test/app.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import * as Effect from "effect/Effect"; import { createSessionToken } from "../src/auth"; import { MemoryPrimitiveDbLive } from "../src/db"; import { appHandler, bundle, json, testAuth, type MemoryStoredObject } from "./helpers"; +import { jwksFetch, makeKeyPair, nowSeconds, signJwt } from "./jwt-helpers"; const user = { id: "user-1", email: "founder@example.com" }; @@ -1032,6 +1033,70 @@ describe("server app", () => { expect(await json(api404)).toEqual({ error: "Not found" }); }); + describe("auth error pages state the specific reason", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + // Its own team domain: the production JWKS cache is process-global and keyed by the + // JWKS URL the auth service derives from the team. + const teamDomain = "https://app-error-page-test.cloudflareaccess.com"; + + async function cloudflareHandler() { + return appHandler({ + config: { + auth: { + mode: "cloudflare-access", + teamDomain, + audience: "expected-aud-tag", + sessionSecret: "session-secret-session-secret-32-bytes", + allowedUsers: "public", + sessionTtlSeconds: 60, + }, + }, + }); + } + + test("a rejected Access assertion names the verification failure", async () => { + const keyPair = await makeKeyPair(); + globalThis.fetch = jwksFetch(keyPair.publicJwk); + // The misconfiguration this catches in the field: the edge injects a valid JWT, + // but the server was deployed with a different Access application's AUD tag. + const token = await signJwt(keyPair.privateKey, { + iss: teamDomain, + aud: ["some-other-application"], + sub: "cf-user-1", + email: "founder@example.com", + exp: nowSeconds() + 600, + iat: nowSeconds(), + type: "app", + }); + const handler = await cloudflareHandler(); + + const page = await handler(new Request("https://scratch.test/auth/login", { + headers: { accept: "text/html", "cf-access-jwt-assertion": token }, + redirect: "manual", + })); + expect(page.status).toBe(401); + const html = await page.text(); + expect(html).toContain("Sign-in required"); + expect(html).toContain("Invalid Access token audience"); + }); + + test("a missing Access assertion names the absent header", async () => { + const handler = await cloudflareHandler(); + const page = await handler(new Request("https://scratch.test/auth/login", { + headers: { accept: "text/html" }, + redirect: "manual", + })); + expect(page.status).toBe(401); + const html = await page.text(); + expect(html).toContain("Sign-in required"); + expect(html).toContain("no Cf-Access-Jwt-Assertion header"); + }); + }); + test("binds OAuth callback state to a browser cookie", async () => { const handler = await appHandler({ config: { From 68d3be0dfbdb20bf6d2fa8429e95af333b9a17a5 Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Thu, 9 Jul 2026 06:46:57 -1000 Subject: [PATCH 13/13] Open the first real page when a project has no root index publish and dev both opened / for directory targets, a 404 for projects without an index.html or index.md. resolveDevTarget now checks that the root actually serves / (a marked renderer shell no longer counts without an index.md) and otherwise opens the shallowest .html/.md page's route. SKIPPED_DIRECTORIES moves to dev/target.ts so the scan and publish share it. Co-Authored-By: Claude Fable 5 --- cli/src/commands/projects.ts | 3 +- cli/src/commands/publish.ts | 5 +- cli/src/dev/target.ts | 116 ++++++++++++++++++++++++++++++++++- cli/test/e2e.test.js | 88 ++++++++++++++++++++++++++ 4 files changed, 206 insertions(+), 6 deletions(-) diff --git a/cli/src/commands/projects.ts b/cli/src/commands/projects.ts index 8a3f538..75b15cc 100644 --- a/cli/src/commands/projects.ts +++ b/cli/src/commands/projects.ts @@ -17,10 +17,11 @@ 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"; import { CliError, errorMessage } from "../errors"; import { PROJECT_CONFIG_FILE, resolveProjectRef, resolveServerFromCwd, writeProjectConfig } from "../project-config"; import type { CloneConfig, PathConfig, ProjectRefConfig, ServerConfig, ShareConfig } from "../types"; -import { runPublish, SKIPPED_DIRECTORIES, type PublishServices } from "./publish"; +import { runPublish, type PublishServices } from "./publish"; /** Project metadata as returned by /api/projects. */ interface ApiProject { diff --git a/cli/src/commands/publish.ts b/cli/src/commands/publish.ts index c3b1c24..7a2ff5b 100644 --- a/cli/src/commands/publish.ts +++ b/cli/src/commands/publish.ts @@ -19,7 +19,7 @@ import { nonEmpty } from "../../../shared/src/util/strings"; import { apiErrorText, apiRequest } from "../api"; import { readAuthToken, serverApiUrl } from "../auth"; import { openBrowser } from "../browser"; -import { resolveDevTarget } from "../dev/target"; +import { resolveDevTarget, SKIPPED_DIRECTORIES } from "../dev/target"; import { CliError, errorMessage } from "../errors"; import { PROJECT_CONFIG_FILE, @@ -31,9 +31,6 @@ import { import type { PublishConfig } from "../types"; import { runLogin } from "./login"; -/** Directories never uploaded by publish and never watched by stream. */ -export const SKIPPED_DIRECTORIES = new Set([".git", "node_modules", ".scratchwork-data"]); - /** Services runPublish needs; exported for callers that wrap it (stream). */ export type PublishServices = CommandExecutor | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path; diff --git a/cli/src/dev/target.ts b/cli/src/dev/target.ts index 69a15b8..565d7a9 100644 --- a/cli/src/dev/target.ts +++ b/cli/src/dev/target.ts @@ -7,9 +7,15 @@ import * as FileSystem from "@effect/platform/FileSystem"; import type { PlatformError } from "@effect/platform/Error"; import * as Path from "@effect/platform/Path"; import * as Effect from "effect/Effect"; +import { isMarkedMarkdownRenderer } from "../../../shared/src/site/marker"; +import { isSafeSitePath } from "../../../shared/src/site/paths"; import { CliError } from "../errors"; import type { DevTarget } from "./types"; +/** Directories never uploaded by publish, never watched, and never scanned + * when picking the browser path to open for a directory target. */ +export const SKIPPED_DIRECTORIES = new Set([".git", "node_modules", ".scratchwork-data"]); + /** Converts the CLI path argument into the server root and browser path to open. */ export function resolveDevTarget( pathArg: string, @@ -28,7 +34,9 @@ export function resolveDevTarget( } const info = yield* fs.stat(target); - if (info.type === "Directory") return { root: target, openPath: "/" }; + if (info.type === "Directory") { + return { root: target, openPath: yield* directoryOpenPath(target) }; + } if (info.type === "File") { return { root: paths.dirname(target), @@ -40,6 +48,112 @@ export function resolveDevTarget( }); } +/** + * Picks the browser path to open for a directory target. "/" when the root + * serves it (an index.md, or an index.html that is a real page rather than a + * marked Markdown-renderer shell); otherwise the route of the first page file + * found, so dev and publish open a page that exists instead of a 404. "/" when + * the directory has no page files at all. + */ +function directoryOpenPath( + root: string, +): Effect.Effect { + return Effect.gen(function* () { + if (yield* rootIndexServes(root)) return "/"; + return (yield* firstPageRoute(root)) ?? "/"; + }); +} + +/** Whether "/" resolves at the site root: an index.md, or an index.html whose + * content is an authored page rather than a marked renderer shell (a shell + * only answers "/" through an index.md, which the first check covers). */ +function rootIndexServes( + root: string, +): Effect.Effect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const paths = yield* Path.Path; + if (yield* isFile(paths.join(root, "index.md"))) return true; + const indexHtml = paths.join(root, "index.html"); + if (!(yield* isFile(indexHtml))) return false; + const html = yield* fs.readFileString(indexHtml).pipe(Effect.orElseSucceed(() => "")); + return html !== "" && !isMarkedMarkdownRenderer(html); + }); +} + +/** + * Breadth-first search for the first page file (.html or .md) under root, + * returning its extensionless browser route, or undefined when none exists. + * Shallower files win; within a directory, entries are tried in locale order — + * the same order publish bundles them. Marked renderer shells, unsafe site + * paths, and skipped directories are ignored. + */ +function firstPageRoute( + root: string, +): Effect.Effect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const paths = yield* Path.Path; + const queue: string[] = [""]; + + while (queue.length > 0) { + const relativeDir = queue.shift() ?? ""; + const absoluteDir = relativeDir === "" ? root : paths.join(root, ...relativeDir.split("/")); + const entries = yield* fs.readDirectory(absoluteDir).pipe( + Effect.orElseSucceed(() => [] as string[]), + ); + entries.sort((a, b) => a.localeCompare(b)); + + for (const entry of entries) { + const relativePath = relativeDir === "" ? entry : `${relativeDir}/${entry}`; + const absolutePath = paths.join(absoluteDir, entry); + const info = yield* fs.stat(absolutePath).pipe(Effect.orElseSucceed(() => null)); + + if (info?.type === "Directory") { + if (!SKIPPED_DIRECTORIES.has(entry) && isSafeSitePath(relativePath)) { + queue.push(relativePath); + } + continue; + } + if (info?.type !== "File") continue; + if (!isSafeSitePath(relativePath)) continue; + + const lower = entry.toLowerCase(); + if (lower.endsWith(".html")) { + const html = yield* fs.readFileString(absolutePath).pipe(Effect.orElseSucceed(() => "")); + if (isMarkedMarkdownRenderer(html)) continue; + return routeForPage(relativePath, ".html"); + } + if (lower.endsWith(".md")) return routeForPage(relativePath, ".md"); + } + } + return undefined; + }); +} + +/** Converts a page file's site path to the extensionless route it is served + * at: "docs/guide.html" -> "/docs/guide", "docs/index.md" -> "/docs/". Index + * files get the trailing-slash form directly, skipping the 308 redirect the + * slash-less form would answer with. */ +function routeForPage(sitePath: string, extension: ".html" | ".md"): string { + const stripped = sitePath.slice(0, -extension.length); + if (stripped === "index") return "/"; + if (stripped.endsWith("/index")) return `/${stripped.slice(0, -"index".length)}`; + return `/${stripped}`; +} + +/** Whether the given absolute path exists and is a regular file. */ +function isFile( + absolutePath: string, +): Effect.Effect { + return Effect.flatMap(FileSystem.FileSystem, (fs) => + fs.stat(absolutePath).pipe( + Effect.map((info) => info.type === "File"), + Effect.orElseSucceed(() => false), + ), + ); +} + /** Builds the extensionless browser path for a file passed as the target. */ function openPathForFile(filename: string): string { const lower = filename.toLowerCase(); diff --git a/cli/test/e2e.test.js b/cli/test/e2e.test.js index 2a7777b..19f7c19 100644 --- a/cli/test/e2e.test.js +++ b/cli/test/e2e.test.js @@ -353,6 +353,55 @@ describe("path argument → open URL → served content", () => { ); }); + test("(2) a directory without an index opens the first page file's route", async () => { + await withServer( + { "zebra.md": "# zebra\n", "notes.html": staticPage("notes") }, + async ({ openPath, get }) => { + expect(openPath).toBe("/notes"); // alphabetically first page file at the root + expect((await get(openPath)).body).toContain("static@notes"); + }, + ); + }); + + test("(2) a directory whose only pages are nested opens the shallowest one", async () => { + await withServer( + { "docs/guide.md": "# guide\n", "styles.css": "body {}" }, + async ({ openPath, get }) => { + expect(openPath).toBe("/docs/guide"); + expect((await get("/docs/guide.md")).body).toBe("# guide\n"); + }, + ); + }); + + test("(2) a nested index file opens its directory route", async () => { + await withServer( + { "docs/index.html": staticPage("docs") }, + async ({ openPath, get }) => { + expect(openPath).toBe("/docs/"); + expect((await get(openPath)).body).toContain("static@docs"); + }, + ); + }); + + test("(2) a marked renderer shell without index.md is not treated as the index", async () => { + await withServer( + { "index.html": fakeShell("root"), "notes.md": "# notes\n" }, + async ({ openPath, get }) => { + expect(openPath).toBe("/notes"); // "/" would 404: the shell only renders .md routes + expect((await get(openPath)).body).toContain("shell@root"); + }, + ); + }); + + test("(2) a directory with no page files still opens /", async () => { + await withServer( + { "styles.css": "body {}" }, + async ({ openPath }) => { + expect(openPath).toBe("/"); + }, + ); + }); + test("(1) `scratchwork dev dir/index.html` opens /", async () => { await withServer( { "index.html": staticPage("root") }, @@ -720,6 +769,45 @@ describe("scratchwork publish", () => { rmSync(configDir, { recursive: true, force: true }); } }); + + test("publishing a directory without an index sends the first page file's route as openPath", async () => { + const dir = mkdtempSync(join(tmpdir(), "scratchwork-publish-")); + const configDir = mkdtempSync(join(tmpdir(), "scratchwork-config-")); + const port = nextPort++; + const serverUrl = `http://localhost:${port}`; + let publishBody; + + const server = Bun.serve({ + port, + async fetch(request) { + publishBody = await request.json(); + return Response.json({ + project: publishBody.project, + isPublic: true, + openPath: publishBody.openPath, + url: `${serverUrl}/${publishBody.project}${publishBody.openPath}`, + }); + }, + }); + + try { + writeFileSync(join(dir, "notes.md"), "# notes\n"); + writeFileSync(join(dir, "styles.css"), "body {}"); + + const { code, stdout, stderr } = await runCli(["publish", ".", "--server", serverUrl], dir, { + env: { SCRATCHWORK_HOME: configDir }, + }); + + expect(code).toBe(0); + expect(stderr).toBe(""); + expect(publishBody.openPath).toBe("/notes"); + expect(stdout).toContain("/notes"); // the printed (and opened) URL points at a real page + } finally { + server.stop(true); + rmSync(dir, { recursive: true, force: true }); + rmSync(configDir, { recursive: true, force: true }); + } + }); }); describe("scratchwork project commands", () => {