diff --git a/.changeset/delegated-release-client.md b/.changeset/delegated-release-client.md new file mode 100644 index 0000000000..e94812a092 --- /dev/null +++ b/.changeset/delegated-release-client.md @@ -0,0 +1,10 @@ +--- +"@emdash-cms/registry-client": minor +"@emdash-cms/plugin-cli": minor +--- + +Adds typed clients for the experimental delegated release service. `ReleaseServiceClient` submits, polls, and cancels GitHub OpenID Connect release intents, and manages publisher workload policies and retained delegation through a publisher session. `ReleaseServiceOperatorClient` exposes the Cloudflare Access status, pause, suspension, revocation, cancellation, and reconciliation operations. + +Both clients validate response envelopes and return stable `ReleaseServiceError` codes with retry metadata. Mutation helpers require idempotency keys, and workload polling requests a fresh token from the configured provider for each call. + +The plugin CLI adds `emdash-plugin release submit`, `release status`, and `release cancel` for GitHub Actions jobs. The commands request audience-bound OIDC tokens from the runner, support JSON output, and use the GitHub run identity as the default idempotency key. diff --git a/apps/release-action/README.md b/apps/release-action/README.md new file mode 100644 index 0000000000..8fe9ddedca --- /dev/null +++ b/apps/release-action/README.md @@ -0,0 +1,66 @@ +# EmDash delegated release Action + +This experimental Action submits a package release record to an EmDash delegated release service. It requests a GitHub OpenID Connect (OIDC) token for each service call, so the workflow does not store a release-service secret. + +## Workflow setup + +Grant the job permission to request an OIDC token, then pass the publisher DID and generated release record to the Action: + +```yaml title=".github/workflows/release.yml" +name: Release plugin + +on: + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Build release record + run: pnpm build:release-record --output release.json + + - name: Publish through EmDash + id: release + uses: emdash-cms/emdash/apps/release-action@ + with: + service-url: https://release.example.com + publisher-did: did:web:publisher.example.com + release-file: release.json +``` + +Replace the example service URL, publisher DID, build command, and exact commit with values for your publisher. Pin the Action to an exact commit while the delegated release protocol remains experimental. + +The release record must conform to `com.emdashcms.experimental.package.release`. The service validates its package, version, artifact, declared access, and provenance before publication. + +## Inputs + +| Input | Required | Default | Purpose | +| ----------------------- | -------- | -------------- | ---------------------------------------------------------------------------------------------- | +| `service-url` | Yes | — | HTTPS origin of the delegated release service. | +| `publisher-did` | Yes | — | DID that owns the package profile and release records. | +| `release-file` | Yes | — | JSON file containing the package release record. The path must stay inside `GITHUB_WORKSPACE`. | +| `idempotency-key` | No | Current run ID | Stable key used to replay the same submission. | +| `poll-interval-seconds` | No | `5` | Delay between intent status requests. | +| `timeout-minutes` | No | `30` | Maximum polling time. | +| `wait-for-approval` | No | `false` | Continue polling when the intent reaches `awaiting_approval`. | + +The default idempotency key is stable across attempts of one GitHub run. Set `idempotency-key` when separate runs or jobs must replay the same submission identity. + +## Outputs + +| Output | Value | +| -------------- | -------------------------------------------------- | +| `intent-id` | Release intent ULID. | +| `state` | Published, terminal, or `awaiting_approval` state. | +| `approval-url` | Approval URL when passkey approval is required. | +| `release-uri` | Published AT URI. | +| `release-cid` | Published record CID. | +| `reason-code` | Stable failure reason for a terminal intent. | + +With the default `wait-for-approval: false`, an intent awaiting approval returns successfully with `state` and `approval-url` outputs. Terminal states other than `published` fail the step. Network failures, service pauses, and polling timeouts also fail with a stable client error code. diff --git a/apps/release-action/action.yml b/apps/release-action/action.yml new file mode 100644 index 0000000000..1b01697138 --- /dev/null +++ b/apps/release-action/action.yml @@ -0,0 +1,47 @@ +name: EmDash delegated release +description: Publish an EmDash plugin release through the delegated release service. +author: EmDash +inputs: + service-url: + description: HTTPS origin of the delegated release service. + required: true + publisher-did: + description: Publisher DID that owns the package. + required: true + release-file: + description: Path to the package release record JSON file. + required: true + idempotency-key: + description: Stable key for replaying this submission. Defaults to the GitHub run. + required: false + poll-interval-seconds: + description: Seconds between status requests. + required: false + default: "5" + timeout-minutes: + description: Maximum time to wait for publication or approval. + required: false + default: "30" + wait-for-approval: + description: Continue polling while the intent awaits approval. + required: false + default: "false" +outputs: + intent-id: + description: Release intent ULID. + state: + description: Final or approval-waiting intent state. + approval-url: + description: Approval URL when human approval is required. + release-uri: + description: Published AT URI. + release-cid: + description: Published record CID. + reason-code: + description: Stable terminal reason code when publication does not succeed. +runs: + using: node24 + main: dist/index.js +branding: + icon: upload-cloud + color: purple diff --git a/apps/release-action/dist/index.js b/apps/release-action/dist/index.js new file mode 100644 index 0000000000..e1b27ca4aa --- /dev/null +++ b/apps/release-action/dist/index.js @@ -0,0 +1,1709 @@ +import { appendFile, readFile, realpath, stat } from "node:fs/promises"; +import { isAbsolute, relative, resolve } from "node:path"; +import { Buffer } from "node:buffer"; + +//#region ../../node_modules/.pnpm/@atcute+lexicons@2.0.0/node_modules/@atcute/lexicons/dist/syntax/did.js +const DID_RE = /^did:([a-z]+):([a-zA-Z0-9._:%-]*[a-zA-Z0-9._-])$/; +const isDid = /* @__NO_SIDE_EFFECTS__ */ (input) => { + return typeof input === "string" && input.length >= 7 && input.length <= 2048 && DID_RE.test(input); +}; + +//#endregion +//#region ../../node_modules/.pnpm/@atcute+lexicons@2.0.0/node_modules/@atcute/lexicons/dist/syntax/utils/ascii.js +const isAsciiAlpha = /* @__NO_SIDE_EFFECTS__ */ (c) => { + return c >= 65 && c <= 90 || c >= 97 && c <= 122; +}; +const isAsciiAlphaNum = /* @__NO_SIDE_EFFECTS__ */ (c) => { + return /* @__PURE__ */ isAsciiAlpha(c) || c >= 48 && c <= 57; +}; + +//#endregion +//#region ../../node_modules/.pnpm/@atcute+lexicons@2.0.0/node_modules/@atcute/lexicons/dist/syntax/handle.js +const isValidLabel = (input, start, end) => { + const len = end - start; + if (len === 0 || len > 63) return false; + if (!/* @__PURE__ */ isAsciiAlphaNum(input.charCodeAt(start))) return false; + if (len > 1) { + if (!/* @__PURE__ */ isAsciiAlphaNum(input.charCodeAt(end - 1))) return false; + for (let j = start + 1; j < end - 1; j++) { + const c = input.charCodeAt(j); + if (!/* @__PURE__ */ isAsciiAlphaNum(c) && c !== 45) return false; + } + } + return true; +}; +const isHandle = /* @__NO_SIDE_EFFECTS__ */ (input) => { + if (typeof input !== "string") return false; + const len = input.length; + if (len < 3 || len > 253) return false; + let labelStart = 0; + let labelCount = 0; + let lastLabelStart = 0; + for (let i = 0; i <= len; i++) if (i === len || input.charCodeAt(i) === 46) { + if (!isValidLabel(input, labelStart, i)) return false; + lastLabelStart = labelStart; + labelStart = i + 1; + labelCount++; + } + if (labelCount < 2) return false; + return /* @__PURE__ */ isAsciiAlpha(input.charCodeAt(lastLabelStart)); +}; + +//#endregion +//#region ../../node_modules/.pnpm/@atcute+lexicons@2.0.0/node_modules/@atcute/lexicons/dist/syntax/at-identifier.js +const isActorIdentifier = /* @__NO_SIDE_EFFECTS__ */ (input) => { + return /* @__PURE__ */ isDid(input) || /* @__PURE__ */ isHandle(input); +}; + +//#endregion +//#region ../../node_modules/.pnpm/@atcute+lexicons@2.0.0/node_modules/@atcute/lexicons/dist/syntax/nsid.js +const isNsid = /* @__NO_SIDE_EFFECTS__ */ (input) => { + if (typeof input !== "string") return false; + const len = input.length; + if (len < 5 || len > 317) return false; + let lastDot = -1; + for (let j = len - 1; j >= 0; j--) if (input.charCodeAt(j) === 46) { + lastDot = j; + break; + } + if (lastDot === -1) return false; + let segStart = 0; + let segIdx = 0; + for (let i = 0; i <= lastDot; i++) if (i === lastDot || input.charCodeAt(i) === 46) { + const segLen = i - segStart; + if (segLen === 0 || segLen > 63) return false; + const first = input.charCodeAt(segStart); + if (segIdx === 0) { + if (!/* @__PURE__ */ isAsciiAlpha(first)) return false; + } else if (!/* @__PURE__ */ isAsciiAlphaNum(first)) return false; + if (segLen > 1) { + if (!/* @__PURE__ */ isAsciiAlphaNum(input.charCodeAt(i - 1))) return false; + for (let j = segStart + 1; j < i - 1; j++) { + const c = input.charCodeAt(j); + if (!/* @__PURE__ */ isAsciiAlphaNum(c) && c !== 45) return false; + } + } + segStart = i + 1; + segIdx++; + } + if (segIdx < 2) return false; + const nameStart = lastDot + 1; + const nameLen = len - nameStart; + if (nameLen === 0 || nameLen > 63) return false; + if (!/* @__PURE__ */ isAsciiAlpha(input.charCodeAt(nameStart))) return false; + for (let j = nameStart + 1; j < len; j++) if (!/* @__PURE__ */ isAsciiAlphaNum(input.charCodeAt(j))) return false; + return true; +}; + +//#endregion +//#region ../../node_modules/.pnpm/@atcute+lexicons@2.0.0/node_modules/@atcute/lexicons/dist/syntax/record-key.js +const isRecordKey = /* @__NO_SIDE_EFFECTS__ */ (input) => { + if (typeof input !== "string") return false; + const len = input.length; + if (len < 1 || len > 512) return false; + if (len <= 2 && input.charCodeAt(0) === 46 && (len === 1 || input.charCodeAt(1) === 46)) return false; + for (let i = 0; i < len; i++) { + const c = input.charCodeAt(i); + if (!/* @__PURE__ */ isAsciiAlphaNum(c) && c !== 95 && c !== 126 && c !== 46 && c !== 58 && c !== 45) return false; + } + return true; +}; + +//#endregion +//#region ../../node_modules/.pnpm/@atcute+lexicons@2.0.0/node_modules/@atcute/lexicons/dist/syntax/at-uri.js +const AT_URI_MIN_LENGTH = 8; +const AT_URI_MAX_LENGTH = 2884; +const isFragmentChar = (c) => { + return /* @__PURE__ */ isAsciiAlphaNum(c) || c === 46 || c === 95 || c === 126 || c === 58 || c === 64 || c === 33 || c === 36 || c === 38 || c === 37 || c === 39 || c === 41 || c === 40 || c === 42 || c === 43 || c === 44 || c === 59 || c === 61 || c === 45 || c === 91 || c === 93 || c === 47 || c === 92; +}; +const isResourceUri = /* @__NO_SIDE_EFFECTS__ */ (input) => { + if (typeof input !== "string") return false; + const len = input.length; + if (len < AT_URI_MIN_LENGTH || len > AT_URI_MAX_LENGTH) return false; + if (input.charCodeAt(0) !== 97 || input.charCodeAt(1) !== 116 || input.charCodeAt(2) !== 58 || input.charCodeAt(3) !== 47 || input.charCodeAt(4) !== 47) return false; + const hash = input.indexOf("#", 5); + const stop = hash === -1 ? len : hash; + if (hash !== -1) { + const fragmentStart = hash + 1; + if (fragmentStart >= len || input.charCodeAt(fragmentStart) !== 47) return false; + for (let idx = fragmentStart; idx < len; idx++) if (!isFragmentChar(input.charCodeAt(idx))) return false; + } + const firstSlash = input.indexOf("/", 5); + let repoEnd = stop; + let collection; + let rkey; + if (firstSlash !== -1 && firstSlash < stop) { + repoEnd = firstSlash; + const collectionStart = firstSlash + 1; + if (collectionStart >= stop) return false; + const secondSlash = input.indexOf("/", collectionStart); + if (secondSlash !== -1 && secondSlash < stop) { + if (secondSlash === collectionStart || secondSlash + 1 >= stop) return false; + const thirdSlash = input.indexOf("/", secondSlash + 1); + if (thirdSlash !== -1 && thirdSlash < stop) return false; + collection = input.substring(collectionStart, secondSlash); + rkey = input.substring(secondSlash + 1, stop); + } else collection = input.substring(collectionStart, stop); + } + if (repoEnd <= 5) return false; + return /* @__PURE__ */ isActorIdentifier(input.substring(5, repoEnd)) && (collection === void 0 || /* @__PURE__ */ isNsid(collection)) && (rkey === void 0 || /* @__PURE__ */ isRecordKey(rkey)); +}; + +//#endregion +//#region ../../node_modules/.pnpm/@atcute+uint8array@1.1.1/node_modules/@atcute/uint8array/dist/index.node.js +const _alloc = Buffer.alloc; +const _allocUnsafe = Buffer.allocUnsafe; +const _concat = Buffer.concat; +const _from = Buffer.from; +const _byteLength = Buffer.byteLength; +const _compare = Buffer.prototype.compare; +const _equals = Buffer.prototype.equals; +const _utf8Slice = Buffer.prototype.utf8Slice; +const _utf8Write = Buffer.prototype.utf8Write; +const _fromCharCode = String.fromCharCode; +/** +* checks if a string's UTF-8 byte length is within a given range +* @param str string to measure +* @param min minimum byte length (inclusive) +* @param max maximum byte length (inclusive) +* @returns true if byte length is within [min, max] +*/ +const isUtf8LengthInRange = (str, min, max) => { + const len = str.length; + if (len * 3 < min) return false; + if (len >= min && len * 3 <= max) return true; + const utf8len = _byteLength(str, "utf8"); + return utf8len >= min && utf8len <= max; +}; + +//#endregion +//#region ../../node_modules/.pnpm/@atcute+lexicons@2.0.0/node_modules/@atcute/lexicons/dist/syntax/cid.js +const DASL_CID_RE = /^baf[ky]rei[a-z2-7]{52}$/; +const isCid = /* @__NO_SIDE_EFFECTS__ */ (input) => { + return typeof input === "string" && input.length === 59 && DASL_CID_RE.test(input); +}; + +//#endregion +//#region ../../node_modules/.pnpm/@atcute+lexicons@2.0.0/node_modules/@atcute/lexicons/dist/syntax/datetime.js +const DATE_TIME_RE = /^((?!0{3})\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01]))T((?:[01]\d|2[0-3]):(?:[0-5]\d):(?:[0-5]\d))(\.\d+)?(Z|(?!-00:00)[+-](?:[01]\d|2[0-3]):(?:[0-5]\d))$/; +const isDatetime = /* @__NO_SIDE_EFFECTS__ */ (input) => { + return typeof input === "string" && input.length >= 20 && input.length <= 64 && DATE_TIME_RE.test(input); +}; + +//#endregion +//#region ../../node_modules/.pnpm/@atcute+lexicons@2.0.0/node_modules/@atcute/lexicons/dist/syntax/language.js +const LANGUAGE_CODE_RE = /^((?(en-GB-oed|i-ami|i-bnn|i-default|i-enochian|i-hak|i-klingon|i-lux|i-mingo|i-navajo|i-pwn|i-tao|i-tay|i-tsu|sgn-BE-FR|sgn-BE-NL|sgn-CH-DE)|(art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|zh-hakka|zh-min|zh-min-nan|zh-xiang))|((?([A-Za-z]{2,3}(-(?[A-Za-z]{3}(-[A-Za-z]{3}){0,2}))?)|[A-Za-z]{4}|[A-Za-z]{5,8})(-(? + + + diff --git a/apps/release-service/package.json b/apps/release-service/package.json index f330f60f29..4014b3eefd 100644 --- a/apps/release-service/package.json +++ b/apps/release-service/package.json @@ -9,27 +9,44 @@ "build": "vite build", "preview": "vite preview", "deploy": "vite build && wrangler deploy", - "typecheck": "tsgo --noEmit", - "test": "vitest run", + "typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.ui.json", + "pretest": "vite build", + "test": "vitest run && pnpm run test:ui", + "test:ui": "vitest run --config vitest.ui.config.ts", "types": "wrangler types" }, "dependencies": { + "@atcute/atproto": "catalog:", + "@atcute/client": "catalog:", "@atcute/identity-resolver": "catalog:", "@atcute/lexicons": "catalog:", "@atcute/oauth-node-client": "catalog:", + "@cloudflare/kumo": "catalog:", "@emdash-cms/auth": "workspace:*", "@emdash-cms/plugin-types": "workspace:*", "@emdash-cms/registry-client": "workspace:*", "@emdash-cms/registry-lexicons": "workspace:*", "@emdash-cms/registry-verification": "workspace:*", + "@lingui/core": "catalog:", + "@lingui/react": "catalog:", "jose": "^6.1.3", - "semver": "catalog:" + "react": "catalog:", + "react-dom": "catalog:", + "semver": "catalog:", + "ulidx": "^2.4.1" }, "devDependencies": { "@cloudflare/vite-plugin": "catalog:", "@cloudflare/vitest-pool-workers": "catalog:", + "@tailwindcss/vite": "^4.3.3", + "@testing-library/react": "^16.3.0", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", "@types/semver": "catalog:", "@types/node": "catalog:", + "@vitejs/plugin-react": "^4.6.0", + "jsdom": "^26.1.0", + "tailwindcss": "^4.1.10", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:", diff --git a/apps/release-service/public/react-preamble.js b/apps/release-service/public/react-preamble.js new file mode 100644 index 0000000000..24f269a7d0 --- /dev/null +++ b/apps/release-service/public/react-preamble.js @@ -0,0 +1,2 @@ +window.$RefreshReg$ = () => {}; +window.$RefreshSig$ = () => (type) => type; diff --git a/apps/release-service/src/access/auth.ts b/apps/release-service/src/access/auth.ts new file mode 100644 index 0000000000..e825c25722 --- /dev/null +++ b/apps/release-service/src/access/auth.ts @@ -0,0 +1,111 @@ +import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from "jose"; + +import { ApiError } from "../api/errors.js"; + +const ACCESS_JWKS_CACHE_SYMBOL = Symbol.for("@emdash-cms/release-service/access-jwks-cache"); +const ACCESS_TOKEN_HEADER = "cf-access-jwt-assertion"; +const OPERATOR_REQUEST_HEADER = "x-emdash-request"; +const MAX_ACCESS_TOKEN_CHARS = 16 * 1024; +const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$/; +const HUMAN_SUBJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,255}$/; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+$/; + +export type AccessRole = "viewer" | "reviewer" | "admin"; + +export interface AccessConfiguration { + teamDomain: string; + audiences: Readonly>; +} + +export interface AccessActor { + realm: "access"; + identity: string; + email: string; + role: AccessRole; +} + +export function accessRoleForOperatorPath(pathname: string): AccessRole | null { + for (const role of ["viewer", "reviewer", "admin"] as const) { + if (pathname.startsWith(`/admin/api/${role}/`)) return role; + } + return null; +} + +function getAccessJwksCache(): Map { + const target = globalThis as typeof globalThis & { + [ACCESS_JWKS_CACHE_SYMBOL]?: Map; + }; + return (target[ACCESS_JWKS_CACHE_SYMBOL] ??= new Map()); +} + +function getAccessJwks(teamDomain: string): JWTVerifyGetKey { + const cache = getAccessJwksCache(); + let resolver = cache.get(teamDomain); + if (!resolver) { + resolver = createRemoteJWKSet(new URL(`${teamDomain}/cdn-cgi/access/certs`)); + cache.set(teamDomain, resolver); + } + return resolver; +} + +export async function authenticateAccessRequest( + request: Request, + requiredRole: AccessRole, + configuration: AccessConfiguration, + keyResolver: JWTVerifyGetKey = getAccessJwks(configuration.teamDomain), +): Promise { + const token = request.headers.get(ACCESS_TOKEN_HEADER); + if (!token) { + throw new ApiError("ACCESS_AUTH_REQUIRED", 401, "Access authentication required"); + } + if (token.length > MAX_ACCESS_TOKEN_CHARS) { + throw new ApiError("ACCESS_AUTH_INVALID", 403, "Access authorization failed"); + } + try { + const { payload } = await jwtVerify(token, keyResolver, { + algorithms: ["RS256"], + audience: configuration.audiences[requiredRole], + clockTolerance: 5, + issuer: configuration.teamDomain, + typ: "JWT", + requiredClaims: ["exp", "iat", "nbf", "sub", "email", "type"], + }); + const now = Math.floor(Date.now() / 1000); + if ( + payload["type"] !== "app" || + !Number.isSafeInteger(payload.iat) || + !Number.isSafeInteger(payload.nbf) || + !Number.isSafeInteger(payload.exp) || + Number(payload.iat) > now + 5 || + Number(payload.iat) > Number(payload.exp) || + typeof payload.sub !== "string" || + !HUMAN_SUBJECT_PATTERN.test(payload.sub) || + typeof payload["email"] !== "string" || + payload["email"].length > 320 || + !EMAIL_PATTERN.test(payload["email"]) + ) { + throw new Error("Invalid Access identity claims"); + } + return { + realm: "access", + identity: payload.sub, + email: payload["email"], + role: requiredRole, + }; + } catch { + throw new ApiError("ACCESS_AUTH_INVALID", 403, "Access authorization failed"); + } +} + +export function validateAccessMutation(request: Request, publicOrigin: string): void { + if ( + request.headers.get("origin") !== publicOrigin || + request.headers.get(OPERATOR_REQUEST_HEADER) !== "1" + ) { + throw new ApiError("CSRF_INVALID", 403, "Request origin validation failed"); + } + const idempotencyKey = request.headers.get("idempotency-key"); + if (!idempotencyKey || !IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) { + throw new ApiError("IDEMPOTENCY_KEY_INVALID", 400, "Valid idempotency key required"); + } +} diff --git a/apps/release-service/src/api/body.ts b/apps/release-service/src/api/body.ts new file mode 100644 index 0000000000..cd6a203d25 --- /dev/null +++ b/apps/release-service/src/api/body.ts @@ -0,0 +1,55 @@ +import { ApiError } from "./errors.js"; + +const DEFAULT_MAX_JSON_BODY_BYTES = 4096; + +export function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export async function readJsonObject( + request: Request, + maxBytes = DEFAULT_MAX_JSON_BODY_BYTES, +): Promise> { + const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); + if (mediaType !== "application/json") { + throw new ApiError("INVALID_REQUEST", 415, "Expected an application/json request body"); + } + const declaredLength = Number(request.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new ApiError("INVALID_REQUEST", 413, "Request body is too large"); + } + if (!request.body) throw new ApiError("INVALID_REQUEST", 400, "Request body is required"); + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + length += value.byteLength; + if (length > maxBytes) { + await reader.cancel(); + throw new ApiError("INVALID_REQUEST", 413, "Request body is too large"); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes)); + } catch { + throw new ApiError("INVALID_REQUEST", 400, "Request body is not valid JSON"); + } + if (!isRecord(parsed)) { + throw new ApiError("INVALID_REQUEST", 400, "Request body must be an object"); + } + return parsed; +} diff --git a/apps/release-service/src/api/errors.ts b/apps/release-service/src/api/errors.ts index 3c4f5adca4..654211e985 100644 --- a/apps/release-service/src/api/errors.ts +++ b/apps/release-service/src/api/errors.ts @@ -1,16 +1,23 @@ export type ApiErrorCode = | "ACCESS_DENIED" + | "ACCESS_AUTH_INVALID" + | "ACCESS_AUTH_REQUIRED" | "APPROVAL_INVALID" | "APPROVER_SESSION_INVALID" | "APPROVER_SUSPENDED" + | "AUTH_INVALID" | "CONFIGURATION_ERROR" | "CREDENTIAL_LIMIT_REACHED" | "CREDENTIAL_NOT_FOUND" | "CREDENTIAL_REVOKED" | "CSRF_INVALID" + | "DELEGATION_REQUIRED" + | "IDEMPOTENCY_KEY_INVALID" + | "IDEMPOTENCY_CONFLICT" | "INTERNAL_ERROR" | "INVALID_REQUEST" | "INTENT_NOT_APPROVABLE" + | "INTENT_NOT_CANCELLABLE" | "NOT_FOUND" | "METHOD_NOT_ALLOWED" | "OAUTH_AUTHORIZATION_FAILED" @@ -18,7 +25,13 @@ export type ApiErrorCode = | "PUBLISHER_SESSION_INVALID" | "PUBLISHER_SUSPENDED" | "PROFILE_CHANGED" - | "PROFILE_FETCH_FAILED"; + | "PROFILE_FETCH_FAILED" + | "RELEASE_EXISTS" + | "SERVICE_PAUSED" + | "SERVICE_UNAVAILABLE" + | "VERSION_RESERVED" + | "WORKFLOW_UNAVAILABLE" + | "WORKLOAD_NOT_ALLOWED"; export interface SerializedApiError { code: ApiErrorCode; diff --git a/apps/release-service/src/approvals/decision-routes.ts b/apps/release-service/src/approvals/decision-routes.ts index 6ac5720aec..f6d8c6dc5e 100644 --- a/apps/release-service/src/approvals/decision-routes.ts +++ b/apps/release-service/src/approvals/decision-routes.ts @@ -1,6 +1,9 @@ +import { safeParse } from "@atcute/lexicons"; import { isDid } from "@atcute/lexicons/syntax"; import type { AuthenticationResponse } from "@emdash-cms/auth/passkey"; +import { NSID, PackageRelease, PackageReleaseExtension } from "@emdash-cms/registry-lexicons"; import { env } from "cloudflare:workers"; +import { base64url } from "jose"; import { ApiError } from "../api/errors.js"; import { apiFailure, apiSuccess } from "../api/response.js"; @@ -10,6 +13,7 @@ import { } from "../approver-session/session.js"; import type { ServiceConfiguration } from "../config.js"; import { ApprovalAuthorityError, loadApprovalIntent, verifyCurrentApprover } from "./authority.js"; +import type { ApprovalEvidence } from "./digest.js"; import { ApprovalPasskeyError, beginApprovalDecision, @@ -132,6 +136,202 @@ function parseDecision(value: unknown): "approve" | "reject" { return value; } +async function digest(value: unknown): Promise { + return base64url.encode( + new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(value))), + ), + ); +} + +function evidenceInvalid(): never { + throw new ApprovalAuthorityError("APPROVAL_EVIDENCE_INVALID"); +} + +function recordField(value: Record, key: string): Record { + const item = value[key]; + return isRecord(item) ? item : evidenceInvalid(); +} + +function stringField(value: Record, key: string): string { + const item = value[key]; + return typeof item === "string" ? item : evidenceInvalid(); +} + +function integerField(value: Record, key: string): number { + const item = value[key]; + return Number.isSafeInteger(item) ? Number(item) : evidenceInvalid(); +} + +function nullableStringField(value: Record, key: string): string | null { + const item = value[key]; + return item === null || typeof item === "string" ? item : evidenceInvalid(); +} + +async function storedWorkloadSource(workloadIdentityJson: string, expectedDigest: string) { + let workload: unknown; + try { + workload = JSON.parse(workloadIdentityJson); + } catch { + evidenceInvalid(); + } + if (!isRecord(workload)) evidenceInvalid(); + const repository = recordField(workload, "repository"); + const workflow = recordField(workload, "workflow"); + const run = recordField(workload, "run"); + const issuer = stringField(workload, "issuer"); + const visibility = stringField(repository, "visibility"); + const refType = stringField(run, "refType"); + const runnerEnvironment = stringField(run, "runnerEnvironment"); + if ( + issuer !== "github-actions" || + (visibility !== "public" && visibility !== "private" && visibility !== "internal") || + (refType !== "branch" && refType !== "tag") || + (runnerEnvironment !== "github-hosted" && runnerEnvironment !== "self-hosted") + ) { + evidenceInvalid(); + } + const source = { + repository: stringField(repository, "name"), + workflowRef: stringField(workflow, "ref"), + commitSha: stringField(run, "commitSha"), + runId: stringField(run, "id"), + actor: stringField(run, "actor"), + }; + const actualDigest = await digest([ + "emdash-release-service", + "workload-identity", + 1, + issuer, + stringField(workload, "subject"), + stringField(workload, "tokenId"), + source.repository, + stringField(repository, "id"), + stringField(repository, "owner"), + stringField(repository, "ownerId"), + visibility, + source.workflowRef, + stringField(workflow, "sha"), + nullableStringField(workflow, "jobRef"), + nullableStringField(workflow, "jobSha"), + source.runId, + integerField(run, "attempt"), + source.actor, + stringField(run, "actorId"), + stringField(run, "eventName"), + stringField(run, "ref"), + refType, + source.commitSha, + nullableStringField(run, "environment"), + runnerEnvironment, + integerField(workload, "issuedAt"), + integerField(workload, "expiresAt"), + ]); + if (actualDigest !== expectedDigest) evidenceInvalid(); + return source; +} + +async function storedReleaseReview(releaseInputJson: string, evidence: ApprovalEvidence) { + let input: unknown; + try { + input = JSON.parse(releaseInputJson); + } catch { + evidenceInvalid(); + } + if (!isRecord(input) || !isRecord(input["release"])) evidenceInvalid(); + const release = safeParse(PackageRelease.mainSchema, input["release"]); + if (!release.ok) evidenceInvalid(); + const extension = safeParse( + PackageReleaseExtension.mainSchema, + release.value.extensions?.[NSID.packageReleaseExtension], + ); + if (!extension.ok || !extension.value.provenance) evidenceInvalid(); + const provenance = extension.value.provenance; + if ( + release.value.package !== evidence.packageSlug || + release.value.version !== evidence.version || + release.value.artifacts.package.checksum !== evidence.artifactChecksum || + provenance.checksum !== evidence.provenanceChecksum || + (await digest(["release-intent", 1, evidence.publisherDid, release.value])) !== + evidence.releaseInputDigest + ) { + evidenceInvalid(); + } + return { + artifact: { + url: release.value.artifacts.package.url, + checksum: release.value.artifacts.package.checksum, + }, + provenance: { + url: provenance.url, + checksum: provenance.checksum, + predicateType: provenance.predicateType, + sourceRepository: provenance.sourceRepository, + builderId: provenance.builderId, + }, + }; +} + +async function storedAccessDiff(resultJson: string | null, expectedDigest: string) { + if (!resultJson) throw new ApprovalAuthorityError("APPROVAL_EVIDENCE_INVALID"); + let result: unknown; + let diff: unknown; + try { + result = JSON.parse(resultJson); + if (!isRecord(result) || typeof result["accessDiffJson"] !== "string") throw new Error(); + diff = JSON.parse(result["accessDiffJson"]); + } catch { + throw new ApprovalAuthorityError("APPROVAL_EVIDENCE_INVALID"); + } + if ( + !isRecord(diff) || + typeof diff["escalation"] !== "boolean" || + !Array.isArray(diff["changes"]) + ) { + throw new ApprovalAuthorityError("APPROVAL_EVIDENCE_INVALID"); + } + if ((await digest(diff)) !== expectedDigest) evidenceInvalid(); + const changes = diff["changes"].map((change) => { + if ( + !isRecord(change) || + typeof change["kind"] !== "string" || + typeof change["category"] !== "string" || + (change["operation"] !== undefined && typeof change["operation"] !== "string") || + !Array.isArray(change["path"]) || + change["path"].some((part) => typeof part !== "string") || + typeof change["escalation"] !== "boolean" + ) { + throw new ApprovalAuthorityError("APPROVAL_EVIDENCE_INVALID"); + } + return { + kind: change["kind"], + category: change["category"], + operation: typeof change["operation"] === "string" ? change["operation"] : null, + path: change["path"], + escalation: change["escalation"], + }; + }); + return { escalation: diff["escalation"], changes }; +} + +async function approvalReview( + workloadIdentityJson: string, + releaseInputJson: string, + policyDecisionJson: string | null, + evidence: ApprovalEvidence, +) { + const [source, releaseReview, accessDiff] = await Promise.all([ + storedWorkloadSource(workloadIdentityJson, evidence.workloadIdentityDigest), + storedReleaseReview(releaseInputJson, evidence), + storedAccessDiff(policyDecisionJson, evidence.declaredAccessDiffDigest), + ]); + return { + source, + ...releaseReview, + accessDiff, + }; +} + function mapApprovalError(error: unknown): ApiError { if (error instanceof ApiError) return error; if (error instanceof ApproverSessionError) { @@ -227,6 +427,9 @@ export async function handleGetApproval( intentId(params), ); await verifyCurrentApprover(loaded.evidence, session.approverDid); + const policyDecision = await env.PUBLISHER_DO.getByName( + loaded.evidence.publisherDid, + ).getVerificationStep(loaded.evidence.publisherDid, loaded.intent.id, "policy-decision"); return apiSuccess( { intent: { @@ -238,6 +441,12 @@ export async function handleGetApproval( }, evidence: loaded.evidence, evidenceDigest: loaded.evidenceDigest, + review: await approvalReview( + loaded.intent.workloadIdentityJson, + loaded.intent.releaseInputJson, + policyDecision?.resultJson ?? null, + loaded.evidence, + ), }, requestId, ); diff --git a/apps/release-service/src/approvals/digest.ts b/apps/release-service/src/approvals/digest.ts index 76cbeba94d..ec09014f7e 100644 --- a/apps/release-service/src/approvals/digest.ts +++ b/apps/release-service/src/approvals/digest.ts @@ -32,6 +32,7 @@ export interface ApprovalEvidence { export interface AwaitingApprovalState { approvalEvidence: ApprovalEvidence; approvalEvidenceDigest: string; + approverDids: readonly string[]; } export interface ApprovalDecisionBinding { @@ -145,9 +146,30 @@ export async function computeApprovalDecisionDigest( ); } -export async function encodeAwaitingApprovalState(value: ApprovalEvidence): Promise { +function normalizeApproverDids(values: readonly string[]): readonly string[] { + if (!Array.isArray(values) || values.length === 0 || values.length > 32) { + throw new ApprovalDigestError(); + } + const normalized = [...values].toSorted((left, right) => left.localeCompare(right)); + if ( + normalized.some((value) => typeof value !== "string" || !DID_PATTERN.test(value)) || + new Set(normalized).size !== normalized.length + ) { + throw new ApprovalDigestError(); + } + return normalized; +} + +export async function encodeAwaitingApprovalState( + value: ApprovalEvidence, + approverDids: readonly string[], +): Promise { const approvalEvidenceDigest = await computeApprovalEvidenceDigest(value); - return JSON.stringify({ approvalEvidence: value, approvalEvidenceDigest }); + return JSON.stringify({ + approvalEvidence: value, + approvalEvidenceDigest, + approverDids: normalizeApproverDids(approverDids), + }); } export async function decodeAwaitingApprovalState(value: string): Promise { @@ -159,9 +181,10 @@ export async function decodeAwaitingApprovalState(value: string): Promise, string >; +const ACCESS_AUDIENCE_PATTERN = /^[a-f0-9]{64}$/; const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/; const DEPLOYMENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/; const MAX_ASSERTION_KEYSET_CHARS = 64 * 1024; @@ -30,6 +36,10 @@ const CONFIGURATION_BINDING_KEYS = [ "OAUTH_REDIRECT_URIS", "OAUTH_ASSERTION_KEYSET", "ENCRYPTION_KEYRING", + "ACCESS_TEAM_DOMAIN", + "ACCESS_VIEWER_AUD", + "ACCESS_REVIEWER_AUD", + "ACCESS_ADMIN_AUD", ] as const satisfies readonly (keyof ConfigurationBindings)[]; interface ConfigurationCacheEntry { @@ -40,6 +50,7 @@ interface ConfigurationCacheEntry { export interface ServiceConfiguration { publicOrigin: string; deploymentId: string; + access: AccessConfiguration; oauth: OAuthConfiguration; encryption: EnvelopeEncryption; } @@ -85,6 +96,28 @@ function parseOrigin(value: unknown): string | null { } } +function parseAccessTeamDomain(value: unknown): string | null { + const origin = parseOrigin(value); + if (!origin) return null; + const url = new URL(origin); + return url.port === "" ? origin : null; +} + +function parseAccessAudiences( + bindings: ConfigurationBindings, +): AccessConfiguration["audiences"] | null { + const audiences = { + viewer: bindings.ACCESS_VIEWER_AUD, + reviewer: bindings.ACCESS_REVIEWER_AUD, + admin: bindings.ACCESS_ADMIN_AUD, + }; + const values = Object.values(audiences); + return values.every((audience) => ACCESS_AUDIENCE_PATTERN.test(audience)) && + new Set(values).size === values.length + ? audiences + : null; +} + function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -223,7 +256,19 @@ async function parseConfiguration(bindings: ConfigurationBindings): Promise 0) { + const accessTeamDomain = parseAccessTeamDomain(bindings.ACCESS_TEAM_DOMAIN); + if (!accessTeamDomain) issues.push("ACCESS_TEAM_DOMAIN_INVALID"); + const accessAudiences = parseAccessAudiences(bindings); + if (!accessAudiences) issues.push("ACCESS_AUDIENCES_INVALID"); + if ( + !publicOrigin || + !deploymentId || + !redirectUris || + !assertionKeyset || + !accessTeamDomain || + !accessAudiences || + issues.length > 0 + ) { throw new ConfigurationError(issues); } let encryption: EnvelopeEncryption; @@ -251,6 +296,7 @@ async function parseConfiguration(bindings: ConfigurationBindings): Promise, keys: readonly string[]): boolean { + const actual = Object.keys(value); + return actual.length === keys.length && actual.every((key) => keys.includes(key)); +} + +function validReasonCode(value: unknown): value is string | null { + return value === null || (typeof value === "string" && REASON_CODE_PATTERN.test(value)); +} + +function requireIdempotencyKey(request: Request): string { + const value = request.headers.get("idempotency-key"); + if (!value) throw new ApiError("IDEMPOTENCY_KEY_INVALID", 400, "Valid idempotency key required"); + return value; +} + +async function requestDigest(parts: readonly unknown[]): Promise { + const encoded = new TextEncoder().encode(JSON.stringify(parts)); + return base64url.encode(new Uint8Array(await crypto.subtle.digest("SHA-256", encoded))); +} + +function parseInteger( + value: string | null, + fallback: number, + minimum: number, + maximum: number, +): number { + if (value === null) return fallback; + if (!DECIMAL_INTEGER_PATTERN.test(value)) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid pagination parameters"); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid pagination parameters"); + } + return parsed; +} + +function mapControlError(error: unknown): never { + if ( + error !== null && + typeof error === "object" && + "code" in error && + error.code === "CONTROL_INPUT_INVALID" + ) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid service-control request"); + } + throw error; +} + +export async function handleServiceStatus( + _request: Request, + requestId: string, + _configuration: ServiceConfiguration, + _params: Readonly>, + accessActor: AccessActor | null, +): Promise { + const state = await control().readServiceState(requireActor(accessActor)); + return apiSuccess({ state }, requestId); +} + +export async function handleReadiness(_request: Request, requestId: string): Promise { + try { + await control().checkReadiness(); + return apiSuccess({ status: "ready" }, requestId); + } catch { + throw new ApiError("SERVICE_UNAVAILABLE", 503, "Service dependency is unavailable"); + } +} + +export async function handleSetServiceMode( + request: Request, + requestId: string, + _configuration: ServiceConfiguration, + _params: Readonly>, + accessActor: AccessActor | null, +): Promise { + const actor = requireActor(accessActor); + const body = await readJsonObject(request); + if ( + !hasExactKeys(body, ["mode", "reasonCode"]) || + (body["mode"] !== "active" && + body["mode"] !== "admission-paused" && + body["mode"] !== "publication-paused") || + !validReasonCode(body["reasonCode"]) || + (body["mode"] === "active") !== (body["reasonCode"] === null) + ) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid service mode request"); + } + const mode: ServiceMode = body["mode"]; + const reasonCode = body["reasonCode"]; + try { + const result = await control().setServiceMode({ + actor, + idempotencyKey: requireIdempotencyKey(request), + requestDigest: await requestDigest(["service-mode", mode, reasonCode]), + mode, + reasonCode, + }); + if (!result.ok) { + throw new ApiError("IDEMPOTENCY_CONFLICT", 409, "Idempotency key conflicts with prior use"); + } + return apiSuccess({ state: result.value, replayed: result.replayed }, requestId); + } catch (error) { + mapControlError(error); + } +} + +export async function handleControlAudit( + request: Request, + requestId: string, + _configuration: ServiceConfiguration, + _params: Readonly>, + accessActor: AccessActor | null, +): Promise { + const url = new URL(request.url); + if ([...url.searchParams.keys()].some((key) => key !== "after" && key !== "limit")) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid pagination parameters"); + } + const after = parseInteger(url.searchParams.get("after"), 0, 0, Number.MAX_SAFE_INTEGER); + const limit = parseInteger(url.searchParams.get("limit"), 50, 1, 100); + try { + const rows = await control().listAudit(requireActor(accessActor), after, limit + 1); + const items = rows.slice(0, limit); + const nextCursor = rows.length > limit ? String(items.at(-1)?.sequence) : undefined; + return apiSuccess({ items, ...(nextCursor ? { nextCursor } : {}) }, requestId); + } catch (error) { + mapControlError(error); + } +} diff --git a/apps/release-service/src/control-do/service-control-do.ts b/apps/release-service/src/control-do/service-control-do.ts new file mode 100644 index 0000000000..db64e106c4 --- /dev/null +++ b/apps/release-service/src/control-do/service-control-do.ts @@ -0,0 +1,842 @@ +import { DurableObject } from "cloudflare:workers"; +import { base64url } from "jose"; + +import type { AccessActor, AccessRole } from "../access/auth.js"; + +export const SERVICE_CONTROL_OBJECT_NAME = "global"; + +const DID_PATTERN = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/; +const ACTOR_IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,255}$/; +const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$/; +const DIGEST_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const REASON_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/; +const INTENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const PERMIT_ID_PATTERN = /^[A-Za-z0-9_-]{22}$/; +const PERMIT_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const MAX_PERMIT_TTL_MS = 30_000; +const OPERATOR_IDEMPOTENCY_TTL_MS = 24 * 60 * 60_000; +const ROLE_RANK: Readonly> = { + viewer: 1, + reviewer: 2, + admin: 3, +}; + +export type ServiceMode = "active" | "admission-paused" | "publication-paused"; +export type PublisherControlStatus = "allowed" | "suspended"; + +export type ServiceControlErrorCode = + | "CONTROL_ACTOR_INVALID" + | "CONTROL_INPUT_INVALID" + | "CONTROL_OBJECT_MISMATCH" + | "CONTROL_STATE_CORRUPT"; + +export class ServiceControlError extends Error { + readonly code: ServiceControlErrorCode; + + constructor(code: ServiceControlErrorCode) { + super(code); + this.name = "ServiceControlError"; + this.code = code; + } +} + +export interface ServiceState { + mode: ServiceMode; + epoch: number; + reasonCode: string | null; + changedBy: string; + changedAt: number; +} + +export interface PublisherControl { + publisherDid: string; + status: PublisherControlStatus; + reasonCode: string | null; + changedBy: string; + changedAt: number; +} + +interface OperatorMutationInput { + actor: AccessActor; + idempotencyKey: string; + requestDigest: string; + now?: number; +} + +export interface SetServiceModeInput extends OperatorMutationInput { + mode: ServiceMode; + reasonCode: string | null; +} + +export interface SetPublisherControlInput extends OperatorMutationInput { + publisherDid: string; + status: PublisherControlStatus; + reasonCode: string | null; +} + +export type OperatorMutationResult = + | { ok: true; value: T; replayed: boolean } + | { ok: false; code: "IDEMPOTENCY_CONFLICT" }; + +export interface AdmissionDecision { + allowed: boolean; + mode: ServiceMode; + modeEpoch: number; + code: "ADMISSION_PAUSED" | "PUBLISHER_SUSPENDED" | null; +} + +export interface PublicationPermit { + id: string; + token: string; + publisherDid: string; + intentId: string; + modeEpoch: number; + expiresAt: number; +} + +export type IssuePublicationPermitResult = + | { ok: true; permit: PublicationPermit } + | { ok: false; code: "PUBLICATION_PAUSED" | "PUBLISHER_SUSPENDED" }; + +export interface ConsumePublicationPermitInput { + id: string; + token: string; + publisherDid: string; + intentId: string; + now?: number; +} + +export type ConsumePublicationPermitResult = + | { ok: true; modeEpoch: number } + | { + ok: false; + code: + | "PERMIT_NOT_FOUND" + | "PERMIT_INVALID" + | "PERMIT_CONSUMED" + | "PERMIT_EXPIRED" + | "PERMIT_STALE" + | "PUBLICATION_PAUSED" + | "PUBLISHER_SUSPENDED"; + }; + +export interface ControlAuditEvent { + sequence: number; + eventType: string; + actorRealm: "access" | "system"; + actorIdentity: string; + actorRole: AccessRole | null; + subject: string; + reasonCode: string | null; + createdAt: number; +} + +interface ServiceStateRow { + [key: string]: string | number | ArrayBuffer | null; + mode: ServiceMode; + epoch: number; + reason_code: string | null; + operator_identity: string; + changed_at: number; +} + +interface PublisherControlRow { + [key: string]: string | number | ArrayBuffer | null; + publisher_did: string; + status: PublisherControlStatus; + reason_code: string | null; + operator_identity: string; + changed_at: number; +} + +interface IdempotencyRow { + [key: string]: string | number | ArrayBuffer | null; + action: string; + request_digest: string; + result_json: string; + expires_at: number; +} + +interface PublicationPermitRow { + [key: string]: string | number | ArrayBuffer | null; + token_hash: string; + publisher_did: string; + intent_id: string; + mode_epoch: number; + expires_at: number; + consumed_at: number | null; +} + +interface AuditRow { + [key: string]: string | number | ArrayBuffer | null; + sequence: number; + event_type: string; + actor_realm: "access" | "system"; + actor_identity: string; + actor_role: AccessRole | null; + subject: string; + reason_code: string | null; + created_at: number; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function validReasonCode(value: unknown): value is string | null { + return value === null || (typeof value === "string" && REASON_CODE_PATTERN.test(value)); +} + +function validTimestamp(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +function parseServiceState(value: string): ServiceState { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new ServiceControlError("CONTROL_STATE_CORRUPT"); + } + if ( + !isRecord(parsed) || + (parsed["mode"] !== "active" && + parsed["mode"] !== "admission-paused" && + parsed["mode"] !== "publication-paused") || + !Number.isSafeInteger(parsed["epoch"]) || + Number(parsed["epoch"]) < 1 || + !validReasonCode(parsed["reasonCode"]) || + typeof parsed["changedBy"] !== "string" || + !ACTOR_IDENTITY_PATTERN.test(parsed["changedBy"]) || + !validTimestamp(parsed["changedAt"]) + ) { + throw new ServiceControlError("CONTROL_STATE_CORRUPT"); + } + return { + mode: parsed["mode"], + epoch: Number(parsed["epoch"]), + reasonCode: parsed["reasonCode"], + changedBy: parsed["changedBy"], + changedAt: parsed["changedAt"], + }; +} + +function parsePublisherControl(value: string): PublisherControl { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new ServiceControlError("CONTROL_STATE_CORRUPT"); + } + if ( + !isRecord(parsed) || + typeof parsed["publisherDid"] !== "string" || + !DID_PATTERN.test(parsed["publisherDid"]) || + (parsed["status"] !== "allowed" && parsed["status"] !== "suspended") || + !validReasonCode(parsed["reasonCode"]) || + typeof parsed["changedBy"] !== "string" || + !ACTOR_IDENTITY_PATTERN.test(parsed["changedBy"]) || + !validTimestamp(parsed["changedAt"]) + ) { + throw new ServiceControlError("CONTROL_STATE_CORRUPT"); + } + return { + publisherDid: parsed["publisherDid"], + status: parsed["status"], + reasonCode: parsed["reasonCode"], + changedBy: parsed["changedBy"], + changedAt: parsed["changedAt"], + }; +} + +async function hashToken(token: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token)); + return base64url.encode(new Uint8Array(digest)); +} + +function hashesEqual(left: string, right: string): boolean { + try { + const leftBytes = base64url.decode(left); + const rightBytes = base64url.decode(right); + return ( + leftBytes.length === rightBytes.length && crypto.subtle.timingSafeEqual(leftBytes, rightBytes) + ); + } catch { + return false; + } +} + +export class ServiceControlDurableObject extends DurableObject { + readonly #objectName: string | undefined; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.#objectName = ctx.id.name; + void ctx.blockConcurrencyWhile(async () => { + this.#initializeSchema(); + }); + } + + #initializeSchema(): void { + this.ctx.storage.sql.exec(` + CREATE TABLE IF NOT EXISTS service_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + mode TEXT NOT NULL CHECK (mode IN ('active', 'admission-paused', 'publication-paused')), + epoch INTEGER NOT NULL CHECK (epoch >= 1), + reason_code TEXT, + operator_identity TEXT NOT NULL, + changed_at INTEGER NOT NULL + ); + INSERT OR IGNORE INTO service_state ( + id, mode, epoch, reason_code, operator_identity, changed_at + ) VALUES (1, 'active', 1, NULL, 'system:bootstrap', 0); + CREATE TABLE IF NOT EXISTS encryption_keys ( + version INTEGER PRIMARY KEY CHECK (version >= 1), + status TEXT NOT NULL CHECK (status IN ('active', 'readable', 'retired')), + activated_at INTEGER, + retired_at INTEGER, + operator_identity TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_encryption_keys_active + ON encryption_keys(status) WHERE status = 'active'; + CREATE TABLE IF NOT EXISTS publisher_controls ( + publisher_did TEXT PRIMARY KEY, + status TEXT NOT NULL CHECK (status IN ('allowed', 'suspended')), + reason_code TEXT, + operator_identity TEXT NOT NULL, + changed_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS operator_idempotency ( + operator_identity TEXT NOT NULL, + mutation_key TEXT NOT NULL, + action TEXT NOT NULL, + request_digest TEXT NOT NULL, + result_json TEXT NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (operator_identity, mutation_key) + ); + CREATE INDEX IF NOT EXISTS idx_operator_idempotency_expiry + ON operator_idempotency(expires_at); + CREATE TABLE IF NOT EXISTS publication_permits ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL, + publisher_did TEXT NOT NULL, + intent_id TEXT NOT NULL, + mode_epoch INTEGER NOT NULL CHECK (mode_epoch >= 1), + expires_at INTEGER NOT NULL, + consumed_at INTEGER, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_publication_permits_expiry + ON publication_permits(expires_at); + CREATE TABLE IF NOT EXISTS audit_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + actor_realm TEXT NOT NULL CHECK (actor_realm IN ('access', 'system')), + actor_identity TEXT NOT NULL, + actor_role TEXT CHECK (actor_role IN ('viewer', 'reviewer', 'admin')), + subject TEXT NOT NULL, + reason_code TEXT, + public_payload TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + `); + } + + #assertObjectName(): void { + if (this.#objectName !== SERVICE_CONTROL_OBJECT_NAME) { + throw new ServiceControlError("CONTROL_OBJECT_MISMATCH"); + } + } + + #assertActor(actor: AccessActor, minimumRole: AccessRole): void { + if ( + !isRecord(actor) || + actor.realm !== "access" || + typeof actor.identity !== "string" || + !ACTOR_IDENTITY_PATTERN.test(actor.identity) || + (actor.role !== "viewer" && actor.role !== "reviewer" && actor.role !== "admin") || + ROLE_RANK[actor.role] < ROLE_RANK[minimumRole] + ) { + throw new ServiceControlError("CONTROL_ACTOR_INVALID"); + } + } + + #assertOperatorMutation(input: OperatorMutationInput): number { + this.#assertActor(input.actor, "admin"); + const now = input.now ?? Date.now(); + if ( + !IDEMPOTENCY_KEY_PATTERN.test(input.idempotencyKey) || + !DIGEST_PATTERN.test(input.requestDigest) || + !validTimestamp(now) || + now > Number.MAX_SAFE_INTEGER - OPERATOR_IDEMPOTENCY_TTL_MS + ) { + throw new ServiceControlError("CONTROL_INPUT_INVALID"); + } + return now; + } + + #readState(): ServiceState { + const row = this.ctx.storage.sql + .exec( + `SELECT mode, epoch, reason_code, operator_identity, changed_at + FROM service_state WHERE id = 1`, + ) + .one(); + return { + mode: row.mode, + epoch: row.epoch, + reasonCode: row.reason_code, + changedBy: row.operator_identity, + changedAt: row.changed_at, + }; + } + + #readPublisherControl(publisherDid: string): PublisherControl { + const row = this.ctx.storage.sql + .exec( + `SELECT publisher_did, status, reason_code, operator_identity, changed_at + FROM publisher_controls WHERE publisher_did = ?`, + publisherDid, + ) + .toArray()[0]; + return row + ? { + publisherDid: row.publisher_did, + status: row.status, + reasonCode: row.reason_code, + changedBy: row.operator_identity, + changedAt: row.changed_at, + } + : { + publisherDid, + status: "allowed", + reasonCode: null, + changedBy: "system:default", + changedAt: 0, + }; + } + + #readIdempotency(actorIdentity: string, mutationKey: string, now: number): IdempotencyRow | null { + const row = this.ctx.storage.sql + .exec( + `SELECT action, request_digest, result_json, expires_at + FROM operator_idempotency + WHERE operator_identity = ? AND mutation_key = ?`, + actorIdentity, + mutationKey, + ) + .toArray()[0]; + if (!row) return null; + if (row.expires_at > now) return row; + this.ctx.storage.sql.exec( + "DELETE FROM operator_idempotency WHERE operator_identity = ? AND mutation_key = ?", + actorIdentity, + mutationKey, + ); + return null; + } + + #writeIdempotency( + input: OperatorMutationInput, + action: string, + result: ServiceState | PublisherControl, + now: number, + ): void { + this.ctx.storage.sql.exec( + `INSERT INTO operator_idempotency ( + operator_identity, mutation_key, action, request_digest, result_json, expires_at + ) VALUES (?, ?, ?, ?, ?, ?)`, + input.actor.identity, + input.idempotencyKey, + action, + input.requestDigest, + JSON.stringify(result), + now + OPERATOR_IDEMPOTENCY_TTL_MS, + ); + } + + #appendAudit( + eventType: string, + actorRealm: "access" | "system", + actorIdentity: string, + actorRole: AccessRole | null, + subject: string, + reasonCode: string | null, + createdAt: number, + ): void { + this.ctx.storage.sql.exec( + `INSERT INTO audit_events ( + event_type, actor_realm, actor_identity, actor_role, subject, + reason_code, public_payload, created_at + ) VALUES (?, ?, ?, ?, ?, ?, '{}', ?)`, + eventType, + actorRealm, + actorIdentity, + actorRole, + subject, + reasonCode, + createdAt, + ); + } + + async #scheduleCleanup(now: number): Promise { + const row = this.ctx.storage.sql + .exec<{ next_expiry: number | null }>( + `SELECT MIN(expires_at) AS next_expiry FROM ( + SELECT expires_at FROM operator_idempotency + UNION ALL + SELECT expires_at FROM publication_permits + )`, + ) + .one(); + if (row.next_expiry === null) { + await this.ctx.storage.deleteAlarm(); + return; + } + await this.ctx.storage.setAlarm(Math.max(now + 1, row.next_expiry)); + } + + async readServiceState(actor: AccessActor): Promise { + this.#assertObjectName(); + this.#assertActor(actor, "viewer"); + return this.#readState(); + } + + async checkReadiness(): Promise { + this.#assertObjectName(); + this.#readState(); + } + + async setServiceMode(input: SetServiceModeInput): Promise> { + this.#assertObjectName(); + const now = this.#assertOperatorMutation(input); + if ( + (input.mode !== "active" && + input.mode !== "admission-paused" && + input.mode !== "publication-paused") || + !validReasonCode(input.reasonCode) || + (input.mode === "active") !== (input.reasonCode === null) + ) { + throw new ServiceControlError("CONTROL_INPUT_INVALID"); + } + const result = this.ctx.storage.transactionSync(() => { + const existing = this.#readIdempotency(input.actor.identity, input.idempotencyKey, now); + if (existing) { + if (existing.action !== "service-mode" || existing.request_digest !== input.requestDigest) { + return { ok: false, code: "IDEMPOTENCY_CONFLICT" } as const; + } + return { + ok: true, + value: parseServiceState(existing.result_json), + replayed: true, + } as const; + } + const current = this.#readState(); + let next = current; + if (current.mode !== input.mode || current.reasonCode !== input.reasonCode) { + next = { + mode: input.mode, + epoch: current.epoch + 1, + reasonCode: input.reasonCode, + changedBy: input.actor.identity, + changedAt: now, + }; + this.ctx.storage.sql.exec( + `UPDATE service_state SET + mode = ?, epoch = ?, reason_code = ?, operator_identity = ?, changed_at = ? + WHERE id = 1`, + next.mode, + next.epoch, + next.reasonCode, + next.changedBy, + next.changedAt, + ); + this.#appendAudit( + "service-mode-changed", + "access", + input.actor.identity, + input.actor.role, + input.mode, + input.reasonCode, + now, + ); + } + this.#writeIdempotency(input, "service-mode", next, now); + return { ok: true, value: next, replayed: false } as const; + }); + await this.#scheduleCleanup(now); + return result; + } + + async readPublisherControl(actor: AccessActor, publisherDid: string): Promise { + this.#assertObjectName(); + this.#assertActor(actor, "viewer"); + if (!DID_PATTERN.test(publisherDid)) { + throw new ServiceControlError("CONTROL_INPUT_INVALID"); + } + return this.#readPublisherControl(publisherDid); + } + + async setPublisherControl( + input: SetPublisherControlInput, + ): Promise> { + this.#assertObjectName(); + const now = this.#assertOperatorMutation(input); + if ( + !DID_PATTERN.test(input.publisherDid) || + (input.status !== "allowed" && input.status !== "suspended") || + !validReasonCode(input.reasonCode) || + (input.status === "allowed") !== (input.reasonCode === null) + ) { + throw new ServiceControlError("CONTROL_INPUT_INVALID"); + } + const action = `publisher-control:${input.publisherDid}`; + const result = this.ctx.storage.transactionSync(() => { + const existing = this.#readIdempotency(input.actor.identity, input.idempotencyKey, now); + if (existing) { + if (existing.action !== action || existing.request_digest !== input.requestDigest) { + return { ok: false, code: "IDEMPOTENCY_CONFLICT" } as const; + } + return { + ok: true, + value: parsePublisherControl(existing.result_json), + replayed: true, + } as const; + } + const current = this.#readPublisherControl(input.publisherDid); + let next = current; + if (current.status !== input.status || current.reasonCode !== input.reasonCode) { + next = { + publisherDid: input.publisherDid, + status: input.status, + reasonCode: input.reasonCode, + changedBy: input.actor.identity, + changedAt: now, + }; + this.ctx.storage.sql.exec( + `INSERT INTO publisher_controls ( + publisher_did, status, reason_code, operator_identity, changed_at + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(publisher_did) DO UPDATE SET + status = excluded.status, + reason_code = excluded.reason_code, + operator_identity = excluded.operator_identity, + changed_at = excluded.changed_at`, + next.publisherDid, + next.status, + next.reasonCode, + next.changedBy, + next.changedAt, + ); + this.#appendAudit( + "publisher-control-changed", + "access", + input.actor.identity, + input.actor.role, + input.publisherDid, + input.reasonCode, + now, + ); + } + this.#writeIdempotency(input, action, next, now); + return { ok: true, value: next, replayed: false } as const; + }); + await this.#scheduleCleanup(now); + return result; + } + + async getAdmissionDecision(publisherDid: string): Promise { + this.#assertObjectName(); + if (!DID_PATTERN.test(publisherDid)) { + throw new ServiceControlError("CONTROL_INPUT_INVALID"); + } + const state = this.#readState(); + const control = this.#readPublisherControl(publisherDid); + if (control.status === "suspended") { + return { + allowed: false, + mode: state.mode, + modeEpoch: state.epoch, + code: "PUBLISHER_SUSPENDED", + }; + } + return { + allowed: state.mode !== "admission-paused", + mode: state.mode, + modeEpoch: state.epoch, + code: state.mode === "admission-paused" ? "ADMISSION_PAUSED" : null, + }; + } + + async issuePublicationPermit( + publisherDid: string, + intentId: string, + ttlMs: number, + now = Date.now(), + ): Promise { + this.#assertObjectName(); + if ( + !DID_PATTERN.test(publisherDid) || + !INTENT_ID_PATTERN.test(intentId) || + !Number.isSafeInteger(ttlMs) || + ttlMs < 1 || + ttlMs > MAX_PERMIT_TTL_MS || + !validTimestamp(now) || + now > Number.MAX_SAFE_INTEGER - ttlMs + ) { + throw new ServiceControlError("CONTROL_INPUT_INVALID"); + } + const id = base64url.encode(crypto.getRandomValues(new Uint8Array(16))); + const token = base64url.encode(crypto.getRandomValues(new Uint8Array(32))); + const tokenHash = await hashToken(token); + const result = this.ctx.storage.transactionSync(() => { + const state = this.#readState(); + if (state.mode === "publication-paused") { + return { ok: false, code: "PUBLICATION_PAUSED" } as const; + } + if (this.#readPublisherControl(publisherDid).status === "suspended") { + return { ok: false, code: "PUBLISHER_SUSPENDED" } as const; + } + const expiresAt = now + ttlMs; + this.ctx.storage.sql.exec( + `INSERT INTO publication_permits ( + id, token_hash, publisher_did, intent_id, mode_epoch, + expires_at, consumed_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, NULL, ?)`, + id, + tokenHash, + publisherDid, + intentId, + state.epoch, + expiresAt, + now, + ); + this.#appendAudit( + "publication-permit-issued", + "system", + "release-service", + null, + `${publisherDid}:${intentId}`, + null, + now, + ); + return { + ok: true, + permit: { id, token, publisherDid, intentId, modeEpoch: state.epoch, expiresAt }, + } as const; + }); + if (result.ok) await this.#scheduleCleanup(now); + return result; + } + + async consumePublicationPermit( + input: ConsumePublicationPermitInput, + ): Promise { + this.#assertObjectName(); + const now = input.now ?? Date.now(); + if ( + !PERMIT_ID_PATTERN.test(input.id) || + !PERMIT_TOKEN_PATTERN.test(input.token) || + !DID_PATTERN.test(input.publisherDid) || + !INTENT_ID_PATTERN.test(input.intentId) || + !validTimestamp(now) + ) { + throw new ServiceControlError("CONTROL_INPUT_INVALID"); + } + const tokenHash = await hashToken(input.token); + return this.ctx.storage.transactionSync(() => { + const permit = this.ctx.storage.sql + .exec( + `SELECT token_hash, publisher_did, intent_id, mode_epoch, expires_at, consumed_at + FROM publication_permits WHERE id = ?`, + input.id, + ) + .toArray()[0]; + if (!permit) return { ok: false, code: "PERMIT_NOT_FOUND" } as const; + if ( + permit.publisher_did !== input.publisherDid || + permit.intent_id !== input.intentId || + !hashesEqual(permit.token_hash, tokenHash) + ) { + return { ok: false, code: "PERMIT_INVALID" } as const; + } + if (permit.consumed_at !== null) { + return { ok: false, code: "PERMIT_CONSUMED" } as const; + } + if (permit.expires_at <= now) return { ok: false, code: "PERMIT_EXPIRED" } as const; + const state = this.#readState(); + if (state.mode === "publication-paused") { + return { ok: false, code: "PUBLICATION_PAUSED" } as const; + } + if (this.#readPublisherControl(input.publisherDid).status === "suspended") { + return { ok: false, code: "PUBLISHER_SUSPENDED" } as const; + } + if (permit.mode_epoch !== state.epoch) { + return { ok: false, code: "PERMIT_STALE" } as const; + } + this.ctx.storage.sql.exec( + "UPDATE publication_permits SET consumed_at = ? WHERE id = ? AND consumed_at IS NULL", + now, + input.id, + ); + this.#appendAudit( + "publication-permit-consumed", + "system", + "release-service", + null, + `${input.publisherDid}:${input.intentId}`, + null, + now, + ); + return { ok: true, modeEpoch: state.epoch } as const; + }); + } + + async listAudit( + actor: AccessActor, + afterSequence = 0, + limit = 50, + ): Promise { + this.#assertObjectName(); + this.#assertActor(actor, "viewer"); + if ( + !Number.isSafeInteger(afterSequence) || + afterSequence < 0 || + !Number.isSafeInteger(limit) || + limit < 1 || + limit > 101 + ) { + throw new ServiceControlError("CONTROL_INPUT_INVALID"); + } + return this.ctx.storage.sql + .exec( + `SELECT sequence, event_type, actor_realm, actor_identity, actor_role, + subject, reason_code, created_at + FROM audit_events WHERE sequence > ? ORDER BY sequence LIMIT ?`, + afterSequence, + limit, + ) + .toArray() + .map((row) => ({ + sequence: row.sequence, + eventType: row.event_type, + actorRealm: row.actor_realm, + actorIdentity: row.actor_identity, + actorRole: row.actor_role, + subject: row.subject, + reasonCode: row.reason_code, + createdAt: row.created_at, + })); + } + + override async alarm(): Promise { + const now = Date.now(); + this.ctx.storage.transactionSync(() => { + this.ctx.storage.sql.exec("DELETE FROM operator_idempotency WHERE expires_at <= ?", now); + this.ctx.storage.sql.exec("DELETE FROM publication_permits WHERE expires_at <= ?", now); + }); + await this.#scheduleCleanup(now); + } +} diff --git a/apps/release-service/src/index.ts b/apps/release-service/src/index.ts index 4cdae8d77a..c155ea257f 100644 --- a/apps/release-service/src/index.ts +++ b/apps/release-service/src/index.ts @@ -1,22 +1,104 @@ +import type { JWTVerifyGetKey } from "jose"; + +import { + accessRoleForOperatorPath, + authenticateAccessRequest, + validateAccessMutation, + type AccessActor, + type AccessRole, +} from "./access/auth.js"; import { ApiError } from "./api/errors.js"; import { getRequestId } from "./api/request-id.js"; -import { apiFailure } from "./api/response.js"; -import { ConfigurationError, loadConfiguration, type ConfigurationBindings } from "./config.js"; +import { apiFailure, apiSuccess } from "./api/response.js"; +import { + ConfigurationError, + loadConfiguration, + type ConfigurationBindings, + type ServiceConfiguration, +} from "./config.js"; import { ROUTES, type RouteDefinition } from "./routes.js"; export { PublisherDurableObject } from "./publisher-do/publisher-do.js"; export { ApproverDurableObject } from "./approver-do/approver-do.js"; +export { OAuthStateDurableObject } from "./oauth/state-do.js"; export { ReleaseIntentWorkflow } from "./workflows/release-intent.js"; +export { ServiceControlDurableObject } from "./control-do/service-control-do.js"; + +const DYNAMIC_PATH_PREFIXES = ["/.well-known/", "/admin/api/", "/oauth/", "/v1/"] as const; + +function isDynamicPath(pathname: string): boolean { + return ( + pathname === "/health" || + pathname === "/ready" || + DYNAMIC_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix)) + ); +} + +async function authenticateOperatorUi( + request: Request, + configuration: ServiceConfiguration, + keyResolver?: JWTVerifyGetKey, +): Promise { + let lastError: unknown; + for (const role of ["admin", "reviewer", "viewer"] satisfies readonly AccessRole[]) { + try { + await authenticateAccessRequest(request, role, configuration.access, keyResolver); + return; + } catch (error) { + lastError = error; + } + } + throw lastError; +} + +export async function handleUiRequest( + request: Request, + bindings: Env, + accessKeyResolver?: JWTVerifyGetKey, +): Promise { + if (request.method !== "GET" && request.method !== "HEAD") { + return apiFailure( + new ApiError("METHOD_NOT_ALLOWED", 405, "Method not allowed"), + getRequestId(request), + ); + } + if (new URL(request.url).pathname.startsWith("/admin")) { + try { + await authenticateOperatorUi(request, await loadConfiguration(bindings), accessKeyResolver); + } catch (error) { + return apiFailure(error, getRequestId(request)); + } + } + const response = await bindings.ASSETS.fetch(request); + const secured = new Response(response.body, response); + secured.headers.set( + "content-security-policy", + "default-src 'self'; base-uri 'none'; connect-src 'self'; frame-ancestors 'none'; form-action 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'", + ); + secured.headers.set("referrer-policy", "no-referrer"); + secured.headers.set("x-content-type-options", "nosniff"); + secured.headers.set("x-frame-options", "DENY"); + if (secured.headers.get("content-type")?.startsWith("text/html")) { + secured.headers.set("cache-control", "no-store"); + } + return secured; +} export async function handleRequest( request: Request, bindings: ConfigurationBindings, routes: readonly RouteDefinition[] = ROUTES, + accessKeyResolver?: JWTVerifyGetKey, ): Promise { const requestId = getRequestId(request); try { - const configuration = await loadConfiguration(bindings); const url = new URL(request.url); + if (url.pathname === "/health") { + return request.method === "GET" + ? apiSuccess({ status: "ok" }, requestId) + : apiFailure(new ApiError("METHOD_NOT_ALLOWED", 405, "Method not allowed"), requestId); + } + const configuration = await loadConfiguration(bindings); const matches = routes.flatMap((candidate) => { const params = candidate.match ? candidate.match(url.pathname) @@ -27,7 +109,33 @@ export async function handleRequest( }); const route = matches.find(({ candidate }) => candidate.method === request.method); if (route) { - return await route.candidate.handler(request, requestId, configuration, route.params); + let accessActor: AccessActor | null = null; + const operatorRole = accessRoleForOperatorPath(url.pathname); + if ( + (url.pathname.startsWith("/admin/api/") && route.candidate.accessRole === undefined) || + (operatorRole !== null && operatorRole !== route.candidate.accessRole) || + (!url.pathname.startsWith("/admin/api/") && route.candidate.accessRole !== undefined) + ) { + throw new Error("Operator route has an invalid Access role boundary"); + } + if (route.candidate.accessRole) { + accessActor = await authenticateAccessRequest( + request, + route.candidate.accessRole, + configuration.access, + accessKeyResolver, + ); + if (route.candidate.method !== "GET") { + validateAccessMutation(request, configuration.publicOrigin); + } + } + return await route.candidate.handler( + request, + requestId, + configuration, + route.params, + accessActor, + ); } if (matches.length > 0) { return apiFailure(new ApiError("METHOD_NOT_ALLOWED", 405, "Method not allowed"), requestId); @@ -57,6 +165,8 @@ export async function handleRequest( export default { fetch(request: Request, env: Env): Promise { - return handleRequest(request, env); + return isDynamicPath(new URL(request.url).pathname) + ? handleRequest(request, env) + : handleUiRequest(request, env); }, } satisfies ExportedHandler; diff --git a/apps/release-service/src/intents/routes.ts b/apps/release-service/src/intents/routes.ts new file mode 100644 index 0000000000..d328e6e263 --- /dev/null +++ b/apps/release-service/src/intents/routes.ts @@ -0,0 +1,525 @@ +import { isDid } from "@atcute/lexicons/syntax"; +import { parseDelegatedReleaseSourceRecord } from "@emdash-cms/registry-client/release-service"; +import { env } from "cloudflare:workers"; +import { base64url, type JWTVerifyGetKey } from "jose"; +import { ulid } from "ulidx"; + +import { readJsonObject } from "../api/body.js"; +import { ApiError } from "../api/errors.js"; +import { apiFailure, apiSuccess } from "../api/response.js"; +import { decodeAwaitingApprovalState } from "../approvals/digest.js"; +import { invalidateApprovalChallenges } from "../approvals/invalidation.js"; +import type { ServiceConfiguration } from "../config.js"; +import { SERVICE_CONTROL_OBJECT_NAME } from "../control-do/service-control-do.js"; +import type { IntentState, StoredIntent } from "../publisher-do/publisher-do.js"; +import { + PublisherSessionError, + requirePublisherApplicationSession, +} from "../publisher-session/session.js"; +import { startReleaseIntentWorkflow } from "../workflows/start.js"; +import { verifyGitHubActionsToken } from "../workload/github-oidc.js"; +import { + digestWorkloadIdempotencyIdentity, + digestWorkloadIdentity, + evaluateWorkloadPolicy, +} from "../workload/policy.js"; +import { WorkloadIdentityError, type VerifiedWorkloadIdentity } from "../workload/types.js"; + +const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/; +const PACKAGE_SLUG_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; +const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z.-]{0,127}$/; +const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$/; +const INTENT_RESOURCE_PATH_PATTERN = /^\/v1\/release-intents\/([0-9A-HJKMNP-TV-Z]{26})$/; +const INTENT_CANCEL_PATH_PATTERN = /^\/v1\/release-intents\/([0-9A-HJKMNP-TV-Z]{26})\/cancel$/; +const MAX_AUTHORIZATION_CHARS = 16 * 1024; +const MAX_INTENT_BODY_BYTES = 128 * 1024; +const MAX_RELEASE_INPUT_CHARS = 64 * 1024; +const INTENT_LIFETIME_MS = 24 * 60 * 60_000; +const CANCELLABLE_STATES: ReadonlySet = new Set([ + "received", + "verifying", + "verified", + "awaiting_approval", + "ready", +]); + +interface IntentActor { + realm: "oidc" | "publisher"; + identity: string; + publisherDid: string; +} + +export interface SubmitIntentDependencies { + keyResolver?: JWTVerifyGetKey; + now?: () => number; + intentId?: (now: number) => string; + startWorkflow?: typeof startReleaseIntentWorkflow; +} + +function hasExactKeys(value: Record, expected: readonly string[]): boolean { + const keys = Object.keys(value); + return keys.length === expected.length && keys.every((key) => expected.includes(key)); +} + +async function digest(value: unknown): Promise { + return base64url.encode( + new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(value))), + ), + ); +} + +function requireIdempotencyKey(request: Request): string { + const value = request.headers.get("idempotency-key"); + if (!value || !IDEMPOTENCY_KEY_PATTERN.test(value)) { + throw new ApiError("IDEMPOTENCY_KEY_INVALID", 400, "Valid idempotency key required"); + } + return value; +} + +function requireBearerToken(request: Request): string { + const value = request.headers.get("authorization"); + if ( + !value || + value.length > MAX_AUTHORIZATION_CHARS || + !value.startsWith("Bearer ") || + value.slice(7).length === 0 || + value.slice(7).includes(" ") || + request.headers.has("cookie") + ) { + throw new ApiError("AUTH_INVALID", 401, "Workload authentication failed"); + } + return value.slice(7); +} + +async function authenticateWorkload( + request: Request, + configuration: ServiceConfiguration, + keyResolver?: JWTVerifyGetKey, +): Promise { + try { + return await verifyGitHubActionsToken( + requireBearerToken(request), + configuration.publicOrigin, + keyResolver, + ); + } catch (error) { + if (error instanceof ApiError) throw error; + throw new ApiError("AUTH_INVALID", 401, "Workload authentication failed"); + } +} + +function requirePublisherQuery(request: Request): string { + const publisherDid = new URL(request.url).searchParams.get("publisher"); + if (!publisherDid || !isDid(publisherDid)) { + throw new ApiError("INVALID_REQUEST", 400, "Valid publisher DID required"); + } + return publisherDid; +} + +function mapPublisherSessionError(error: PublisherSessionError): ApiError { + if (error.code === "PUBLISHER_SUSPENDED") { + return new ApiError("PUBLISHER_SUSPENDED", 403, "Publisher is suspended"); + } + if (error.code === "CSRF_INVALID" || error.code === "ORIGIN_INVALID") { + return new ApiError("CSRF_INVALID", 403, "Request origin could not be verified"); + } + return new ApiError("PUBLISHER_SESSION_INVALID", 401, "Publisher session is not valid"); +} + +function routeFailure(error: unknown, requestId: string): Response { + if (error instanceof ApiError) return apiFailure(error, requestId); + if (error instanceof PublisherSessionError) { + return apiFailure(mapPublisherSessionError(error), requestId); + } + if (error instanceof WorkloadIdentityError) { + return apiFailure( + new ApiError("AUTH_INVALID", 401, "Workload authentication failed"), + requestId, + ); + } + throw error; +} + +function parseResult(stateDataJson: string): { uri: string; cid: string } | null { + let parsed: unknown; + try { + parsed = JSON.parse(stateDataJson); + } catch { + return null; + } + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) || + !("resultUri" in parsed) || + typeof parsed.resultUri !== "string" || + !("resultCid" in parsed) || + typeof parsed.resultCid !== "string" + ) { + return null; + } + return { uri: parsed.resultUri, cid: parsed.resultCid }; +} + +export async function serializeIntentResource( + publisherDid: string, + intent: StoredIntent, + publicOrigin: string, +): Promise> { + const transitions = await env.PUBLISHER_DO.getByName(publisherDid).listIntentTransitions( + publisherDid, + intent.id, + ); + const latest = transitions.at(-1); + const result = parseResult(intent.stateDataJson); + return { + id: intent.id, + publisherDid, + packageSlug: intent.packageSlug, + version: intent.version, + state: intent.state, + stateGeneration: intent.stateGeneration, + reasonCode: latest?.reasonCode ?? null, + workflowId: intent.workflowId, + expiresAt: intent.expiresAt, + createdAt: intent.createdAt, + updatedAt: intent.updatedAt, + result, + approvalUrl: + intent.state === "awaiting_approval" + ? `${publicOrigin}/approvals/${intent.id}?publisher=${encodeURIComponent(publisherDid)}` + : null, + }; +} + +async function authorizeIntent( + request: Request, + configuration: ServiceConfiguration, + intent: StoredIntent, + publisherDid: string, + requireCsrf: boolean, + keyResolver?: JWTVerifyGetKey, +): Promise { + if (request.headers.has("authorization")) { + const identity = await authenticateWorkload(request, configuration, keyResolver); + const workloadDigest = await digestWorkloadIdempotencyIdentity( + identity, + publisherDid, + intent.packageSlug, + intent.version, + ); + if (workloadDigest !== intent.workloadIdempotencyDigest) { + throw new ApiError("ACCESS_DENIED", 403, "Release intent access denied"); + } + return { + realm: "oidc", + identity: await digestWorkloadIdentity(identity), + publisherDid, + }; + } + const session = await requirePublisherApplicationSession( + request, + env.PUBLISHER_DO, + configuration.publicOrigin, + { requireCsrf }, + ); + if (session.publisherDid !== publisherDid) { + throw new ApiError("ACCESS_DENIED", 403, "Release intent access denied"); + } + return { realm: "publisher", identity: session.publisherDid, publisherDid }; +} + +export function matchIntentResourcePath(pathname: string): Readonly> | null { + const match = INTENT_RESOURCE_PATH_PATTERN.exec(pathname); + return match?.[1] ? { intentId: match[1] } : null; +} + +export function matchIntentCancelPath(pathname: string): Readonly> | null { + const match = INTENT_CANCEL_PATH_PATTERN.exec(pathname); + return match?.[1] ? { intentId: match[1] } : null; +} + +export async function handleSubmitReleaseIntent( + request: Request, + requestId: string, + configuration: ServiceConfiguration, + dependencies: SubmitIntentDependencies = {}, +): Promise { + try { + const idempotencyKey = requireIdempotencyKey(request); + const identity = await authenticateWorkload(request, configuration, dependencies.keyResolver); + const body = await readJsonObject(request, MAX_INTENT_BODY_BYTES); + if ( + !hasExactKeys(body, ["publisherDid", "packageSlug", "version", "release"]) || + typeof body["publisherDid"] !== "string" || + !isDid(body["publisherDid"]) || + typeof body["packageSlug"] !== "string" || + !PACKAGE_SLUG_PATTERN.test(body["packageSlug"]) || + typeof body["version"] !== "string" || + !VERSION_PATTERN.test(body["version"]) + ) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid release intent request"); + } + const release = parseDelegatedReleaseSourceRecord(body["release"], { + packageSlug: body["packageSlug"], + version: body["version"], + }); + if (!release) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid release intent request"); + } + const publisherDid = body["publisherDid"]; + const publisher = env.PUBLISHER_DO.getByName(publisherDid); + const workloadIdempotencyDigest = await digestWorkloadIdempotencyIdentity( + identity, + publisherDid, + release.package, + release.version, + ); + const releaseInputJson = JSON.stringify({ release }); + if (releaseInputJson.length > MAX_RELEASE_INPUT_CHARS) { + throw new ApiError("INVALID_REQUEST", 413, "Release intent is too large"); + } + const requestDigest = await digest(["release-intent", 1, publisherDid, release]); + const now = dependencies.now?.() ?? Date.now(); + const replay = await publisher.findIdempotentIntent( + publisherDid, + workloadIdempotencyDigest, + idempotencyKey, + now, + ); + if (replay) { + if (replay.requestDigest !== requestDigest) { + throw new ApiError("IDEMPOTENCY_CONFLICT", 409, "Idempotency key conflicts with prior use"); + } + const started = await (dependencies.startWorkflow ?? startReleaseIntentWorkflow)( + env.RELEASE_INTENT_WORKFLOW, + env.PUBLISHER_DO, + publisherDid, + replay.intent.id, + ); + if (!started.ok) { + throw new ApiError("WORKFLOW_UNAVAILABLE", 503, "Release Workflow is unavailable"); + } + const current = (await publisher.getIntent(publisherDid, replay.intent.id)) ?? replay.intent; + return apiSuccess( + { + intent: await serializeIntentResource(publisherDid, current, configuration.publicOrigin), + replayed: true, + }, + requestId, + ); + } + const admission = await env.SERVICE_CONTROL_DO.getByName( + SERVICE_CONTROL_OBJECT_NAME, + ).getAdmissionDecision(publisherDid); + if (!admission.allowed) { + throw new ApiError( + admission.code === "PUBLISHER_SUSPENDED" ? "PUBLISHER_SUSPENDED" : "SERVICE_PAUSED", + 503, + admission.code === "PUBLISHER_SUSPENDED" + ? "Publisher is suspended" + : "Release admission is paused", + ); + } + const policy = await publisher.getWorkloadPolicy(publisherDid, release.package); + if (!policy || !evaluateWorkloadPolicy(identity, policy).ok) { + throw new ApiError("WORKLOAD_NOT_ALLOWED", 403, "Workload is not authorized"); + } + const workloadIdentityDigest = await digestWorkloadIdentity(identity); + const created = await publisher.createIntent({ + publisherDid, + intentId: dependencies.intentId?.(now) ?? ulid(now), + packageSlug: release.package, + version: release.version, + workloadPolicyVersion: policy.stateVersion, + workloadIdentityDigest, + workloadIdempotencyDigest, + idempotencyKey, + requestDigest, + workloadIdentityJson: JSON.stringify(identity), + releaseInputJson, + expiresAt: now + INTENT_LIFETIME_MS, + now, + }); + if (!created.ok) { + if (created.code === "IDEMPOTENCY_CONFLICT") { + throw new ApiError("IDEMPOTENCY_CONFLICT", 409, "Idempotency key conflicts with prior use"); + } + if (created.code === "RESERVATION_CONFLICT") { + throw new ApiError("VERSION_RESERVED", 409, "Package version is already reserved"); + } + if (created.code === "PUBLISHER_SUSPENDED") { + throw new ApiError("PUBLISHER_SUSPENDED", 403, "Publisher is suspended"); + } + throw new ApiError("WORKLOAD_NOT_ALLOWED", 403, "Workload is not authorized"); + } + const started = await (dependencies.startWorkflow ?? startReleaseIntentWorkflow)( + env.RELEASE_INTENT_WORKFLOW, + env.PUBLISHER_DO, + publisherDid, + created.intent.id, + ); + if (!started.ok) { + throw new ApiError("WORKFLOW_UNAVAILABLE", 503, "Release Workflow is unavailable"); + } + const current = (await publisher.getIntent(publisherDid, created.intent.id)) ?? created.intent; + return apiSuccess( + { + intent: await serializeIntentResource(publisherDid, current, configuration.publicOrigin), + replayed: created.replayed, + }, + requestId, + created.replayed ? 200 : 202, + ); + } catch (error) { + return routeFailure(error, requestId); + } +} + +export async function handleGetReleaseIntent( + request: Request, + requestId: string, + configuration: ServiceConfiguration, + params: Readonly>, + keyResolver?: JWTVerifyGetKey, +): Promise { + try { + const intentId = params["intentId"]; + if (!intentId || !ULID_PATTERN.test(intentId)) { + throw new ApiError("NOT_FOUND", 404, "Release intent not found"); + } + const publisherDid = request.headers.has("authorization") + ? requirePublisherQuery(request) + : ( + await requirePublisherApplicationSession( + request, + env.PUBLISHER_DO, + configuration.publicOrigin, + ) + ).publisherDid; + const intent = await env.PUBLISHER_DO.getByName(publisherDid).getIntent(publisherDid, intentId); + if (!intent) throw new ApiError("NOT_FOUND", 404, "Release intent not found"); + await authorizeIntent(request, configuration, intent, publisherDid, false, keyResolver); + return apiSuccess( + { intent: await serializeIntentResource(publisherDid, intent, configuration.publicOrigin) }, + requestId, + ); + } catch (error) { + return routeFailure(error, requestId); + } +} + +export async function handleCancelReleaseIntent( + request: Request, + requestId: string, + configuration: ServiceConfiguration, + params: Readonly>, + keyResolver?: JWTVerifyGetKey, +): Promise { + try { + const intentId = params["intentId"]; + if (!intentId || !ULID_PATTERN.test(intentId)) { + throw new ApiError("NOT_FOUND", 404, "Release intent not found"); + } + const idempotencyKey = requireIdempotencyKey(request); + const body = await readJsonObject(request); + if (!hasExactKeys(body, [])) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid cancellation request"); + } + const publisherDid = request.headers.has("authorization") + ? requirePublisherQuery(request) + : ( + await requirePublisherApplicationSession( + request, + env.PUBLISHER_DO, + configuration.publicOrigin, + { requireCsrf: true }, + ) + ).publisherDid; + const publisher = env.PUBLISHER_DO.getByName(publisherDid); + const intent = await publisher.getIntent(publisherDid, intentId); + if (!intent) throw new ApiError("NOT_FOUND", 404, "Release intent not found"); + const actor = await authorizeIntent( + request, + configuration, + intent, + publisherDid, + true, + keyResolver, + ); + if (intent.state === "cancelled") { + return apiSuccess( + { intent: await serializeIntentResource(publisherDid, intent, configuration.publicOrigin) }, + requestId, + ); + } + if (!CANCELLABLE_STATES.has(intent.state)) { + throw new ApiError("INTENT_NOT_CANCELLABLE", 409, "Release intent cannot be cancelled"); + } + const approverDids = + intent.state === "awaiting_approval" + ? (await decodeAwaitingApprovalState(intent.stateDataJson)).approverDids + : []; + const transition = await publisher.transitionIntent({ + publisherDid, + intentId, + expectedState: intent.state, + expectedGeneration: intent.stateGeneration, + toState: "cancelled", + transitionDigest: await digest([ + "cancel-intent", + 1, + publisherDid, + intentId, + idempotencyKey, + actor.realm, + actor.identity, + ]), + actorRealm: actor.realm, + actorIdentity: actor.identity, + reasonCode: "CANCELLED", + stateDataJson: JSON.stringify({ reasonCode: "CANCELLED" }), + }); + if (!transition.ok) { + throw new ApiError("INTENT_NOT_CANCELLABLE", 409, "Release intent cannot be cancelled"); + } + if (approverDids.length > 0) { + await invalidateApprovalChallenges(env.APPROVER_DO, approverDids, intentId, "CANCELLED"); + } + if (transition.intent.workflowId) { + try { + const workflow = await env.RELEASE_INTENT_WORKFLOW.get(transition.intent.workflowId); + const status = await workflow.status(); + if ( + status.status !== "complete" && + status.status !== "errored" && + status.status !== "terminated" && + status.status !== "unknown" + ) { + await workflow.terminate(); + } + } catch (error) { + console.error( + JSON.stringify({ + event: "cancel_workflow_termination_failed", + intentId, + name: error instanceof Error ? error.name : "UnknownError", + }), + ); + } + } + return apiSuccess( + { + intent: await serializeIntentResource( + publisherDid, + transition.intent, + configuration.publicOrigin, + ), + }, + requestId, + ); + } catch (error) { + return routeFailure(error, requestId); + } +} diff --git a/apps/release-service/src/oauth/custody.ts b/apps/release-service/src/oauth/custody.ts index 1f7f5a2bd1..dea6f4709c 100644 --- a/apps/release-service/src/oauth/custody.ts +++ b/apps/release-service/src/oauth/custody.ts @@ -20,11 +20,9 @@ import { type StoredSession, type StoredState, } from "@atcute/oauth-node-client"; +import { env } from "cloudflare:workers"; -import type { - ApproverDurableObject, - StoredIdentityTransaction, -} from "../approver-do/approver-do.js"; +import type { ApproverDurableObject } from "../approver-do/approver-do.js"; import type { OAuthConfiguration } from "../config.js"; import { EncryptionError, @@ -36,8 +34,8 @@ import type { DelegationRefreshLease, PublisherDurableObject, StoredDelegation, - StoredOAuthState, } from "../publisher-do/publisher-do.js"; +import type { StoredOAuthTransaction } from "./state-do.js"; const DID_PATTERN = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/; const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/; @@ -366,33 +364,28 @@ interface PutDurableOAuthStateInput { } interface DurableOAuthStateBackend { - objectClass: "PublisherDurableObject" | "ApproverDurableObject"; - table: "oauth_states" | "identity_transactions"; + objectClass: "OAuthStateDurableObject"; + table: "oauth_state"; put(input: PutDurableOAuthStateInput): Promise<{ ok: boolean }>; - consume(stateHash: string): Promise; -} - -function publisherOAuthStateBackend( - stub: DurableObjectStub, - publisherDid: Did, -): DurableOAuthStateBackend { - return { - objectClass: "PublisherDurableObject", - table: "oauth_states", - put: (input) => stub.putOAuthState({ publisherDid, ...input }), - consume: (stateHash) => stub.consumeOAuthState(publisherDid, stateHash), - }; + consume(stateHash: string): Promise; } -function approverOAuthStateBackend( - stub: DurableObjectStub, - approverDid: Did, -): DurableOAuthStateBackend { +function oauthStateBackend(options: PublisherOAuthFlowOptions): DurableOAuthStateBackend { return { - objectClass: "ApproverDurableObject", - table: "identity_transactions", - put: (input) => stub.putIdentityTransaction({ approverDid, ...input }), - consume: (stateHash) => stub.consumeIdentityTransaction(approverDid, stateHash), + objectClass: "OAuthStateDurableObject", + table: "oauth_state", + put: (input) => + env.OAUTH_STATE_DO.getByName(input.stateHash).put({ + ...input, + ownerDid: options.expectedDid, + purpose: options.purpose, + }), + consume: (stateHash) => + env.OAUTH_STATE_DO.getByName(stateHash).consume({ + stateHash, + ownerDid: options.expectedDid, + purpose: options.purpose, + }), }; } @@ -510,6 +503,7 @@ class PublisherOAuthSessionStore implements Store { readonly #options: PublisherShardOAuthFlowOptions; readonly #identitySessions: Store = new MemoryStore(); #activeLease: ActiveRefreshLease | null = null; + #sessionVersion: number | null = null; constructor( stub: DurableObjectStub, @@ -536,10 +530,13 @@ class PublisherOAuthSessionStore implements Store { ) : await this.#stub.getDelegation(did); if (!stored || stored.status !== "active" || stored.encryptedSession.length === 0) { + this.#sessionVersion = null; return undefined; } try { - return await this.#decryptSession(stored, did); + const session = await this.#decryptSession(stored, did); + this.#sessionVersion = stored.stateVersion; + return session; } catch (error) { await this.#requireReauthorization(did, stored, error); throw error; @@ -572,6 +569,7 @@ class PublisherOAuthSessionStore implements Store { ...fields, }); if (!result.ok) throw new OAuthCustodyError("OAUTH_DELEGATION_CAS_REQUIRED"); + this.#sessionVersion = result.delegation.stateVersion; return; } const existing = await this.#stub.getDelegation(did); @@ -586,6 +584,7 @@ class PublisherOAuthSessionStore implements Store { expectedVersion: existing?.stateVersion ?? null, }); if (!result.ok) throw new OAuthCustodyError("OAUTH_DELEGATION_CAS_REQUIRED"); + this.#sessionVersion = result.delegation.stateVersion; } async delete(did: Did): Promise { @@ -596,9 +595,15 @@ class PublisherOAuthSessionStore implements Store { } for (let attempt = 0; attempt < 2; attempt += 1) { const existing = await this.#stub.getDelegation(did); - if (!existing || existing.status === "revoked") return; + if (!existing || existing.status === "revoked") { + this.#sessionVersion = null; + return; + } const result = await this.#stub.revokeDelegation(did, existing.stateVersion); - if (result.ok) return; + if (result.ok) { + this.#sessionVersion = null; + return; + } } throw new OAuthCustodyError("OAUTH_DELEGATION_CAS_REQUIRED"); } @@ -645,6 +650,14 @@ class PublisherOAuthSessionStore implements Store { } } + sessionVersion(did: string): number { + this.#assertDid(did); + if (this.#options.purpose !== "release_delegation" || this.#sessionVersion === null) { + throw new OAuthCustodyError("OAUTH_DELEGATION_UNAVAILABLE"); + } + return this.#sessionVersion; + } + #assertDid(did: string): asserts did is Did { if (did !== this.#options.expectedDid || !isDid(did)) { throw new OAuthCustodyError("OAUTH_IDENTITY_MISMATCH"); @@ -716,6 +729,7 @@ class PublisherOAuthSessionStore implements Store { } async #requireReauthorization(did: Did, stored: StoredDelegation, error: unknown): Promise { + this.#sessionVersion = null; let reason: DelegationReauthorizationReason = "OAUTH_SESSION_INVALID"; if (error instanceof OAuthCustodyError && error.code === "OAUTH_CLIENT_KEY_UNAVAILABLE") { reason = "OAUTH_CLIENT_KEY_UNAVAILABLE"; @@ -756,6 +770,7 @@ function assertSeparateDpopKey(oauth: OAuthConfiguration, dpopKey: StoredSession export interface PublisherOAuthStores { stores: OAuthClientStores; requestLock?: (name: string, callback: () => Promise) => Promise; + sessionVersion?: (did: string) => number; userState: PublisherOAuthUserState; } @@ -777,7 +792,7 @@ export function createPublisherOAuthStores( }; const stub = namespace.getByName(options.expectedDid); const states = new DurableOAuthStateStore( - publisherOAuthStateBackend(stub, options.expectedDid), + oauthStateBackend(normalizedOptions), encryption, oauth, normalizedOptions, @@ -786,7 +801,10 @@ export function createPublisherOAuthStores( return { stores: { states, sessions }, ...(options.purpose === "release_delegation" - ? { requestLock: sessions.requestLock.bind(sessions) } + ? { + requestLock: sessions.requestLock.bind(sessions), + sessionVersion: sessions.sessionVersion.bind(sessions), + } : {}), userState: expectedUserState(normalizedOptions, oauth.clientMetadata.client_uri), }; @@ -834,6 +852,7 @@ export class PublisherOAuthClient { readonly #client: OAuthClient; readonly #oauth: OAuthConfiguration; readonly #flow: PublisherOAuthFlowOptions; + readonly #sessionVersion: ((did: string) => number) | undefined; readonly userState: PublisherOAuthUserState; constructor( @@ -841,10 +860,12 @@ export class PublisherOAuthClient { oauth: OAuthConfiguration, flow: PublisherOAuthFlowOptions, userState: PublisherOAuthUserState, + sessionVersion?: (did: string) => number, ) { this.#client = client; this.#oauth = oauth; this.#flow = flow; + this.#sessionVersion = sessionVersion; this.userState = userState; } @@ -894,6 +915,20 @@ export class PublisherOAuthClient { return this.#client.restore(this.#flow.expectedDid, options); } + async restoreForPublication(options?: RestoreOptions): Promise<{ + session: OAuthSession; + delegationVersion: number; + }> { + if (this.#flow.purpose !== "release_delegation" || !this.#sessionVersion) { + throw new OAuthCustodyError("OAUTH_DELEGATION_UNAVAILABLE"); + } + const session = await this.#client.restore(this.#flow.expectedDid, options); + return { + session, + delegationVersion: this.#sessionVersion(this.#flow.expectedDid), + }; + } + revoke(): Promise { return this.#client.revoke(this.#flow.expectedDid); } @@ -917,7 +952,13 @@ export function createPublisherOAuthClient( ...(custody.requestLock ? { requestLock: custody.requestLock } : {}), fetch: fetchThis, }); - return new PublisherOAuthClient(client, options.oauth, options.flow, custody.userState); + return new PublisherOAuthClient( + client, + options.oauth, + options.flow, + custody.userState, + custody.sessionVersion, + ); } export function createApproverOAuthClient( @@ -933,9 +974,8 @@ export function createApproverOAuthClient( options.oauth.clientMetadata.client_uri, ), }; - const stub = options.namespace.getByName(flow.expectedDid); const states = new DurableOAuthStateStore( - approverOAuthStateBackend(stub, flow.expectedDid), + oauthStateBackend(flow), options.encryption, options.oauth, flow, diff --git a/apps/release-service/src/oauth/routes.ts b/apps/release-service/src/oauth/routes.ts index fe38eefcf4..9f0b93e81f 100644 --- a/apps/release-service/src/oauth/routes.ts +++ b/apps/release-service/src/oauth/routes.ts @@ -1,8 +1,9 @@ import { isDid, isHandle } from "@atcute/lexicons/syntax"; import { env } from "cloudflare:workers"; +import { isRecord, readJsonObject } from "../api/body.js"; import { ApiError } from "../api/errors.js"; -import { apiFailure } from "../api/response.js"; +import { apiFailure, apiSuccess } from "../api/response.js"; import { createApproverApplicationSession } from "../approver-session/session.js"; import type { ServiceConfiguration } from "../config.js"; import { @@ -20,15 +21,10 @@ import { canonicalizeRedirectTarget, } from "./custody.js"; -const MAX_JSON_BODY_BYTES = 4096; const OAUTH_NETWORK_TIMEOUT_MS = 30_000; const ERROR_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9]{0,63}$/; const ERROR_CODE_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/; -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - export async function handleApproverIdentityAuthorize( request: Request, requestId: string, @@ -36,7 +32,7 @@ export async function handleApproverIdentityAuthorize( ): Promise { try { requireSameOriginRequest(request, configuration.publicOrigin); - const body = await readJsonBody(request); + const body = await readJsonObject(request); if ( Object.keys(body).length !== 2 || typeof body["identifier"] !== "string" || @@ -69,6 +65,7 @@ export async function handleApproverIdentityAuthorize( { signal: AbortSignal.timeout(30_000) }, ); return redirectToAuthorization( + request, authorization.url, createOAuthRouteCookie({ purpose: "approver_identity", @@ -129,51 +126,6 @@ const callbackFetch: typeof fetch = (input, init) => signal: init?.signal ?? AbortSignal.timeout(OAUTH_NETWORK_TIMEOUT_MS), }); -async function readJsonBody(request: Request): Promise> { - const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); - if (mediaType !== "application/json") { - throw new ApiError("INVALID_REQUEST", 415, "Expected an application/json request body"); - } - const declaredLength = Number(request.headers.get("content-length")); - if (Number.isFinite(declaredLength) && declaredLength > MAX_JSON_BODY_BYTES) { - throw new ApiError("INVALID_REQUEST", 413, "Request body is too large"); - } - if (!request.body) throw new ApiError("INVALID_REQUEST", 400, "Request body is required"); - const reader = request.body.getReader(); - const chunks: Uint8Array[] = []; - let length = 0; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - length += value.byteLength; - if (length > MAX_JSON_BODY_BYTES) { - await reader.cancel(); - throw new ApiError("INVALID_REQUEST", 413, "Request body is too large"); - } - chunks.push(value); - } - } finally { - reader.releaseLock(); - } - const bytes = new Uint8Array(length); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.byteLength; - } - let parsed: unknown; - try { - parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes)); - } catch { - throw new ApiError("INVALID_REQUEST", 400, "Request body is not valid JSON"); - } - if (!isRecord(parsed)) { - throw new ApiError("INVALID_REQUEST", 400, "Request body must be an object"); - } - return parsed; -} - function requireSameOriginRequest(request: Request, publicOrigin: string): void { if ( request.headers.get("origin") !== publicOrigin || @@ -183,7 +135,23 @@ function requireSameOriginRequest(request: Request, publicOrigin: string): void } } -function redirectToAuthorization(url: URL, stateCookie: string, requestId: string): Response { +function redirectToAuthorization( + request: Request, + url: URL, + stateCookie: string, + requestId: string, +): Response { + if ( + request.headers + .get("accept") + ?.split(",") + .some((value) => value.trim() === "application/json") + ) { + const response = apiSuccess({ authorizationUrl: url.toString() }, requestId); + const headers = new Headers(response.headers); + headers.append("set-cookie", stateCookie); + return new Response(response.body, { status: response.status, headers }); + } const headers = new Headers({ "cache-control": "no-store", location: url.toString(), @@ -201,7 +169,7 @@ export async function handlePublisherIdentityAuthorize( ): Promise { try { requireSameOriginRequest(request, configuration.publicOrigin); - const body = await readJsonBody(request); + const body = await readJsonObject(request); if ( Object.keys(body).length !== 2 || typeof body["identifier"] !== "string" || @@ -234,6 +202,7 @@ export async function handlePublisherIdentityAuthorize( { signal: AbortSignal.timeout(30_000) }, ); return redirectToAuthorization( + request, authorization.url, createOAuthRouteCookie({ purpose: "publisher_identity", @@ -245,7 +214,6 @@ export async function handlePublisherIdentityAuthorize( ); } catch (error) { if (error instanceof ApiError) return apiFailure(error, requestId); - logOAuthError("oauth_authorization_error", requestId, error); return oauthError( "OAUTH_AUTHORIZATION_FAILED", 400, @@ -267,7 +235,7 @@ export async function handlePublisherDelegationAuthorize( configuration.publicOrigin, { requireCsrf: true }, ); - const body = await readJsonBody(request); + const body = await readJsonObject(request); if (Object.keys(body).length !== 1 || typeof body["redirectTarget"] !== "string") { throw new ApiError("INVALID_REQUEST", 400, "Invalid delegation authorization request"); } @@ -293,6 +261,7 @@ export async function handlePublisherDelegationAuthorize( { signal: AbortSignal.timeout(30_000) }, ); return redirectToAuthorization( + request, authorization.url, createOAuthRouteCookie({ purpose: "release_delegation", @@ -315,7 +284,6 @@ export async function handlePublisherDelegationAuthorize( requestId, ); } - logOAuthError("oauth_delegation_authorization_error", requestId, error); return oauthError( "OAUTH_AUTHORIZATION_FAILED", 400, diff --git a/apps/release-service/src/oauth/state-do.ts b/apps/release-service/src/oauth/state-do.ts new file mode 100644 index 0000000000..58179fae20 --- /dev/null +++ b/apps/release-service/src/oauth/state-do.ts @@ -0,0 +1,218 @@ +import { DurableObject } from "cloudflare:workers"; + +const HASH_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const DID_PATTERN = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/; +const MAX_CIPHERTEXT_CHARS = 256 * 1024; +const MAX_STATE_LIFETIME_MS = 11 * 60_000; + +export type OAuthTransactionPurpose = + | "publisher_identity" + | "approver_identity" + | "release_delegation"; + +export interface PutOAuthTransactionInput { + stateHash: string; + ownerDid: string; + purpose: OAuthTransactionPurpose; + encryptedState: string; + encryptionKeyVersion: number; + clientKeyId: string; + redirectTarget: string; + expiresAt: number; + now?: number; +} + +export interface StoredOAuthTransaction { + encryptedState: string; + encryptionKeyVersion: number; + clientKeyId: string; + redirectTarget: string; + expiresAt: number; +} + +export type PutOAuthTransactionResult = + | { ok: true } + | { ok: false; code: "OAUTH_TRANSACTION_EXISTS" }; + +export interface ConsumeOAuthTransactionInput { + stateHash: string; + ownerDid: string; + purpose: OAuthTransactionPurpose; + now?: number; +} + +interface OAuthTransactionRow { + [key: string]: string | number | ArrayBuffer | null; + owner_did: string; + purpose: OAuthTransactionPurpose; + encrypted_state: string; + encryption_key_version: number; + client_key_id: string; + redirect_target: string; + expires_at: number; +} + +export class OAuthStateError extends Error { + readonly code = "OAUTH_TRANSACTION_INVALID"; + + constructor() { + super("OAUTH_TRANSACTION_INVALID"); + this.name = "OAuthStateError"; + } +} + +function validPurpose(value: unknown): value is OAuthTransactionPurpose { + return ( + value === "publisher_identity" || + value === "approver_identity" || + value === "release_delegation" + ); +} + +function validRedirectTarget(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > 4096 || + !value.startsWith("/") || + value.startsWith("//") || + value.includes("\\") + ) { + return false; + } + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return false; + } + return true; +} + +function validPutInput(input: PutOAuthTransactionInput, now: number): boolean { + return ( + HASH_PATTERN.test(input.stateHash) && + DID_PATTERN.test(input.ownerDid) && + validPurpose(input.purpose) && + input.encryptedState.length > 0 && + input.encryptedState.length <= MAX_CIPHERTEXT_CHARS && + Number.isSafeInteger(input.encryptionKeyVersion) && + input.encryptionKeyVersion >= 1 && + input.clientKeyId.length > 0 && + input.clientKeyId.length <= 128 && + validRedirectTarget(input.redirectTarget) && + Number.isSafeInteger(now) && + now >= 0 && + Number.isSafeInteger(input.expiresAt) && + input.expiresAt > now && + input.expiresAt - now <= MAX_STATE_LIFETIME_MS + ); +} + +export class OAuthStateDurableObject extends DurableObject { + readonly #objectName: string | undefined; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.#objectName = ctx.id.name; + void ctx.blockConcurrencyWhile(async () => { + ctx.storage.sql.exec(` + CREATE TABLE IF NOT EXISTS oauth_state ( + state_hash TEXT PRIMARY KEY, + owner_did TEXT NOT NULL, + purpose TEXT NOT NULL CHECK ( + purpose IN ('publisher_identity', 'approver_identity', 'release_delegation') + ), + encrypted_state TEXT NOT NULL, + encryption_key_version INTEGER NOT NULL CHECK (encryption_key_version >= 1), + client_key_id TEXT NOT NULL, + redirect_target TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); + `); + }); + } + + async put(input: PutOAuthTransactionInput): Promise { + this.#assertObjectName(input.stateHash); + const now = input.now ?? Date.now(); + if (!validPutInput(input, now)) throw new OAuthStateError(); + const result = this.ctx.storage.transactionSync(() => { + const existing = this.ctx.storage.sql + .exec<{ state_hash: string }>( + "SELECT state_hash FROM oauth_state WHERE state_hash = ?", + input.stateHash, + ) + .toArray()[0]; + if (existing) return { ok: false, code: "OAUTH_TRANSACTION_EXISTS" } as const; + this.ctx.storage.sql.exec( + `INSERT INTO oauth_state ( + state_hash, owner_did, purpose, encrypted_state, encryption_key_version, + client_key_id, redirect_target, expires_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + input.stateHash, + input.ownerDid, + input.purpose, + input.encryptedState, + input.encryptionKeyVersion, + input.clientKeyId, + input.redirectTarget, + input.expiresAt, + now, + ); + return { ok: true } as const; + }); + if (result.ok) await this.ctx.storage.setAlarm(input.expiresAt); + return result; + } + + async consume(input: ConsumeOAuthTransactionInput): Promise { + this.#assertObjectName(input.stateHash); + const now = input.now ?? Date.now(); + if ( + !HASH_PATTERN.test(input.stateHash) || + !DID_PATTERN.test(input.ownerDid) || + !validPurpose(input.purpose) || + !Number.isSafeInteger(now) || + now < 0 + ) { + throw new OAuthStateError(); + } + const result = this.ctx.storage.transactionSync(() => { + const row = this.ctx.storage.sql + .exec( + `SELECT owner_did, purpose, encrypted_state, encryption_key_version, + client_key_id, redirect_target, expires_at + FROM oauth_state WHERE state_hash = ?`, + input.stateHash, + ) + .toArray()[0]; + if (!row || row.owner_did !== input.ownerDid || row.purpose !== input.purpose) { + return { consumed: false, value: null } as const; + } + this.ctx.storage.sql.exec("DELETE FROM oauth_state WHERE state_hash = ?", input.stateHash); + if (row.expires_at <= now) return { consumed: true, value: null } as const; + return { + consumed: true, + value: { + encryptedState: row.encrypted_state, + encryptionKeyVersion: row.encryption_key_version, + clientKeyId: row.client_key_id, + redirectTarget: row.redirect_target, + expiresAt: row.expires_at, + }, + } as const; + }); + if (result.consumed) await this.ctx.storage.deleteAlarm(); + return result.value; + } + + override async alarm(): Promise { + this.ctx.storage.sql.exec("DELETE FROM oauth_state WHERE expires_at <= ?", Date.now()); + } + + #assertObjectName(stateHash: string): void { + if (!HASH_PATTERN.test(stateHash) || this.#objectName !== stateHash) { + throw new OAuthStateError(); + } + } +} diff --git a/apps/release-service/src/operator/routes.ts b/apps/release-service/src/operator/routes.ts new file mode 100644 index 0000000000..3f7b46263f --- /dev/null +++ b/apps/release-service/src/operator/routes.ts @@ -0,0 +1,399 @@ +import { isDid } from "@atcute/lexicons/syntax"; +import { env } from "cloudflare:workers"; +import { base64url } from "jose"; + +import type { AccessActor } from "../access/auth.js"; +import { readJsonObject } from "../api/body.js"; +import { ApiError } from "../api/errors.js"; +import { apiFailure, apiSuccess } from "../api/response.js"; +import { decodeAwaitingApprovalState } from "../approvals/digest.js"; +import { invalidateApprovalChallenges } from "../approvals/invalidation.js"; +import type { ServiceConfiguration } from "../config.js"; +import { SERVICE_CONTROL_OBJECT_NAME } from "../control-do/service-control-do.js"; +import { serializeIntentResource } from "../intents/routes.js"; +import type { IntentState } from "../publisher-do/publisher-do.js"; +import { sanitizedDelegation } from "../publisher/routes.js"; +import { restartReleaseIntentWorkflow } from "../workflows/start.js"; + +const PUBLISHER_PATH_PATTERN = /^\/admin\/api\/publishers\/([^/]+)$/; +const PUBLISHER_SUSPEND_PATH_PATTERN = /^\/admin\/api\/publishers\/([^/]+)\/suspend$/; +const PUBLISHER_REVOKE_PATH_PATTERN = /^\/admin\/api\/publishers\/([^/]+)\/revoke$/; +const INTENT_CANCEL_PATH_PATTERN = /^\/admin\/api\/intents\/([0-9A-HJKMNP-TV-Z]{26})\/cancel$/; +const INTENT_RECONCILE_PATH_PATTERN = + /^\/admin\/api\/intents\/([0-9A-HJKMNP-TV-Z]{26})\/reconcile$/; +const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$/; +const REASON_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/; +const CANCELLABLE_STATES: ReadonlySet = new Set([ + "received", + "verifying", + "verified", + "awaiting_approval", + "ready", +]); + +export interface OperatorRouteDependencies { + restartWorkflow?: typeof restartReleaseIntentWorkflow; +} + +function hasExactKeys(value: Record, expected: readonly string[]): boolean { + const keys = Object.keys(value); + return keys.length === expected.length && keys.every((key) => expected.includes(key)); +} + +function requireActor(actor: AccessActor | null): AccessActor { + if (!actor) throw new ApiError("ACCESS_AUTH_REQUIRED", 401, "Access authentication required"); + return actor; +} + +function requireIdempotencyKey(request: Request): string { + const value = request.headers.get("idempotency-key"); + if (!value || !IDEMPOTENCY_KEY_PATTERN.test(value)) { + throw new ApiError("IDEMPOTENCY_KEY_INVALID", 400, "Valid idempotency key required"); + } + return value; +} + +async function digest(value: unknown): Promise { + return base64url.encode( + new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(value))), + ), + ); +} + +function routeFailure(error: unknown, requestId: string): Response { + if (error instanceof ApiError) return apiFailure(error, requestId); + throw error; +} + +function matchPublisher( + pathname: string, + pattern: RegExp, +): Readonly> | null { + const match = pattern.exec(pathname); + if (!match?.[1]) return null; + let publisherDid: string; + try { + publisherDid = decodeURIComponent(match[1]); + } catch { + return null; + } + return isDid(publisherDid) ? { publisherDid } : null; +} + +export function matchOperatorPublisherPath( + pathname: string, +): Readonly> | null { + return matchPublisher(pathname, PUBLISHER_PATH_PATTERN); +} + +export function matchOperatorPublisherSuspendPath( + pathname: string, +): Readonly> | null { + return matchPublisher(pathname, PUBLISHER_SUSPEND_PATH_PATTERN); +} + +export function matchOperatorPublisherRevokePath( + pathname: string, +): Readonly> | null { + return matchPublisher(pathname, PUBLISHER_REVOKE_PATH_PATTERN); +} + +export function matchOperatorIntentCancelPath( + pathname: string, +): Readonly> | null { + const match = INTENT_CANCEL_PATH_PATTERN.exec(pathname); + return match?.[1] ? { intentId: match[1] } : null; +} + +export function matchOperatorIntentReconcilePath( + pathname: string, +): Readonly> | null { + const match = INTENT_RECONCILE_PATH_PATTERN.exec(pathname); + return match?.[1] ? { intentId: match[1] } : null; +} + +export async function handleGetOperatorPublisher( + _request: Request, + requestId: string, + _configuration: ServiceConfiguration, + params: Readonly>, + accessActor: AccessActor | null, +): Promise { + try { + const actor = requireActor(accessActor); + const publisherDid = params["publisherDid"]; + if (!publisherDid || !isDid(publisherDid)) { + throw new ApiError("NOT_FOUND", 404, "Publisher not found"); + } + const [delegation, control] = await Promise.all([ + env.PUBLISHER_DO.getByName(publisherDid).getDelegation(publisherDid), + env.SERVICE_CONTROL_DO.getByName(SERVICE_CONTROL_OBJECT_NAME).readPublisherControl( + actor, + publisherDid, + ), + ]); + return apiSuccess( + { publisher: { did: publisherDid, control, delegation: sanitizedDelegation(delegation) } }, + requestId, + ); + } catch (error) { + return routeFailure(error, requestId); + } +} + +export async function handleSetOperatorPublisherSuspension( + request: Request, + requestId: string, + _configuration: ServiceConfiguration, + params: Readonly>, + accessActor: AccessActor | null, +): Promise { + try { + const actor = requireActor(accessActor); + const idempotencyKey = requireIdempotencyKey(request); + const publisherDid = params["publisherDid"]; + if (!publisherDid || !isDid(publisherDid)) { + throw new ApiError("NOT_FOUND", 404, "Publisher not found"); + } + const body = await readJsonObject(request); + const suspended = body["suspended"]; + if (!hasExactKeys(body, ["suspended", "reasonCode"]) || typeof suspended !== "boolean") { + throw new ApiError("INVALID_REQUEST", 400, "Invalid publisher suspension request"); + } + const rawReasonCode = body["reasonCode"]; + let reasonCode: string | null; + if (suspended) { + if (typeof rawReasonCode !== "string" || !REASON_CODE_PATTERN.test(rawReasonCode)) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid publisher suspension request"); + } + reasonCode = rawReasonCode; + } else { + if (rawReasonCode !== null) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid publisher suspension request"); + } + reasonCode = null; + } + const control = env.SERVICE_CONTROL_DO.getByName(SERVICE_CONTROL_OBJECT_NAME); + const publisher = env.PUBLISHER_DO.getByName(publisherDid); + const requestDigest = await digest([ + "publisher-suspension", + publisherDid, + suspended, + reasonCode, + ]); + if (suspended) { + const result = await control.setPublisherControl({ + actor, + idempotencyKey, + requestDigest, + publisherDid, + status: "suspended", + reasonCode, + }); + if (!result.ok) { + throw new ApiError("IDEMPOTENCY_CONFLICT", 409, "Idempotency key conflicts with prior use"); + } + await publisher.setPublisherSuspended(publisherDid, true, actor.identity); + } else { + await publisher.setPublisherSuspended(publisherDid, false, actor.identity); + const result = await control.setPublisherControl({ + actor, + idempotencyKey, + requestDigest, + publisherDid, + status: "allowed", + reasonCode: null, + }); + if (!result.ok) { + throw new ApiError("IDEMPOTENCY_CONFLICT", 409, "Idempotency key conflicts with prior use"); + } + } + const current = await control.readPublisherControl(actor, publisherDid); + return apiSuccess({ publisher: { did: publisherDid, control: current } }, requestId); + } catch (error) { + return routeFailure(error, requestId); + } +} + +export async function handleRevokeOperatorPublisher( + request: Request, + requestId: string, + _configuration: ServiceConfiguration, + params: Readonly>, + accessActor: AccessActor | null, +): Promise { + try { + const actor = requireActor(accessActor); + requireIdempotencyKey(request); + const publisherDid = params["publisherDid"]; + if (!publisherDid || !isDid(publisherDid)) { + throw new ApiError("NOT_FOUND", 404, "Publisher not found"); + } + const body = await readJsonObject(request); + if (!hasExactKeys(body, [])) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid publisher revocation request"); + } + const publisher = env.PUBLISHER_DO.getByName(publisherDid); + const current = await publisher.getDelegation(publisherDid); + if (current && current.status !== "revoked") { + const revoked = await publisher.revokeDelegation( + publisherDid, + current.stateVersion, + actor.identity, + ); + if (!revoked.ok) { + throw new ApiError("IDEMPOTENCY_CONFLICT", 409, "Publisher authority changed"); + } + } + await publisher.revokeAllPublisherSessions(publisherDid, actor.identity); + const delegation = await publisher.getDelegation(publisherDid); + return apiSuccess( + { + publisher: { + did: publisherDid, + delegation: sanitizedDelegation(delegation), + revokedBy: actor.identity, + }, + }, + requestId, + ); + } catch (error) { + return routeFailure(error, requestId); + } +} + +export async function handleCancelOperatorIntent( + request: Request, + requestId: string, + configuration: ServiceConfiguration, + params: Readonly>, + accessActor: AccessActor | null, +): Promise { + try { + const actor = requireActor(accessActor); + const idempotencyKey = requireIdempotencyKey(request); + const intentId = params["intentId"]; + if (!intentId) throw new ApiError("NOT_FOUND", 404, "Release intent not found"); + const body = await readJsonObject(request); + if ( + !hasExactKeys(body, ["publisherDid"]) || + typeof body["publisherDid"] !== "string" || + !isDid(body["publisherDid"]) + ) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid intent cancellation request"); + } + const publisherDid = body["publisherDid"]; + const publisher = env.PUBLISHER_DO.getByName(publisherDid); + const intent = await publisher.getIntent(publisherDid, intentId); + if (!intent) throw new ApiError("NOT_FOUND", 404, "Release intent not found"); + if (intent.state === "cancelled") { + return apiSuccess( + { intent: await serializeIntentResource(publisherDid, intent, configuration.publicOrigin) }, + requestId, + ); + } + if (!CANCELLABLE_STATES.has(intent.state)) { + throw new ApiError("INTENT_NOT_CANCELLABLE", 409, "Release intent cannot be cancelled"); + } + const approverDids = + intent.state === "awaiting_approval" + ? (await decodeAwaitingApprovalState(intent.stateDataJson)).approverDids + : []; + const transitioned = await publisher.transitionIntent({ + publisherDid, + intentId, + expectedState: intent.state, + expectedGeneration: intent.stateGeneration, + toState: "cancelled", + transitionDigest: await digest([ + "operator-cancel", + publisherDid, + intentId, + idempotencyKey, + actor.identity, + ]), + actorRealm: "access", + actorIdentity: actor.identity, + reasonCode: "OPERATOR_CANCELLED", + stateDataJson: JSON.stringify({ reasonCode: "OPERATOR_CANCELLED" }), + }); + if (!transitioned.ok) { + throw new ApiError("INTENT_NOT_CANCELLABLE", 409, "Release intent cannot be cancelled"); + } + if (approverDids.length > 0) { + await invalidateApprovalChallenges(env.APPROVER_DO, approverDids, intentId, "CANCELLED"); + } + if (transitioned.intent.workflowId) { + try { + await (await env.RELEASE_INTENT_WORKFLOW.get(transitioned.intent.workflowId)).terminate(); + } catch { + // The Durable Object transition is authoritative even if the Workflow already ended. + } + } + return apiSuccess( + { + intent: await serializeIntentResource( + publisherDid, + transitioned.intent, + configuration.publicOrigin, + ), + }, + requestId, + ); + } catch (error) { + return routeFailure(error, requestId); + } +} + +export async function handleReconcileOperatorIntent( + request: Request, + requestId: string, + configuration: ServiceConfiguration, + params: Readonly>, + accessActor: AccessActor | null, + dependencies: OperatorRouteDependencies = {}, +): Promise { + try { + requireActor(accessActor); + requireIdempotencyKey(request); + const intentId = params["intentId"]; + if (!intentId) throw new ApiError("NOT_FOUND", 404, "Release intent not found"); + const body = await readJsonObject(request); + if ( + !hasExactKeys(body, ["publisherDid"]) || + typeof body["publisherDid"] !== "string" || + !isDid(body["publisherDid"]) + ) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid reconciliation request"); + } + const publisherDid = body["publisherDid"]; + const result = await (dependencies.restartWorkflow ?? restartReleaseIntentWorkflow)( + env.RELEASE_INTENT_WORKFLOW, + env.PUBLISHER_DO, + publisherDid, + intentId, + ); + if (!result.ok) { + throw new ApiError( + result.code === "INTENT_NOT_FOUND" ? "NOT_FOUND" : "WORKFLOW_UNAVAILABLE", + result.code === "INTENT_NOT_FOUND" ? 404 : 409, + result.code === "INTENT_NOT_FOUND" + ? "Release intent not found" + : "Release intent cannot be reconciled", + ); + } + const intent = await env.PUBLISHER_DO.getByName(publisherDid).getIntent(publisherDid, intentId); + if (!intent) throw new ApiError("NOT_FOUND", 404, "Release intent not found"); + return apiSuccess( + { + intent: await serializeIntentResource(publisherDid, intent, configuration.publicOrigin), + restarted: result.restarted, + }, + requestId, + result.restarted ? 202 : 200, + ); + } catch (error) { + return routeFailure(error, requestId); + } +} diff --git a/apps/release-service/src/publisher-do/intent-state.ts b/apps/release-service/src/publisher-do/intent-state.ts index e39f0de0f7..0c0c5e5a49 100644 --- a/apps/release-service/src/publisher-do/intent-state.ts +++ b/apps/release-service/src/publisher-do/intent-state.ts @@ -35,9 +35,9 @@ const ALLOWED_TRANSITIONS: Readonly verifying: new Set(["verified", "invalid", "failed", "cancelled", "expired"]), verified: new Set(["ready", "awaiting_approval", "invalid", "failed", "cancelled", "expired"]), awaiting_approval: new Set(["ready", "rejected", "invalid", "cancelled", "expired"]), - ready: new Set(["publishing", "invalid", "cancelled", "expired"]), + ready: new Set(["publishing", "invalid", "cancelled", "expired", "conflict"]), publishing: new Set(["ready", "published", "reconciling", "failed", "conflict"]), - reconciling: new Set(["published", "failed", "conflict"]), + reconciling: new Set(["ready", "published", "failed", "conflict"]), published: new Set(), invalid: new Set(), rejected: new Set(), @@ -63,6 +63,7 @@ export interface StoredIntent { stateGeneration: number; workloadPolicyVersion: number; workloadIdentityDigest: string; + workloadIdempotencyDigest: string; requestDigest: string; workloadIdentityJson: string; releaseInputJson: string; @@ -80,6 +81,7 @@ export interface CreateIntentInput { version: string; workloadPolicyVersion: number; workloadIdentityDigest: string; + workloadIdempotencyDigest: string; idempotencyKey: string; requestDigest: string; workloadIdentityJson: string; @@ -95,6 +97,11 @@ export type CreateIntentResult = | { ok: false; code: "WORKLOAD_POLICY_UNAVAILABLE" } | { ok: false; code: "PUBLISHER_SUSPENDED" }; +export interface IntentIdempotencyMatch { + intent: StoredIntent; + requestDigest: string; +} + export interface TransitionIntentInput { publisherDid: string; intentId: string; @@ -138,6 +145,7 @@ interface IntentRow { state_generation: number; workload_policy_version: number; workload_identity_digest: string; + workload_idempotency_digest: string; request_digest: string; workload_identity_json: string; release_input_json: string; @@ -210,6 +218,7 @@ function rowToIntent(row: IntentRow): StoredIntent { stateGeneration: row.state_generation, workloadPolicyVersion: row.workload_policy_version, workloadIdentityDigest: row.workload_identity_digest, + workloadIdempotencyDigest: row.workload_idempotency_digest, requestDigest: row.request_digest, workloadIdentityJson: row.workload_identity_json, releaseInputJson: row.release_input_json, @@ -231,6 +240,7 @@ export function initializeIntentStateSchema(storage: DurableObjectStorage): void state_generation INTEGER NOT NULL CHECK (state_generation >= 1), workload_policy_version INTEGER NOT NULL CHECK (workload_policy_version >= 1), workload_identity_digest TEXT NOT NULL, + workload_idempotency_digest TEXT NOT NULL, request_digest TEXT NOT NULL, workload_identity_json TEXT NOT NULL, release_input_json TEXT NOT NULL, @@ -265,12 +275,12 @@ export function initializeIntentStateSchema(storage: DurableObjectStorage): void PRIMARY KEY (package_slug, version) ); CREATE TABLE IF NOT EXISTS intent_idempotency ( - workload_identity_digest TEXT NOT NULL, + workload_idempotency_digest TEXT NOT NULL, mutation_key TEXT NOT NULL, request_digest TEXT NOT NULL, intent_id TEXT NOT NULL, expires_at INTEGER NOT NULL, - PRIMARY KEY (workload_identity_digest, mutation_key) + PRIMARY KEY (workload_idempotency_digest, mutation_key) ); CREATE INDEX IF NOT EXISTS idx_intent_idempotency_expiry ON intent_idempotency(expires_at); @@ -294,6 +304,7 @@ export class IntentStateStore { !Number.isSafeInteger(input.workloadPolicyVersion) || input.workloadPolicyVersion < 1 || !DIGEST_PATTERN.test(input.workloadIdentityDigest) || + !DIGEST_PATTERN.test(input.workloadIdempotencyDigest) || !IDEMPOTENCY_KEY_PATTERN.test(input.idempotencyKey) || !DIGEST_PATTERN.test(input.requestDigest) || !validCanonicalObjectJson(input.workloadIdentityJson, MAX_WORKLOAD_JSON_CHARS) || @@ -310,8 +321,8 @@ export class IntentStateStore { const idempotency = this.#storage.sql .exec( `SELECT request_digest, intent_id, expires_at FROM intent_idempotency - WHERE workload_identity_digest = ? AND mutation_key = ?`, - input.workloadIdentityDigest, + WHERE workload_idempotency_digest = ? AND mutation_key = ?`, + input.workloadIdempotencyDigest, input.idempotencyKey, ) .toArray()[0]; @@ -326,8 +337,8 @@ export class IntentStateStore { if (idempotency) { this.#storage.sql.exec( `DELETE FROM intent_idempotency - WHERE workload_identity_digest = ? AND mutation_key = ?`, - input.workloadIdentityDigest, + WHERE workload_idempotency_digest = ? AND mutation_key = ?`, + input.workloadIdempotencyDigest, input.idempotencyKey, ); } @@ -384,15 +395,17 @@ export class IntentStateStore { this.#storage.sql.exec( `INSERT INTO intents ( id, package_slug, version, state, state_generation, - workload_policy_version, workload_identity_digest, request_digest, workload_identity_json, + workload_policy_version, workload_identity_digest, workload_idempotency_digest, + request_digest, workload_identity_json, release_input_json, state_data_json, workflow_id, expires_at, created_at, updated_at - ) VALUES (?, ?, ?, 'received', 1, ?, ?, ?, ?, ?, '{}', NULL, ?, ?, ?)`, + ) VALUES (?, ?, ?, 'received', 1, ?, ?, ?, ?, ?, ?, '{}', NULL, ?, ?, ?)`, input.intentId, input.packageSlug, input.version, input.workloadPolicyVersion, input.workloadIdentityDigest, + input.workloadIdempotencyDigest, input.requestDigest, input.workloadIdentityJson, input.releaseInputJson, @@ -410,9 +423,9 @@ export class IntentStateStore { ); this.#storage.sql.exec( `INSERT INTO intent_idempotency ( - workload_identity_digest, mutation_key, request_digest, intent_id, expires_at + workload_idempotency_digest, mutation_key, request_digest, intent_id, expires_at ) VALUES (?, ?, ?, ?, ?)`, - input.workloadIdentityDigest, + input.workloadIdempotencyDigest, input.idempotencyKey, input.requestDigest, input.intentId, @@ -442,6 +455,44 @@ export class IntentStateStore { }); } + findIdempotent( + workloadIdempotencyDigest: string, + idempotencyKey: string, + now = Date.now(), + ): IntentIdempotencyMatch | null { + if ( + !DIGEST_PATTERN.test(workloadIdempotencyDigest) || + !IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey) || + !Number.isSafeInteger(now) || + now < 0 + ) { + throw new IntentStateError(); + } + return this.#storage.transactionSync(() => { + const row = this.#storage.sql + .exec( + `SELECT request_digest, intent_id, expires_at FROM intent_idempotency + WHERE workload_idempotency_digest = ? AND mutation_key = ?`, + workloadIdempotencyDigest, + idempotencyKey, + ) + .toArray()[0]; + if (!row) return null; + if (row.expires_at <= now) { + this.#storage.sql.exec( + `DELETE FROM intent_idempotency + WHERE workload_idempotency_digest = ? AND mutation_key = ?`, + workloadIdempotencyDigest, + idempotencyKey, + ); + return null; + } + const intent = this.get(row.intent_id); + if (!intent) throw new IntentStateError(); + return { intent, requestDigest: row.request_digest }; + }); + } + transition(input: TransitionIntentInput): TransitionIntentResult { const now = input.now ?? Date.now(); if ( @@ -567,7 +618,8 @@ export class IntentStateStore { const row = this.#storage.sql .exec( `SELECT id, package_slug, version, state, state_generation, - workload_policy_version, workload_identity_digest, request_digest, workload_identity_json, + workload_policy_version, workload_identity_digest, workload_idempotency_digest, + request_digest, workload_identity_json, release_input_json, state_data_json, workflow_id, expires_at, created_at, updated_at FROM intents WHERE id = ?`, @@ -577,6 +629,31 @@ export class IntentStateStore { return row ? rowToIntent(row) : null; } + list(afterIntentId: string | null, limit: number): readonly StoredIntent[] { + if ( + (afterIntentId !== null && !ULID_PATTERN.test(afterIntentId)) || + !Number.isSafeInteger(limit) || + limit < 1 || + limit > 100 + ) { + throw new IntentStateError(); + } + return this.#storage.sql + .exec( + `SELECT id, package_slug, version, state, state_generation, + workload_policy_version, workload_identity_digest, workload_idempotency_digest, + request_digest, workload_identity_json, release_input_json, state_data_json, + workflow_id, expires_at, created_at, updated_at + FROM intents WHERE (? IS NULL OR id < ?) + ORDER BY id DESC LIMIT ?`, + afterIntentId, + afterIntentId, + limit, + ) + .toArray() + .map(rowToIntent); + } + listTransitions(intentId: string): readonly IntentTransition[] { if (!ULID_PATTERN.test(intentId)) throw new IntentStateError(); return this.#storage.sql diff --git a/apps/release-service/src/publisher-do/publication-materialization.ts b/apps/release-service/src/publisher-do/publication-materialization.ts index d21b5f8779..23a907a3bf 100644 --- a/apps/release-service/src/publisher-do/publication-materialization.ts +++ b/apps/release-service/src/publisher-do/publication-materialization.ts @@ -1,3 +1,5 @@ +import { safeParse } from "@atcute/lexicons"; +import { PackageRelease } from "@emdash-cms/registry-lexicons"; import { multihashFromBlobCid } from "@emdash-cms/registry-verification/checksum"; import { base64url } from "jose"; @@ -11,6 +13,7 @@ const STAGING_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,511}$/; const MAX_PACKAGE_BYTES = 256 * 1024; const MAX_IMAGE_BYTES = 1024 * 1024; const MAX_IMAGE_DIMENSION = 8192; +const MAX_RELEASE_INPUT_JSON_CHARS = 64 * 1024; const MAX_RECORD_JSON_CHARS = 128 * 1024; export type PublicationArtifactSlot = @@ -26,6 +29,17 @@ export type PublicationArtifactSlot = | "screenshots[6]" | "screenshots[7]"; +const SCREENSHOT_SLOTS = [ + "screenshots[0]", + "screenshots[1]", + "screenshots[2]", + "screenshots[3]", + "screenshots[4]", + "screenshots[5]", + "screenshots[6]", + "screenshots[7]", +] as const satisfies readonly PublicationArtifactSlot[]; + export interface PublicationBlob { $type: "blob"; ref: { $link: string }; @@ -106,8 +120,11 @@ export type PublicationMaterializationMutationResult = interface IntentRow { [key: string]: string | number | ArrayBuffer | null; + package_slug: string; + version: string; state: string; request_digest: string; + release_input_json: string; } interface MaterializationRow { @@ -247,21 +264,181 @@ function canonicalBlob(value: unknown): string | null { }); } -function canonicalRecordJson(value: string): boolean { +function parseCanonicalReleaseRecord(value: string): PackageRelease.Main | null { if (typeof value !== "string" || value.length < 2 || value.length > MAX_RECORD_JSON_CHARS) { - return false; + return null; } try { const parsed: unknown = JSON.parse(value); - return ( - parsed !== null && - typeof parsed === "object" && - !Array.isArray(parsed) && - JSON.stringify(parsed) === value - ); + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) || + JSON.stringify(parsed) !== value + ) { + return null; + } + const release = safeParse(PackageRelease.mainSchema, parsed, { strict: true }); + return release.ok ? release.value : null; } catch { - return false; + return null; + } +} + +function parseIntentRelease(value: string): PackageRelease.Main | null { + if (value.length < 2 || value.length > MAX_RELEASE_INPUT_JSON_CHARS) return null; + try { + const parsed: unknown = JSON.parse(value); + if ( + !isRecord(parsed) || + Object.keys(parsed).length !== 1 || + !("release" in parsed) || + JSON.stringify(parsed) !== value + ) { + return null; + } + const release = safeParse(PackageRelease.mainSchema, parsed["release"], { strict: true }); + return release.ok ? release.value : null; + } catch { + return null; + } +} + +type ArtifactDescriptor = PackageRelease.Artifact | PackageRelease.ImageArtifact; + +function releaseArtifacts( + release: PackageRelease.Main, +): readonly (readonly [PublicationArtifactSlot, ArtifactDescriptor])[] { + const screenshots = (release.artifacts.screenshots ?? []).map((descriptor, index) => { + const slot = SCREENSHOT_SLOTS[index]; + if (!slot) throw new PublicationMaterializationError(); + return [slot, descriptor] as const; + }); + return [ + ["package", release.artifacts.package], + ...(release.artifacts.icon ? ([["icon", release.artifacts.icon]] as const) : []), + ...(release.artifacts.banner ? ([["banner", release.artifacts.banner]] as const) : []), + ...screenshots, + ]; +} + +function canonicalize(value: unknown): unknown { + if (value === null || typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new TypeError("Non-finite JSON number"); + return Object.is(value, -0) ? 0 : value; + } + if (Array.isArray(value)) return value.map(canonicalize); + if (!isRecord(value)) throw new TypeError("Non-JSON value"); + const result: Record = Object.create(null); + for (const [key, item] of Object.entries(value).toSorted(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + )) { + if (item === undefined) throw new TypeError("Undefined JSON value"); + result[key] = canonicalize(item); + } + return result; +} + +function canonicalJson(value: unknown): string { + return JSON.stringify(canonicalize(value)); +} + +function expectedDescriptor( + slot: PublicationArtifactSlot, + source: ArtifactDescriptor, + artifact: StoredPublicationArtifact, +): ArtifactDescriptor | null { + if ( + typeof source.url !== "string" || + Object.hasOwn(source, "blob") || + Object.hasOwn(source, "requiresAuth") || + source.checksum !== artifact.checksum || + (source.contentType !== undefined && source.contentType.toLowerCase() !== artifact.mimeType) || + !artifact.blob + ) { + return null; + } + const blobChecksum = multihashFromBlobCid(artifact.blob.ref.$link); + if ( + !blobChecksum.success || + blobChecksum.value !== artifact.checksum || + artifact.blob.mimeType !== artifact.mimeType || + artifact.blob.size !== artifact.size + ) { + return null; + } + if (slot === "package") { + if ( + artifact.mimeType !== "application/gzip" || + artifact.width !== null || + artifact.height !== null + ) { + return null; + } + } else if ( + artifact.mimeType === "application/gzip" || + artifact.width === null || + artifact.height === null || + (source.width !== undefined && source.width !== artifact.width) || + (source.height !== undefined && source.height !== artifact.height) + ) { + return null; + } + const expected = structuredClone(source); + delete expected.url; + delete expected.blob; + delete expected.requiresAuth; + delete expected.releaseAsset; + expected.contentType = artifact.mimeType; + expected.blob = artifact.blob; + if (slot !== "package") { + if (artifact.width === null || artifact.height === null) return null; + expected.width = artifact.width; + expected.height = artifact.height; + } + return expected; +} + +function validateCompletedRecord( + intent: IntentRow, + source: PackageRelease.Main, + record: PackageRelease.Main, + artifacts: readonly StoredPublicationArtifact[], + sourceUrlDigests: ReadonlyMap, +): "complete" | "conflict" | "incomplete" { + if ( + source.package !== intent.package_slug || + source.version !== intent.version || + record.package !== intent.package_slug || + record.version !== intent.version + ) { + return "conflict"; + } + const sourceEntries = releaseArtifacts(source); + const recordEntries = new Map(releaseArtifacts(record)); + const stored = new Map(artifacts.map((artifact) => [artifact.slot, artifact])); + if (sourceEntries.some(([slot]) => !stored.has(slot))) return "incomplete"; + if (stored.size !== sourceEntries.length || recordEntries.size !== sourceEntries.length) { + return "conflict"; + } + const { artifacts: sourceArtifactSet, ...sourceRecord } = source; + const { artifacts: recordArtifactSet, ...completedRecord } = record; + if ( + canonicalJson(sourceRecord) !== canonicalJson(completedRecord) || + sourceArtifactSet.$type !== recordArtifactSet.$type + ) { + return "conflict"; + } + for (const [slot, sourceDescriptor] of sourceEntries) { + const artifact = stored.get(slot); + const recordDescriptor = recordEntries.get(slot); + if (!artifact?.blob || !recordDescriptor) return "incomplete"; + if (artifact.sourceUrlDigest !== sourceUrlDigests.get(slot)) return "conflict"; + const expected = expectedDescriptor(slot, sourceDescriptor, artifact); + if (!expected || canonicalJson(expected) !== canonicalJson(recordDescriptor)) return "conflict"; } + return "complete"; } async function digest(value: string): Promise { @@ -477,41 +654,80 @@ export class PublicationMaterializationStore { input: CompletePublicationMaterializationInput, ): Promise { const now = input.now ?? Date.now(); + const record = parseCanonicalReleaseRecord(input.recordJson); if ( !DID_PATTERN.test(input.publisherDid) || !ULID_PATTERN.test(input.intentId) || !DIGEST_PATTERN.test(input.sourceDigest) || - !canonicalRecordJson(input.recordJson) || + record === null || !DIGEST_PATTERN.test(input.recordDigest) || !validTimestamp(now) || (await digest(input.recordJson)) !== input.recordDigest ) { throw new PublicationMaterializationError(); } + const intentSnapshot = this.#intent(input.intentId); + let source = intentSnapshot ? parseIntentRelease(intentSnapshot.release_input_json) : null; + const sourceUrlDigests = new Map(); + if (source) { + const digests = await Promise.all( + releaseArtifacts(source).map(async ([slot, descriptor]) => { + if ( + typeof descriptor.url !== "string" || + Object.hasOwn(descriptor, "blob") || + Object.hasOwn(descriptor, "requiresAuth") + ) { + return null; + } + return [slot, await digest(descriptor.url)] as const; + }), + ); + for (const entry of digests) { + if (!entry) { + source = null; + break; + } + const [slot, sourceUrlDigest] = entry; + sourceUrlDigests.set(slot, sourceUrlDigest); + } + } return this.storage.transactionSync(() => { const parent = this.#materialization(input.intentId); if (!parent) return { ok: false, code: "MATERIALIZATION_NOT_FOUND" } as const; if (parent.source_digest !== input.sourceDigest) { return { ok: false, code: "MATERIALIZATION_CONFLICT" } as const; } + const intent = this.#intent(input.intentId); + if ( + !intentSnapshot || + !intent || + !source || + intent.package_slug !== intentSnapshot.package_slug || + intent.version !== intentSnapshot.version || + intent.request_digest !== intentSnapshot.request_digest || + intent.release_input_json !== intentSnapshot.release_input_json + ) { + return { ok: false, code: "MATERIALIZATION_CONFLICT" } as const; + } if (parent.status === "complete") { return parent.record_json === input.recordJson && parent.record_digest === input.recordDigest ? ({ ok: true, replayed: true } as const) : ({ ok: false, code: "MATERIALIZATION_CONFLICT" } as const); } - const counts = this.storage.sql - .exec<{ slots: number; receipts: number; packages: number }>( - `SELECT COUNT(*) AS slots, - SUM(CASE WHEN blob_json IS NOT NULL THEN 1 ELSE 0 END) AS receipts, - SUM(CASE WHEN slot = 'package' THEN 1 ELSE 0 END) AS packages - FROM publication_materialization_slots WHERE intent_id = ?`, - input.intentId, - ) - .one(); - if (counts.slots < 1 || counts.packages !== 1 || counts.receipts !== counts.slots) { + const validation = validateCompletedRecord( + intent, + source, + record, + this.#artifacts(input.intentId), + sourceUrlDigests, + ); + if (validation === "incomplete") { return { ok: false, code: "MATERIALIZATION_INCOMPLETE" } as const; } + if (validation === "conflict") { + return { ok: false, code: "MATERIALIZATION_CONFLICT" } as const; + } if (!this.#mutableIntent(input.intentId)) { return { ok: false, code: "INTENT_STATE_INVALID" } as const; } @@ -532,21 +748,6 @@ export class PublicationMaterializationStore { if (!ULID_PATTERN.test(intentId)) throw new PublicationMaterializationError(); const parent = this.#materialization(intentId); if (!parent) return null; - const slots = this.storage.sql - .exec( - `SELECT slot, source_url_digest, checksum, staging_key, mime_type, - byte_size, width, height, blob_json, staged_at, uploaded_at - FROM publication_materialization_slots WHERE intent_id = ? - ORDER BY CASE slot - WHEN 'package' THEN 0 WHEN 'icon' THEN 1 WHEN 'banner' THEN 2 - WHEN 'screenshots[0]' THEN 3 WHEN 'screenshots[1]' THEN 4 - WHEN 'screenshots[2]' THEN 5 WHEN 'screenshots[3]' THEN 6 - WHEN 'screenshots[4]' THEN 7 WHEN 'screenshots[5]' THEN 8 - WHEN 'screenshots[6]' THEN 9 WHEN 'screenshots[7]' THEN 10 ELSE 11 END`, - intentId, - ) - .toArray() - .map(rowToArtifact); return { intentId: parent.intent_id, sourceDigest: parent.source_digest, @@ -555,14 +756,18 @@ export class PublicationMaterializationStore { recordDigest: parent.record_digest, createdAt: parent.created_at, updatedAt: parent.updated_at, - slots, + slots: this.#artifacts(intentId), }; } #intent(intentId: string): IntentRow | null { return ( this.storage.sql - .exec("SELECT state, request_digest FROM intents WHERE id = ?", intentId) + .exec( + `SELECT package_slug, version, state, request_digest, release_input_json + FROM intents WHERE id = ?`, + intentId, + ) .toArray()[0] ?? null ); } @@ -599,6 +804,24 @@ export class PublicationMaterializationStore { ); } + #artifacts(intentId: string): readonly StoredPublicationArtifact[] { + return this.storage.sql + .exec( + `SELECT slot, source_url_digest, checksum, staging_key, mime_type, + byte_size, width, height, blob_json, staged_at, uploaded_at + FROM publication_materialization_slots WHERE intent_id = ? + ORDER BY CASE slot + WHEN 'package' THEN 0 WHEN 'icon' THEN 1 WHEN 'banner' THEN 2 + WHEN 'screenshots[0]' THEN 3 WHEN 'screenshots[1]' THEN 4 + WHEN 'screenshots[2]' THEN 5 WHEN 'screenshots[3]' THEN 6 + WHEN 'screenshots[4]' THEN 7 WHEN 'screenshots[5]' THEN 8 + WHEN 'screenshots[6]' THEN 9 WHEN 'screenshots[7]' THEN 10 ELSE 11 END`, + intentId, + ) + .toArray() + .map(rowToArtifact); + } + #touch(intentId: string, now: number): void { this.storage.sql.exec( "UPDATE publication_materializations SET updated_at = ? WHERE intent_id = ?", diff --git a/apps/release-service/src/publisher-do/publication-operation.ts b/apps/release-service/src/publisher-do/publication-operation.ts index 0fa650afd9..4d3cb82dd8 100644 --- a/apps/release-service/src/publisher-do/publication-operation.ts +++ b/apps/release-service/src/publisher-do/publication-operation.ts @@ -4,6 +4,7 @@ const DID_PATTERN = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/; const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/; const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; const DIGEST_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const REASON_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/; const AT_URI_PATTERN = /^at:\/\/did:[a-z0-9]+:[A-Za-z0-9._:%-]+\/[a-zA-Z0-9.-]+\/[A-Za-z0-9._:~-]+$/; const CID_PATTERN = /^[A-Za-z0-9]+$/; @@ -46,14 +47,14 @@ export type AdvancePublicationOperationPhaseResult = }; export type BeginPublicationOperationResult = - | { ok: true; lease: PublicationOperationLease } + | { ok: true; lease: PublicationOperationLease; replayed: boolean } | { ok: false; code: "INTENT_UNAVAILABLE" | "INTENT_CAS_REQUIRED" | "PUBLICATION_RECOVERY_REQUIRED"; } | { ok: false; code: "PUBLICATION_BUSY"; retryAt: number }; -export type PublicationOutcome = "published" | "ambiguous" | "conflict"; +export type PublicationOutcome = "published" | "ambiguous" | "blocked" | "conflict" | "failed"; export interface CompletePublicationOperationInput { publisherDid: string; @@ -63,6 +64,7 @@ export interface CompletePublicationOperationInput { expectedIntentGeneration: number; completionDigest: string; outcome: PublicationOutcome; + reasonCode?: string | null; resultUri: string | null; resultCid: string | null; now?: number; @@ -71,7 +73,7 @@ export interface CompletePublicationOperationInput { export type CompletePublicationOperationResult = | { ok: true; - state: "published" | "reconciling" | "conflict"; + state: "published" | "reconciling" | "ready" | "conflict" | "failed"; stateGeneration: number; replayed: boolean; } @@ -80,6 +82,7 @@ export type CompletePublicationOperationResult = interface OperationRow { [key: string]: string | number | ArrayBuffer | null; generation: number; + attempt_key: string; token_hash: string | null; intent_generation: number; status: "active" | "completed"; @@ -88,6 +91,9 @@ interface OperationRow { expires_at: number; completion_digest: string | null; outcome: PublicationOutcome | null; + reason_code: string | null; + result_uri: string | null; + result_cid: string | null; completed_at: number | null; } @@ -132,11 +138,37 @@ function readIntent(storage: DurableObjectStorage, intentId: string): IntentRow ); } +function stateForOutcome(outcome: PublicationOutcome) { + if (outcome === "published") return "published" as const; + if (outcome === "ambiguous") return "reconciling" as const; + if (outcome === "blocked") return "ready" as const; + if (outcome === "conflict") return "conflict" as const; + return "failed" as const; +} + +function reasonForOutcome(input: CompletePublicationOperationInput): string | null { + if (input.outcome === "ambiguous") return "PDS_AMBIGUOUS"; + if (input.outcome === "conflict") return "RELEASE_CONFLICT"; + if (input.outcome === "blocked" || input.outcome === "failed") return input.reasonCode!; + return null; +} + +function requiresCreatingPhase(outcome: PublicationOutcome): boolean { + return outcome === "published" || outcome === "ambiguous" || outcome === "conflict"; +} + +function phaseAllowsOutcome(operation: OperationRow, outcome: PublicationOutcome): boolean { + return operation.phase === "creating" + ? requiresCreatingPhase(outcome) && operation.materialization_digest !== null + : !requiresCreatingPhase(outcome); +} + export function initializePublicationOperationSchema(storage: DurableObjectStorage): void { storage.sql.exec(` CREATE TABLE IF NOT EXISTS publication_operations ( intent_id TEXT PRIMARY KEY, generation INTEGER NOT NULL CHECK (generation >= 1), + attempt_key TEXT NOT NULL, token_hash TEXT, intent_generation INTEGER NOT NULL CHECK (intent_generation >= 1), status TEXT NOT NULL CHECK (status IN ('active', 'completed')), @@ -144,7 +176,10 @@ export function initializePublicationOperationSchema(storage: DurableObjectStora materialization_digest TEXT, expires_at INTEGER NOT NULL, completion_digest TEXT, - outcome TEXT CHECK (outcome IN ('published', 'ambiguous', 'conflict')), + outcome TEXT CHECK (outcome IN ('published', 'ambiguous', 'blocked', 'conflict', 'failed')), + reason_code TEXT, + result_uri TEXT, + result_cid TEXT, started_at INTEGER NOT NULL, completed_at INTEGER ); @@ -174,8 +209,11 @@ export class PublicationOperationStore { intentId: string, expectedIntentGeneration: number, leaseMs: number, + attemptKey: string, + token: string, now = Date.now(), ): Promise { + const operationNow = now; if ( !DID_PATTERN.test(publisherDid) || !ULID_PATTERN.test(intentId) || @@ -184,13 +222,14 @@ export class PublicationOperationStore { !Number.isSafeInteger(leaseMs) || leaseMs < 1 || leaseMs > MAX_LEASE_MS || - !Number.isSafeInteger(now) || - now < 0 || - now > Number.MAX_SAFE_INTEGER - leaseMs + !DIGEST_PATTERN.test(attemptKey) || + !TOKEN_PATTERN.test(token) || + !Number.isSafeInteger(operationNow) || + operationNow < 0 || + operationNow > Number.MAX_SAFE_INTEGER - leaseMs ) { throw new PublicationOperationError(); } - const token = base64url.encode(crypto.getRandomValues(new Uint8Array(32))); const tokenHash = await hashToken(token); return this.#storage.transactionSync(() => { const intent = readIntent(this.#storage, intentId); @@ -202,28 +241,51 @@ export class PublicationOperationStore { } const current = this.#storage.sql .exec( - `SELECT generation, token_hash, intent_generation, status, phase, - materialization_digest, expires_at, completion_digest, outcome, completed_at + `SELECT generation, attempt_key, token_hash, intent_generation, status, phase, + materialization_digest, expires_at, completion_digest, outcome, + reason_code, result_uri, result_cid, completed_at FROM publication_operations WHERE intent_id = ?`, intentId, ) .toArray()[0]; - if (current?.status === "active" && current.expires_at > now) { + if ( + current?.status === "active" && + current.expires_at > operationNow && + current.attempt_key === attemptKey && + current.token_hash !== null && + current.intent_generation === expectedIntentGeneration && + hashesEqual(current.token_hash, tokenHash) + ) { + return { + ok: true, + lease: { + intentId, + generation: current.generation, + token, + expectedIntentGeneration, + expiresAt: current.expires_at, + }, + replayed: true, + } as const; + } + if (current?.status === "active" && current.expires_at > operationNow) { return { ok: false, code: "PUBLICATION_BUSY", retryAt: current.expires_at } as const; } if (current?.status === "active") { return { ok: false, code: "PUBLICATION_RECOVERY_REQUIRED" } as const; } const generation = (current?.generation ?? 0) + 1; - const expiresAt = now + leaseMs; + const expiresAt = operationNow + leaseMs; this.#storage.sql.exec( `INSERT INTO publication_operations ( - intent_id, generation, token_hash, intent_generation, status, phase, + intent_id, generation, attempt_key, token_hash, intent_generation, status, phase, materialization_digest, - expires_at, completion_digest, outcome, started_at, completed_at - ) VALUES (?, ?, ?, ?, 'active', 'uploading', NULL, ?, NULL, NULL, ?, NULL) + expires_at, completion_digest, outcome, reason_code, result_uri, result_cid, + started_at, completed_at + ) VALUES (?, ?, ?, ?, ?, 'active', 'uploading', NULL, ?, NULL, NULL, NULL, NULL, NULL, ?, NULL) ON CONFLICT(intent_id) DO UPDATE SET generation = excluded.generation, + attempt_key = excluded.attempt_key, token_hash = excluded.token_hash, intent_generation = excluded.intent_generation, status = 'active', @@ -232,14 +294,18 @@ export class PublicationOperationStore { expires_at = excluded.expires_at, completion_digest = NULL, outcome = NULL, + reason_code = NULL, + result_uri = NULL, + result_cid = NULL, started_at = excluded.started_at, completed_at = NULL`, intentId, generation, + attemptKey, tokenHash, expectedIntentGeneration, expiresAt, - now, + operationNow, ); this.#storage.sql.exec( `INSERT INTO deadlines (kind, subject_id, generation, scheduled_at) @@ -257,11 +323,12 @@ export class PublicationOperationStore { ) VALUES ('publication-operation-started', 'system', 'release-service', ?, NULL, '{}', ?)`, intentId, - now, + operationNow, ); return { ok: true, lease: { intentId, generation, token, expectedIntentGeneration, expiresAt }, + replayed: false, } as const; }); } @@ -289,7 +356,7 @@ export class PublicationOperationStore { return this.#storage.transactionSync(() => { const operation = this.#storage.sql .exec( - `SELECT generation, token_hash, intent_generation, status, phase, + `SELECT generation, attempt_key, token_hash, intent_generation, status, phase, materialization_digest, expires_at, completion_digest, outcome, completed_at FROM publication_operations WHERE intent_id = ?`, input.intentId, @@ -377,7 +444,12 @@ export class PublicationOperationStore { !DIGEST_PATTERN.test(input.completionDigest) || (input.outcome !== "published" && input.outcome !== "ambiguous" && - input.outcome !== "conflict") || + input.outcome !== "blocked" && + input.outcome !== "conflict" && + input.outcome !== "failed") || + ((input.outcome === "blocked" || input.outcome === "failed") && + (typeof input.reasonCode !== "string" || !REASON_CODE_PATTERN.test(input.reasonCode))) || + (input.outcome !== "blocked" && input.outcome !== "failed" && input.reasonCode != null) || (input.outcome === "published" && (typeof input.resultUri !== "string" || !AT_URI_PATTERN.test(input.resultUri) || @@ -393,28 +465,27 @@ export class PublicationOperationStore { return this.#storage.transactionSync(() => { const operation = this.#storage.sql .exec( - `SELECT generation, token_hash, intent_generation, status, phase, - materialization_digest, expires_at, completion_digest, outcome, completed_at + `SELECT generation, attempt_key, token_hash, intent_generation, status, phase, + materialization_digest, expires_at, completion_digest, outcome, + reason_code, result_uri, result_cid, completed_at FROM publication_operations WHERE intent_id = ?`, input.intentId, ) .toArray()[0]; const intent = readIntent(this.#storage, input.intentId); - const replayState = - input.outcome === "published" - ? "published" - : input.outcome === "ambiguous" - ? "reconciling" - : "conflict"; + const replayState = stateForOutcome(input.outcome); + const reasonCode = reasonForOutcome(input); if ( operation?.status === "completed" && - operation.phase === "creating" && - operation.materialization_digest !== null && + phaseAllowsOutcome(operation, input.outcome) && operation.generation === input.generation && operation.token_hash !== null && hashesEqual(operation.token_hash, tokenHash) && operation.completion_digest === input.completionDigest && operation.outcome === input.outcome && + operation.reason_code === reasonCode && + operation.result_uri === input.resultUri && + operation.result_cid === input.resultCid && intent?.state === replayState && intent.state_generation === input.expectedIntentGeneration + 1 ) { @@ -428,31 +499,20 @@ export class PublicationOperationStore { if ( !operation || operation.status !== "active" || - operation.phase !== "creating" || - operation.materialization_digest === null || + !phaseAllowsOutcome(operation, input.outcome) || operation.generation !== input.generation || operation.token_hash === null || !hashesEqual(operation.token_hash, tokenHash) || operation.intent_generation !== input.expectedIntentGeneration || - operation.expires_at <= now || + (operation.expires_at <= now && + (input.outcome === "published" || input.outcome === "conflict")) || !intent || intent.state !== "publishing" || intent.state_generation !== input.expectedIntentGeneration ) { return { ok: false, code: "PUBLICATION_CAS_REQUIRED" } as const; } - const nextState = - input.outcome === "published" - ? "published" - : input.outcome === "ambiguous" - ? "reconciling" - : "conflict"; - const reasonCode = - input.outcome === "ambiguous" - ? "PDS_AMBIGUOUS" - : input.outcome === "conflict" - ? "RELEASE_CONFLICT" - : null; + const nextState = stateForOutcome(input.outcome); const nextGeneration = intent.state_generation + 1; const stateData = JSON.stringify({ resultUri: input.resultUri, resultCid: input.resultCid }); this.#storage.sql.exec( @@ -491,10 +551,14 @@ export class PublicationOperationStore { ); this.#storage.sql.exec( `UPDATE publication_operations SET - status = 'completed', completion_digest = ?, outcome = ?, completed_at = ? + status = 'completed', completion_digest = ?, outcome = ?, reason_code = ?, + result_uri = ?, result_cid = ?, completed_at = ? WHERE intent_id = ?`, input.completionDigest, input.outcome, + reasonCode, + input.resultUri, + input.resultCid, now, input.intentId, ); @@ -581,6 +645,9 @@ export class PublicationOperationStore { `UPDATE publication_operations SET status = 'completed', completion_digest = token_hash, outcome = CASE WHEN phase = 'creating' THEN 'ambiguous' ELSE NULL END, + reason_code = CASE WHEN phase = 'creating' + THEN 'PDS_AMBIGUOUS' ELSE 'PUBLICATION_RETRY_REQUIRED' END, + result_uri = NULL, result_cid = NULL, completed_at = ? WHERE intent_id = ? AND generation = ?`, now, diff --git a/apps/release-service/src/publisher-do/publisher-do.ts b/apps/release-service/src/publisher-do/publisher-do.ts index 0920f57413..024df4ffad 100644 --- a/apps/release-service/src/publisher-do/publisher-do.ts +++ b/apps/release-service/src/publisher-do/publisher-do.ts @@ -5,6 +5,7 @@ import { IntentStateStore, type CreateIntentInput, type CreateIntentResult, + type IntentIdempotencyMatch, type IntentTransition, type StoredIntent, type TransitionIntentInput, @@ -54,6 +55,7 @@ export type { CreateIntentResult, IntentState, IntentTransition, + IntentIdempotencyMatch, StoredIntent, TransitionIntentInput, TransitionIntentResult, @@ -88,6 +90,7 @@ export type { const DID_PATTERN = /^did:[a-z][a-z0-9]*:[A-Za-z0-9._:%-]+$/; const HASH_PATTERN = /^[A-Za-z0-9_-]{32,128}$/; const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const ACTOR_IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,255}$/; const MAX_CIPHERTEXT_CHARS = 1_500_000; const MAX_REFRESH_LEASE_MS = 5 * 60_000; const MAX_PUBLISHER_SESSION_MS = 24 * 60 * 60_000; @@ -465,7 +468,7 @@ export class PublisherDurableObject extends DurableObject { #appendAudit( eventType: string, - actorRealm: "publisher" | "system", + actorRealm: "access" | "publisher" | "system", actorIdentity: string, subject: string, createdAt: number, @@ -489,6 +492,47 @@ export class PublisherDurableObject extends DurableObject { this.#assertPublisherDid(publisherDid); } + setPublisherSuspended( + publisherDid: string, + suspended: boolean, + actorIdentity: string, + now = Date.now(), + ): { status: "active" | "suspended"; changed: boolean } { + this.#assertPublisherDid(publisherDid); + if ( + typeof suspended !== "boolean" || + !ACTOR_IDENTITY_PATTERN.test(actorIdentity) || + !Number.isSafeInteger(now) || + now < 0 + ) { + throw new PublisherStateError("PUBLISHER_SESSION_INVALID"); + } + return this.ctx.storage.transactionSync(() => { + const row = this.ctx.storage.sql + .exec<{ status: "active" | "suspended"; session_epoch: number }>( + "SELECT status, session_epoch FROM publisher WHERE id = 1", + ) + .one(); + const status = suspended ? "suspended" : "active"; + if (row.status === status) return { status, changed: false }; + this.ctx.storage.sql.exec( + "UPDATE publisher SET status = ?, session_epoch = ? WHERE id = 1", + status, + suspended ? row.session_epoch + 1 : row.session_epoch, + ); + if (suspended) this.ctx.storage.sql.exec("DELETE FROM publisher_sessions"); + this.#appendAudit( + "publisher-suspension-changed", + "access", + actorIdentity, + publisherDid, + now, + suspended ? "PUBLISHER_SUSPENDED" : null, + ); + return { status, changed: true }; + }); + } + putWorkloadPolicy(input: PutWorkloadPolicyInput): PutWorkloadPolicyResult { this.#assertPublisherDid(input.publisherDid); return this.#workloadPolicies.put(input); @@ -515,6 +559,16 @@ export class PublisherDurableObject extends DurableObject { return result; } + findIdempotentIntent( + publisherDid: string, + workloadIdempotencyDigest: string, + idempotencyKey: string, + now = Date.now(), + ): IntentIdempotencyMatch | null { + this.#assertPublisherDid(publisherDid); + return this.#intents.findIdempotent(workloadIdempotencyDigest, idempotencyKey, now); + } + transitionIntent(input: TransitionIntentInput): TransitionIntentResult { this.#assertPublisherDid(input.publisherDid); return this.#intents.transition(input); @@ -525,6 +579,15 @@ export class PublisherDurableObject extends DurableObject { return this.#intents.get(intentId); } + listIntents( + publisherDid: string, + afterIntentId: string | null, + limit: number, + ): readonly StoredIntent[] { + this.#assertPublisherDid(publisherDid); + return this.#intents.list(afterIntentId, limit); + } + listIntentTransitions(publisherDid: string, intentId: string): readonly IntentTransition[] { this.#assertPublisherDid(publisherDid); return this.#intents.listTransitions(intentId); @@ -554,6 +617,8 @@ export class PublisherDurableObject extends DurableObject { intentId: string, expectedIntentGeneration: number, leaseMs: number, + attemptKey: string, + token: string, now = Date.now(), ): Promise { this.#assertPublisherDid(publisherDid); @@ -562,6 +627,8 @@ export class PublisherDurableObject extends DurableObject { intentId, expectedIntentGeneration, leaseMs, + attemptKey, + token, now, ); await this.#scheduleNextAlarm(now); @@ -756,8 +823,11 @@ export class PublisherDurableObject extends DurableObject { }); } - revokeAllPublisherSessions(publisherDid: string): number | null { + revokeAllPublisherSessions(publisherDid: string, actorIdentity?: string): number | null { this.#assertPublisherObjectName(publisherDid); + if (actorIdentity !== undefined && !ACTOR_IDENTITY_PATTERN.test(actorIdentity)) { + throw new PublisherStateError("PUBLISHER_SESSION_INVALID"); + } return this.ctx.storage.transactionSync(() => { const owner = this.#readPublisherSessionOwner(); if (!owner) return null; @@ -765,7 +835,13 @@ export class PublisherDurableObject extends DurableObject { const now = Date.now(); this.ctx.storage.sql.exec("UPDATE publisher SET session_epoch = ? WHERE id = 1", nextEpoch); this.ctx.storage.sql.exec("DELETE FROM publisher_sessions"); - this.#appendAudit("publisher-sessions-revoked", "publisher", publisherDid, publisherDid, now); + this.#appendAudit( + "publisher-sessions-revoked", + actorIdentity ? "access" : "publisher", + actorIdentity ?? publisherDid, + publisherDid, + now, + ); return nextEpoch; }); } @@ -1159,8 +1235,15 @@ export class PublisherDurableObject extends DurableObject { }); } - revokeDelegation(publisherDid: string, expectedVersion: number): RevokeDelegationResult { + revokeDelegation( + publisherDid: string, + expectedVersion: number, + actorIdentity?: string, + ): RevokeDelegationResult { this.#assertPublisherDid(publisherDid); + if (actorIdentity !== undefined && !ACTOR_IDENTITY_PATTERN.test(actorIdentity)) { + throw new PublisherStateError("DELEGATION_INVALID"); + } return this.ctx.storage.transactionSync(() => { const now = Date.now(); const current = this.#readDelegation(); @@ -1176,7 +1259,13 @@ export class PublisherDurableObject extends DurableObject { now, ); this.#clearRefreshOperation(now); - this.#appendAudit("delegation-revoked", "publisher", publisherDid, current.releaseNsid, now); + this.#appendAudit( + "delegation-revoked", + actorIdentity ? "access" : "publisher", + actorIdentity ?? publisherDid, + current.releaseNsid, + now, + ); return { ok: true, delegation: this.#readDelegation()! } as const; }); } diff --git a/apps/release-service/src/publisher/routes.ts b/apps/release-service/src/publisher/routes.ts new file mode 100644 index 0000000000..834270af46 --- /dev/null +++ b/apps/release-service/src/publisher/routes.ts @@ -0,0 +1,388 @@ +import { isDid } from "@atcute/lexicons/syntax"; +import { env } from "cloudflare:workers"; + +import { readJsonObject } from "../api/body.js"; +import { ApiError } from "../api/errors.js"; +import { apiFailure, apiSuccess } from "../api/response.js"; +import type { ServiceConfiguration } from "../config.js"; +import { serializeIntentResource } from "../intents/routes.js"; +import { createPublisherOAuthClient } from "../oauth/custody.js"; +import type { StoredWorkloadPolicy } from "../publisher-do/publisher-do.js"; +import { WorkloadPolicyError } from "../publisher-do/workload-policy.js"; +import { + PublisherSessionError, + requirePublisherApplicationSession, +} from "../publisher-session/session.js"; + +const PACKAGE_SLUG_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; +const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/; +const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$/; +const POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/; +const WORKLOAD_PATH_PATTERN = /^\/v1\/publisher\/workloads\/([A-Za-z][A-Za-z0-9_-]{0,63})$/; +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 100; + +export interface PublisherRouteDependencies { + revokeDelegation?: (publisherDid: `did:${string}:${string}`) => Promise; +} + +function hasExactKeys(value: Record, expected: readonly string[]): boolean { + const keys = Object.keys(value); + return keys.length === expected.length && keys.every((key) => expected.includes(key)); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +function isPositiveIntegerOrNull(value: unknown): value is number | null { + return value === null || (Number.isSafeInteger(value) && Number(value) >= 1); +} + +function isPositiveInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 1; +} + +function requireIdempotencyKey(request: Request): string { + const value = request.headers.get("idempotency-key"); + if (!value || !IDEMPOTENCY_KEY_PATTERN.test(value)) { + throw new ApiError("IDEMPOTENCY_KEY_INVALID", 400, "Valid idempotency key required"); + } + return value; +} + +function parseLimit(url: URL): number { + const value = url.searchParams.get("limit"); + if (value === null) return DEFAULT_LIMIT; + if (!POSITIVE_INTEGER_PATTERN.test(value)) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid pagination parameters"); + } + const limit = Number(value); + if (!Number.isSafeInteger(limit) || limit > MAX_LIMIT) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid pagination parameters"); + } + return limit; +} + +function mapPublisherSessionError(error: PublisherSessionError): ApiError { + if (error.code === "PUBLISHER_SUSPENDED") { + return new ApiError("PUBLISHER_SUSPENDED", 403, "Publisher is suspended"); + } + if (error.code === "CSRF_INVALID" || error.code === "ORIGIN_INVALID") { + return new ApiError("CSRF_INVALID", 403, "Request origin could not be verified"); + } + return new ApiError("PUBLISHER_SESSION_INVALID", 401, "Publisher session is not valid"); +} + +function routeFailure(error: unknown, requestId: string): Response { + if (error instanceof ApiError) return apiFailure(error, requestId); + if (error instanceof PublisherSessionError) { + return apiFailure(mapPublisherSessionError(error), requestId); + } + if (error instanceof WorkloadPolicyError) { + return apiFailure( + new ApiError("INVALID_REQUEST", 400, "Invalid workload policy request"), + requestId, + ); + } + throw error; +} + +async function publisherSession( + request: Request, + configuration: ServiceConfiguration, + requireCsrf = false, +) { + return await requirePublisherApplicationSession( + request, + env.PUBLISHER_DO, + configuration.publicOrigin, + { requireCsrf }, + ); +} + +function samePolicy( + policy: StoredWorkloadPolicy, + input: { + packageSlug: string; + repository: string; + repositoryId: string; + repositoryOwnerId: string; + workflowRef: string; + allowedRefs: readonly string[]; + allowedEnvironments: readonly string[]; + active: boolean; + }, +): boolean { + return ( + policy.packageSlug === input.packageSlug && + policy.repository === input.repository.toLowerCase() && + policy.repositoryId === input.repositoryId && + policy.repositoryOwnerId === input.repositoryOwnerId && + policy.workflowRef === input.workflowRef && + JSON.stringify(policy.allowedRefs) === JSON.stringify([...input.allowedRefs].toSorted()) && + JSON.stringify(policy.allowedEnvironments) === + JSON.stringify([...input.allowedEnvironments].toSorted()) && + policy.active === input.active + ); +} + +export function sanitizedDelegation( + value: Awaited["getDelegation"]>>, +) { + return value + ? { + releaseNsid: value.releaseNsid, + scope: value.scope, + issuer: value.issuer, + pdsUrl: value.pdsUrl, + expiresAt: value.expiresAt, + refreshBefore: value.refreshBefore, + status: value.status, + stateVersion: value.stateVersion, + } + : null; +} + +export function matchPublisherWorkloadPath( + pathname: string, +): Readonly> | null { + const match = WORKLOAD_PATH_PATTERN.exec(pathname); + return match?.[1] ? { packageSlug: match[1] } : null; +} + +export async function handleGetPublisher( + request: Request, + requestId: string, + configuration: ServiceConfiguration, +): Promise { + try { + const session = await publisherSession(request, configuration); + const delegation = await env.PUBLISHER_DO.getByName(session.publisherDid).getDelegation( + session.publisherDid, + ); + return apiSuccess( + { + publisher: { + did: session.publisherDid, + delegation: sanitizedDelegation(delegation), + sessionExpiresAt: session.expiresAt, + }, + }, + requestId, + ); + } catch (error) { + return routeFailure(error, requestId); + } +} + +export async function handleRevokePublisherDelegation( + request: Request, + requestId: string, + configuration: ServiceConfiguration, + dependencies: PublisherRouteDependencies = {}, +): Promise { + try { + requireIdempotencyKey(request); + const session = await publisherSession(request, configuration, true); + const body = await readJsonObject(request); + if (!hasExactKeys(body, [])) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid delegation revocation request"); + } + const publisherDid = session.publisherDid; + if (!isDid(publisherDid)) { + throw new ApiError("PUBLISHER_SESSION_INVALID", 401, "Publisher session is not valid"); + } + const publisher = env.PUBLISHER_DO.getByName(publisherDid); + const existing = await publisher.getDelegation(publisherDid); + if (existing?.status === "active" || existing?.status === "reauthorization_required") { + if (dependencies.revokeDelegation) { + await dependencies.revokeDelegation(publisherDid); + } else { + const client = createPublisherOAuthClient({ + namespace: env.PUBLISHER_DO, + encryption: configuration.encryption, + oauth: configuration.oauth, + flow: { + purpose: "release_delegation", + expectedDid: publisherDid, + redirectTarget: "/", + }, + }); + try { + await client.revoke(); + } catch { + const current = await publisher.getDelegation(publisherDid); + if (current?.status !== "revoked") throw new Error("Delegation revocation failed"); + } + } + } + const delegation = await publisher.getDelegation(publisherDid); + return apiSuccess( + { publisher: { did: publisherDid, delegation: sanitizedDelegation(delegation) } }, + requestId, + ); + } catch (error) { + return routeFailure(error, requestId); + } +} + +export async function handleListPublisherWorkloads( + request: Request, + requestId: string, + configuration: ServiceConfiguration, +): Promise { + try { + const session = await publisherSession(request, configuration); + const url = new URL(request.url); + const limit = parseLimit(url); + const cursor = url.searchParams.get("cursor"); + if (cursor !== null && !PACKAGE_SLUG_PATTERN.test(cursor)) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid pagination parameters"); + } + const rows = await env.PUBLISHER_DO.getByName(session.publisherDid).listWorkloadPolicies( + session.publisherDid, + cursor, + limit + 1, + ); + const items = rows.slice(0, limit); + const nextCursor = rows.length > limit ? items.at(-1)?.packageSlug : undefined; + return apiSuccess({ items, ...(nextCursor ? { nextCursor } : {}) }, requestId); + } catch (error) { + return routeFailure(error, requestId); + } +} + +export async function handlePutPublisherWorkload( + request: Request, + requestId: string, + configuration: ServiceConfiguration, +): Promise { + try { + requireIdempotencyKey(request); + const session = await publisherSession(request, configuration, true); + const body = await readJsonObject(request, 16 * 1024); + if ( + !hasExactKeys(body, [ + "packageSlug", + "repository", + "repositoryId", + "repositoryOwnerId", + "workflowRef", + "allowedRefs", + "allowedEnvironments", + "expectedVersion", + ]) || + typeof body["packageSlug"] !== "string" || + typeof body["repository"] !== "string" || + typeof body["repositoryId"] !== "string" || + typeof body["repositoryOwnerId"] !== "string" || + typeof body["workflowRef"] !== "string" || + !isStringArray(body["allowedRefs"]) || + !isStringArray(body["allowedEnvironments"]) || + !isPositiveIntegerOrNull(body["expectedVersion"]) + ) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid workload policy request"); + } + const publisher = env.PUBLISHER_DO.getByName(session.publisherDid); + const input = { + publisherDid: session.publisherDid, + packageSlug: body["packageSlug"], + repository: body["repository"], + repositoryId: body["repositoryId"], + repositoryOwnerId: body["repositoryOwnerId"], + workflowRef: body["workflowRef"], + allowedRefs: body["allowedRefs"], + allowedEnvironments: body["allowedEnvironments"], + active: true, + expectedVersion: body["expectedVersion"], + }; + const current = await publisher.getWorkloadPolicy(session.publisherDid, input.packageSlug); + if (current && samePolicy(current, input)) { + return apiSuccess({ policy: current, replayed: true }, requestId); + } + const result = await publisher.putWorkloadPolicy(input); + if (!result.ok) { + throw new ApiError("IDEMPOTENCY_CONFLICT", 409, "Workload policy changed"); + } + return apiSuccess({ policy: result.policy, replayed: false }, requestId, current ? 200 : 201); + } catch (error) { + return routeFailure(error, requestId); + } +} + +export async function handleDisablePublisherWorkload( + request: Request, + requestId: string, + configuration: ServiceConfiguration, + params: Readonly>, +): Promise { + try { + requireIdempotencyKey(request); + const session = await publisherSession(request, configuration, true); + const packageSlug = params["packageSlug"]; + if (!packageSlug || !PACKAGE_SLUG_PATTERN.test(packageSlug)) { + throw new ApiError("NOT_FOUND", 404, "Workload policy not found"); + } + const body = await readJsonObject(request); + if (!hasExactKeys(body, ["expectedVersion"]) || !isPositiveInteger(body["expectedVersion"])) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid workload policy request"); + } + const publisher = env.PUBLISHER_DO.getByName(session.publisherDid); + const current = await publisher.getWorkloadPolicy(session.publisherDid, packageSlug); + if (!current) throw new ApiError("NOT_FOUND", 404, "Workload policy not found"); + if (!current.active) { + return apiSuccess({ policy: current, replayed: true }, requestId); + } + const result = await publisher.putWorkloadPolicy({ + publisherDid: session.publisherDid, + packageSlug: current.packageSlug, + repository: current.repository, + repositoryId: current.repositoryId, + repositoryOwnerId: current.repositoryOwnerId, + workflowRef: current.workflowRef, + allowedRefs: current.allowedRefs, + allowedEnvironments: current.allowedEnvironments, + active: false, + expectedVersion: body["expectedVersion"], + }); + if (!result.ok) { + throw new ApiError("IDEMPOTENCY_CONFLICT", 409, "Workload policy changed"); + } + return apiSuccess({ policy: result.policy, replayed: false }, requestId); + } catch (error) { + return routeFailure(error, requestId); + } +} + +export async function handleListPublisherIntents( + request: Request, + requestId: string, + configuration: ServiceConfiguration, +): Promise { + try { + const session = await publisherSession(request, configuration); + const url = new URL(request.url); + const limit = parseLimit(url); + const cursor = url.searchParams.get("cursor"); + if (cursor !== null && !ULID_PATTERN.test(cursor)) { + throw new ApiError("INVALID_REQUEST", 400, "Invalid pagination parameters"); + } + const rows = await env.PUBLISHER_DO.getByName(session.publisherDid).listIntents( + session.publisherDid, + cursor, + limit + 1, + ); + const items = await Promise.all( + rows + .slice(0, limit) + .map((intent) => + serializeIntentResource(session.publisherDid, intent, configuration.publicOrigin), + ), + ); + const nextCursor = rows.length > limit ? rows[limit - 1]?.id : undefined; + return apiSuccess({ items, ...(nextCursor ? { nextCursor } : {}) }, requestId); + } catch (error) { + return routeFailure(error, requestId); + } +} diff --git a/apps/release-service/src/publishing/create-only.ts b/apps/release-service/src/publishing/create-only.ts new file mode 100644 index 0000000000..f403939839 --- /dev/null +++ b/apps/release-service/src/publishing/create-only.ts @@ -0,0 +1,81 @@ +// eslint-disable-next-line @typescript-eslint/no-empty-named-blocks, eslint-plugin-import/no-empty-named-blocks, eslint-plugin-unicorn/require-module-specifiers, import/no-empty-named-blocks, unicorn/require-module-specifiers -- registers com.atproto.repo RPC types +import type {} from "@atcute/atproto"; +import { Client, ok, type FetchHandlerObject } from "@atcute/client"; +import type { Blob } from "@atcute/lexicons/interfaces"; +import { isDid } from "@atcute/lexicons/syntax"; +import { NSID, type PackageRelease } from "@emdash-cms/registry-lexicons"; + +const DID_PATTERN = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/; +const RKEY_PATTERN = /^[A-Za-z0-9._:~-]{1,512}$/; +const CID_PATTERN = /^[A-Za-z0-9]+$/; + +export interface CreateReleaseInput { + publisherDid: string; + rkey: string; + record: PackageRelease.Main; +} + +export interface CreatedRelease { + uri: string; + cid: string; +} + +export class CreateReleaseError extends Error { + readonly code: "CREATE_INPUT_INVALID" | "CREATE_RESPONSE_INVALID"; + + constructor(code: CreateReleaseError["code"]) { + super(code); + this.name = "CreateReleaseError"; + this.code = code; + } +} + +export async function createReleaseRecord( + session: FetchHandlerObject, + input: CreateReleaseInput, +): Promise { + if ( + !DID_PATTERN.test(input.publisherDid) || + !isDid(input.publisherDid) || + !RKEY_PATTERN.test(input.rkey) || + input.rkey !== `${input.record.package}:${input.record.version}` + ) { + throw new CreateReleaseError("CREATE_INPUT_INVALID"); + } + const client = new Client({ handler: session }); + const result = await ok( + client.post("com.atproto.repo.createRecord", { + input: { + repo: input.publisherDid, + collection: NSID.packageRelease, + rkey: input.rkey, + record: input.record, + validate: true, + }, + }), + ); + const expectedUri = `at://${input.publisherDid}/${NSID.packageRelease}/${input.rkey}`; + if ( + result.uri !== expectedUri || + typeof result.cid !== "string" || + !CID_PATTERN.test(result.cid) + ) { + throw new CreateReleaseError("CREATE_RESPONSE_INVALID"); + } + return { uri: result.uri, cid: result.cid }; +} + +export async function uploadReleaseBlob( + session: FetchHandlerObject, + bytes: Uint8Array, + mimeType: string, +): Promise { + const client = new Client({ handler: session }); + const result = await ok( + client.post("com.atproto.repo.uploadBlob", { + headers: { "content-type": mimeType }, + input: bytes, + }), + ); + return result.blob; +} diff --git a/apps/release-service/src/publishing/image-metadata.ts b/apps/release-service/src/publishing/image-metadata.ts new file mode 100644 index 0000000000..91988a2729 --- /dev/null +++ b/apps/release-service/src/publishing/image-metadata.ts @@ -0,0 +1,151 @@ +export interface ImageDimensions { + width: number; + height: number; +} + +export type ImageMimeType = "image/png" | "image/jpeg" | "image/webp"; + +function uint16BigEndian(bytes: Uint8Array, offset: number): number | null { + if (offset < 0 || offset + 2 > bytes.byteLength) return null; + return ((bytes[offset] ?? 0) << 8) | (bytes[offset + 1] ?? 0); +} + +function uint16LittleEndian(bytes: Uint8Array, offset: number): number | null { + if (offset < 0 || offset + 2 > bytes.byteLength) return null; + return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8); +} + +function uint32BigEndian(bytes: Uint8Array, offset: number): number | null { + if (offset < 0 || offset + 4 > bytes.byteLength) return null; + return ( + (bytes[offset] ?? 0) * 0x1000000 + + (bytes[offset + 1] ?? 0) * 0x10000 + + (bytes[offset + 2] ?? 0) * 0x100 + + (bytes[offset + 3] ?? 0) + ); +} + +function uint24LittleEndian(bytes: Uint8Array, offset: number): number | null { + if (offset < 0 || offset + 3 > bytes.byteLength) return null; + return ( + (bytes[offset] ?? 0) + (bytes[offset + 1] ?? 0) * 0x100 + (bytes[offset + 2] ?? 0) * 0x10000 + ); +} + +function uint32LittleEndian(bytes: Uint8Array, offset: number): number | null { + if (offset < 0 || offset + 4 > bytes.byteLength) return null; + return ( + (bytes[offset] ?? 0) + + (bytes[offset + 1] ?? 0) * 0x100 + + (bytes[offset + 2] ?? 0) * 0x10000 + + (bytes[offset + 3] ?? 0) * 0x1000000 + ); +} + +function matches(bytes: Uint8Array, offset: number, expected: readonly number[]): boolean { + return expected.every((value, index) => bytes[offset + index] === value); +} + +function dimensions(width: number | null, height: number | null): ImageDimensions | null { + return width !== null && height !== null && width > 0 && height > 0 ? { width, height } : null; +} + +function pngDimensions(bytes: Uint8Array): ImageDimensions | null { + if ( + bytes.byteLength < 33 || + !matches(bytes, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) || + uint32BigEndian(bytes, 8) !== 13 || + !matches(bytes, 12, [0x49, 0x48, 0x44, 0x52]) + ) { + return null; + } + return dimensions(uint32BigEndian(bytes, 16), uint32BigEndian(bytes, 20)); +} + +const JPEG_START_OF_FRAME_MARKERS = new Set([ + 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf, +]); + +function jpegDimensions(bytes: Uint8Array): ImageDimensions | null { + if (bytes.byteLength < 4 || !matches(bytes, 0, [0xff, 0xd8])) return null; + let offset = 2; + while (offset < bytes.byteLength) { + if (bytes[offset] !== 0xff) return null; + while (offset < bytes.byteLength && bytes[offset] === 0xff) offset += 1; + const marker = bytes[offset]; + if (marker === undefined || marker === 0x00) return null; + offset += 1; + if (marker === 0xd9 || marker === 0xda) return null; + if (marker === 0x01 || marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7)) continue; + const segmentLength = uint16BigEndian(bytes, offset); + if (segmentLength === null || segmentLength < 2) return null; + const segmentEnd = offset + segmentLength; + if (segmentEnd > bytes.byteLength) return null; + if (JPEG_START_OF_FRAME_MARKERS.has(marker)) { + if (segmentLength < 7) return null; + return dimensions(uint16BigEndian(bytes, offset + 5), uint16BigEndian(bytes, offset + 3)); + } + offset = segmentEnd; + } + return null; +} + +function webpDimensions(bytes: Uint8Array): ImageDimensions | null { + if ( + bytes.byteLength < 20 || + !matches(bytes, 0, [0x52, 0x49, 0x46, 0x46]) || + !matches(bytes, 8, [0x57, 0x45, 0x42, 0x50]) + ) { + return null; + } + const riffSize = uint32LittleEndian(bytes, 4); + if (riffSize === null || riffSize < 12 || riffSize > bytes.byteLength - 8) return null; + const end = riffSize + 8; + let offset = 12; + while (offset + 8 <= end) { + const chunkSize = uint32LittleEndian(bytes, offset + 4); + if (chunkSize === null) return null; + const dataOffset = offset + 8; + const dataEnd = dataOffset + chunkSize; + if (!Number.isSafeInteger(dataEnd) || dataEnd > end) return null; + + if (matches(bytes, offset, [0x56, 0x50, 0x38, 0x58])) { + if (chunkSize < 10) return null; + const width = uint24LittleEndian(bytes, dataOffset + 4); + const height = uint24LittleEndian(bytes, dataOffset + 7); + return dimensions(width === null ? null : width + 1, height === null ? null : height + 1); + } + if (matches(bytes, offset, [0x56, 0x50, 0x38, 0x4c])) { + if (chunkSize < 5 || bytes[dataOffset] !== 0x2f) return null; + const packed = uint32LittleEndian(bytes, dataOffset + 1); + if (packed === null) return null; + return dimensions((packed & 0x3fff) + 1, ((packed >>> 14) & 0x3fff) + 1); + } + if (matches(bytes, offset, [0x56, 0x50, 0x38, 0x20])) { + if (chunkSize < 10 || !matches(bytes, dataOffset + 3, [0x9d, 0x01, 0x2a])) return null; + const width = uint16LittleEndian(bytes, dataOffset + 6); + const height = uint16LittleEndian(bytes, dataOffset + 8); + return dimensions( + width === null ? null : width & 0x3fff, + height === null ? null : height & 0x3fff, + ); + } + + offset = dataEnd + (chunkSize % 2); + } + return null; +} + +export function readImageDimensions( + bytes: Uint8Array, + mimeType: ImageMimeType, +): ImageDimensions | null { + switch (mimeType) { + case "image/png": + return pngDimensions(bytes); + case "image/jpeg": + return jpegDimensions(bytes); + case "image/webp": + return webpDimensions(bytes); + } +} diff --git a/apps/release-service/src/publishing/materialize.ts b/apps/release-service/src/publishing/materialize.ts new file mode 100644 index 0000000000..f242c25425 --- /dev/null +++ b/apps/release-service/src/publishing/materialize.ts @@ -0,0 +1,545 @@ +import { safeParse } from "@atcute/lexicons"; +import { isBlob, type Blob } from "@atcute/lexicons/interfaces"; +import { PackageRelease } from "@emdash-cms/registry-lexicons"; +import { + DEFAULT_FETCH_LIMITS, + fetchVerifiedResource, + multihashFromBlobCid, + verifyMultihash, + type FetchImplementation, + type HostnameResolver, + type VerificationErrorCode, +} from "@emdash-cms/registry-verification"; + +import { readImageDimensions, type ImageMimeType } from "./image-metadata.js"; + +const MATERIALIZATION_PLAN_VERSION = 1; +const PACKAGE_MAX_BYTES = 256 * 1024; +const IMAGE_MAX_BYTES = 1024 * 1024; +const IMAGE_MAX_DIMENSION = 8192; +const GENERIC_BINARY_MIME = "application/octet-stream"; +const MIME_TYPE_PATTERN = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/; +const SCREENSHOT_PATH_PATTERN = /^screenshots\[([0-7])\]$/; +const IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]); + +export type ArtifactMaterializationPath = "package" | "icon" | "banner" | `screenshots[${number}]`; + +export type ArtifactMaterializationErrorCode = + | VerificationErrorCode + | "ARTIFACT_BLOB_INVALID" + | "ARTIFACT_DIMENSIONS_INVALID" + | "ARTIFACT_MIME_INVALID" + | "ARTIFACT_OPTIONS_INVALID" + | "ARTIFACT_RECEIPTS_INVALID" + | "ARTIFACT_SOURCE_UNVERIFIABLE" + | "ARTIFACT_UPLOAD_FAILED" + | "RELEASE_INVALID"; + +export class ArtifactMaterializationError extends Error { + readonly code: ArtifactMaterializationErrorCode; + readonly artifact: ArtifactMaterializationPath | null; + + constructor( + code: ArtifactMaterializationErrorCode, + artifact: ArtifactMaterializationPath | null, + ) { + super(code); + this.name = "ArtifactMaterializationError"; + this.code = code; + this.artifact = artifact; + } +} + +export type ArtifactBlobUploader = (bytes: Uint8Array, mimeType: string) => Promise; + +export interface StageReleaseArtifactsOptions { + fetch: FetchImplementation; + resolveHostname: HostnameResolver; + allowHttpLocalhost?: boolean; + headerTimeoutMs?: number; + totalTimeoutMs?: number; + maxRedirects?: number; +} + +export interface MaterializeReleaseArtifactsOptions extends StageReleaseArtifactsOptions { + uploadBlob: ArtifactBlobUploader; +} + +export interface StagedArtifactMetadata { + path: ArtifactMaterializationPath; + checksum: string; + mimeType: string; + size: number; + width?: number; + height?: number; +} + +export interface StagedReleaseArtifact { + metadata: StagedArtifactMetadata; + bytes: Uint8Array; +} + +export interface ReleaseArtifactMaterializationPlan { + version: 1; + release: PackageRelease.Main; + artifacts: readonly StagedArtifactMetadata[]; +} + +export interface StagedReleaseArtifacts { + plan: ReleaseArtifactMaterializationPlan; + artifacts: readonly StagedReleaseArtifact[]; +} + +export interface ArtifactUploadReceipt { + path: ArtifactMaterializationPath; + checksum: string; + blob: Blob; +} + +type ArtifactDescriptor = PackageRelease.Artifact | PackageRelease.ImageArtifact; + +function hasPrefix(bytes: Uint8Array, expected: readonly number[], offset = 0): boolean { + return expected.every((value, index) => bytes[offset + index] === value); +} + +function detectedMimeType( + path: ArtifactMaterializationPath, + bytes: Uint8Array, +): "application/gzip" | ImageMimeType | null { + if (path === "package") { + return hasPrefix(bytes, [0x1f, 0x8b]) ? "application/gzip" : null; + } + if (hasPrefix(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { + return "image/png"; + } + if (hasPrefix(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"; + if (hasPrefix(bytes, [0x52, 0x49, 0x46, 0x46]) && hasPrefix(bytes, [0x57, 0x45, 0x42, 0x50], 8)) { + return "image/webp"; + } + return null; +} + +function responseMimeType(headers: Headers): string | null { + const raw = headers.get("content-type"); + if (raw === null) return null; + const value = raw.split(";", 1)[0]?.trim().toLowerCase(); + return value && MIME_TYPE_PATTERN.test(value) ? value : null; +} + +function maxBytesForPath(path: ArtifactMaterializationPath): number { + return path === "package" ? PACKAGE_MAX_BYTES : IMAGE_MAX_BYTES; +} + +function isImageMimeType(value: string): value is ImageMimeType { + return IMAGE_MIME_TYPES.has(value); +} + +function isMaterializationPath(value: unknown): value is ArtifactMaterializationPath { + return ( + value === "package" || + value === "icon" || + value === "banner" || + (typeof value === "string" && SCREENSHOT_PATH_PATTERN.test(value)) + ); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function validMetadata(value: unknown): value is StagedArtifactMetadata { + if (!isRecord(value) || !isMaterializationPath(value["path"])) return false; + const path = value["path"]; + const width = value["width"]; + const height = value["height"]; + const dimensionsValid = + path === "package" + ? width === undefined && height === undefined + : Number.isSafeInteger(width) && + Number.isSafeInteger(height) && + Number(width) > 0 && + Number(width) <= IMAGE_MAX_DIMENSION && + Number(height) > 0 && + Number(height) <= IMAGE_MAX_DIMENSION; + return ( + typeof value["checksum"] === "string" && + value["checksum"].length > 0 && + value["checksum"].length <= 256 && + typeof value["mimeType"] === "string" && + (path === "package" + ? value["mimeType"] === "application/gzip" + : IMAGE_MIME_TYPES.has(value["mimeType"])) && + Number.isSafeInteger(value["size"]) && + Number(value["size"]) > 0 && + Number(value["size"]) <= maxBytesForPath(path) && + dimensionsValid + ); +} + +function fetchImplementation( + descriptor: ArtifactDescriptor, + options: StageReleaseArtifactsOptions, +): FetchImplementation { + return (url, init) => { + if (descriptor.releaseAsset !== true) return options.fetch(url, init); + const headers = new Headers(init.headers); + headers.set("accept", GENERIC_BINARY_MIME); + return options.fetch(url, { ...init, headers }); + }; +} + +async function stageArtifact( + path: ArtifactMaterializationPath, + descriptor: T, + deadline: number, + options: StageReleaseArtifactsOptions, +): Promise { + if (descriptor.requiresAuth === true) { + throw new ArtifactMaterializationError("AUTH_METHOD_UNSUPPORTED", path); + } + if (!descriptor.url) { + throw new ArtifactMaterializationError("ARTIFACT_SOURCE_UNVERIFIABLE", path); + } + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new ArtifactMaterializationError("RESOURCE_TIMEOUT", path); + const maxBytes = maxBytesForPath(path); + const fetched = await fetchVerifiedResource(descriptor.url, { + fetch: fetchImplementation(descriptor, options), + resolveHostname: options.resolveHostname, + ...(options.allowHttpLocalhost === undefined + ? {} + : { allowHttpLocalhost: options.allowHttpLocalhost }), + ...(options.headerTimeoutMs === undefined ? {} : { headerTimeoutMs: options.headerTimeoutMs }), + totalTimeoutMs: remaining, + maxBytes, + ...(options.maxRedirects === undefined ? {} : { maxRedirects: options.maxRedirects }), + }); + if (!fetched.success) { + throw new ArtifactMaterializationError(fetched.error.code, path); + } + const bytes = new Uint8Array(fetched.value.bytes); + const verified = await verifyMultihash(bytes, descriptor.checksum); + if (!verified.success) { + throw new ArtifactMaterializationError(verified.error.code, path); + } + const mimeType = detectedMimeType(path, bytes); + if (!mimeType) throw new ArtifactMaterializationError("ARTIFACT_MIME_INVALID", path); + if (descriptor.contentType && descriptor.contentType.trim().toLowerCase() !== mimeType) { + throw new ArtifactMaterializationError("ARTIFACT_MIME_INVALID", path); + } + const responseMime = responseMimeType(fetched.value.headers); + if (responseMime && responseMime !== GENERIC_BINARY_MIME && responseMime !== mimeType) { + throw new ArtifactMaterializationError("ARTIFACT_MIME_INVALID", path); + } + if (path !== "package") { + if (!isImageMimeType(mimeType)) { + throw new ArtifactMaterializationError("ARTIFACT_MIME_INVALID", path); + } + const measured = readImageDimensions(bytes, mimeType); + if ( + !measured || + measured.width > IMAGE_MAX_DIMENSION || + measured.height > IMAGE_MAX_DIMENSION || + ("width" in descriptor && + descriptor.width !== undefined && + descriptor.width !== measured.width) || + ("height" in descriptor && + descriptor.height !== undefined && + descriptor.height !== measured.height) + ) { + throw new ArtifactMaterializationError("ARTIFACT_DIMENSIONS_INVALID", path); + } + return { + metadata: { + path, + checksum: descriptor.checksum, + mimeType, + size: bytes.byteLength, + width: measured.width, + height: measured.height, + }, + bytes, + }; + } + return { + metadata: { path, checksum: descriptor.checksum, mimeType, size: bytes.byteLength }, + bytes, + }; +} + +function withoutSources(descriptor: T): T { + const result = structuredClone(descriptor); + delete result.url; + delete result.blob; + delete result.requiresAuth; + delete result.releaseAsset; + return result; +} + +function materializationPaths(release: PackageRelease.Main): ArtifactMaterializationPath[] { + return [ + "package", + ...(release.artifacts.icon ? (["icon"] as const) : []), + ...(release.artifacts.banner ? (["banner"] as const) : []), + ...(release.artifacts.screenshots ?? []).map((_, index) => `screenshots[${index}]` as const), + ]; +} + +function applyMeasuredDimensions( + descriptor: PackageRelease.ImageArtifact, + metadata: StagedArtifactMetadata | undefined, +): void { + if (!metadata || metadata.width === undefined || metadata.height === undefined) { + throw new ArtifactMaterializationError("ARTIFACT_DIMENSIONS_INVALID", metadata?.path ?? null); + } + descriptor.contentType = metadata.mimeType; + descriptor.width = metadata.width; + descriptor.height = metadata.height; +} + +function releaseTemplate( + release: PackageRelease.Main, + artifacts: readonly StagedReleaseArtifact[], +): PackageRelease.Main { + const result = structuredClone(release); + const metadata = new Map( + artifacts.map((artifact) => [artifact.metadata.path, artifact.metadata]), + ); + result.artifacts.package = withoutSources(result.artifacts.package); + const packageMetadata = metadata.get("package"); + if (!packageMetadata) { + throw new ArtifactMaterializationError("ARTIFACT_MIME_INVALID", "package"); + } + result.artifacts.package.contentType = packageMetadata.mimeType; + if (result.artifacts.icon) { + result.artifacts.icon = withoutSources(result.artifacts.icon); + applyMeasuredDimensions(result.artifacts.icon, metadata.get("icon")); + } + if (result.artifacts.banner) { + result.artifacts.banner = withoutSources(result.artifacts.banner); + applyMeasuredDimensions(result.artifacts.banner, metadata.get("banner")); + } + if (result.artifacts.screenshots) { + result.artifacts.screenshots = result.artifacts.screenshots.map((screenshot, index) => { + const descriptor = withoutSources(screenshot); + applyMeasuredDimensions(descriptor, metadata.get(`screenshots[${index}]`)); + return descriptor; + }); + } + return result; +} + +export async function stageReleaseArtifacts( + release: PackageRelease.Main, + options: StageReleaseArtifactsOptions, +): Promise { + let snapshot: unknown; + try { + snapshot = structuredClone(release); + } catch { + throw new ArtifactMaterializationError("RELEASE_INVALID", null); + } + const parsed = safeParse(PackageRelease.mainSchema, snapshot, { strict: true }); + if (!parsed.ok) throw new ArtifactMaterializationError("RELEASE_INVALID", null); + const timeout = options.totalTimeoutMs ?? DEFAULT_FETCH_LIMITS.totalTimeoutMs; + if ( + !Number.isSafeInteger(timeout) || + timeout <= 0 || + Date.now() > Number.MAX_SAFE_INTEGER - timeout + ) { + throw new ArtifactMaterializationError("ARTIFACT_OPTIONS_INVALID", null); + } + const deadline = Date.now() + timeout; + const artifacts: StagedReleaseArtifact[] = [ + await stageArtifact("package", parsed.value.artifacts.package, deadline, options), + ]; + if (parsed.value.artifacts.icon) { + artifacts.push(await stageArtifact("icon", parsed.value.artifacts.icon, deadline, options)); + } + if (parsed.value.artifacts.banner) { + artifacts.push(await stageArtifact("banner", parsed.value.artifacts.banner, deadline, options)); + } + for (const [index, screenshot] of (parsed.value.artifacts.screenshots ?? []).entries()) { + artifacts.push(await stageArtifact(`screenshots[${index}]`, screenshot, deadline, options)); + } + const template = releaseTemplate(parsed.value, artifacts); + const validTemplate = safeParse(PackageRelease.mainSchema, template, { strict: true }); + if (!validTemplate.ok) throw new ArtifactMaterializationError("RELEASE_INVALID", null); + return { + plan: { + version: MATERIALIZATION_PLAN_VERSION, + release: validTemplate.value, + artifacts: artifacts.map(({ metadata }) => ({ ...metadata })), + }, + artifacts, + }; +} + +export function validateArtifactUploadReceipt( + metadata: StagedArtifactMetadata, + uploaded: unknown, +): ArtifactUploadReceipt { + if ( + !validMetadata(metadata) || + !isBlob(uploaded) || + uploaded.size !== metadata.size || + uploaded.mimeType !== metadata.mimeType || + typeof uploaded.ref.$link !== "string" + ) { + throw new ArtifactMaterializationError("ARTIFACT_BLOB_INVALID", metadata.path); + } + const uploadedChecksum = multihashFromBlobCid(uploaded.ref.$link); + if (!uploadedChecksum.success || uploadedChecksum.value !== metadata.checksum) { + throw new ArtifactMaterializationError("ARTIFACT_BLOB_INVALID", metadata.path); + } + return { + path: metadata.path, + checksum: metadata.checksum, + blob: { + $type: "blob", + ref: { $link: uploaded.ref.$link }, + mimeType: uploaded.mimeType, + size: uploaded.size, + }, + }; +} + +export async function uploadStagedArtifact( + artifact: StagedReleaseArtifact, + uploadBlob: ArtifactBlobUploader, +): Promise { + let uploaded: unknown; + try { + uploaded = await uploadBlob(new Uint8Array(artifact.bytes), artifact.metadata.mimeType); + } catch { + throw new ArtifactMaterializationError("ARTIFACT_UPLOAD_FAILED", artifact.metadata.path); + } + return validateArtifactUploadReceipt(artifact.metadata, uploaded); +} + +function withBlob(descriptor: T, blob: Blob): T { + const result = withoutSources(descriptor); + result.blob = blob; + return result; +} + +function sourcesAbsent(descriptor: ArtifactDescriptor): boolean { + return ( + !Object.hasOwn(descriptor, "url") && + !Object.hasOwn(descriptor, "blob") && + !Object.hasOwn(descriptor, "requiresAuth") && + !Object.hasOwn(descriptor, "releaseAsset") + ); +} + +function templateDescriptors(release: PackageRelease.Main): ArtifactDescriptor[] { + return [ + release.artifacts.package, + ...(release.artifacts.icon ? [release.artifacts.icon] : []), + ...(release.artifacts.banner ? [release.artifacts.banner] : []), + ...(release.artifacts.screenshots ?? []), + ]; +} + +function dimensionsMatch( + path: ArtifactMaterializationPath, + descriptor: ArtifactDescriptor, + metadata: StagedArtifactMetadata, +): boolean { + if (path === "package") { + return metadata.width === undefined && metadata.height === undefined; + } + return ( + "width" in descriptor && + "height" in descriptor && + descriptor.width === metadata.width && + descriptor.height === metadata.height + ); +} + +export function buildMaterializedRelease( + plan: unknown, + receipts: readonly ArtifactUploadReceipt[], +): PackageRelease.Main { + let snapshot: unknown; + try { + snapshot = structuredClone(plan); + } catch { + throw new ArtifactMaterializationError("RELEASE_INVALID", null); + } + if ( + !isRecord(snapshot) || + snapshot["version"] !== MATERIALIZATION_PLAN_VERSION || + !Array.isArray(snapshot["artifacts"]) + ) { + throw new ArtifactMaterializationError("RELEASE_INVALID", null); + } + const parsed = safeParse(PackageRelease.mainSchema, snapshot["release"], { strict: true }); + if (!parsed.ok) throw new ArtifactMaterializationError("RELEASE_INVALID", null); + const artifactMetadata = snapshot["artifacts"]; + const paths = materializationPaths(parsed.value); + const descriptors = templateDescriptors(parsed.value); + if ( + paths.length !== descriptors.length || + paths.length !== artifactMetadata.length || + paths.length !== receipts.length || + descriptors.some((descriptor) => !sourcesAbsent(descriptor)) + ) { + throw new ArtifactMaterializationError("ARTIFACT_RECEIPTS_INVALID", null); + } + const blobs = new Map(); + for (const [index, path] of paths.entries()) { + const descriptor = descriptors[index]; + const metadata = artifactMetadata[index]; + const receipt = receipts[index]; + if ( + !descriptor || + !validMetadata(metadata) || + !receipt || + metadata.path !== path || + metadata.checksum !== descriptor.checksum || + !dimensionsMatch(path, descriptor, metadata) || + (descriptor.contentType !== undefined && + descriptor.contentType.trim().toLowerCase() !== metadata.mimeType) || + receipt.path !== path || + receipt.checksum !== metadata.checksum + ) { + throw new ArtifactMaterializationError("ARTIFACT_RECEIPTS_INVALID", path); + } + const validated = validateArtifactUploadReceipt(metadata, receipt.blob); + blobs.set(path, validated.blob); + } + const result = structuredClone(parsed.value); + const blobForPath = (path: ArtifactMaterializationPath): Blob => { + const blob = blobs.get(path); + if (!blob) throw new ArtifactMaterializationError("ARTIFACT_RECEIPTS_INVALID", path); + return blob; + }; + result.artifacts.package = withBlob(result.artifacts.package, blobForPath("package")); + if (result.artifacts.icon) { + result.artifacts.icon = withBlob(result.artifacts.icon, blobForPath("icon")); + } + if (result.artifacts.banner) { + result.artifacts.banner = withBlob(result.artifacts.banner, blobForPath("banner")); + } + if (result.artifacts.screenshots) { + result.artifacts.screenshots = result.artifacts.screenshots.map((screenshot, index) => + withBlob(screenshot, blobForPath(`screenshots[${index}]`)), + ); + } + const output = safeParse(PackageRelease.mainSchema, result, { strict: true }); + if (!output.ok) throw new ArtifactMaterializationError("RELEASE_INVALID", null); + return output.value; +} + +export async function materializeReleaseArtifacts( + release: PackageRelease.Main, + options: MaterializeReleaseArtifactsOptions, +): Promise { + const staged = await stageReleaseArtifacts(release, options); + const receipts: ArtifactUploadReceipt[] = []; + for (const artifact of staged.artifacts) { + receipts.push(await uploadStagedArtifact(artifact, options.uploadBlob)); + } + return buildMaterializedRelease(staged.plan, receipts); +} diff --git a/apps/release-service/src/publishing/reconcile.ts b/apps/release-service/src/publishing/reconcile.ts new file mode 100644 index 0000000000..a386791d6a --- /dev/null +++ b/apps/release-service/src/publishing/reconcile.ts @@ -0,0 +1,68 @@ +import { safeParse } from "@atcute/lexicons"; +import { NSID, PackageRelease } from "@emdash-cms/registry-lexicons"; + +import type { AuthoritativeRecord } from "../verification/pds.js"; + +export type ReconciliationResult = + | { outcome: "absent" } + | { outcome: "exact"; uri: string; cid: string } + | { outcome: "conflict" }; + +function canonicalize(value: unknown): unknown { + if (value === null || typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new TypeError("Non-finite JSON number"); + return Object.is(value, -0) ? 0 : value; + } + if (Array.isArray(value)) return value.map(canonicalize); + if (typeof value !== "object") throw new TypeError("Non-JSON value"); + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) + throw new TypeError("Non-plain JSON object"); + const result: Record = Object.create(null); + for (const [key, item] of Object.entries(value).toSorted(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + )) { + if (item === undefined) throw new TypeError("Undefined JSON value"); + result[key] = canonicalize(item); + } + return result; +} + +function canonicalJson(value: unknown): string { + return JSON.stringify(canonicalize(value)); +} + +export function canonicalReleaseJson(value: PackageRelease.Main): string { + return canonicalJson(value); +} + +export function parseCanonicalReleaseJson(value: string): PackageRelease.Main | null { + try { + const parsed = safeParse(PackageRelease.mainSchema, JSON.parse(value), { strict: true }); + return parsed.ok && canonicalJson(parsed.value) === value ? parsed.value : null; + } catch { + return null; + } +} + +export function reconcileReleaseRecord( + publisherDid: string, + packageSlug: string, + version: string, + expected: PackageRelease.Main, + authoritative: AuthoritativeRecord | null, +): ReconciliationResult { + if (!authoritative) return { outcome: "absent" }; + const expectedUri = `at://${publisherDid}/${NSID.packageRelease}/${packageSlug}:${version}`; + if (authoritative.uri !== expectedUri) return { outcome: "conflict" }; + const parsed = safeParse(PackageRelease.mainSchema, authoritative.value); + if (!parsed.ok) return { outcome: "conflict" }; + try { + return canonicalJson(parsed.value) === canonicalJson(expected) + ? { outcome: "exact", uri: authoritative.uri, cid: authoritative.cid } + : { outcome: "conflict" }; + } catch { + return { outcome: "conflict" }; + } +} diff --git a/apps/release-service/src/publishing/staging.ts b/apps/release-service/src/publishing/staging.ts new file mode 100644 index 0000000000..899117c923 --- /dev/null +++ b/apps/release-service/src/publishing/staging.ts @@ -0,0 +1,171 @@ +import { verifyMultihash } from "@emdash-cms/registry-verification/checksum"; +import { base64url } from "jose"; + +import type { StagedArtifactMetadata, StagedReleaseArtifact } from "./materialize.js"; + +const DID_PATTERN = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/; +const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/; +const CHECKSUM_PATTERN = /^b[a-z2-7]{10,255}$/; +const DIGEST_PATTERN = /^[-A-Za-z0-9_]{43}$/; +const SLOT_PATTERN = /^(?:package|icon|banner|screenshots\[[0-7]\])$/; +const MAX_STAGED_BYTES = 1024 * 1024; + +export interface PersistedStagedArtifact { + key: string; + metadata: StagedArtifactMetadata; + sourceUrlDigest: string; +} + +export class PublicationStagingError extends Error { + readonly code: + | "PUBLICATION_STAGING_CONFLICT" + | "PUBLICATION_STAGING_CORRUPT" + | "PUBLICATION_STAGING_INVALID" + | "PUBLICATION_STAGING_MISSING" + | "PUBLICATION_STAGING_WRITE_FAILED"; + + constructor(code: PublicationStagingError["code"]) { + super(code); + this.name = "PublicationStagingError"; + this.code = code; + } +} + +async function digest(value: string): Promise { + return base64url.encode( + new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))), + ); +} + +function validMetadata(metadata: StagedArtifactMetadata): boolean { + return ( + SLOT_PATTERN.test(metadata.path) && + CHECKSUM_PATTERN.test(metadata.checksum) && + typeof metadata.mimeType === "string" && + metadata.mimeType.length >= 3 && + metadata.mimeType.length <= 128 && + Number.isSafeInteger(metadata.size) && + metadata.size >= 1 && + metadata.size <= MAX_STAGED_BYTES && + ((metadata.width === undefined && metadata.height === undefined) || + (Number.isSafeInteger(metadata.width) && + Number.isSafeInteger(metadata.height) && + (metadata.width ?? 0) >= 1 && + (metadata.width ?? 0) <= 8192 && + (metadata.height ?? 0) >= 1 && + (metadata.height ?? 0) <= 8192)) + ); +} + +function keySlot(path: StagedArtifactMetadata["path"]): string { + return path.startsWith("screenshots[") ? path.replaceAll("[", "-").replaceAll("]", "") : path; +} + +async function readAndVerify( + object: R2ObjectBody, + metadata: StagedArtifactMetadata, +): Promise { + if (object.size !== metadata.size || object.size > MAX_STAGED_BYTES) { + throw new PublicationStagingError("PUBLICATION_STAGING_CORRUPT"); + } + const bytes = new Uint8Array(await object.arrayBuffer()); + if ( + bytes.byteLength !== metadata.size || + !(await verifyMultihash(bytes, metadata.checksum)).success + ) { + throw new PublicationStagingError("PUBLICATION_STAGING_CORRUPT"); + } + return bytes; +} + +async function existingMatches( + bucket: R2Bucket, + key: string, + metadata: StagedArtifactMetadata, +): Promise { + const existing = await bucket.get(key); + if (!existing) return false; + try { + await readAndVerify(existing, metadata); + return true; + } catch (error) { + if (error instanceof PublicationStagingError) return false; + throw error; + } +} + +export async function persistStagedArtifact( + bucket: R2Bucket, + input: { + publisherDid: string; + intentId: string; + sourceUrl: string; + artifact: StagedReleaseArtifact; + }, +): Promise { + if ( + !DID_PATTERN.test(input.publisherDid) || + !ULID_PATTERN.test(input.intentId) || + typeof input.sourceUrl !== "string" || + input.sourceUrl.length < 1 || + input.sourceUrl.length > 2048 || + !validMetadata(input.artifact.metadata) || + input.artifact.bytes.byteLength !== input.artifact.metadata.size + ) { + throw new PublicationStagingError("PUBLICATION_STAGING_INVALID"); + } + const sourceUrlDigest = await digest(input.sourceUrl); + const ownerHash = await digest(input.publisherDid); + const key = `publication/${ownerHash}/${input.intentId}/${keySlot(input.artifact.metadata.path)}/${input.artifact.metadata.checksum}`; + try { + const created = await bucket.put(key, input.artifact.bytes, { + onlyIf: { etagDoesNotMatch: "*" }, + httpMetadata: { contentType: input.artifact.metadata.mimeType }, + customMetadata: { + checksum: input.artifact.metadata.checksum, + sourceUrlDigest, + }, + }); + if (!created && !(await existingMatches(bucket, key, input.artifact.metadata))) { + throw new PublicationStagingError("PUBLICATION_STAGING_CONFLICT"); + } + } catch (error) { + if (error instanceof PublicationStagingError) throw error; + if (!(await existingMatches(bucket, key, input.artifact.metadata))) { + throw new PublicationStagingError("PUBLICATION_STAGING_WRITE_FAILED"); + } + } + return { + key, + metadata: structuredClone(input.artifact.metadata), + sourceUrlDigest, + }; +} + +export async function loadStagedArtifact( + bucket: R2Bucket, + staged: PersistedStagedArtifact, +): Promise { + if ( + typeof staged.key !== "string" || + !staged.key.startsWith("publication/") || + !validMetadata(staged.metadata) || + !DIGEST_PATTERN.test(staged.sourceUrlDigest) + ) { + throw new PublicationStagingError("PUBLICATION_STAGING_INVALID"); + } + const object = await bucket.get(staged.key); + if (!object) throw new PublicationStagingError("PUBLICATION_STAGING_MISSING"); + return { + metadata: structuredClone(staged.metadata), + bytes: await readAndVerify(object, staged.metadata), + }; +} + +export async function deleteStagedArtifacts( + bucket: R2Bucket, + artifacts: readonly PersistedStagedArtifact[], +): Promise { + const keys = artifacts.map((artifact) => artifact.key); + if (keys.length > 0) await bucket.delete(keys); +} diff --git a/apps/release-service/src/publishing/workflow.ts b/apps/release-service/src/publishing/workflow.ts new file mode 100644 index 0000000000..c6420e37e7 --- /dev/null +++ b/apps/release-service/src/publishing/workflow.ts @@ -0,0 +1,1097 @@ +import { isDid } from "@atcute/lexicons/syntax"; +import { + parseDelegatedReleaseSourceRecord, + type DelegatedReleaseSourceRecord, +} from "@emdash-cms/registry-client/release-service"; +import type { PackageRelease } from "@emdash-cms/registry-lexicons"; +import type { WorkflowStep } from "cloudflare:workers"; +import { NonRetryableError } from "cloudflare:workflows"; +import { base64url } from "jose"; + +import type ReleaseVerifier from "../../../release-verifier/src/index.js"; +import { computeApprovalEvidenceDigest, type ApprovalEvidence } from "../approvals/digest.js"; +import { loadConfiguration } from "../config.js"; +import { + SERVICE_CONTROL_OBJECT_NAME, + type ServiceControlDurableObject, +} from "../control-do/service-control-do.js"; +import { createPublisherOAuthClient, OAuthCustodyError } from "../oauth/custody.js"; +import type { + IntentState, + PublicationArtifactSlot, + PublicationOperationLease, + PublisherDurableObject, + StoredIntent, + StoredPublicationMaterialization, +} from "../publisher-do/publisher-do.js"; +import { + evaluateVerifiedRelease, + normalizeVerifierReport, + prepareVerifierInput, +} from "../verification/evaluate.js"; +import { + findProofVerifiedRelease, + PublisherSnapshotError, + readPublisherVerificationSnapshot, + resolvePublicHostname, +} from "../verification/pds.js"; +import { createReleaseRecord, uploadReleaseBlob } from "./create-only.js"; +import { + buildMaterializedRelease, + stageReleaseArtifacts, + validateArtifactUploadReceipt, + type ArtifactMaterializationPath, + type ArtifactUploadReceipt, + type StagedArtifactMetadata, +} from "./materialize.js"; +import { + canonicalReleaseJson, + parseCanonicalReleaseJson, + reconcileReleaseRecord, +} from "./reconcile.js"; +import { deleteStagedArtifacts, loadStagedArtifact, persistStagedArtifact } from "./staging.js"; + +const PUBLICATION_PERMIT_TTL_MS = 30_000; +const PUBLICATION_OPERATION_LEASE_MS = 5 * 60_000; +const MAX_PUBLICATION_ATTEMPTS = 3; +const FINAL_VERIFICATION_STEP_CONFIG = { + retries: { limit: 3, delay: "1 second", backoff: "exponential" }, + timeout: "2 minutes", +} as const; +const RECONCILIATION_STEP_CONFIG = { + retries: { limit: 3, delay: "1 second", backoff: "exponential" }, + timeout: "2 minutes", +} as const; +const MATERIALIZATION_STEP_CONFIG = { + retries: { limit: 3, delay: "1 second", backoff: "exponential" }, + timeout: "5 minutes", +} as const; +const UPLOAD_STEP_CONFIG = { + retries: { limit: 5, delay: "1 second", backoff: "exponential" }, + timeout: "2 minutes", +} as const; +const ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/; +const NON_RETRYABLE_ERROR_PREFIX = /^NonRetryableError:\s*/; +const SCREENSHOT_PATH_PATTERN = /^screenshots\[([0-7])\]$/; +const STEP_SLOT_PATTERN = /[[\]]/g; + +export interface PublicationWorkflowOutput { + intentId: string; + state: "conflict" | "failed" | "invalid" | "published" | "ready"; + reasonCode: string | null; +} + +type PublicationWorkflowEnv = Env & { + RELEASE_VERIFIER: Service; + SERVICE_CONTROL_DO: DurableObjectNamespace; +}; + +type TransitionSummary = + | { ok: true; state: IntentState; stateGeneration: number } + | { ok: false; code: string }; + +type AttemptResult = + | { state: "published"; uri: string; cid: string } + | { state: "reconciling" } + | { state: "blocked"; reasonCode: string } + | { state: "failed"; reasonCode: string }; + +interface AttemptCredential { + attemptKey: string; + token: string; +} + +interface MaterializationStageResult { + planJson: string | null; +} + +type OperationBeginSummary = + | { ok: true; lease: PublicationOperationLease; replayed: boolean } + | { ok: false; code: string }; + +type OperationPhaseSummary = + | { ok: true; phase: "creating" | "materialized"; materializationDigest: string } + | { ok: false; code: string }; + +interface MaterializedRelease { + record: PackageRelease.Main; + recordDigest: string; + recordJson: string; +} + +interface MaterializedSummary { + recordDigest: string; +} + +type FinalVerificationResult = + | { ok: true; verificationDigest: string } + | { ok: false; reasonCode: string; terminalState: "conflict" | "invalid" }; + +const PUBLISHER_SNAPSHOT_ERROR_CODES: readonly PublisherSnapshotError["code"][] = [ + "PUBLISHER_IDENTITY_INVALID", + "PUBLISHER_PDS_INVALID", + "PROFILE_INVALID", + "RELEASE_EXISTS", + "RELEASE_RECORD_INVALID", + "RELEASE_LIST_INVALID", +]; + +function publisherSnapshotErrorCode(error: unknown): PublisherSnapshotError["code"] | null { + if (error instanceof PublisherSnapshotError) return error.code; + if (!(error instanceof Error)) return null; + return ( + PUBLISHER_SNAPSHOT_ERROR_CODES.find( + (code) => error.message === `PublisherSnapshotError: ${code}`, + ) ?? null + ); +} + +function isRetryablePublicationBlock(code: string): boolean { + return ( + code === "PERMIT_EXPIRED" || + code === "PERMIT_STALE" || + code === "PUBLICATION_PAUSED" || + code === "PUBLISHER_SUSPENDED" + ); +} + +function publicationErrorCode(error: unknown, fallback: string): string { + if (error instanceof OAuthCustodyError) return error.code; + if (error instanceof Error) { + const message = error.message.replace(NON_RETRYABLE_ERROR_PREFIX, ""); + if (ERROR_CODE_PATTERN.test(message)) return message; + } + if ( + error !== null && + typeof error === "object" && + "code" in error && + typeof error.code === "string" && + ERROR_CODE_PATTERN.test(error.code) + ) { + return error.code; + } + return fallback; +} + +async function digest(value: unknown): Promise { + return base64url.encode( + new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(value))), + ), + ); +} + +async function digestText(value: string): Promise { + return base64url.encode( + new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))), + ); +} + +function randomCredential(): AttemptCredential { + return { + attemptKey: base64url.encode(crypto.getRandomValues(new Uint8Array(32))), + token: base64url.encode(crypto.getRandomValues(new Uint8Array(32))), + }; +} + +export function releaseFromIntent(intent: StoredIntent): DelegatedReleaseSourceRecord | null { + let parsed: unknown; + try { + parsed = JSON.parse(intent.releaseInputJson); + } catch { + return null; + } + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) || + Object.keys(parsed).length !== 1 || + !("release" in parsed) + ) { + return null; + } + return parseDelegatedReleaseSourceRecord(parsed.release, { + packageSlug: intent.packageSlug, + version: intent.version, + }); +} + +function sourceDescriptor( + release: DelegatedReleaseSourceRecord, + path: ArtifactMaterializationPath, +) { + if (path === "package") return release.artifacts.package; + if (path === "icon") return release.artifacts.icon ?? null; + if (path === "banner") return release.artifacts.banner ?? null; + const match = SCREENSHOT_PATH_PATTERN.exec(path); + if (!match) return null; + return release.artifacts.screenshots?.[Number(match[1])] ?? null; +} + +function isPublicationSlot(path: string): path is PublicationArtifactSlot { + return ( + path === "package" || + path === "icon" || + path === "banner" || + path === "screenshots[0]" || + path === "screenshots[1]" || + path === "screenshots[2]" || + path === "screenshots[3]" || + path === "screenshots[4]" || + path === "screenshots[5]" || + path === "screenshots[6]" || + path === "screenshots[7]" + ); +} + +function publicationSlot(path: ArtifactMaterializationPath): PublicationArtifactSlot | null { + return isPublicationSlot(path) ? path : null; +} + +function stagedMetadata( + artifact: StoredPublicationMaterialization["slots"][number], +): StagedArtifactMetadata { + return { + path: artifact.slot, + checksum: artifact.checksum, + mimeType: artifact.mimeType, + size: artifact.size, + ...(artifact.width === null ? {} : { width: artifact.width }), + ...(artifact.height === null ? {} : { height: artifact.height }), + }; +} + +function receiptFromStored( + artifact: StoredPublicationMaterialization["slots"][number], +): ArtifactUploadReceipt | null { + if (!artifact.blob) return null; + return validateArtifactUploadReceipt(stagedMetadata(artifact), artifact.blob); +} + +export async function readPersistedMaterializedRelease( + publisher: DurableObjectStub, + publisherDid: string, + intentId: string, + expectedSourceDigest: string, +): Promise { + const stored = await publisher.getPublicationMaterialization(publisherDid, intentId); + if ( + stored?.status !== "complete" || + stored.sourceDigest !== expectedSourceDigest || + stored.recordJson === null || + stored.recordDigest === null || + (await digestText(stored.recordJson)) !== stored.recordDigest + ) { + return null; + } + const record = parseCanonicalReleaseJson(stored.recordJson); + return record + ? { record, recordDigest: stored.recordDigest, recordJson: stored.recordJson } + : null; +} + +async function restorePublicationSession(env: PublicationWorkflowEnv, publisherDid: string) { + if (!isDid(publisherDid)) throw new OAuthCustodyError("OAUTH_IDENTITY_MISMATCH"); + const configuration = await loadConfiguration(env); + return createPublisherOAuthClient({ + namespace: env.PUBLISHER_DO, + encryption: configuration.encryption, + oauth: configuration.oauth, + flow: { + purpose: "release_delegation", + expectedDid: publisherDid, + redirectTarget: "/", + }, + }).restoreForPublication(); +} + +async function transition( + publisher: DurableObjectStub, + input: Parameters[0], +): Promise { + const result = await publisher.transitionIntent(input); + return result.ok + ? { ok: true, state: result.intent.state, stateGeneration: result.intent.stateGeneration } + : { ok: false, code: result.code }; +} + +async function currentState( + publisher: DurableObjectStub, + publisherDid: string, + intentId: string, +): Promise<{ state: IntentState; stateGeneration: number } | null> { + const intent = await publisher.getIntent(publisherDid, intentId); + return intent ? { state: intent.state, stateGeneration: intent.stateGeneration } : null; +} + +async function stageSourceArtifacts( + env: PublicationWorkflowEnv, + publisher: DurableObjectStub, + publisherDid: string, + intent: StoredIntent, + release: DelegatedReleaseSourceRecord, +): Promise { + const existing = await readPersistedMaterializedRelease( + publisher, + publisherDid, + intent.id, + intent.requestDigest, + ); + if (existing) return { planJson: null }; + const begun = await publisher.beginPublicationMaterialization( + publisherDid, + intent.id, + intent.requestDigest, + ); + if (!begun.ok) throw new Error(begun.code); + const staged = await stageReleaseArtifacts(release, { + fetch: globalThis.fetch, + resolveHostname: (hostname) => resolvePublicHostname(hostname, globalThis.fetch), + }); + for (const artifact of staged.artifacts) { + const descriptor = sourceDescriptor(release, artifact.metadata.path); + const slot = publicationSlot(artifact.metadata.path); + if (!descriptor || !slot) throw new Error("MATERIALIZATION_SOURCE_INVALID"); + const persisted = await persistStagedArtifact(env.PUBLICATION_STAGING, { + publisherDid, + intentId: intent.id, + sourceUrl: descriptor.url, + artifact, + }); + const stored = await publisher.putPublicationArtifactStage({ + publisherDid, + intentId: intent.id, + sourceDigest: intent.requestDigest, + slot, + sourceUrlDigest: persisted.sourceUrlDigest, + checksum: artifact.metadata.checksum, + stagingKey: persisted.key, + mimeType: artifact.metadata.mimeType, + size: artifact.metadata.size, + width: artifact.metadata.width ?? null, + height: artifact.metadata.height ?? null, + }); + if (!stored.ok) throw new Error(stored.code); + } + return { planJson: JSON.stringify(staged.plan) }; +} + +async function uploadMaterializedArtifacts( + env: PublicationWorkflowEnv, + step: WorkflowStep, + publisher: DurableObjectStub, + publisherDid: string, + intent: StoredIntent, +): Promise { + const materialization = await publisher.getPublicationMaterialization(publisherDid, intent.id); + if (!materialization || materialization.sourceDigest !== intent.requestDigest) { + throw new Error("MATERIALIZATION_UNAVAILABLE"); + } + const receipts: ArtifactUploadReceipt[] = []; + for (const artifact of materialization.slots) { + const existing = receiptFromStored(artifact); + if (existing) { + receipts.push(existing); + continue; + } + const receipt = await step.do( + `publication-upload-${artifact.slot.replaceAll(STEP_SLOT_PATTERN, "-")}`, + UPLOAD_STEP_CONFIG, + async () => { + const latest = await publisher.getPublicationMaterialization(publisherDid, intent.id); + const latestArtifact = latest?.slots.find((item) => item.slot === artifact.slot); + if (!latestArtifact || latest?.sourceDigest !== intent.requestDigest) { + throw new Error("MATERIALIZATION_UNAVAILABLE"); + } + const replayed = receiptFromStored(latestArtifact); + if (replayed) return replayed; + const staged = await loadStagedArtifact(env.PUBLICATION_STAGING, { + key: latestArtifact.stagingKey, + metadata: stagedMetadata(latestArtifact), + sourceUrlDigest: latestArtifact.sourceUrlDigest, + }); + let restored; + try { + restored = await restorePublicationSession(env, publisherDid); + } catch (error) { + throw new NonRetryableError(publicationErrorCode(error, "OAUTH_DELEGATION_UNAVAILABLE")); + } + const delegation = await publisher.getDelegation(publisherDid); + if ( + delegation?.status !== "active" || + delegation.stateVersion !== restored.delegationVersion + ) { + throw new OAuthCustodyError("OAUTH_DELEGATION_UNAVAILABLE"); + } + const uploaded = await uploadReleaseBlob( + restored.session, + staged.bytes, + staged.metadata.mimeType, + ); + const validated = validateArtifactUploadReceipt(staged.metadata, uploaded); + const stored = await publisher.putPublicationBlobReceipt({ + publisherDid, + intentId: intent.id, + sourceDigest: intent.requestDigest, + slot: artifact.slot, + blob: validated.blob, + }); + if (!stored.ok) throw new Error(stored.code); + return validated; + }, + ); + receipts.push(receipt); + } + return receipts; +} + +async function completeMaterialization( + publisher: DurableObjectStub, + publisherDid: string, + intent: StoredIntent, + planJson: string | null, +): Promise { + const existing = await readPersistedMaterializedRelease( + publisher, + publisherDid, + intent.id, + intent.requestDigest, + ); + if (existing) return existing; + if (!planJson) throw new Error("MATERIALIZATION_UNAVAILABLE"); + const stored = await publisher.getPublicationMaterialization(publisherDid, intent.id); + if (!stored || stored.sourceDigest !== intent.requestDigest) { + throw new Error("MATERIALIZATION_UNAVAILABLE"); + } + const receipts = stored.slots.map(receiptFromStored); + if (receipts.some((receipt) => receipt === null)) { + throw new Error("MATERIALIZATION_INCOMPLETE"); + } + const completeReceipts = receipts.filter( + (receipt): receipt is ArtifactUploadReceipt => receipt !== null, + ); + let plan: unknown; + try { + plan = JSON.parse(planJson); + } catch { + throw new Error("MATERIALIZATION_UNAVAILABLE"); + } + const record = buildMaterializedRelease(plan, completeReceipts); + const recordJson = canonicalReleaseJson(record); + const recordDigest = await digestText(recordJson); + const completed = await publisher.completePublicationMaterialization({ + publisherDid, + intentId: intent.id, + sourceDigest: intent.requestDigest, + recordJson, + recordDigest, + }); + if (!completed.ok) throw new Error(completed.code); + const persisted = await readPersistedMaterializedRelease( + publisher, + publisherDid, + intent.id, + intent.requestDigest, + ); + if (!persisted) throw new Error("MATERIALIZATION_UNAVAILABLE"); + return persisted; +} + +async function closeBeforeCreate( + publisher: DurableObjectStub, + publisherDid: string, + intentId: string, + lease: PublicationOperationLease, + attempt: number, + reasonCode: string, + retryable: boolean, +): Promise { + const completed = await publisher.completePublicationOperation({ + publisherDid, + intentId, + generation: lease.generation, + token: lease.token, + expectedIntentGeneration: lease.expectedIntentGeneration, + completionDigest: await digest(["pre-create", attempt, reasonCode]), + outcome: retryable ? "blocked" : "failed", + reasonCode, + resultUri: null, + resultCid: null, + }); + if (completed.ok) { + return retryable ? { state: "blocked", reasonCode } : { state: "failed", reasonCode }; + } + const latest = await publisher.getIntent(publisherDid, intentId); + if (latest?.state === "published") return { state: "published", uri: "", cid: "" }; + if (latest?.state === "reconciling") return { state: "reconciling" }; + if (latest?.state === "ready") { + return { state: "blocked", reasonCode: "PUBLICATION_RETRY_REQUIRED" }; + } + return { state: "failed", reasonCode: completed.code }; +} + +export async function publishVerifiedIntent( + env: PublicationWorkflowEnv, + step: WorkflowStep, + publisherDid: string, + originalIntent: StoredIntent, + approvalEvidence: ApprovalEvidence, +): Promise { + if (!isDid(publisherDid)) { + return { intentId: originalIntent.id, state: "invalid", reasonCode: "PUBLISHER_INVALID" }; + } + const release = releaseFromIntent(originalIntent); + if (!release) { + return { intentId: originalIntent.id, state: "invalid", reasonCode: "RELEASE_INVALID" }; + } + const publisher = env.PUBLISHER_DO.getByName(publisherDid); + const control = env.SERVICE_CONTROL_DO.getByName(SERVICE_CONTROL_OBJECT_NAME); + const expectedEvidenceDigest = await computeApprovalEvidenceDigest(approvalEvidence); + + for (let attempt = 1; attempt <= MAX_PUBLICATION_ATTEMPTS; attempt += 1) { + let finalVerification: FinalVerificationResult; + try { + finalVerification = await step.do( + `final-verification-${attempt}`, + FINAL_VERIFICATION_STEP_CONFIG, + async () => { + const snapshot = await readPublisherVerificationSnapshot( + publisherDid, + originalIntent.packageSlug, + originalIntent.version, + ); + const verifierInput = prepareVerifierInput(originalIntent, snapshot); + if (!verifierInput) { + return { ok: false, reasonCode: "FINAL_INPUT_INVALID", terminalState: "invalid" }; + } + const verifier = normalizeVerifierReport( + await env.RELEASE_VERIFIER.verifyRelease(verifierInput), + ); + const evaluation = await evaluateVerifiedRelease( + publisherDid, + originalIntent, + snapshot, + verifier, + ); + if (!evaluation.success) { + return { ok: false, reasonCode: evaluation.reasonCode, terminalState: "invalid" }; + } + if ( + (await computeApprovalEvidenceDigest(evaluation.value.approvalEvidence)) !== + expectedEvidenceDigest + ) { + return { + ok: false, + reasonCode: "FINAL_VERIFICATION_CHANGED", + terminalState: "invalid", + }; + } + const stored = await publisher.putVerificationStep({ + publisherDid, + intentId: originalIntent.id, + name: "final-verification", + inputDigest: expectedEvidenceDigest, + resultJson: JSON.stringify({ + verificationDigest: evaluation.value.approvalEvidence.verificationDigest, + }), + }); + return stored.ok + ? { + ok: true, + verificationDigest: evaluation.value.approvalEvidence.verificationDigest, + } + : { ok: false, reasonCode: stored.code, terminalState: "invalid" }; + }, + ); + } catch (error) { + const code = publisherSnapshotErrorCode(error); + if (!code) throw error; + finalVerification = { + ok: false, + reasonCode: code, + terminalState: code === "RELEASE_EXISTS" ? "conflict" : "invalid", + }; + } + if (!finalVerification.ok) { + const current = await step.do(`final-invalid-state-${attempt}`, () => + currentState(publisher, publisherDid, originalIntent.id), + ); + if (current?.state === finalVerification.terminalState) { + return { + intentId: originalIntent.id, + state: finalVerification.terminalState, + reasonCode: finalVerification.reasonCode, + }; + } + if (current?.state !== "ready") { + return { + intentId: originalIntent.id, + state: "failed", + reasonCode: "INTENT_STATE_INVALID", + }; + } + const terminal = await step.do( + `mark-final-${finalVerification.terminalState}-${attempt}`, + () => + transition(publisher, { + publisherDid, + intentId: originalIntent.id, + expectedState: "ready", + expectedGeneration: current.stateGeneration, + toState: finalVerification.terminalState, + transitionDigest: expectedEvidenceDigest, + actorRealm: "system", + actorIdentity: "release-service", + reasonCode: finalVerification.reasonCode, + stateDataJson: JSON.stringify({ reasonCode: finalVerification.reasonCode }), + }), + ); + if (!terminal.ok) { + return { intentId: originalIntent.id, state: "failed", reasonCode: terminal.code }; + } + return { + intentId: originalIntent.id, + state: finalVerification.terminalState, + reasonCode: finalVerification.reasonCode, + }; + } + + const attemptResult = await (async (): Promise => { + const current = await publisher.getIntent(publisherDid, originalIntent.id); + if (current?.state === "published") { + return { state: "published", uri: "", cid: "" }; + } + if (current?.state === "reconciling") return { state: "reconciling" }; + if (!current || (current.state !== "ready" && current.state !== "publishing")) { + return { state: "failed", reasonCode: "INTENT_NOT_READY" }; + } + const staged = await step.do( + "publication-stage", + MATERIALIZATION_STEP_CONFIG, + () => stageSourceArtifacts(env, publisher, publisherDid, originalIntent, release), + ); + const credential = await step.do( + `publication-attempt-credential-${attempt}`, + async () => randomCredential(), + ); + let publishingGeneration = current.stateGeneration; + if (current.state === "ready") { + const publishing = await transition(publisher, { + publisherDid, + intentId: originalIntent.id, + expectedState: "ready", + expectedGeneration: current.stateGeneration, + toState: "publishing", + transitionDigest: await digest(["publishing", attempt, expectedEvidenceDigest]), + actorRealm: "system", + actorIdentity: "release-service", + reasonCode: null, + stateDataJson: JSON.stringify({ attempt }), + }); + if (!publishing.ok) return { state: "failed", reasonCode: publishing.code }; + publishingGeneration = publishing.stateGeneration; + } + const operation = await step.do( + `publication-begin-${attempt}`, + async () => { + const result = await publisher.beginPublicationOperation( + publisherDid, + originalIntent.id, + publishingGeneration, + PUBLICATION_OPERATION_LEASE_MS, + credential.attemptKey, + credential.token, + ); + if ( + !result.ok && + (result.code === "PUBLICATION_BUSY" || result.code === "PUBLICATION_RECOVERY_REQUIRED") + ) { + throw new Error(result.code); + } + return result.ok + ? { + ok: true, + lease: { + intentId: result.lease.intentId, + generation: result.lease.generation, + token: result.lease.token, + expectedIntentGeneration: result.lease.expectedIntentGeneration, + expiresAt: result.lease.expiresAt, + }, + replayed: result.replayed, + } + : { ok: false, code: result.code }; + }, + ); + if (!operation.ok) { + const failed = await transition(publisher, { + publisherDid, + intentId: originalIntent.id, + expectedState: "publishing", + expectedGeneration: publishingGeneration, + toState: "failed", + transitionDigest: await digest(["operation-failed", attempt, operation.code]), + actorRealm: "system", + actorIdentity: "release-service", + reasonCode: operation.code, + stateDataJson: JSON.stringify({ reasonCode: operation.code }), + }); + return { + state: "failed", + reasonCode: failed.ok ? operation.code : failed.code, + }; + } + const completionBase = { + publisherDid, + intentId: originalIntent.id, + generation: operation.lease.generation, + token: operation.lease.token, + expectedIntentGeneration: operation.lease.expectedIntentGeneration, + }; + const failBeforeWrite = async ( + reasonCode: string, + retryable = false, + ): Promise => + closeBeforeCreate( + publisher, + publisherDid, + originalIntent.id, + operation.lease, + attempt, + reasonCode, + retryable, + ); + let materializedDigest: string | null = null; + try { + await uploadMaterializedArtifacts(env, step, publisher, publisherDid, originalIntent); + const materialized = await step.do( + "publication-complete-materialization", + async () => { + const completed = await completeMaterialization( + publisher, + publisherDid, + originalIntent, + staged.planJson, + ); + return { + recordDigest: completed.recordDigest, + }; + }, + ); + materializedDigest = materialized.recordDigest; + const materializedPhase = await step.do( + `publication-materialized-${attempt}`, + async () => { + const result = await publisher.advancePublicationOperationPhase({ + ...completionBase, + phase: "materialized", + materializationDigest: materialized.recordDigest, + }); + return result.ok + ? { + ok: true, + phase: result.phase, + materializationDigest: result.materializationDigest, + } + : { ok: false, code: result.code }; + }, + ); + if (!materializedPhase.ok) return failBeforeWrite(materializedPhase.code); + await step.do("publication-staging-cleanup", async () => { + const stored = await publisher.getPublicationMaterialization( + publisherDid, + originalIntent.id, + ); + if (stored?.status !== "complete") return false; + try { + await deleteStagedArtifacts( + env.PUBLICATION_STAGING, + stored.slots.map((artifact) => ({ + key: artifact.stagingKey, + metadata: stagedMetadata(artifact), + sourceUrlDigest: artifact.sourceUrlDigest, + })), + ); + return true; + } catch (error) { + console.error( + JSON.stringify({ + event: "publication_staging_cleanup_failed", + intentId: originalIntent.id, + name: error instanceof Error ? error.name : "UnknownError", + }), + ); + return false; + } + }); + } catch (error) { + return failBeforeWrite(publicationErrorCode(error, "PUBLICATION_PRECONDITION_FAILED")); + } + if (materializedDigest === null) { + return failBeforeWrite("MATERIALIZATION_UNAVAILABLE"); + } + return await step.do(`publication-create-${attempt}`, async () => { + let writeStarted = false; + try { + const restored = await restorePublicationSession(env, publisherDid); + const delegation = await publisher.getDelegation(publisherDid); + if ( + delegation?.status !== "active" || + delegation.stateVersion !== restored.delegationVersion + ) { + return failBeforeWrite("OAUTH_DELEGATION_UNAVAILABLE"); + } + const permit = await control.issuePublicationPermit( + publisherDid, + originalIntent.id, + PUBLICATION_PERMIT_TTL_MS, + ); + if (!permit.ok) { + return failBeforeWrite(permit.code, isRetryablePublicationBlock(permit.code)); + } + const consumed = await control.consumePublicationPermit({ + id: permit.permit.id, + token: permit.permit.token, + publisherDid, + intentId: originalIntent.id, + }); + if (!consumed.ok) { + return failBeforeWrite(consumed.code, isRetryablePublicationBlock(consumed.code)); + } + const recheckedDelegation = await publisher.getDelegation(publisherDid); + if ( + recheckedDelegation?.status !== "active" || + recheckedDelegation.stateVersion !== restored.delegationVersion + ) { + return failBeforeWrite("OAUTH_DELEGATION_UNAVAILABLE"); + } + const persistedRecord = await readPersistedMaterializedRelease( + publisher, + publisherDid, + originalIntent.id, + originalIntent.requestDigest, + ); + if (!persistedRecord) return failBeforeWrite("MATERIALIZATION_UNAVAILABLE"); + const creatingPhase = await publisher.advancePublicationOperationPhase({ + ...completionBase, + phase: "creating", + materializationDigest: materializedDigest, + }); + if (!creatingPhase.ok) return failBeforeWrite(creatingPhase.code); + writeStarted = true; + const created = await createReleaseRecord(restored.session, { + publisherDid, + rkey: `${originalIntent.packageSlug}:${originalIntent.version}`, + record: persistedRecord.record, + }); + const completionDigest = await digest(["published", created.uri, created.cid]); + const completed = await publisher.completePublicationOperation({ + ...completionBase, + completionDigest, + outcome: "published", + resultUri: created.uri, + resultCid: created.cid, + }); + if (completed.ok) return { state: "published", uri: created.uri, cid: created.cid }; + const ambiguous = await publisher.completePublicationOperation({ + ...completionBase, + completionDigest: await digest(["ambiguous", attempt, expectedEvidenceDigest]), + outcome: "ambiguous", + resultUri: null, + resultCid: null, + }); + if (ambiguous.ok) return { state: "reconciling" }; + const latest = await publisher.getIntent(publisherDid, originalIntent.id); + return latest?.state === "published" + ? { state: "published", uri: created.uri, cid: created.cid } + : { state: "reconciling" }; + } catch (error) { + const errorCode = writeStarted + ? "PUBLICATION_AMBIGUOUS" + : publicationErrorCode(error, "PUBLICATION_PRECONDITION_FAILED"); + if (!writeStarted) return failBeforeWrite(errorCode); + console.error( + JSON.stringify({ + event: "publication_attempt_ambiguous", + intentId: originalIntent.id, + attempt, + name: error instanceof Error ? error.name : "UnknownError", + code: errorCode, + }), + ); + const ambiguous = await publisher.completePublicationOperation({ + ...completionBase, + completionDigest: await digest(["ambiguous", attempt, expectedEvidenceDigest]), + outcome: "ambiguous", + resultUri: null, + resultCid: null, + }); + if (ambiguous.ok) return { state: "reconciling" }; + const latest = await publisher.getIntent(publisherDid, originalIntent.id); + return latest?.state === "published" + ? { state: "published", uri: "", cid: "" } + : { state: "reconciling" }; + } + }); + })(); + if (attemptResult.state === "published") { + return { intentId: originalIntent.id, state: "published", reasonCode: null }; + } + if (attemptResult.state === "failed") { + await step.do(`publication-terminal-staging-cleanup-${attempt}`, async () => { + const stored = await publisher.getPublicationMaterialization( + publisherDid, + originalIntent.id, + ); + if (!stored) return true; + try { + await deleteStagedArtifacts( + env.PUBLICATION_STAGING, + stored.slots.map((artifact) => ({ + key: artifact.stagingKey, + metadata: stagedMetadata(artifact), + sourceUrlDigest: artifact.sourceUrlDigest, + })), + ); + return true; + } catch (error) { + console.error( + JSON.stringify({ + event: "publication_terminal_staging_cleanup_failed", + intentId: originalIntent.id, + name: error instanceof Error ? error.name : "UnknownError", + }), + ); + return false; + } + }); + return { intentId: originalIntent.id, state: "failed", reasonCode: attemptResult.reasonCode }; + } + if (attemptResult.state === "blocked") { + return { intentId: originalIntent.id, state: "ready", reasonCode: attemptResult.reasonCode }; + } + + const reconciliation = await step.do< + | { outcome: "absent" } + | { outcome: "exact"; uri: string; cid: string } + | { outcome: "conflict" } + >(`reconcile-${attempt}`, RECONCILIATION_STEP_CONFIG, async () => { + const materialized = await readPersistedMaterializedRelease( + publisher, + publisherDid, + originalIntent.id, + originalIntent.requestDigest, + ); + if (!materialized) { + throw new NonRetryableError("MATERIALIZATION_UNAVAILABLE"); + } + const authoritative = await findProofVerifiedRelease( + publisherDid, + originalIntent.packageSlug, + originalIntent.version, + ); + return reconcileReleaseRecord( + publisherDid, + originalIntent.packageSlug, + originalIntent.version, + materialized.record, + authoritative, + ); + }); + const current = await step.do(`reconciliation-state-${attempt}`, () => + currentState(publisher, publisherDid, originalIntent.id), + ); + if (current?.state === "published") { + return { intentId: originalIntent.id, state: "published", reasonCode: null }; + } + if (current?.state === "conflict") { + return { intentId: originalIntent.id, state: "conflict", reasonCode: "RELEASE_CONFLICT" }; + } + if (!current || current.state !== "reconciling") { + return { + intentId: originalIntent.id, + state: "failed", + reasonCode: "RECONCILIATION_STATE_INVALID", + }; + } + if (reconciliation.outcome === "exact") { + const published = await step.do(`reconcile-published-${attempt}`, () => + transition(publisher, { + publisherDid, + intentId: originalIntent.id, + expectedState: "reconciling", + expectedGeneration: current.stateGeneration, + toState: "published", + transitionDigest: expectedEvidenceDigest, + actorRealm: "system", + actorIdentity: "release-service", + reasonCode: null, + stateDataJson: JSON.stringify({ + resultUri: reconciliation.uri, + resultCid: reconciliation.cid, + }), + }), + ); + return published.ok + ? { intentId: originalIntent.id, state: "published", reasonCode: null } + : { intentId: originalIntent.id, state: "failed", reasonCode: published.code }; + } + if (reconciliation.outcome === "conflict") { + const conflict = await step.do(`reconcile-conflict-${attempt}`, () => + transition(publisher, { + publisherDid, + intentId: originalIntent.id, + expectedState: "reconciling", + expectedGeneration: current.stateGeneration, + toState: "conflict", + transitionDigest: expectedEvidenceDigest, + actorRealm: "system", + actorIdentity: "release-service", + reasonCode: "RELEASE_CONFLICT", + stateDataJson: JSON.stringify({ reasonCode: "RELEASE_CONFLICT" }), + }), + ); + return conflict.ok + ? { intentId: originalIntent.id, state: "conflict", reasonCode: "RELEASE_CONFLICT" } + : { intentId: originalIntent.id, state: "failed", reasonCode: conflict.code }; + } + if (attempt < MAX_PUBLICATION_ATTEMPTS) { + const retry = await step.do(`reconcile-absence-${attempt}`, async () => + transition(publisher, { + publisherDid, + intentId: originalIntent.id, + expectedState: "reconciling", + expectedGeneration: current.stateGeneration, + toState: "ready", + transitionDigest: await digest(["retry", attempt, expectedEvidenceDigest]), + actorRealm: "system", + actorIdentity: "release-service", + reasonCode: "PDS_RETRY_ABSENT", + stateDataJson: JSON.stringify({ attempt, absenceConfirmed: true }), + }), + ); + if (!retry.ok) + return { intentId: originalIntent.id, state: "failed", reasonCode: retry.code }; + continue; + } + const failed = await step.do("reconciliation-exhausted", () => + transition(publisher, { + publisherDid, + intentId: originalIntent.id, + expectedState: "reconciling", + expectedGeneration: current.stateGeneration, + toState: "failed", + transitionDigest: expectedEvidenceDigest, + actorRealm: "system", + actorIdentity: "release-service", + reasonCode: "PDS_RETRY_EXHAUSTED", + stateDataJson: JSON.stringify({ reasonCode: "PDS_RETRY_EXHAUSTED" }), + }), + ); + return failed.ok + ? { intentId: originalIntent.id, state: "failed", reasonCode: "PDS_RETRY_EXHAUSTED" } + : { intentId: originalIntent.id, state: "failed", reasonCode: failed.code }; + } + + return { intentId: originalIntent.id, state: "failed", reasonCode: "PDS_RETRY_EXHAUSTED" }; +} diff --git a/apps/release-service/src/routes.ts b/apps/release-service/src/routes.ts index 75fc01f46c..2ad17ce894 100644 --- a/apps/release-service/src/routes.ts +++ b/apps/release-service/src/routes.ts @@ -1,4 +1,4 @@ -import { apiSuccess } from "./api/response.js"; +import type { AccessActor, AccessRole } from "./access/auth.js"; import { handleBeginApprovalDecision, handleCompleteApprovalDecision, @@ -14,6 +14,19 @@ import { matchApproverCredentialPath, } from "./approvals/routes.js"; import type { ServiceConfiguration } from "./config.js"; +import { + handleControlAudit, + handleReadiness, + handleServiceStatus, + handleSetServiceMode, +} from "./control-do/routes.js"; +import { + handleCancelReleaseIntent, + handleGetReleaseIntent, + handleSubmitReleaseIntent, + matchIntentCancelPath, + matchIntentResourcePath, +} from "./intents/routes.js"; import { getClientMetadata, getPublicJwks, publicOAuthJson } from "./oauth/metadata.js"; import { handleApproverIdentityAuthorize, @@ -21,16 +34,39 @@ import { handlePublisherDelegationAuthorize, handlePublisherIdentityAuthorize, } from "./oauth/routes.js"; +import { + handleCancelOperatorIntent, + handleGetOperatorPublisher, + handleReconcileOperatorIntent, + handleRevokeOperatorPublisher, + handleSetOperatorPublisherSuspension, + matchOperatorIntentCancelPath, + matchOperatorIntentReconcilePath, + matchOperatorPublisherPath, + matchOperatorPublisherRevokePath, + matchOperatorPublisherSuspendPath, +} from "./operator/routes.js"; +import { + handleDisablePublisherWorkload, + handleGetPublisher, + handleListPublisherIntents, + handleListPublisherWorkloads, + handlePutPublisherWorkload, + handleRevokePublisherDelegation, + matchPublisherWorkloadPath, +} from "./publisher/routes.js"; export interface RouteDefinition { - method: "DELETE" | "GET" | "POST"; + method: "DELETE" | "GET" | "PATCH" | "POST" | "PUT"; path: string; match?(pathname: string): Readonly> | null; + accessRole?: AccessRole; handler( request: Request, requestId: string, configuration: ServiceConfiguration, params: Readonly>, + accessActor: AccessActor | null, ): Response | Promise; } @@ -47,11 +83,63 @@ export const ROUTES = Object.freeze([ handler: (_request, _requestId, configuration) => publicOAuthJson(getPublicJwks(configuration.oauth)), }, + { + method: "POST", + path: "/v1/release-intents", + handler: (request, requestId, configuration) => + handleSubmitReleaseIntent(request, requestId, configuration), + }, + { + method: "GET", + path: "/v1/release-intents/{intentId}", + match: matchIntentResourcePath, + handler: (request, requestId, configuration, params) => + handleGetReleaseIntent(request, requestId, configuration, params), + }, + { + method: "POST", + path: "/v1/release-intents/{intentId}/cancel", + match: matchIntentCancelPath, + handler: (request, requestId, configuration, params) => + handleCancelReleaseIntent(request, requestId, configuration, params), + }, { method: "POST", path: "/v1/publisher/session/authorize", handler: handlePublisherIdentityAuthorize, }, + { + method: "GET", + path: "/v1/publisher", + handler: handleGetPublisher, + }, + { + method: "DELETE", + path: "/v1/publisher/delegation", + handler: (request, requestId, configuration) => + handleRevokePublisherDelegation(request, requestId, configuration), + }, + { + method: "GET", + path: "/v1/publisher/workloads", + handler: handleListPublisherWorkloads, + }, + { + method: "POST", + path: "/v1/publisher/workloads", + handler: handlePutPublisherWorkload, + }, + { + method: "DELETE", + path: "/v1/publisher/workloads/{packageSlug}", + match: matchPublisherWorkloadPath, + handler: handleDisablePublisherWorkload, + }, + { + method: "GET", + path: "/v1/publisher/intents", + handler: handleListPublisherIntents, + }, { method: "POST", path: "/v1/approver/session/authorize", @@ -108,7 +196,60 @@ export const ROUTES = Object.freeze([ }, { method: "GET", - path: "/health", - handler: (_request, requestId) => apiSuccess({ status: "ok" }, requestId), + path: "/ready", + handler: handleReadiness, + }, + { + method: "GET", + path: "/admin/api/status", + accessRole: "viewer", + handler: handleServiceStatus, + }, + { + method: "POST", + path: "/admin/api/pause", + accessRole: "admin", + handler: handleSetServiceMode, + }, + { + method: "GET", + path: "/admin/api/publishers/{publisherDid}", + match: matchOperatorPublisherPath, + accessRole: "viewer", + handler: handleGetOperatorPublisher, + }, + { + method: "POST", + path: "/admin/api/publishers/{publisherDid}/suspend", + match: matchOperatorPublisherSuspendPath, + accessRole: "admin", + handler: handleSetOperatorPublisherSuspension, + }, + { + method: "POST", + path: "/admin/api/publishers/{publisherDid}/revoke", + match: matchOperatorPublisherRevokePath, + accessRole: "admin", + handler: handleRevokeOperatorPublisher, + }, + { + method: "POST", + path: "/admin/api/intents/{intentId}/cancel", + match: matchOperatorIntentCancelPath, + accessRole: "reviewer", + handler: handleCancelOperatorIntent, + }, + { + method: "POST", + path: "/admin/api/intents/{intentId}/reconcile", + match: matchOperatorIntentReconcilePath, + accessRole: "reviewer", + handler: handleReconcileOperatorIntent, + }, + { + method: "GET", + path: "/admin/api/audit", + accessRole: "viewer", + handler: handleControlAudit, }, ] as const satisfies readonly RouteDefinition[]); diff --git a/apps/release-service/src/ui/App.test.tsx b/apps/release-service/src/ui/App.test.tsx new file mode 100644 index 0000000000..a1414925b6 --- /dev/null +++ b/apps/release-service/src/ui/App.test.tsx @@ -0,0 +1,229 @@ +import { I18nProvider } from "@lingui/react"; +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { getApproval, listApproverCredentials } from "./api.js"; +import { App } from "./App.js"; +import { applyLocale, i18n } from "./i18n.js"; + +const PUBLISHER_DID = "did:web:publisher.example.com"; +const INTENT_ID = "01JABCDEFGHJKMNPQRSTVWXYZ0"; + +function success(data: unknown): Response { + return Response.json({ data, requestId: "request-1" }); +} + +function renderApp(path: string) { + history.replaceState(null, "", path); + return render( + + + , + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); + applyLocale("en"); +}); + +describe("release-service web surfaces", () => { + it("shows publisher identity login when no application session exists", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json( + { + error: { code: "PUBLISHER_SESSION_INVALID", message: "Publisher session is not valid" }, + requestId: "request-1", + }, + { status: 401 }, + ), + ), + ); + renderApp("/publisher"); + + expect(await screen.findByRole("heading", { name: "Sign in as a publisher" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Continue with Atmosphere" })).toBeTruthy(); + }); + + it("renders publisher authority, workloads, and intent state", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const path = new URL( + input instanceof Request ? input.url : input.toString(), + location.origin, + ).pathname; + if (path === "/v1/publisher") { + return success({ + publisher: { + did: PUBLISHER_DID, + delegation: { + releaseNsid: "com.emdashcms.experimental.package.release", + scope: "atproto repo:com.emdashcms.experimental.package.release?action=create", + issuer: "https://authorization.example.com", + pdsUrl: "https://pds.example.com", + expiresAt: null, + refreshBefore: null, + status: "active", + stateVersion: 1, + }, + }, + }); + } + if (path === "/v1/publisher/workloads") { + return success({ + items: [ + { + packageSlug: "gallery", + repository: "example/gallery", + repositoryId: "123", + repositoryOwnerId: "456", + workflowRef: "example/gallery/.github/workflows/release.yml@refs/heads/main", + allowedRefs: ["refs/heads/main"], + allowedEnvironments: [], + active: true, + stateVersion: 1, + authorizedBy: PUBLISHER_DID, + createdAt: 1_799_999_000_000, + updatedAt: 1_799_999_000_000, + }, + ], + }); + } + return success({ + items: [ + { + id: INTENT_ID, + publisherDid: PUBLISHER_DID, + packageSlug: "gallery", + version: "1.2.3", + state: "awaiting_approval", + stateGeneration: 4, + reasonCode: "APPROVAL_REQUIRED", + workflowId: INTENT_ID, + expiresAt: 1_800_000_000_000, + createdAt: 1_799_999_000_000, + updatedAt: 1_799_999_500_000, + result: null, + approvalUrl: `${location.origin}/approvals/${INTENT_ID}?publisher=${encodeURIComponent(PUBLISHER_DID)}`, + }, + ], + }); + }), + ); + renderApp("/publisher"); + + expect(await screen.findByText(PUBLISHER_DID)).toBeTruthy(); + expect(screen.getAllByText("gallery").length).toBeGreaterThan(0); + expect(screen.getByText("Awaiting approval")).toBeTruthy(); + }); + + it("shows immutable workload and provenance evidence before approval", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const path = new URL( + input instanceof Request ? input.url : input.toString(), + location.origin, + ).pathname; + if (path === "/v1/approver/credentials") { + return success({ + items: [ + { + id: "credential", + name: "Work laptop", + transports: ["internal"], + createdAt: 1_799_999_000_000, + lastUsedAt: null, + revokedAt: null, + }, + ], + }); + } + return success({ + intent: { + id: INTENT_ID, + packageSlug: "gallery", + version: "1.2.3", + state: "awaiting_approval", + expiresAt: 1_800_000_000_000, + }, + evidence: { profileCid: "bafyprofile" }, + evidenceDigest: "D".repeat(43), + review: { + source: { + repository: "example/gallery", + workflowRef: "example/gallery/.github/workflows/release.yml@refs/heads/main", + commitSha: "a".repeat(40), + runId: "100", + actor: "release-bot", + }, + artifact: { url: "https://example.com/gallery.tgz", checksum: "sha256:artifact" }, + provenance: { + url: "https://example.com/provenance.json", + checksum: "sha256:provenance", + predicateType: "https://slsa.dev/provenance/v1", + sourceRepository: "https://github.com/example/gallery", + builderId: + "https://github.com/example/gallery/.github/workflows/release.yml@refs/heads/main", + }, + accessDiff: { + escalation: true, + changes: [ + { + kind: "operation-added", + category: "network", + operation: "request", + path: ["network", "request"], + escalation: true, + }, + ], + }, + }, + }); + }), + ); + await expect(listApproverCredentials()).resolves.toHaveLength(1); + await expect(getApproval(PUBLISHER_DID, INTENT_ID)).resolves.toMatchObject({ + review: { source: { repository: "example/gallery" } }, + }); + renderApp(`/approvals/${INTENT_ID}?publisher=${encodeURIComponent(PUBLISHER_DID)}`); + + expect(await screen.findByText("example/gallery")).toBeTruthy(); + expect(screen.getByText("sha256:artifact")).toBeTruthy(); + expect(screen.getByText("sha256:provenance")).toBeTruthy(); + const approve = screen.getByRole("button", { name: "Approve release" }); + expect(approve).toBeInstanceOf(HTMLButtonElement); + expect(approve.hasAttribute("disabled")).toBe(false); + }); + + it("renders the Access operator control surface", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + success({ + state: { + mode: "active", + epoch: 1, + reasonCode: null, + changedBy: "system:bootstrap", + changedAt: 0, + }, + }), + ), + ); + renderApp("/admin"); + + expect(await screen.findByRole("heading", { name: "Service control" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Pause admission" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "Publisher lookup" })).toBeTruthy(); + }); + + it("applies right-to-left document direction for Arabic", async () => { + applyLocale("ar"); + expect(document.documentElement.dir).toBe("rtl"); + expect(document.documentElement.lang).toBe("ar"); + }); +}); diff --git a/apps/release-service/src/ui/App.tsx b/apps/release-service/src/ui/App.tsx new file mode 100644 index 0000000000..474409455a --- /dev/null +++ b/apps/release-service/src/ui/App.tsx @@ -0,0 +1,16 @@ +import { ApproverPage } from "./ApproverPage.js"; +import { Page } from "./components.js"; +import { OperatorPage } from "./OperatorPage.js"; +import { PublisherPage } from "./PublisherPage.js"; + +export function App() { + const path = location.pathname; + const content = path.startsWith("/admin") ? ( + + ) : path.startsWith("/approvals/") ? ( + + ) : ( + + ); + return {content}; +} diff --git a/apps/release-service/src/ui/ApproverPage.tsx b/apps/release-service/src/ui/ApproverPage.tsx new file mode 100644 index 0000000000..179df7e0fb --- /dev/null +++ b/apps/release-service/src/ui/ApproverPage.tsx @@ -0,0 +1,287 @@ +import { Badge, Button, Input, Surface } from "@cloudflare/kumo"; +import { type FormEvent, useCallback, useEffect, useState } from "react"; + +import { + beginApprovalDecision, + beginPasskeyRegistration, + completeApprovalDecision, + completePasskeyRegistration, + getApproval, + listApproverCredentials, + type ApprovalResource, + type ApproverCredential, + UiApiError, +} from "./api.js"; +import { ErrorBanner, LoadingPanel, LoginPanel } from "./components.js"; +import { useT } from "./i18n.js"; +import { + authenticationResponse, + creationOptions, + registrationResponse, + requestOptions, +} from "./webauthn.js"; + +function detail(value: string | null, fallback: string): string { + return value || fallback; +} + +export function ApproverPage() { + const t = useT(); + const intentId = location.pathname.startsWith("/approvals/") + ? location.pathname.slice("/approvals/".length) + : ""; + const publisherDid = new URLSearchParams(location.search).get("publisher") ?? ""; + const [approval, setApproval] = useState(null); + const [credentials, setCredentials] = useState([]); + const [loginRequired, setLoginRequired] = useState(false); + const [credentialName, setCredentialName] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [completedDecision, setCompletedDecision] = useState<"approve" | "reject" | null>(null); + + const refresh = useCallback(async () => { + setError(null); + if (!intentId || !publisherDid) { + setError(new UiApiError("INVALID_REQUEST", 400, "Approval link is incomplete")); + return; + } + try { + const [credentialItems, approvalResource] = await Promise.all([ + listApproverCredentials(), + getApproval(publisherDid, intentId), + ]); + setCredentials(credentialItems); + setApproval(approvalResource); + setLoginRequired(false); + } catch (cause) { + if (cause instanceof UiApiError && cause.code === "APPROVER_SESSION_INVALID") { + setLoginRequired(true); + return; + } + setError(cause); + } + }, [intentId, publisherDid]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + async function enrol(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setError(null); + try { + if (!navigator.credentials) throw new Error("Passkeys are unavailable"); + const options = creationOptions(await beginPasskeyRegistration(credentialName)); + const created = await navigator.credentials.create({ publicKey: options }); + if (!(created instanceof PublicKeyCredential)) + throw new Error("Passkey creation was cancelled"); + await completePasskeyRegistration(registrationResponse(created)); + setCredentialName(""); + await refresh(); + } catch (cause) { + setError(cause); + } finally { + setBusy(false); + } + } + + async function decide(decision: "approve" | "reject") { + setBusy(true); + setError(null); + try { + if (!navigator.credentials) throw new Error("Passkeys are unavailable"); + const options = requestOptions(await beginApprovalDecision(publisherDid, intentId, decision)); + const assertion = await navigator.credentials.get({ publicKey: options }); + if (!(assertion instanceof PublicKeyCredential)) + throw new Error("Passkey request was cancelled"); + await completeApprovalDecision( + publisherDid, + intentId, + decision, + authenticationResponse(assertion), + ); + setCompletedDecision(decision); + await refresh(); + } catch (cause) { + setError(cause); + } finally { + setBusy(false); + } + } + + if (loginRequired) return ; + if (!approval && !error) return ; + if (!approval) return ; + const review = approval.review; + const none = t("approval.none", "Not available"); + + return ( +
+ {error ? : null} + {completedDecision ? ( + + {completedDecision === "approve" + ? t( + "approval.completed.approve", + "Approval recorded. The release workflow can continue.", + ) + : t( + "approval.completed.reject", + "Rejection recorded. The release will not be published.", + )} + + ) : null} + +
+
+

+ {t("approval.title", "Review delegated release")} +

+

+ {t("approval.package", "{packageSlug} version {version}", { + packageSlug: approval.intent.packageSlug, + version: approval.intent.version, + })} +

+
+ {t("approval.required", "Approval required")} +
+
+ + + + + + + + + + +
+
+ + +
+

+ {t("approval.access.title", "Declared access changes")} +

+ + {review.accessDiff.escalation + ? t("approval.access.escalation", "Escalation") + : t("approval.access.noEscalation", "No escalation")} + +
+ {review.accessDiff.changes.length === 0 ? ( +

+ {t("approval.access.empty", "This release does not change declared access.")} +

+ ) : ( +
    + {review.accessDiff.changes.map((change) => ( +
  • +

    + {t("approval.access.change", "{kind}: {category}", { + kind: change.kind, + category: change.category, + })} +

    + {change.operation ? ( +

    + {t("approval.access.operation", "Operation: {operation}", { + operation: change.operation, + })} +

    + ) : null} +
  • + ))} +
+ )} +
+ + +

+ {t("approval.credentials.title", "Approver passkeys")} +

+
+ {credentials.map((credential) => ( + + {credential.name} + + ))} +
+
+ setCredentialName(event.currentTarget.value)} + /> + +
+
+ +
+ + +
+
+ ); +} + +function ReviewItem({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/apps/release-service/src/ui/OperatorPage.tsx b/apps/release-service/src/ui/OperatorPage.tsx new file mode 100644 index 0000000000..ef58d76973 --- /dev/null +++ b/apps/release-service/src/ui/OperatorPage.tsx @@ -0,0 +1,252 @@ +import { Badge, Button, Input, Surface } from "@cloudflare/kumo"; +import { + ReleaseServiceOperatorClient, + createReleaseIdempotencyKey, + type OperatorPublisherResource, + type ServiceControlState, +} from "@emdash-cms/registry-client/release-service"; +import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react"; + +import { ErrorBanner, LoadingPanel } from "./components.js"; +import { useT } from "./i18n.js"; + +function operatorStatus(t: ReturnType, status: string): string { + if (status === "active") return t("operator.status.active", "Active"); + if (status === "admission-paused") + return t("operator.status.admissionPaused", "Admission paused"); + if (status === "publication-paused") + return t("operator.status.publicationPaused", "Publication paused"); + if (status === "allowed") return t("operator.status.allowed", "Allowed"); + if (status === "suspended") return t("operator.status.suspended", "Suspended"); + if (status === "revoked") return t("operator.status.revoked", "Revoked"); + if (status === "reauthorization_required") + return t("operator.status.reauthorize", "Reauthorization required"); + return t("operator.status.unknown", "Unknown"); +} + +export function OperatorPage() { + const t = useT(); + const client = useMemo( + () => new ReleaseServiceOperatorClient({ serviceUrl: location.origin }), + [], + ); + const [state, setState] = useState(null); + const [publisher, setPublisher] = useState(null); + const [publisherDid, setPublisherDid] = useState(""); + const [intentId, setIntentId] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const refreshStatus = useCallback(async () => { + try { + setState(await client.getStatus()); + } catch (cause) { + setError(cause); + } + }, [client]); + + useEffect(() => { + void refreshStatus(); + }, [refreshStatus]); + + async function setMode(mode: ServiceControlState["mode"]) { + setBusy(true); + setError(null); + try { + const result = await client.setMode(mode, mode === "active" ? null : "OPERATOR_PAUSE", { + idempotencyKey: createReleaseIdempotencyKey("web-service-mode"), + }); + setState(result.value); + } catch (cause) { + setError(cause); + } finally { + setBusy(false); + } + } + + async function lookupPublisher(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setError(null); + try { + setPublisher(await client.getPublisher(publisherDid)); + } catch (cause) { + setError(cause); + } finally { + setBusy(false); + } + } + + async function setSuspended(suspended: boolean) { + setBusy(true); + setError(null); + try { + await client.setPublisherSuspended( + publisherDid, + suspended, + suspended ? "OPERATOR_SUSPENDED" : null, + { idempotencyKey: createReleaseIdempotencyKey("web-publisher-control") }, + ); + setPublisher(await client.getPublisher(publisherDid)); + } catch (cause) { + setError(cause); + } finally { + setBusy(false); + } + } + + async function revokePublisher() { + setBusy(true); + setError(null); + try { + await client.revokePublisher(publisherDid, { + idempotencyKey: createReleaseIdempotencyKey("web-operator-revoke"), + }); + setPublisher(await client.getPublisher(publisherDid)); + } catch (cause) { + setError(cause); + } finally { + setBusy(false); + } + } + + async function operateIntent(action: "cancel" | "reconcile") { + setBusy(true); + setError(null); + try { + if (action === "cancel") { + await client.cancelIntent(publisherDid, intentId, { + idempotencyKey: createReleaseIdempotencyKey("web-operator-cancel"), + }); + } else { + await client.reconcileIntent(publisherDid, intentId, { + idempotencyKey: createReleaseIdempotencyKey("web-operator-reconcile"), + }); + } + } catch (cause) { + setError(cause); + } finally { + setBusy(false); + } + } + + if (!state && !error) return ; + + return ( +
+ {error ? : null} + +
+
+

+ {t("operator.service.title", "Service control")} +

+

+ {t( + "operator.service.description", + "Pause admission or publication across the hosted service.", + )} +

+
+ + {operatorStatus(t, state?.mode ?? "unknown")} + +
+
+ + + +
+
+ + +

+ {t("operator.publisher.title", "Publisher lookup")} +

+
+ setPublisherDid(event.currentTarget.value)} + /> + +
+ {publisher ? ( +
+ + {operatorStatus(t, publisher.control.status)} + + + {publisher.delegation + ? operatorStatus(t, publisher.delegation.status) + : t("operator.publisher.noDelegation", "No delegation")} + + + +
+ ) : null} +
+ + +

+ {t("operator.intent.title", "Intent recovery")} +

+
+ setPublisherDid(event.currentTarget.value)} + /> + setIntentId(event.currentTarget.value)} + /> +
+
+ + +
+
+
+ ); +} diff --git a/apps/release-service/src/ui/PublisherPage.tsx b/apps/release-service/src/ui/PublisherPage.tsx new file mode 100644 index 0000000000..2952f06ecb --- /dev/null +++ b/apps/release-service/src/ui/PublisherPage.tsx @@ -0,0 +1,327 @@ +import { Badge, Button, Input, Surface, Table } from "@cloudflare/kumo"; +import { + ReleaseServiceClient, + ReleaseServiceError, + createReleaseIdempotencyKey, + type PublisherResource, + type ReleaseIntentResource, + type WorkloadPolicyResource, +} from "@emdash-cms/registry-client/release-service"; +import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react"; + +import { beginPublisherDelegation, publisherCsrfToken } from "./api.js"; +import { ErrorBanner, LoadingPanel, LoginPanel } from "./components.js"; +import { useT } from "./i18n.js"; + +interface PublisherData { + publisher: PublisherResource; + workloads: WorkloadPolicyResource[]; + intents: ReleaseIntentResource[]; +} + +function stateVariant(state: string): "error" | "neutral" | "success" | "warning" { + if (state === "published" || state === "active") return "success"; + if (state === "failed" || state === "conflict" || state === "invalid" || state === "revoked") { + return "error"; + } + if (state === "awaiting_approval" || state === "reconciling") return "warning"; + return "neutral"; +} + +function stateLabel(t: ReturnType, state: string): string { + switch (state) { + case "active": + return t("status.active", "Active"); + case "awaiting_approval": + return t("status.awaitingApproval", "Awaiting approval"); + case "cancelled": + return t("status.cancelled", "Cancelled"); + case "conflict": + return t("status.conflict", "Conflict"); + case "expired": + return t("status.expired", "Expired"); + case "failed": + return t("status.failed", "Failed"); + case "invalid": + return t("status.invalid", "Invalid"); + case "published": + return t("status.published", "Published"); + case "publishing": + return t("status.publishing", "Publishing"); + case "ready": + return t("status.ready", "Ready"); + case "reauthorization_required": + return t("status.reauthorizationRequired", "Reauthorization required"); + case "received": + return t("status.received", "Received"); + case "reconciling": + return t("status.reconciling", "Reconciling"); + case "rejected": + return t("status.rejected", "Rejected"); + case "revoked": + return t("status.revoked", "Revoked"); + case "verified": + return t("status.verified", "Verified"); + case "verifying": + return t("status.verifying", "Verifying"); + default: + return t("status.unknown", "Unknown"); + } +} + +export function PublisherPage() { + const t = useT(); + const client = useMemo( + () => + new ReleaseServiceClient({ + serviceUrl: location.origin, + csrfToken: publisherCsrfToken, + }), + [], + ); + const [data, setData] = useState(null); + const [loginRequired, setLoginRequired] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [packageSlug, setPackageSlug] = useState(""); + const [repository, setRepository] = useState(""); + const [repositoryId, setRepositoryId] = useState(""); + const [repositoryOwnerId, setRepositoryOwnerId] = useState(""); + const [workflowRef, setWorkflowRef] = useState(""); + const [allowedRef, setAllowedRef] = useState("refs/heads/main"); + + const refresh = useCallback(async () => { + setError(null); + try { + const [publisher, workloads, intents] = await Promise.all([ + client.getPublisher(), + client.listWorkloads({ limit: 100 }), + client.listPublisherIntents({ limit: 100 }), + ]); + setData({ publisher, workloads: workloads.items, intents: intents.items }); + setLoginRequired(false); + } catch (cause) { + if ( + cause instanceof ReleaseServiceError && + (cause.code === "PUBLISHER_SESSION_INVALID" || cause.code === "AUTH_INVALID") + ) { + setLoginRequired(true); + return; + } + setError(cause); + } + }, [client]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + async function authorizeDelegation() { + setBusy(true); + setError(null); + try { + location.assign(await beginPublisherDelegation("/publisher")); + } catch (cause) { + setError(cause); + setBusy(false); + } + } + + async function revokeDelegation() { + setBusy(true); + setError(null); + try { + await client.revokeDelegation({ idempotencyKey: createReleaseIdempotencyKey("web-revoke") }); + await refresh(); + } catch (cause) { + setError(cause); + } finally { + setBusy(false); + } + } + + async function saveWorkload(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setError(null); + try { + await client.putWorkload( + { + packageSlug, + repository, + repositoryId, + repositoryOwnerId, + workflowRef, + allowedRefs: [allowedRef], + allowedEnvironments: [], + expectedVersion: null, + }, + { idempotencyKey: createReleaseIdempotencyKey("web-workload") }, + ); + setPackageSlug(""); + setRepository(""); + setRepositoryId(""); + setRepositoryOwnerId(""); + setWorkflowRef(""); + await refresh(); + } catch (cause) { + setError(cause); + } finally { + setBusy(false); + } + } + + if (loginRequired) return ; + if (!data && !error) return ; + if (!data) return ; + const delegation = data.publisher.delegation; + + return ( +
+ {error ? : null} + +
+
+

+ {t("publisher.authority.title", "Publishing authority")} +

+

{data.publisher.did}

+
+ + {delegation + ? stateLabel(t, delegation.status) + : t("publisher.delegation.missing", "Not delegated")} + +
+

+ {t( + "publisher.authority.description", + "The service can only create package release records. It cannot update or delete records.", + )} +

+
+ + {delegation && delegation.status !== "revoked" ? ( + + ) : null} +
+
+ + +

+ {t("publisher.workload.addTitle", "Add GitHub workload")} +

+
+ setPackageSlug(event.currentTarget.value)} + /> + setRepository(event.currentTarget.value)} + /> + setRepositoryId(event.currentTarget.value)} + /> + setRepositoryOwnerId(event.currentTarget.value)} + /> + setWorkflowRef(event.currentTarget.value)} + /> + setAllowedRef(event.currentTarget.value)} + /> +
+ +
+
+
+ + + + + + {t("publisher.workloads.package", "Package")} + {t("publisher.workloads.repository", "Repository")} + {t("publisher.workloads.workflow", "Workflow")} + {t("publisher.workloads.status", "Status")} + + + + {data.workloads.map((workload) => ( + + {workload.packageSlug} + {workload.repository} + {workload.workflowRef} + + + {workload.active + ? t("status.active", "Active") + : t("status.disabled", "Disabled")} + + + + ))} + +
+
+ + + + + + {t("publisher.intents.package", "Package")} + {t("publisher.intents.version", "Version")} + {t("publisher.intents.state", "State")} + {t("publisher.intents.updated", "Updated")} + + + + {data.intents.map((intent) => ( + + {intent.packageSlug} + {intent.version} + + {stateLabel(t, intent.state)} + + + {new Intl.DateTimeFormat(document.documentElement.lang, { + dateStyle: "medium", + timeStyle: "short", + }).format(intent.updatedAt)} + + + ))} + +
+
+
+ ); +} diff --git a/apps/release-service/src/ui/api.test.ts b/apps/release-service/src/ui/api.test.ts new file mode 100644 index 0000000000..5d18894d69 --- /dev/null +++ b/apps/release-service/src/ui/api.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { parseApprovalResource } from "./api.js"; + +describe("approval UI response validation", () => { + it("accepts the sanitized immutable review shape", () => { + expect( + parseApprovalResource({ + intent: { + id: "01JABCDEFGHJKMNPQRSTVWXYZ0", + packageSlug: "gallery", + version: "1.2.3", + state: "awaiting_approval", + expiresAt: 1_800_000_000_000, + }, + evidence: { profileCid: "bafyprofile" }, + evidenceDigest: "D".repeat(43), + review: { + source: { + repository: "example/gallery", + workflowRef: "example/gallery/.github/workflows/release.yml@refs/heads/main", + commitSha: "a".repeat(40), + runId: "100", + actor: "release-bot", + }, + artifact: { url: "https://example.com/gallery.tgz", checksum: "sha256:artifact" }, + provenance: { + url: "https://example.com/provenance.json", + checksum: "sha256:provenance", + predicateType: "https://slsa.dev/provenance/v1", + sourceRepository: "https://github.com/example/gallery", + builderId: + "https://github.com/example/gallery/.github/workflows/release.yml@refs/heads/main", + }, + accessDiff: { + escalation: true, + changes: [ + { + kind: "operation-added", + category: "network", + operation: "request", + path: ["network", "request"], + escalation: true, + }, + ], + }, + }, + }), + ).toMatchObject({ review: { source: { repository: "example/gallery" } } }); + }); +}); diff --git a/apps/release-service/src/ui/api.ts b/apps/release-service/src/ui/api.ts new file mode 100644 index 0000000000..1da91f4658 --- /dev/null +++ b/apps/release-service/src/ui/api.ts @@ -0,0 +1,394 @@ +export class UiApiError extends Error { + constructor( + readonly code: string, + readonly status: number, + message: string, + ) { + super(message); + this.name = "UiApiError"; + } +} + +export interface ApproverCredential { + id: string; + name: string; + transports: string[]; + createdAt: number; + lastUsedAt: number | null; + revokedAt: number | null; +} + +export interface ApprovalReview { + source: { + repository: string | null; + workflowRef: string | null; + commitSha: string | null; + runId: string | null; + actor: string | null; + }; + artifact: { url: string; checksum: string }; + provenance: { + url: string; + checksum: string; + predicateType: string; + sourceRepository: string; + builderId: string; + } | null; + accessDiff: { + escalation: boolean; + changes: Array<{ + kind: string; + category: string; + operation: string | null; + path: string[]; + escalation: boolean; + }>; + }; +} + +export interface ApprovalResource { + intent: { + id: string; + packageSlug: string; + version: string; + state: string; + expiresAt: number; + }; + evidence: Record; + evidenceDigest: string; + review: ApprovalReview; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function credential(value: unknown): ApproverCredential { + if (!isRecord(value) || typeof value["id"] !== "string" || typeof value["name"] !== "string") { + throw new UiApiError("CLIENT_RESPONSE_INVALID", 502, "Invalid service response"); + } + const transports = value["transports"]; + const createdAt = value["createdAt"]; + const lastUsedAt = value["lastUsedAt"]; + const revokedAt = value["revokedAt"]; + if ( + !Array.isArray(transports) || + transports.some((item) => typeof item !== "string") || + !Number.isSafeInteger(createdAt) || + (lastUsedAt !== null && !Number.isSafeInteger(lastUsedAt)) || + (revokedAt !== null && !Number.isSafeInteger(revokedAt)) + ) { + throw new UiApiError("CLIENT_RESPONSE_INVALID", 502, "Invalid service response"); + } + return { + id: value["id"], + name: value["name"], + transports, + createdAt: Number(createdAt), + lastUsedAt: lastUsedAt === null ? null : Number(lastUsedAt), + revokedAt: revokedAt === null ? null : Number(revokedAt), + }; +} + +function nullableString(value: unknown): string | null | undefined { + return value === null || typeof value === "string" ? value : undefined; +} + +export function parseApprovalResource(value: unknown): ApprovalResource { + if ( + !isRecord(value) || + !isRecord(value["intent"]) || + !isRecord(value["evidence"]) || + typeof value["evidenceDigest"] !== "string" || + !isRecord(value["review"]) + ) { + throw new UiApiError("CLIENT_RESPONSE_INVALID", 502, "Invalid service response"); + } + const intent = value["intent"]; + const review = value["review"]; + if ( + typeof intent["id"] !== "string" || + typeof intent["packageSlug"] !== "string" || + typeof intent["version"] !== "string" || + typeof intent["state"] !== "string" || + !Number.isSafeInteger(intent["expiresAt"]) || + !isRecord(review["source"]) || + !isRecord(review["artifact"]) + ) { + throw new UiApiError("CLIENT_RESPONSE_INVALID", 502, "Invalid service response"); + } + const source = review["source"]; + const artifact = review["artifact"]; + const repository = nullableString(source["repository"]); + const workflowRef = nullableString(source["workflowRef"]); + const commitSha = nullableString(source["commitSha"]); + const runId = nullableString(source["runId"]); + const actor = nullableString(source["actor"]); + if ( + repository === undefined || + workflowRef === undefined || + commitSha === undefined || + runId === undefined || + actor === undefined || + typeof artifact["url"] !== "string" || + typeof artifact["checksum"] !== "string" + ) { + throw new UiApiError("CLIENT_RESPONSE_INVALID", 502, "Invalid service response"); + } + let provenance: ApprovalReview["provenance"] = null; + if (review["provenance"] !== null) { + if (!isRecord(review["provenance"])) { + throw new UiApiError("CLIENT_RESPONSE_INVALID", 502, "Invalid service response"); + } + const raw = review["provenance"]; + if ( + typeof raw["url"] !== "string" || + typeof raw["checksum"] !== "string" || + typeof raw["predicateType"] !== "string" || + typeof raw["sourceRepository"] !== "string" || + typeof raw["builderId"] !== "string" + ) { + throw new UiApiError("CLIENT_RESPONSE_INVALID", 502, "Invalid service response"); + } + provenance = { + url: raw["url"], + checksum: raw["checksum"], + predicateType: raw["predicateType"], + sourceRepository: raw["sourceRepository"], + builderId: raw["builderId"], + }; + } + if ( + !isRecord(review["accessDiff"]) || + typeof review["accessDiff"]["escalation"] !== "boolean" || + !Array.isArray(review["accessDiff"]["changes"]) + ) { + throw new UiApiError("CLIENT_RESPONSE_INVALID", 502, "Invalid service response"); + } + const accessDiff = { + escalation: review["accessDiff"]["escalation"], + changes: review["accessDiff"]["changes"].map((change) => { + if ( + !isRecord(change) || + typeof change["kind"] !== "string" || + typeof change["category"] !== "string" || + (change["operation"] !== null && typeof change["operation"] !== "string") || + !Array.isArray(change["path"]) || + change["path"].some((part) => typeof part !== "string") || + typeof change["escalation"] !== "boolean" + ) { + throw new UiApiError("CLIENT_RESPONSE_INVALID", 502, "Invalid service response"); + } + return { + kind: change["kind"], + category: change["category"], + operation: change["operation"], + path: change["path"], + escalation: change["escalation"], + }; + }), + }; + const evidence: Record = {}; + for (const [key, item] of Object.entries(value["evidence"])) { + if (item !== null && typeof item !== "string" && typeof item !== "number") { + throw new UiApiError("CLIENT_RESPONSE_INVALID", 502, "Invalid service response"); + } + evidence[key] = item; + } + return { + intent: { + id: intent["id"], + packageSlug: intent["packageSlug"], + version: intent["version"], + state: intent["state"], + expiresAt: Number(intent["expiresAt"]), + }, + evidence, + evidenceDigest: value["evidenceDigest"], + review: { + source: { + repository, + workflowRef, + commitSha, + runId, + actor, + }, + artifact: { url: artifact["url"], checksum: artifact["checksum"] }, + provenance, + accessDiff, + }, + }; +} + +function cookie(name: string): string | null { + for (const part of document.cookie.split(";")) { + const separator = part.indexOf("="); + if (separator < 1 || part.slice(0, separator).trim() !== name) continue; + return part.slice(separator + 1).trim(); + } + return null; +} + +export function publisherCsrfToken(): string { + return cookie("__Host-emdash_publisher_csrf") ?? ""; +} + +export function approverCsrfToken(): string { + return cookie("__Host-emdash_approver_csrf") ?? ""; +} + +export function mutationHeaders(csrfToken?: string): Headers { + const headers = new Headers({ + accept: "application/json", + "content-type": "application/json", + "idempotency-key": `web-${crypto.randomUUID()}`, + "x-emdash-request": "1", + }); + if (csrfToken) headers.set("x-emdash-csrf", csrfToken); + return headers; +} + +export async function apiRequest( + path: string, + init: RequestInit = {}, + parse: (value: unknown) => T, +): Promise { + const response = await fetch(path, init); + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new UiApiError("CLIENT_RESPONSE_INVALID", 502, "Invalid service response"); + } + if (!isRecord(payload)) { + throw new UiApiError("CLIENT_RESPONSE_INVALID", 502, "Invalid service response"); + } + if (!response.ok) { + const error = isRecord(payload["error"]) ? payload["error"] : null; + throw new UiApiError( + error && typeof error["code"] === "string" ? error["code"] : "INTERNAL_ERROR", + response.status, + error && typeof error["message"] === "string" ? error["message"] : "Request failed", + ); + } + return parse(payload["data"]); +} + +export async function beginIdentityAuthorization( + realm: "approver" | "publisher", + identifier: string, + redirectTarget: string, +): Promise { + return await apiRequest( + `/v1/${realm}/session/authorize`, + { + method: "POST", + headers: mutationHeaders(), + body: JSON.stringify({ identifier, redirectTarget }), + }, + (value) => { + if (!isRecord(value) || typeof value["authorizationUrl"] !== "string") { + throw new UiApiError("CLIENT_RESPONSE_INVALID", 502, "Invalid service response"); + } + return value["authorizationUrl"]; + }, + ); +} + +export async function beginPublisherDelegation(redirectTarget: string): Promise { + return await apiRequest( + "/v1/publisher/delegation/authorize", + { + method: "POST", + headers: mutationHeaders(publisherCsrfToken()), + body: JSON.stringify({ redirectTarget }), + }, + (value) => { + if (!isRecord(value) || typeof value["authorizationUrl"] !== "string") { + throw new UiApiError("CLIENT_RESPONSE_INVALID", 502, "Invalid service response"); + } + return value["authorizationUrl"]; + }, + ); +} + +export async function listApproverCredentials(): Promise { + return await apiRequest("/v1/approver/credentials", {}, (value) => { + if (!isRecord(value) || !Array.isArray(value["items"])) { + throw new UiApiError("CLIENT_RESPONSE_INVALID", 502, "Invalid service response"); + } + return value["items"].map(credential); + }); +} + +export async function getApproval( + publisherDid: string, + intentId: string, +): Promise { + return await apiRequest( + `/v1/approvals/${encodeURIComponent(intentId)}?publisher=${encodeURIComponent(publisherDid)}`, + {}, + parseApprovalResource, + ); +} + +export async function beginPasskeyRegistration(name: string): Promise { + return await apiRequest( + "/v1/approver/credentials/options", + { + method: "POST", + headers: mutationHeaders(approverCsrfToken()), + body: JSON.stringify({ name }), + }, + (value) => value, + ); +} + +export async function completePasskeyRegistration(response: unknown): Promise { + await apiRequest( + "/v1/approver/credentials", + { + method: "POST", + headers: mutationHeaders(approverCsrfToken()), + body: JSON.stringify(response), + }, + () => undefined, + ); +} + +export async function beginApprovalDecision( + publisherDid: string, + intentId: string, + decision: "approve" | "reject", +): Promise { + return await apiRequest( + `/v1/approvals/${encodeURIComponent(intentId)}/options?publisher=${encodeURIComponent(publisherDid)}`, + { + method: "POST", + headers: mutationHeaders(approverCsrfToken()), + body: JSON.stringify({ decision }), + }, + (value) => value, + ); +} + +export async function completeApprovalDecision( + publisherDid: string, + intentId: string, + decision: "approve" | "reject", + response: unknown, +): Promise { + await apiRequest( + `/v1/approvals/${encodeURIComponent(intentId)}?publisher=${encodeURIComponent(publisherDid)}`, + { + method: "POST", + headers: mutationHeaders(approverCsrfToken()), + body: JSON.stringify({ + decision, + idempotencyKey: `approval-${crypto.randomUUID()}`, + response, + }), + }, + () => undefined, + ); +} diff --git a/apps/release-service/src/ui/components.tsx b/apps/release-service/src/ui/components.tsx new file mode 100644 index 0000000000..9381f8392f --- /dev/null +++ b/apps/release-service/src/ui/components.tsx @@ -0,0 +1,101 @@ +import { Banner, Button, Input, Link, Loader, Surface } from "@cloudflare/kumo"; +import { type FormEvent, type ReactNode, useState } from "react"; + +import { beginIdentityAuthorization, UiApiError } from "./api.js"; +import { useT } from "./i18n.js"; + +export function Page({ children }: { children: ReactNode }) { + const t = useT(); + return ( +
+
+
+

{t("brand.name", "EmDash")}

+

+ {t("brand.releaseService", "Delegated release service")} +

+
+ +
+ {children} +
+ ); +} + +export function LoadingPanel() { + const t = useT(); + return ( + +
+ + {t("loading.label", "Loading release service…")} +
+
+ ); +} + +export function ErrorBanner({ error }: { error: unknown }) { + const t = useT(); + const description = + error instanceof UiApiError + ? t("error.withCode", "{message} ({code})", { message: error.message, code: error.code }) + : t("error.generic", "The release service request failed."); + return ( + + ); +} + +export function LoginPanel({ realm }: { realm: "approver" | "publisher" }) { + const t = useT(); + const [identifier, setIdentifier] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const label = + realm === "publisher" + ? t("login.publisherTitle", "Sign in as a publisher") + : t("login.approverTitle", "Sign in as an approver"); + + async function submit(event: FormEvent) { + event.preventDefault(); + setError(null); + setLoading(true); + try { + const authorizationUrl = await beginIdentityAuthorization( + realm, + identifier, + `${location.pathname}${location.search}`, + ); + location.assign(authorizationUrl); + } catch (cause) { + setError(cause); + setLoading(false); + } + } + + return ( + +
+
+

{label}

+

+ {t("login.description", "Use the Atmosphere account that owns this release role.")} +

+
+ {error ? : null} + setIdentifier(event.currentTarget.value)} + required + /> + + +
+ ); +} diff --git a/apps/release-service/src/ui/i18n.ts b/apps/release-service/src/ui/i18n.ts new file mode 100644 index 0000000000..253f0b24fb --- /dev/null +++ b/apps/release-service/src/ui/i18n.ts @@ -0,0 +1,27 @@ +import { i18n } from "@lingui/core"; +import { useLingui } from "@lingui/react"; +import { useCallback } from "react"; + +const RTL_LOCALES = new Set(["ar", "fa", "he", "ur"]); +const requestedLocale = new URLSearchParams(globalThis.location?.search ?? "").get("locale"); +const locale = requestedLocale || globalThis.navigator?.language?.split("-")[0] || "en"; + +export function applyLocale(value: string): void { + i18n.load(value, {}); + i18n.activate(value); + document.documentElement.lang = value; + document.documentElement.dir = RTL_LOCALES.has(value) ? "rtl" : "ltr"; +} + +applyLocale(locale); + +export { i18n }; + +export function useT() { + const { i18n: activeI18n } = useLingui(); + return useCallback( + (id: string, message: string, values?: Record) => + activeI18n._(id, values, { message }), + [activeI18n], + ); +} diff --git a/apps/release-service/src/ui/main.tsx b/apps/release-service/src/ui/main.tsx new file mode 100644 index 0000000000..a80daed4d2 --- /dev/null +++ b/apps/release-service/src/ui/main.tsx @@ -0,0 +1,19 @@ +import { I18nProvider } from "@lingui/react"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./App.js"; +import { i18n } from "./i18n.js"; + +import "./styles.css"; + +const root = document.getElementById("root"); +if (!root) throw new Error("Application root is missing"); + +createRoot(root).render( + + + + + , +); diff --git a/apps/release-service/src/ui/styles.css b/apps/release-service/src/ui/styles.css new file mode 100644 index 0000000000..d34e6a2bc7 --- /dev/null +++ b/apps/release-service/src/ui/styles.css @@ -0,0 +1,29 @@ +@source "../../node_modules/@cloudflare/kumo/dist/**/*.{js,jsx,ts,tsx}"; +@source "./**/*.{ts,tsx}"; + +@import "@cloudflare/kumo/styles"; +@import "tailwindcss"; + +@theme { + --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; +} + +* { + border-color: var(--color-kumo-line); +} + +body { + margin: 0; + min-width: 20rem; + min-height: 100vh; + background: var(--color-kumo-canvas); + color: var(--text-color-kumo-default); + font-family: var(--font-sans); +} + +button, +input, +textarea, +select { + font: inherit; +} diff --git a/apps/release-service/src/ui/test-setup.ts b/apps/release-service/src/ui/test-setup.ts new file mode 100644 index 0000000000..0d98a835f6 --- /dev/null +++ b/apps/release-service/src/ui/test-setup.ts @@ -0,0 +1,14 @@ +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +class ResizeObserverStub { + disconnect(): void {} + observe(): void {} + unobserve(): void {} +} + +globalThis.ResizeObserver = ResizeObserverStub; + +afterEach(() => { + cleanup(); +}); diff --git a/apps/release-service/src/ui/vite-env.d.ts b/apps/release-service/src/ui/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/apps/release-service/src/ui/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/release-service/src/ui/webauthn.test.ts b/apps/release-service/src/ui/webauthn.test.ts new file mode 100644 index 0000000000..83f1da4414 --- /dev/null +++ b/apps/release-service/src/ui/webauthn.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { creationOptions, requestOptions } from "./webauthn.js"; + +function bytes(value: BufferSource): number[] { + return value instanceof ArrayBuffer + ? [...new Uint8Array(value)] + : [...new Uint8Array(value.buffer, value.byteOffset, value.byteLength)]; +} + +describe("passkey option decoding", () => { + it("decodes required-UV registration options", () => { + const options = creationOptions({ + challenge: "AQID", + rp: { id: "release.example.com", name: "EmDash" }, + user: { id: "BAUG", name: "did:plc:approver", displayName: "Approver" }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], + authenticatorSelection: { userVerification: "required", residentKey: "preferred" }, + excludeCredentials: [{ type: "public-key", id: "BwgJ", transports: ["internal"] }], + }); + + expect(bytes(options.challenge)).toEqual([1, 2, 3]); + expect(bytes(options.user.id)).toEqual([4, 5, 6]); + expect(options.authenticatorSelection?.userVerification).toBe("required"); + expect(options.excludeCredentials?.[0]?.transports).toEqual(["internal"]); + }); + + it("decodes required-UV approval options and rejects malformed challenges", () => { + const options = requestOptions({ + challenge: "AQID", + rpId: "release.example.com", + userVerification: "required", + allowCredentials: [{ type: "public-key", id: "BwgJ" }], + }); + expect(bytes(options.challenge)).toEqual([1, 2, 3]); + expect(options.userVerification).toBe("required"); + expect(() => requestOptions({ challenge: "not base64!" })).toThrow("Invalid passkey options"); + }); +}); diff --git a/apps/release-service/src/ui/webauthn.ts b/apps/release-service/src/ui/webauthn.ts new file mode 100644 index 0000000000..93877bcf20 --- /dev/null +++ b/apps/release-service/src/ui/webauthn.ts @@ -0,0 +1,188 @@ +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function decode(value: unknown): ArrayBuffer { + if (typeof value !== "string" || !BASE64URL_PATTERN.test(value) || value.length % 4 === 1) { + throw new Error("Invalid passkey options"); + } + const binary = atob( + value + .replaceAll("-", "+") + .replaceAll("_", "/") + .padEnd(value.length + ((4 - (value.length % 4)) % 4), "="), + ); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes.buffer; +} + +function encode(value: ArrayBuffer): string { + let binary = ""; + for (const byte of new Uint8Array(value)) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +function descriptors(value: unknown): PublicKeyCredentialDescriptor[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) throw new Error("Invalid passkey options"); + return value.map((item) => { + if (!isRecord(item) || item["type"] !== "public-key") { + throw new Error("Invalid passkey options"); + } + const transports = item["transports"]; + if ( + transports !== undefined && + (!Array.isArray(transports) || transports.some((transport) => typeof transport !== "string")) + ) { + throw new Error("Invalid passkey options"); + } + return { + type: "public-key", + id: decode(item["id"]), + ...(transports ? { transports: transports.filter(isTransport) } : {}), + }; + }); +} + +function isTransport(value: string): value is AuthenticatorTransport { + return ( + value === "ble" || + value === "hybrid" || + value === "internal" || + value === "nfc" || + value === "usb" + ); +} + +function userVerification(value: unknown): UserVerificationRequirement | undefined { + return value === "discouraged" || value === "preferred" || value === "required" + ? value + : undefined; +} + +export function creationOptions(value: unknown): PublicKeyCredentialCreationOptions { + if ( + !isRecord(value) || + !isRecord(value["rp"]) || + !isRecord(value["user"]) || + !Array.isArray(value["pubKeyCredParams"]) + ) { + throw new Error("Invalid passkey options"); + } + const rp = value["rp"]; + const user = value["user"]; + if ( + typeof rp["name"] !== "string" || + (rp["id"] !== undefined && typeof rp["id"] !== "string") || + typeof user["name"] !== "string" || + typeof user["displayName"] !== "string" + ) { + throw new Error("Invalid passkey options"); + } + const pubKeyCredParams = value["pubKeyCredParams"].map((item): PublicKeyCredentialParameters => { + if (!isRecord(item) || item["type"] !== "public-key" || !Number.isSafeInteger(item["alg"])) { + throw new Error("Invalid passkey options"); + } + return { type: "public-key", alg: Number(item["alg"]) }; + }); + const selection = isRecord(value["authenticatorSelection"]) + ? value["authenticatorSelection"] + : null; + const attachment = selection?.["authenticatorAttachment"]; + const residentKey = selection?.["residentKey"]; + return { + challenge: decode(value["challenge"]), + rp: { name: rp["name"], ...(typeof rp["id"] === "string" ? { id: rp["id"] } : {}) }, + user: { id: decode(user["id"]), name: user["name"], displayName: user["displayName"] }, + pubKeyCredParams, + ...(Number.isSafeInteger(value["timeout"]) ? { timeout: Number(value["timeout"]) } : {}), + ...(descriptors(value["excludeCredentials"]) + ? { excludeCredentials: descriptors(value["excludeCredentials"]) } + : {}), + ...(selection + ? { + authenticatorSelection: { + ...(attachment === "cross-platform" || attachment === "platform" + ? { authenticatorAttachment: attachment } + : {}), + ...(residentKey === "discouraged" || + residentKey === "preferred" || + residentKey === "required" + ? { residentKey } + : {}), + ...(typeof selection["requireResidentKey"] === "boolean" + ? { requireResidentKey: selection["requireResidentKey"] } + : {}), + ...(userVerification(selection["userVerification"]) + ? { userVerification: userVerification(selection["userVerification"]) } + : {}), + }, + } + : {}), + ...(value["attestation"] === "direct" || + value["attestation"] === "enterprise" || + value["attestation"] === "indirect" || + value["attestation"] === "none" + ? { attestation: value["attestation"] } + : {}), + }; +} + +export function requestOptions(value: unknown): PublicKeyCredentialRequestOptions { + if (!isRecord(value)) throw new Error("Invalid passkey options"); + return { + challenge: decode(value["challenge"]), + ...(typeof value["rpId"] === "string" ? { rpId: value["rpId"] } : {}), + ...(Number.isSafeInteger(value["timeout"]) ? { timeout: Number(value["timeout"]) } : {}), + ...(descriptors(value["allowCredentials"]) + ? { allowCredentials: descriptors(value["allowCredentials"]) } + : {}), + ...(userVerification(value["userVerification"]) + ? { userVerification: userVerification(value["userVerification"]) } + : {}), + }; +} + +export function registrationResponse(credential: PublicKeyCredential) { + if (!(credential.response instanceof AuthenticatorAttestationResponse)) { + throw new Error("Invalid passkey registration response"); + } + return { + id: credential.id, + rawId: encode(credential.rawId), + type: "public-key", + response: { + clientDataJSON: encode(credential.response.clientDataJSON), + attestationObject: encode(credential.response.attestationObject), + transports: credential.response.getTransports(), + }, + ...(credential.authenticatorAttachment + ? { authenticatorAttachment: credential.authenticatorAttachment } + : {}), + }; +} + +export function authenticationResponse(credential: PublicKeyCredential) { + if (!(credential.response instanceof AuthenticatorAssertionResponse)) { + throw new Error("Invalid passkey authentication response"); + } + return { + id: credential.id, + rawId: encode(credential.rawId), + type: "public-key", + response: { + clientDataJSON: encode(credential.response.clientDataJSON), + authenticatorData: encode(credential.response.authenticatorData), + signature: encode(credential.response.signature), + ...(credential.response.userHandle + ? { userHandle: encode(credential.response.userHandle) } + : {}), + }, + ...(credential.authenticatorAttachment + ? { authenticatorAttachment: credential.authenticatorAttachment } + : {}), + }; +} diff --git a/apps/release-service/src/verification/evaluate.ts b/apps/release-service/src/verification/evaluate.ts index 499f65fd2c..ac0255c8ff 100644 --- a/apps/release-service/src/verification/evaluate.ts +++ b/apps/release-service/src/verification/evaluate.ts @@ -1,5 +1,6 @@ import { safeParse } from "@atcute/lexicons"; import { diffDeclaredAccess, type AccessDiff, type DeclaredAccess } from "@emdash-cms/plugin-types"; +import { parseDelegatedReleaseSourceRecord } from "@emdash-cms/registry-client/release-service"; import { NSID, PackageProfileExtension, @@ -245,27 +246,25 @@ export function prepareVerifierInput( ): VerifyReleaseInput | null { const payload = parseReleaseIntent(intent.releaseInputJson); if (!payload) return null; - const release = safeParse(PackageRelease.mainSchema, payload.release); + const release = parseDelegatedReleaseSourceRecord(payload.release, { + packageSlug: intent.packageSlug, + version: intent.version, + }); const profileExtensionRaw = isRecord(snapshot.profile.value) ? isRecord(snapshot.profile.value["extensions"]) ? snapshot.profile.value["extensions"][NSID.packageProfileExtension] : undefined : undefined; const profileExtension = safeParse(PackageProfileExtension.mainSchema, profileExtensionRaw); - if (!release.ok || !profileExtension.ok || !isRecord(release.value.extensions)) return null; - const releaseExtension = safeParse( - PackageReleaseExtension.mainSchema, - release.value.extensions[NSID.packageReleaseExtension], - ); - if (!releaseExtension.ok || !releaseExtension.value.provenance) return null; + if (!release || !profileExtension.ok) return null; return { artifact: { - url: release.value.artifacts.package.url, - checksum: release.value.artifacts.package.checksum, + url: release.artifacts.package.url, + checksum: release.artifacts.package.checksum, packageSlug: intent.packageSlug, version: intent.version, }, - provenance: releaseExtension.value.provenance, + provenance: release.extensions[NSID.packageReleaseExtension].provenance, profileRepository: profileExtension.value.repository, }; } diff --git a/apps/release-service/src/verification/pds.ts b/apps/release-service/src/verification/pds.ts index 850cb45cf7..492615e989 100644 --- a/apps/release-service/src/verification/pds.ts +++ b/apps/release-service/src/verification/pds.ts @@ -19,6 +19,7 @@ const MAX_RELEASE_PAGES = 100; const PAGE_LIMIT = 100; const PACKAGE_SLUG_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z.-]{0,127}$/; +const UPSTREAM_STATUS_HEADER = "x-emdash-upstream-status"; export interface AuthoritativeRecord { uri: string; @@ -46,6 +47,7 @@ export class PublisherSnapshotError extends Error { | "PUBLISHER_PDS_INVALID" | "PROFILE_INVALID" | "RELEASE_EXISTS" + | "RELEASE_RECORD_INVALID" | "RELEASE_LIST_INVALID"; constructor(code: PublisherSnapshotError["code"]) { @@ -123,7 +125,7 @@ async function resolveDnsType( }); } -async function resolvePublicHostname( +export async function resolvePublicHostname( hostname: string, fetchImplementation: typeof fetch, ): Promise { @@ -165,11 +167,19 @@ function guardedFetch(fetchImplementation: typeof fetch): typeof fetch { } const headers = init?.headers ?? (input instanceof Request ? input.headers : undefined); const resource = await fetchVerifiedResource(url, { - fetch: (verifiedUrl, verifiedInit) => - fetchImplementation(verifiedUrl, { + fetch: async (verifiedUrl, verifiedInit) => { + const response = await fetchImplementation(verifiedUrl, { ...verifiedInit, ...(headers === undefined ? {} : { headers }), - }), + }); + const responseHeaders = new Headers(response.headers); + responseHeaders.set(UPSTREAM_STATUS_HEADER, String(response.status)); + return new Response(response.body, { + status: response.status === 404 ? 200 : response.status, + statusText: response.status === 404 ? "OK" : response.statusText, + headers: responseHeaders, + }); + }, resolveHostname: (hostname) => resolvePublicHostname(hostname, fetchImplementation), headerTimeoutMs: 10_000, totalTimeoutMs: 30_000, @@ -179,13 +189,55 @@ function guardedFetch(fetchImplementation: typeof fetch): typeof fetch { if (!resource.success || resource.value.url.toString() !== url.toString()) { throw new PublisherSnapshotError("PUBLISHER_PDS_INVALID"); } + const upstreamStatus = Number(resource.value.headers.get(UPSTREAM_STATUS_HEADER)); + if (!Number.isSafeInteger(upstreamStatus)) { + throw new PublisherSnapshotError("PUBLISHER_PDS_INVALID"); + } return new Response(resource.value.bytes, { - status: resource.value.status, + status: upstreamStatus, headers: resource.value.headers, }); }; } +async function guardedRecordJson( + url: URL, + fetchImplementation: typeof fetch, +): Promise<{ status: number; value: unknown }> { + const resource = await fetchVerifiedResource(url, { + fetch: async (input, init) => { + const response = await fetchImplementation(input, init); + const headers = new Headers(response.headers); + headers.set(UPSTREAM_STATUS_HEADER, String(response.status)); + return new Response(response.body, { + status: response.status === 400 ? 200 : response.status, + statusText: response.status === 400 ? "OK" : response.statusText, + headers, + }); + }, + resolveHostname: (hostname) => resolvePublicHostname(hostname, fetchImplementation), + headerTimeoutMs: 10_000, + totalTimeoutMs: 30_000, + maxBytes: MAX_PDS_RESPONSE_BYTES, + maxRedirects: 1, + }); + if (!resource.success || resource.value.url.toString() !== url.toString()) { + throw new PublisherSnapshotError("PUBLISHER_PDS_INVALID"); + } + const status = Number(resource.value.headers.get(UPSTREAM_STATUS_HEADER)); + if (!Number.isSafeInteger(status)) throw new PublisherSnapshotError("PUBLISHER_PDS_INVALID"); + try { + return { + status, + value: JSON.parse( + new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(resource.value.bytes), + ), + }; + } catch { + throw new PublisherSnapshotError("PUBLISHER_PDS_INVALID"); + } +} + function pdsXrpcUrl(pds: string, method: string): URL { let url: URL; try { @@ -273,6 +325,35 @@ async function getProfile( } } +async function getRelease( + pds: string, + publisherDid: string, + packageSlug: string, + version: string, + fetchImplementation: typeof fetch, +): Promise { + const rkey = `${packageSlug}:${version}`; + const url = pdsXrpcUrl(pds, "com.atproto.repo.getRecord"); + url.searchParams.set("repo", publisherDid); + url.searchParams.set("collection", NSID.packageRelease); + url.searchParams.set("rkey", rkey); + const response = await guardedRecordJson(url, fetchImplementation); + if ( + response.status === 400 && + isRecord(response.value) && + response.value["error"] === "RecordNotFound" + ) { + return null; + } + if (response.status !== 200) throw new PublisherSnapshotError("RELEASE_RECORD_INVALID"); + const record = parseRecord(response.value); + const expectedUri = `at://${publisherDid}/${NSID.packageRelease}/${rkey}`; + if (!record || record.uri !== expectedUri) { + throw new PublisherSnapshotError("RELEASE_RECORD_INVALID"); + } + return record; +} + async function listPackageReleases( pds: string, publisherDid: string, @@ -373,3 +454,80 @@ export async function readPublisherVerificationSnapshot( baselineVersion, }; } + +export async function findAuthoritativeRelease( + publisherDid: string, + packageSlug: string, + version: string, + options: ReadPublisherSnapshotOptions = {}, +): Promise { + if ( + !isDid(publisherDid) || + !PACKAGE_SLUG_PATTERN.test(packageSlug) || + !VERSION_PATTERN.test(version) + ) { + throw new PublisherSnapshotError("PUBLISHER_IDENTITY_INVALID"); + } + const fetchImplementation = options.fetch ?? globalThis.fetch; + let actor; + try { + actor = await ( + options.actorResolver ?? createWorkerActorResolver(guardedIdentityFetch(fetchImplementation)) + ).resolve(publisherDid, { signal: AbortSignal.timeout(30_000), noCache: true }); + } catch { + throw new PublisherSnapshotError("PUBLISHER_IDENTITY_INVALID"); + } + if (actor.did !== publisherDid) throw new PublisherSnapshotError("PUBLISHER_IDENTITY_INVALID"); + return getRelease(actor.pds, publisherDid, packageSlug, version, fetchImplementation); +} + +export async function findProofVerifiedRelease( + publisherDid: string, + packageSlug: string, + version: string, + options: ReadPublisherSnapshotOptions = {}, +): Promise { + if ( + !isDid(publisherDid) || + !PACKAGE_SLUG_PATTERN.test(packageSlug) || + !VERSION_PATTERN.test(version) + ) { + throw new PublisherSnapshotError("PUBLISHER_IDENTITY_INVALID"); + } + const advertised = await findAuthoritativeRelease(publisherDid, packageSlug, version, options); + if (!advertised) return null; + const fetchImplementation = options.fetch ?? globalThis.fetch; + try { + const record = await new DirectPdsClient({ + did: publisherDid, + fetch: guardedFetch(fetchImplementation), + ...(options.didDocumentResolver === undefined + ? {} + : { didDocumentResolver: options.didDocumentResolver }), + requestTimeoutMs: 30_000, + maxResponseBytes: MAX_PDS_RESPONSE_BYTES, + }).getPackageRelease(packageSlug, version); + return { uri: record.uri, cid: record.cid, value: record.value }; + } catch (error) { + if (error instanceof DirectPdsReadError) { + if (error.code === "RECORD_NOT_FOUND") return null; + if ( + error.code === "DID_DOCUMENT_INVALID" || + error.code === "DID_RESOLUTION_FAILED" || + error.code === "DID_SIGNING_KEY_INVALID" || + error.code === "DID_SIGNING_KEY_MISSING" || + error.code === "PDS_ENDPOINT_INVALID" || + error.code === "PDS_ENDPOINT_MISSING" + ) { + throw new PublisherSnapshotError("PUBLISHER_IDENTITY_INVALID"); + } + if (error.code === "RELEASE_LEXICON_INVALID" || error.code === "RECORD_PROOF_INVALID") { + throw new PublisherSnapshotError("RELEASE_RECORD_INVALID"); + } + } + if (error instanceof TypeError) { + throw new PublisherSnapshotError("PUBLISHER_IDENTITY_INVALID"); + } + throw new PublisherSnapshotError("PUBLISHER_PDS_INVALID"); + } +} diff --git a/apps/release-service/src/workflows/release-intent.ts b/apps/release-service/src/workflows/release-intent.ts index c513182a43..a8a8cb369a 100644 --- a/apps/release-service/src/workflows/release-intent.ts +++ b/apps/release-service/src/workflows/release-intent.ts @@ -11,13 +11,18 @@ import type { StoredIntent, TransitionIntentInput, } from "../publisher-do/publisher-do.js"; +import { reconcileReleaseRecord } from "../publishing/reconcile.js"; +import { publishVerifiedIntent, readPersistedMaterializedRelease } from "../publishing/workflow.js"; import { evaluateVerifiedRelease, normalizeVerifierReport, parseNormalizedVerifierReport, prepareVerifierInput, } from "../verification/evaluate.js"; -import { readPublisherVerificationSnapshot } from "../verification/pds.js"; +import { + findProofVerifiedRelease, + readPublisherVerificationSnapshot, +} from "../verification/pds.js"; const DID_PATTERN = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/; const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/; @@ -30,7 +35,7 @@ export interface ReleaseIntentWorkflowParams { export interface ReleaseIntentWorkflowOutput { intentId: string; - state: "expired" | "invalid" | "ready" | "rejected"; + state: "conflict" | "expired" | "failed" | "invalid" | "published" | "ready" | "rejected"; reasonCode: string | null; } @@ -64,6 +69,87 @@ interface IntentSummary { expiresAt: number; } +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function stringField(value: Record, key: string): string | null { + const field = value[key]; + return typeof field === "string" ? field : null; +} + +function parseStoredWorkflowDecision(value: string): WorkflowDecision | null { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return null; + } + if ( + !isRecord(parsed) || + typeof parsed["requiresApproval"] !== "boolean" || + !isRecord(parsed["approvalEvidence"]) || + !Array.isArray(parsed["approvers"]) || + parsed["approvers"].some((approver) => typeof approver !== "string") || + (parsed["confirmation"] !== "always" && parsed["confirmation"] !== "escalation-only") || + typeof parsed["accessDiffJson"] !== "string" + ) { + return null; + } + const source = parsed["approvalEvidence"]; + const intentId = stringField(source, "intentId"); + const publisherDid = stringField(source, "publisherDid"); + const packageSlug = stringField(source, "packageSlug"); + const version = stringField(source, "version"); + const workloadIdentityDigest = stringField(source, "workloadIdentityDigest"); + const releaseInputDigest = stringField(source, "releaseInputDigest"); + const profileCid = stringField(source, "profileCid"); + const artifactChecksum = stringField(source, "artifactChecksum"); + const provenanceChecksum = stringField(source, "provenanceChecksum"); + const declaredAccessDiffDigest = stringField(source, "declaredAccessDiffDigest"); + const verificationDigest = stringField(source, "verificationDigest"); + const baselineReleaseCid = source["baselineReleaseCid"]; + if ( + !intentId || + !publisherDid || + !packageSlug || + !version || + !Number.isSafeInteger(source["verificationGeneration"]) || + Number(source["verificationGeneration"]) < 3 || + !workloadIdentityDigest || + !releaseInputDigest || + !profileCid || + (baselineReleaseCid !== null && typeof baselineReleaseCid !== "string") || + !artifactChecksum || + !provenanceChecksum || + !declaredAccessDiffDigest || + !verificationDigest + ) { + return null; + } + return { + requiresApproval: parsed["requiresApproval"], + approvalEvidence: { + intentId, + publisherDid, + packageSlug, + version, + verificationGeneration: Number(source["verificationGeneration"]), + workloadIdentityDigest, + releaseInputDigest, + profileCid, + baselineReleaseCid, + artifactChecksum, + provenanceChecksum, + declaredAccessDiffDigest, + verificationDigest, + }, + approvers: [...parsed["approvers"]], + confirmation: parsed["confirmation"], + accessDiffJson: parsed["accessDiffJson"], + }; +} + type ReleaseWorkflowEnv = Env & { RELEASE_VERIFIER: Service; }; @@ -86,6 +172,7 @@ function plainIntent(value: StoredIntent): StoredIntent { stateGeneration: value.stateGeneration, workloadPolicyVersion: value.workloadPolicyVersion, workloadIdentityDigest: value.workloadIdentityDigest, + workloadIdempotencyDigest: value.workloadIdempotencyDigest, requestDigest: value.requestDigest, workloadIdentityJson: value.workloadIdentityJson, releaseInputJson: value.releaseInputJson, @@ -105,7 +192,7 @@ function requireIntent( if ( !value || value.id !== params.intentId || - value.state !== "verifying" || + (value.state !== "verifying" && value.state !== "ready" && value.state !== "reconciling") || value.workflowId !== instanceId ) { throw new NonRetryableError("Release intent is not in the expected Workflow state"); @@ -185,6 +272,116 @@ export class ReleaseIntentWorkflow extends WorkflowEntrypoint< event.instanceId, ), ); + if (intent.state === "ready" || intent.state === "reconciling") { + const decision = await step.do("recovery-policy-decision", async () => { + const stored = await publisher.getVerificationStep( + params.publisherDid, + params.intentId, + "policy-decision", + ); + const parsed = stored ? parseStoredWorkflowDecision(stored.resultJson) : null; + if (!parsed) throw new NonRetryableError("Stored Workflow decision is invalid"); + return parsed; + }); + const verificationIntent = { + ...intent, + stateGeneration: decision.approvalEvidence.verificationGeneration - 2, + }; + if (intent.state === "reconciling") { + const reconciliation = await step.do("recovery-reconciliation", async () => { + const materialized = await readPersistedMaterializedRelease( + publisher, + params.publisherDid, + params.intentId, + intent.requestDigest, + ); + if (!materialized) { + throw new NonRetryableError("Stored materialized release is unavailable"); + } + const authoritative = await findProofVerifiedRelease( + params.publisherDid, + intent.packageSlug, + intent.version, + ); + return reconcileReleaseRecord( + params.publisherDid, + intent.packageSlug, + intent.version, + materialized.record, + authoritative, + ); + }); + if (reconciliation.outcome === "exact") { + const published = await step.do("recovery-published", async () => + transitionIntent(publisher, { + publisherDid: params.publisherDid, + intentId: params.intentId, + expectedState: "reconciling", + expectedGeneration: intent.stateGeneration, + toState: "published", + transitionDigest: await digest([ + "recovery-published", + reconciliation.uri, + reconciliation.cid, + ]), + actorRealm: "system", + actorIdentity: WORKFLOW_ACTOR, + reasonCode: null, + stateDataJson: JSON.stringify({ + resultUri: reconciliation.uri, + resultCid: reconciliation.cid, + }), + }), + ); + if (!published.ok) throw new NonRetryableError(published.code); + return { intentId: params.intentId, state: "published", reasonCode: null }; + } + if (reconciliation.outcome === "conflict") { + const conflict = await step.do("recovery-conflict", async () => + transitionIntent(publisher, { + publisherDid: params.publisherDid, + intentId: params.intentId, + expectedState: "reconciling", + expectedGeneration: intent.stateGeneration, + toState: "conflict", + transitionDigest: await digest(["recovery-conflict", params.intentId]), + actorRealm: "system", + actorIdentity: WORKFLOW_ACTOR, + reasonCode: "RELEASE_CONFLICT", + stateDataJson: JSON.stringify({ reasonCode: "RELEASE_CONFLICT" }), + }), + ); + if (!conflict.ok) throw new NonRetryableError(conflict.code); + return { + intentId: params.intentId, + state: "conflict", + reasonCode: "RELEASE_CONFLICT", + }; + } + const ready = await step.do("recovery-absence", async () => + transitionIntent(publisher, { + publisherDid: params.publisherDid, + intentId: params.intentId, + expectedState: "reconciling", + expectedGeneration: intent.stateGeneration, + toState: "ready", + transitionDigest: await digest(["recovery-absence", params.intentId]), + actorRealm: "system", + actorIdentity: WORKFLOW_ACTOR, + reasonCode: "PDS_RETRY_ABSENT", + stateDataJson: JSON.stringify({ absenceConfirmed: true }), + }), + ); + if (!ready.ok) throw new NonRetryableError(ready.code); + } + return await publishVerifiedIntent( + this.env, + step, + params.publisherDid, + verificationIntent, + decision.approvalEvidence, + ); + } const authoritative = await step.do("authoritative-records", async () => { const snapshot = await readPublisherVerificationSnapshot( params.publisherDid, @@ -382,7 +579,13 @@ export class ReleaseIntentWorkflow extends WorkflowEntrypoint< }), ); if (!ready.ok) throw new NonRetryableError(ready.code); - return { intentId: params.intentId, state: "ready", reasonCode: null }; + return await publishVerifiedIntent( + this.env, + step, + params.publisherDid, + intent, + decision.approvalEvidence, + ); } const awaiting = await step.do("await-approval", async () => transitionIntent(publisher, { @@ -395,61 +598,84 @@ export class ReleaseIntentWorkflow extends WorkflowEntrypoint< actorRealm: "system", actorIdentity: WORKFLOW_ACTOR, reasonCode: "APPROVAL_REQUIRED", - stateDataJson: await encodeAwaitingApprovalState(decision.approvalEvidence), + stateDataJson: await encodeAwaitingApprovalState( + decision.approvalEvidence, + decision.approvers, + ), }), ); if (!awaiting.ok) throw new NonRetryableError(awaiting.code); - const timeout = Math.max(1, awaiting.expiresAt - event.timestamp.getTime()); - try { - await step.waitForEvent("approval-decision", { type: "approval-decision", timeout }); - } catch { - const timeoutState = await step.do<{ intent: IntentSummary | null; checkedAt: number }>( - "approval-timeout-state", - async () => ({ - intent: await currentIntent(publisher, params.publisherDid, params.intentId), - checkedAt: Date.now(), - }), - ); - if ( - timeoutState.intent?.state === "awaiting_approval" && - timeoutState.checkedAt >= timeoutState.intent.expiresAt - ) { - const expired = await step.do("mark-expired", async () => { - const result = await transitionIntent(publisher, { - publisherDid: params.publisherDid, - intentId: params.intentId, - expectedState: "awaiting_approval", - expectedGeneration: timeoutState.intent!.stateGeneration, - toState: "expired", - transitionDigest: await digest(["expired", decision.approvalEvidence]), - actorRealm: "system", - actorIdentity: WORKFLOW_ACTOR, - reasonCode: "APPROVAL_EXPIRED", - stateDataJson: JSON.stringify({ reasonCode: "APPROVAL_EXPIRED" }), - }); - if (result.ok) { - await invalidateApprovalChallenges( - this.env.APPROVER_DO, - decision.approvers, - params.intentId, - "EXPIRED", - timeoutState.checkedAt, - ); - } - return result; + let waitStartedAt = event.timestamp.getTime(); + let waitSequence = 1; + for (;;) { + const waitName = + waitSequence === 1 ? "approval-decision" : `approval-decision-${waitSequence}`; + try { + await step.waitForEvent(waitName, { + type: "approval-decision", + timeout: Math.max(1, awaiting.expiresAt - waitStartedAt), }); - if (!expired.ok) throw new NonRetryableError(expired.code); - return { intentId: params.intentId, state: "expired", reasonCode: "APPROVAL_EXPIRED" }; - } - if (timeoutState.intent?.state === "awaiting_approval") { - throw new Error("Approval wait ended before the intent deadline"); + break; + } catch { + const timeoutStateName = + waitSequence === 1 ? "approval-timeout-state" : `approval-timeout-state-${waitSequence}`; + const timeoutState = await step.do<{ intent: IntentSummary | null; checkedAt: number }>( + timeoutStateName, + async () => ({ + intent: await currentIntent(publisher, params.publisherDid, params.intentId), + checkedAt: Date.now(), + }), + ); + if ( + timeoutState.intent?.state === "awaiting_approval" && + timeoutState.checkedAt >= timeoutState.intent.expiresAt + ) { + const expired = await step.do("mark-expired", async () => { + const result = await transitionIntent(publisher, { + publisherDid: params.publisherDid, + intentId: params.intentId, + expectedState: "awaiting_approval", + expectedGeneration: timeoutState.intent!.stateGeneration, + toState: "expired", + transitionDigest: await digest(["expired", decision.approvalEvidence]), + actorRealm: "system", + actorIdentity: WORKFLOW_ACTOR, + reasonCode: "APPROVAL_EXPIRED", + stateDataJson: JSON.stringify({ reasonCode: "APPROVAL_EXPIRED" }), + }); + if (result.ok) { + await invalidateApprovalChallenges( + this.env.APPROVER_DO, + decision.approvers, + params.intentId, + "EXPIRED", + timeoutState.checkedAt, + ); + } + return result; + }); + if (!expired.ok) throw new NonRetryableError(expired.code); + return { intentId: params.intentId, state: "expired", reasonCode: "APPROVAL_EXPIRED" }; + } + if (timeoutState.intent?.state === "awaiting_approval") { + waitStartedAt = timeoutState.checkedAt; + waitSequence += 1; + continue; + } + break; } } const completed = await step.do("approval-result", () => currentIntent(publisher, params.publisherDid, params.intentId), ); if (completed?.state === "ready") { - return { intentId: params.intentId, state: "ready", reasonCode: null }; + return await publishVerifiedIntent( + this.env, + step, + params.publisherDid, + intent, + decision.approvalEvidence, + ); } if (completed?.state === "rejected") { return { intentId: params.intentId, state: "rejected", reasonCode: "REJECTED" }; diff --git a/apps/release-service/src/workflows/start.ts b/apps/release-service/src/workflows/start.ts index fcf6cfb5d0..b5564ac8cb 100644 --- a/apps/release-service/src/workflows/start.ts +++ b/apps/release-service/src/workflows/start.ts @@ -13,6 +13,13 @@ export type StartReleaseWorkflowResult = code: "INTENT_NOT_FOUND" | "INTENT_STATE_INVALID" | "WORKFLOW_UNAVAILABLE"; }; +export type RestartReleaseWorkflowResult = + | { ok: true; workflowId: string; restarted: boolean } + | { + ok: false; + code: "INTENT_NOT_FOUND" | "INTENT_STATE_INVALID" | "WORKFLOW_UNAVAILABLE"; + }; + async function digest(value: unknown): Promise { const bytes = new TextEncoder().encode(JSON.stringify(value)); return base64url.encode(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))); @@ -83,3 +90,40 @@ export async function startReleaseIntentWorkflow( } } } + +export async function restartReleaseIntentWorkflow( + workflow: Workflow, + publishers: DurableObjectNamespace, + publisherDid: string, + intentId: string, +): Promise { + if (!DID_PATTERN.test(publisherDid) || !ULID_PATTERN.test(intentId)) { + return { ok: false, code: "INTENT_STATE_INVALID" }; + } + const intent = await publishers.getByName(publisherDid).getIntent(publisherDid, intentId); + if (!intent) return { ok: false, code: "INTENT_NOT_FOUND" }; + if ( + intent.workflowId !== intentId || + (intent.state !== "ready" && intent.state !== "reconciling") + ) { + return { ok: false, code: "INTENT_STATE_INVALID" }; + } + try { + const instance = await workflow.get(intentId); + const status = await instance.status(); + if ( + status.status === "queued" || + status.status === "running" || + status.status === "waiting" || + status.status === "paused" || + status.status === "waitingForPause" + ) { + return { ok: true, workflowId: intentId, restarted: false }; + } + if (status.status === "unknown") return { ok: false, code: "WORKFLOW_UNAVAILABLE" }; + await instance.restart(); + return { ok: true, workflowId: intentId, restarted: true }; + } catch { + return { ok: false, code: "WORKFLOW_UNAVAILABLE" }; + } +} diff --git a/apps/release-service/src/workload/policy.ts b/apps/release-service/src/workload/policy.ts index 5b85bbd38e..fc89a1e135 100644 --- a/apps/release-service/src/workload/policy.ts +++ b/apps/release-service/src/workload/policy.ts @@ -103,6 +103,5 @@ export function digestWorkloadIdempotencyIdentity( identity.repository.ownerId, identity.workflow.ref, identity.run.id, - identity.run.attempt, ]); } diff --git a/apps/release-service/test/access-auth.test.ts b/apps/release-service/test/access-auth.test.ts new file mode 100644 index 0000000000..a03a9ee9b9 --- /dev/null +++ b/apps/release-service/test/access-auth.test.ts @@ -0,0 +1,346 @@ +import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT, type JWTVerifyGetKey } from "jose"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { + authenticateAccessRequest, + validateAccessMutation, + type AccessRole, +} from "../src/access/auth.js"; +import { apiSuccess } from "../src/api/response.js"; +import { handleRequest } from "../src/index.js"; +import type { RouteDefinition } from "../src/routes.js"; +import { TEST_ACCESS_AUDIENCES, TEST_BINDINGS } from "./fixtures/oauth.js"; + +const ACCESS_KEY_ID = "access-test-key"; +const ACCESS_SUBJECT = "7335d417-61da-459d-899c-0a01c76a2f94"; +const ACCESS_EMAIL = "operator@example.com"; +const ACCESS_CONFIGURATION = { + teamDomain: TEST_BINDINGS.ACCESS_TEAM_DOMAIN, + audiences: TEST_ACCESS_AUDIENCES, +} as const; + +let privateKey: CryptoKey; +let keyResolver: JWTVerifyGetKey; + +interface TokenOptions { + role?: AccessRole; + audience?: string; + issuer?: string; + subject?: string; + email?: string | null; + type?: string; + issuedAt?: number; + notBefore?: number; + expiresAt?: number; + custom?: Record; +} + +beforeAll(async () => { + const keys = await generateKeyPair("RS256", { extractable: true }); + privateKey = keys.privateKey; + const publicJwk = await exportJWK(keys.publicKey); + publicJwk.kid = ACCESS_KEY_ID; + publicJwk.alg = "RS256"; + publicJwk.use = "sig"; + keyResolver = createLocalJWKSet({ keys: [publicJwk] }); +}); + +async function createAccessToken(options: TokenOptions = {}): Promise { + const now = Math.floor(Date.now() / 1000); + const payload: Record = { + type: options.type ?? "app", + ...options.custom, + }; + if (options.email !== null) payload["email"] = options.email ?? ACCESS_EMAIL; + return new SignJWT(payload) + .setProtectedHeader({ alg: "RS256", kid: ACCESS_KEY_ID, typ: "JWT" }) + .setIssuer(options.issuer ?? ACCESS_CONFIGURATION.teamDomain) + .setAudience(options.audience ?? ACCESS_CONFIGURATION.audiences[options.role ?? "viewer"]) + .setSubject(options.subject ?? ACCESS_SUBJECT) + .setIssuedAt(options.issuedAt ?? now) + .setNotBefore(options.notBefore ?? now - 1) + .setExpirationTime(options.expiresAt ?? now + 300) + .sign(privateKey); +} + +function authenticatedRequest( + token: string, + role: AccessRole = "viewer", + init?: RequestInit, +): Request { + const headers = new Headers(init?.headers); + headers.set("cf-access-jwt-assertion", token); + return new Request(`https://release.example.com/admin/api/${role}/test`, { + ...init, + headers, + }); +} + +describe("Cloudflare Access authentication", () => { + it.each(["viewer", "reviewer", "admin"] as const)( + "authenticates a human %s audience", + async (role) => { + const token = await createAccessToken({ role }); + + await expect( + authenticateAccessRequest( + authenticatedRequest(token), + role, + ACCESS_CONFIGURATION, + keyResolver, + ), + ).resolves.toEqual({ + realm: "access", + identity: ACCESS_SUBJECT, + email: ACCESS_EMAIL, + role, + }); + }, + ); + + it("requires the Access assertion header and does not trust the browser cookie", async () => { + const request = new Request("https://release.example.com/admin/api/viewer/test", { + headers: { cookie: "CF_Authorization=unverified" }, + }); + + await expect( + authenticateAccessRequest(request, "viewer", ACCESS_CONFIGURATION, keyResolver), + ).rejects.toMatchObject({ code: "ACCESS_AUTH_REQUIRED", status: 401 }); + }); + + it("uses route audiences rather than optional group claims", async () => { + const viewerTokenClaimingAdmin = await createAccessToken({ + role: "viewer", + custom: { groups: ["release-service-admin"] }, + }); + + await expect( + authenticateAccessRequest( + authenticatedRequest(viewerTokenClaimingAdmin), + "admin", + ACCESS_CONFIGURATION, + keyResolver, + ), + ).rejects.toMatchObject({ code: "ACCESS_AUTH_INVALID", status: 403 }); + }); + + it.each([ + ["wrong issuer", { issuer: "https://other.cloudflareaccess.com" }], + ["wrong audience", { audience: "d".repeat(64) }], + ["expired token", { expiresAt: 1 }], + ["future token", { notBefore: Math.floor(Date.now() / 1000) + 3600 }], + ["future issuance", { issuedAt: Math.floor(Date.now() / 1000) + 3600 }], + ["service token", { subject: "", email: null }], + ["missing email", { email: null }], + ["wrong token type", { type: "org" }], + ] satisfies ReadonlyArray)( + "rejects a %s", + async (_name, options) => { + const token = await createAccessToken(options); + + await expect( + authenticateAccessRequest( + authenticatedRequest(token), + "viewer", + ACCESS_CONFIGURATION, + keyResolver, + ), + ).rejects.toMatchObject({ code: "ACCESS_AUTH_INVALID", status: 403 }); + }, + ); + + it("rejects malformed assertions without exposing verifier errors", async () => { + await expect( + authenticateAccessRequest( + authenticatedRequest("not-a-jwt"), + "viewer", + ACCESS_CONFIGURATION, + keyResolver, + ), + ).rejects.toMatchObject({ + code: "ACCESS_AUTH_INVALID", + message: "Access authorization failed", + }); + }); + + it("rejects oversized assertions before key resolution", async () => { + await expect( + authenticateAccessRequest( + authenticatedRequest("a".repeat(16 * 1024 + 1)), + "viewer", + ACCESS_CONFIGURATION, + keyResolver, + ), + ).rejects.toMatchObject({ code: "ACCESS_AUTH_INVALID", status: 403 }); + }); +}); + +describe("Access route enforcement", () => { + const getRoute: RouteDefinition = { + method: "GET", + path: "/admin/api/viewer/test", + accessRole: "viewer", + handler: (_request, requestId, _configuration, _params, actor) => + apiSuccess({ actor }, requestId), + }; + const postRoute: RouteDefinition = { + method: "POST", + path: "/admin/api/admin/test", + accessRole: "admin", + handler: (_request, requestId, _configuration, _params, actor) => + apiSuccess({ actor }, requestId), + }; + + it("authenticates before dispatch and passes the Access actor", async () => { + const token = await createAccessToken({ role: "viewer" }); + const response = await handleRequest( + authenticatedRequest(token), + TEST_BINDINGS, + [getRoute], + keyResolver, + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + data: { + actor: { + realm: "access", + identity: ACCESS_SUBJECT, + email: ACCESS_EMAIL, + role: "viewer", + }, + }, + }); + }); + + it("uses the route declaration for roleless operator API paths", async () => { + const token = await createAccessToken({ role: "viewer" }); + const route: RouteDefinition = { + method: "GET", + path: "/admin/api/status", + accessRole: "viewer", + handler: (_request, requestId, _configuration, _params, actor) => + apiSuccess({ actor }, requestId), + }; + const response = await handleRequest( + new Request("https://release.example.com/admin/api/status", { + headers: { "cf-access-jwt-assertion": token }, + }), + TEST_BINDINGS, + [route], + keyResolver, + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + data: { actor: { identity: ACCESS_SUBJECT, role: "viewer" } }, + }); + }); + + it("fails closed when an operator route omits its Access role", async () => { + const unguardedRoute: RouteDefinition = { + method: "GET", + path: "/admin/api/viewer/test", + handler: () => apiSuccess({ reached: true }, "unguarded"), + }; + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const response = await handleRequest( + new Request("https://release.example.com/admin/api/viewer/test"), + TEST_BINDINGS, + [unguardedRoute], + keyResolver, + ); + + expect(response.status).toBe(500); + expect(await response.json()).toMatchObject({ error: { code: "INTERNAL_ERROR" } }); + } finally { + errorLog.mockRestore(); + } + }); + + it("fails closed when the declared role does not match the route family", async () => { + const mismatchedRoute: RouteDefinition = { + method: "GET", + path: "/admin/api/admin/test", + accessRole: "viewer", + handler: () => apiSuccess({ reached: true }, "mismatched"), + }; + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const response = await handleRequest( + new Request("https://release.example.com/admin/api/admin/test"), + TEST_BINDINGS, + [mismatchedRoute], + keyResolver, + ); + + expect(response.status).toBe(500); + expect(await response.json()).toMatchObject({ error: { code: "INTERNAL_ERROR" } }); + } finally { + errorLog.mockRestore(); + } + }); + + it("requires origin, custom-header, and idempotency checks for mutations", async () => { + const token = await createAccessToken({ role: "admin" }); + const missingCsrf = await handleRequest( + authenticatedRequest(token, "admin", { method: "POST" }), + TEST_BINDINGS, + [postRoute], + keyResolver, + ); + expect(missingCsrf.status).toBe(403); + expect(await missingCsrf.json()).toMatchObject({ error: { code: "CSRF_INVALID" } }); + + const invalidIdempotency = await handleRequest( + authenticatedRequest(token, "admin", { + method: "POST", + headers: { + origin: TEST_BINDINGS.PUBLIC_ORIGIN, + "x-emdash-request": "1", + "idempotency-key": "short", + }, + }), + TEST_BINDINGS, + [postRoute], + keyResolver, + ); + expect(invalidIdempotency.status).toBe(400); + expect(await invalidIdempotency.json()).toMatchObject({ + error: { code: "IDEMPOTENCY_KEY_INVALID" }, + }); + + const accepted = await handleRequest( + authenticatedRequest(token, "admin", { + method: "POST", + headers: { + origin: TEST_BINDINGS.PUBLIC_ORIGIN, + "x-emdash-request": "1", + "idempotency-key": "operator-request-0001", + }, + }), + TEST_BINDINGS, + [postRoute], + keyResolver, + ); + expect(accepted.status).toBe(200); + }); +}); + +describe("Access mutation validation", () => { + it("rejects a cross-origin request even with the custom header", () => { + const request = new Request("https://release.example.com/admin/api/admin/test", { + method: "POST", + headers: { + origin: "https://attacker.example", + "x-emdash-request": "1", + "idempotency-key": "operator-request-0001", + }, + }); + + expect(() => validateAccessMutation(request, TEST_BINDINGS.PUBLIC_ORIGIN)).toThrowError( + expect.objectContaining({ code: "CSRF_INVALID" }), + ); + }); +}); diff --git a/apps/release-service/test/approval-authority.test.ts b/apps/release-service/test/approval-authority.test.ts index 9d2fe9e588..fc4a6c1319 100644 --- a/apps/release-service/test/approval-authority.test.ts +++ b/apps/release-service/test/approval-authority.test.ts @@ -59,6 +59,7 @@ async function createAwaitingApprovalIntent() { version: "1.2.3", workloadPolicyVersion: 1, workloadIdentityDigest: "A".repeat(43), + workloadIdempotencyDigest: "I".repeat(43), idempotencyKey: "github-run-100-attempt-1", requestDigest: "B".repeat(43), workloadIdentityJson: JSON.stringify({ issuer: "github-actions", runId: "100" }), @@ -103,7 +104,7 @@ async function createAwaitingApprovalIntent() { actorRealm: "system", actorIdentity: "release-service", reasonCode: "APPROVAL_REQUIRED", - stateDataJson: await encodeAwaitingApprovalState(EVIDENCE), + stateDataJson: await encodeAwaitingApprovalState(EVIDENCE, [APPROVER_DID]), now: NOW + 4, }); } diff --git a/apps/release-service/test/approval-decision-routes.test.ts b/apps/release-service/test/approval-decision-routes.test.ts index 2445f08d70..b3fb365095 100644 --- a/apps/release-service/test/approval-decision-routes.test.ts +++ b/apps/release-service/test/approval-decision-routes.test.ts @@ -10,13 +10,84 @@ import { createApproverApplicationSession } from "../src/approver-session/sessio import { handleRequest } from "../src/index.js"; import { TEST_BINDINGS } from "./fixtures/oauth.js"; -const ORIGIN = "https://release.example.invalid"; +const ORIGIN = "https://release.example.com"; const PUBLISHER_DID = "did:web:publisher.example.com"; const APPROVER_DID = "did:plc:approver"; const INTENT_ID = "01JABCDEFGHJKMNPQRSTVWXYZ0"; const CREDENTIAL_ID = "approval-credential"; const PROFILE_CID = "bafyreib3p6qexampleprofilecid"; const NOW = 1_800_000_000_000; +const WORKLOAD_IDENTITY = { + issuer: "github-actions", + subject: "repo:emdash-cms/gallery:ref:refs/heads/main", + tokenId: "release-token-100", + repository: { + name: "emdash-cms/gallery", + id: "123456789", + owner: "emdash-cms", + ownerId: "987654321", + visibility: "public", + }, + workflow: { + ref: "emdash-cms/gallery/.github/workflows/release.yml@refs/heads/main", + sha: "b".repeat(40), + jobRef: null, + jobSha: null, + }, + run: { + id: "100", + attempt: 1, + actor: "release-bot", + actorId: "123", + eventName: "workflow_dispatch", + ref: "refs/heads/main", + refType: "branch", + commitSha: "a".repeat(40), + environment: null, + runnerEnvironment: "github-hosted", + }, + issuedAt: 1_799_999_000, + expiresAt: 1_800_000_000, +}; +const RELEASE_INPUT = { + release: { + $type: NSID.packageRelease, + package: "gallery", + version: "1.2.3", + artifacts: { + package: { + url: "https://example.com/gallery.tgz", + checksum: "bciqcz4snxjp3biyoe3udwkwfxhrj4gywdzob7j2clzzqim3csofzqja", + }, + }, + extensions: { + [NSID.packageReleaseExtension]: { + $type: NSID.packageReleaseExtension, + declaredAccess: {}, + provenance: { + url: "https://example.com/provenance.json", + checksum: "bciqaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + predicateType: "https://slsa.dev/provenance/v1", + sourceRepository: "https://github.com/emdash-cms/gallery", + builderId: + "https://github.com/emdash-cms/gallery/.github/workflows/release.yml@refs/heads/main", + }, + }, + }, + }, +}; +const ACCESS_DIFF = { + changes: [ + { + kind: "operation-added", + category: "network", + operation: "request", + path: ["network", "request"], + escalation: true, + }, + ], + escalation: true, +}; const EVIDENCE: ApprovalEvidence = { intentId: INTENT_ID, @@ -24,13 +95,13 @@ const EVIDENCE: ApprovalEvidence = { packageSlug: "gallery", version: "1.2.3", verificationGeneration: 4, - workloadIdentityDigest: "A".repeat(43), - releaseInputDigest: "B".repeat(43), + workloadIdentityDigest: "7u8b16-443AUWBwwI1uVQmsjeU_KTHiyKxjy4z04FlA", + releaseInputDigest: "9bHOUQ7KoEcAlBHom7rb9MHmVn1b32woiveMIxZk-Hg", profileCid: PROFILE_CID, baselineReleaseCid: null, - artifactChecksum: "sha256:0123456789abcdef", - provenanceChecksum: "sha256:fedcba9876543210", - declaredAccessDiffDigest: "C".repeat(43), + artifactChecksum: "bciqcz4snxjp3biyoe3udwkwfxhrj4gywdzob7j2clzzqim3csofzqja", + provenanceChecksum: "bciqaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + declaredAccessDiffDigest: "LBGKX2dDy6Ht_ClZjUrp5tfSzuPg_Zw-sEykbB1biYc", verificationDigest: "D".repeat(43), }; @@ -57,7 +128,13 @@ async function sessionHeaders() { }; } -async function createAwaitingIntent() { +async function createAwaitingIntent( + overrides: { + workloadIdentityJson?: string; + releaseInputJson?: string; + accessDiffJson?: string; + } = {}, +) { const stub = env.PUBLISHER_DO.getByName(PUBLISHER_DID); await stub.putWorkloadPolicy({ publisherDid: PUBLISHER_DID, @@ -78,11 +155,12 @@ async function createAwaitingIntent() { packageSlug: "gallery", version: "1.2.3", workloadPolicyVersion: 1, - workloadIdentityDigest: "A".repeat(43), + workloadIdentityDigest: EVIDENCE.workloadIdentityDigest, + workloadIdempotencyDigest: "I".repeat(43), idempotencyKey: "github-run-100-attempt-1", - requestDigest: "B".repeat(43), - workloadIdentityJson: JSON.stringify({ issuer: "github-actions", runId: "100" }), - releaseInputJson: JSON.stringify({ package: "gallery", version: "1.2.3" }), + requestDigest: EVIDENCE.releaseInputDigest, + workloadIdentityJson: overrides.workloadIdentityJson ?? JSON.stringify(WORKLOAD_IDENTITY), + releaseInputJson: overrides.releaseInputJson ?? JSON.stringify(RELEASE_INPUT), expiresAt: NOW + 60_000, now: NOW + 1, }); @@ -100,6 +178,15 @@ async function createAwaitingIntent() { workflowId: "workflow-approval-route", now: NOW + 2, }); + await stub.putVerificationStep({ + publisherDid: PUBLISHER_DID, + intentId: INTENT_ID, + name: "policy-decision", + inputDigest: "H".repeat(43), + resultJson: JSON.stringify({ + accessDiffJson: overrides.accessDiffJson ?? JSON.stringify(ACCESS_DIFF), + }), + }); await stub.transitionIntent({ publisherDid: PUBLISHER_DID, intentId: INTENT_ID, @@ -123,7 +210,7 @@ async function createAwaitingIntent() { actorRealm: "system", actorIdentity: "release-service", reasonCode: "APPROVAL_REQUIRED", - stateDataJson: await encodeAwaitingApprovalState(EVIDENCE), + stateDataJson: await encodeAwaitingApprovalState(EVIDENCE, [APPROVER_DID]), now: NOW + 4, }); } @@ -203,7 +290,7 @@ function assertion( const clientDataJSON = Buffer.from( JSON.stringify({ type: "webauthn.get", challenge, origin: ORIGIN }), ); - const rpIdHash = createHash("sha256").update("release.example.invalid").digest(); + const rpIdHash = createHash("sha256").update("release.example.com").digest(); const counter = Buffer.alloc(4); counter.writeUInt32BE(1); const authenticatorData = Buffer.concat([ @@ -256,11 +343,23 @@ describe("approval decision routes", () => { const resource = `${ORIGIN}/v1/approvals/${INTENT_ID}?publisher=${encodeURIComponent(PUBLISHER_DID)}`; const detail = await handleRequest(new Request(resource, { headers }), bindings()); - expect(detail.status).toBe(200); + expect(detail.status, await detail.clone().text()).toBe(200); await expect(detail.json()).resolves.toMatchObject({ data: { intent: { state: "awaiting_approval", packageSlug: "gallery", version: "1.2.3" }, evidence: { profileCid: PROFILE_CID }, + review: { + source: { + repository: "emdash-cms/gallery", + commitSha: "a".repeat(40), + }, + artifact: { checksum: EVIDENCE.artifactChecksum }, + provenance: { checksum: EVIDENCE.provenanceChecksum }, + accessDiff: { + escalation: true, + changes: [{ category: "network", operation: "request" }], + }, + }, }, }); @@ -328,6 +427,57 @@ describe("approval decision routes", () => { }); }); + it.each([ + [ + "workload identity", + { + workloadIdentityJson: JSON.stringify({ + ...WORKLOAD_IDENTITY, + repository: { ...WORKLOAD_IDENTITY.repository, name: "attacker/gallery" }, + }), + }, + ], + [ + "release input", + { + releaseInputJson: JSON.stringify({ + release: { + ...RELEASE_INPUT.release, + artifacts: { + package: { + ...RELEASE_INPUT.release.artifacts.package, + checksum: EVIDENCE.provenanceChecksum, + }, + }, + }, + }), + }, + ], + [ + "declared access diff", + { + accessDiffJson: JSON.stringify({ + ...ACCESS_DIFF, + changes: [{ ...ACCESS_DIFF.changes[0], category: "storage" }], + }), + }, + ], + ] as const)( + "fails closed when stored %s diverges from approval evidence", + async (_, overrides) => { + await createAwaitingIntent(overrides); + vi.stubGlobal("fetch", approvalNetwork({ approvers: [APPROVER_DID], cid: PROFILE_CID })); + const resource = `${ORIGIN}/v1/approvals/${INTENT_ID}?publisher=${encodeURIComponent(PUBLISHER_DID)}`; + const response = await handleRequest( + new Request(resource, { headers: await sessionHeaders() }), + bindings(), + ); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ error: { code: "NOT_FOUND" } }); + }, + ); + it("rejects an unlisted approver before creating a challenge", async () => { await createAwaitingIntent(); await enrolCredential(); diff --git a/apps/release-service/test/approval-digest.test.ts b/apps/release-service/test/approval-digest.test.ts index c4e4c5a6e8..f1f4bc2f80 100644 --- a/apps/release-service/test/approval-digest.test.ts +++ b/apps/release-service/test/approval-digest.test.ts @@ -92,15 +92,17 @@ describe("approval digest", () => { }); it("round-trips only its canonical awaiting-approval state", async () => { - const encoded = await encodeAwaitingApprovalState(EVIDENCE); + const encoded = await encodeAwaitingApprovalState(EVIDENCE, ["did:plc:approver"]); await expect(decodeAwaitingApprovalState(encoded)).resolves.toEqual({ approvalEvidence: EVIDENCE, approvalEvidenceDigest: await computeApprovalEvidenceDigest(EVIDENCE), + approverDids: ["did:plc:approver"], }); const reordered = JSON.stringify({ approvalEvidenceDigest: await computeApprovalEvidenceDigest(EVIDENCE), approvalEvidence: EVIDENCE, + approverDids: ["did:plc:approver"], }); await expect(decodeAwaitingApprovalState(reordered)).rejects.toBeInstanceOf( ApprovalDigestError, @@ -108,7 +110,7 @@ describe("approval digest", () => { }); it("rejects a substituted evidence digest", async () => { - const encoded = await encodeAwaitingApprovalState(EVIDENCE); + const encoded = await encodeAwaitingApprovalState(EVIDENCE, ["did:plc:approver"]); const substituted = encoded.replace(await computeApprovalEvidenceDigest(EVIDENCE), DIGEST_A); await expect(decodeAwaitingApprovalState(substituted)).rejects.toBeInstanceOf( diff --git a/apps/release-service/test/approval-passkeys.test.ts b/apps/release-service/test/approval-passkeys.test.ts index 9a7dc2c1dd..eabe514505 100644 --- a/apps/release-service/test/approval-passkeys.test.ts +++ b/apps/release-service/test/approval-passkeys.test.ts @@ -18,8 +18,8 @@ const INTENT_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; const EVIDENCE_DIGEST = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const CREDENTIAL_ID = "approval-credential"; const RELYING_PARTY = { - rpId: "release.example.invalid", - origin: "https://release.example.invalid", + rpId: "release.example.com", + origin: "https://release.example.com", } as const; const REQUEST: ApprovalDecisionRequest = { diff --git a/apps/release-service/test/approver-routes.test.ts b/apps/release-service/test/approver-routes.test.ts index 4b3f66d3a3..e66993b108 100644 --- a/apps/release-service/test/approver-routes.test.ts +++ b/apps/release-service/test/approver-routes.test.ts @@ -6,7 +6,7 @@ import { createApproverApplicationSession } from "../src/approver-session/sessio import { handleRequest } from "../src/index.js"; import { TEST_BINDINGS } from "./fixtures/oauth.js"; -const ORIGIN = "https://release.example.invalid"; +const ORIGIN = "https://release.example.com"; const APPROVER_DID = "did:plc:approver"; const CREDENTIAL_ID = "credential-one"; @@ -84,7 +84,7 @@ describe("approver credential routes", () => { await expect(response.json()).resolves.toMatchObject({ data: { authenticatorSelection: { userVerification: "required" }, - rp: { id: "release.example.invalid" }, + rp: { id: "release.example.com" }, }, }); }); diff --git a/apps/release-service/test/approver-session.test.ts b/apps/release-service/test/approver-session.test.ts index 5825c8a73d..95b0688611 100644 --- a/apps/release-service/test/approver-session.test.ts +++ b/apps/release-service/test/approver-session.test.ts @@ -10,7 +10,7 @@ import { } from "../src/approver-session/session.js"; const APPROVER_DID = "did:plc:approver"; -const ORIGIN = "https://release.example.invalid"; +const ORIGIN = "https://release.example.com"; function cookiePair(setCookieHeaders: readonly string[]): string { return setCookieHeaders.map((header) => header.split(";", 1)[0]).join("; "); diff --git a/apps/release-service/test/artifact-materialization.test.ts b/apps/release-service/test/artifact-materialization.test.ts new file mode 100644 index 0000000000..6b4df30b81 --- /dev/null +++ b/apps/release-service/test/artifact-materialization.test.ts @@ -0,0 +1,547 @@ +import { safeParse } from "@atcute/lexicons"; +import type { Blob } from "@atcute/lexicons/interfaces"; +import { PackageRelease } from "@emdash-cms/registry-lexicons"; +import { computeMultihash } from "@emdash-cms/registry-verification"; +import { describe, expect, it, vi } from "vitest"; + +import releaseFixture from "../../../packages/registry-verification/fixtures/records/release.json"; +import { + ArtifactMaterializationError, + buildMaterializedRelease, + materializeReleaseArtifacts, + stageReleaseArtifacts, + uploadStagedArtifact, + type ArtifactUploadReceipt, + type ReleaseArtifactMaterializationPlan, +} from "../src/publishing/materialize.js"; + +function writeUint24LittleEndian(bytes: Uint8Array, offset: number, value: number): void { + bytes[offset] = value & 0xff; + bytes[offset + 1] = (value >>> 8) & 0xff; + bytes[offset + 2] = (value >>> 16) & 0xff; +} + +function writeUint32BigEndian(bytes: Uint8Array, offset: number, value: number): void { + bytes[offset] = (value >>> 24) & 0xff; + bytes[offset + 1] = (value >>> 16) & 0xff; + bytes[offset + 2] = (value >>> 8) & 0xff; + bytes[offset + 3] = value & 0xff; +} + +function pngBytes(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(33); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); + writeUint32BigEndian(bytes, 8, 13); + bytes.set([0x49, 0x48, 0x44, 0x52], 12); + writeUint32BigEndian(bytes, 16, width); + writeUint32BigEndian(bytes, 20, height); + bytes.set([8, 6, 0, 0, 0], 24); + return bytes; +} + +function jpegBytes(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(23); + bytes.set([0xff, 0xd8, 0xff, 0xc0, 0x00, 0x11, 0x08], 0); + bytes[7] = (height >>> 8) & 0xff; + bytes[8] = height & 0xff; + bytes[9] = (width >>> 8) & 0xff; + bytes[10] = width & 0xff; + bytes[11] = 3; + bytes.set([1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0], 12); + bytes.set([0xff, 0xd9], 21); + return bytes; +} + +function webpBytes(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(30); + bytes.set([0x52, 0x49, 0x46, 0x46, 22, 0, 0, 0, 0x57, 0x45, 0x42, 0x50], 0); + bytes.set([0x56, 0x50, 0x38, 0x58, 10, 0, 0, 0], 12); + writeUint24LittleEndian(bytes, 24, width - 1); + writeUint24LittleEndian(bytes, 27, height - 1); + return bytes; +} + +const PACKAGE_BYTES = new Uint8Array([0x1f, 0x8b, 0x08, 0x00, 0x01]); +const PNG_BYTES = pngBytes(128, 128); +const JPEG_BYTES = jpegBytes(1200, 400); +const WEBP_BYTES = webpBytes(1440, 900); +const MOBILE_PNG_BYTES = pngBytes(390, 844); +const PUBLIC_ADDRESS = ["203.0.113.10"]; + +interface ArtifactSource { + bytes: Uint8Array; + contentType?: string; + contentLength?: number; +} + +function encodeBase32(bytes: Uint8Array): string { + const alphabet = "abcdefghijklmnopqrstuvwxyz234567"; + let result = ""; + let buffer = 0; + let bits = 0; + for (const byte of bytes) { + buffer = (buffer << 8) | byte; + bits += 8; + while (bits >= 5) { + result += alphabet[(buffer >>> (bits - 5)) & 31] ?? ""; + bits -= 5; + } + } + if (bits > 0) result += alphabet[(buffer << (5 - bits)) & 31] ?? ""; + return result; +} + +async function rawCid(bytes: Uint8Array): Promise { + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new Uint8Array(bytes))); + const cid = new Uint8Array(4 + digest.byteLength); + cid.set([0x01, 0x55, 0x12, 0x20]); + cid.set(digest, 4); + return `b${encodeBase32(cid)}`; +} + +async function checksum(bytes: Uint8Array): Promise { + const result = await computeMultihash(bytes); + if (!result.success) throw new Error("Test checksum could not be computed"); + return result.value; +} + +async function blobFor(bytes: Uint8Array, mimeType: string): Promise { + return { + $type: "blob", + ref: { $link: await rawCid(bytes) }, + mimeType, + size: bytes.byteLength, + }; +} + +async function completeRelease(): Promise { + const release = structuredClone(releaseFixture) as PackageRelease.Main; + release.repo = "https://github.com/example/gallery"; + release.requires = { "env:emdash": ">=0.12.0" }; + release.provides = { blocks: ["gallery"] }; + release.artifacts = { + package: { + url: "https://assets.example/gallery.tgz", + checksum: await checksum(PACKAGE_BYTES), + contentType: "application/gzip", + releaseAsset: true, + requiresAuth: false, + signature: "package-signature", + }, + icon: { + url: "https://assets.example/icon.png", + checksum: await checksum(PNG_BYTES), + contentType: "image/png", + id: "primary-icon", + width: 128, + height: 128, + }, + banner: { + url: "https://assets.example/banner.jpg", + checksum: await checksum(JPEG_BYTES), + contentType: "image/jpeg", + width: 1200, + height: 400, + }, + screenshots: [ + { + url: "https://assets.example/screenshot.webp", + checksum: await checksum(WEBP_BYTES), + contentType: "image/webp", + id: "desktop", + lang: "en", + width: 1440, + height: 900, + }, + { + url: "https://assets.example/screenshot.png", + checksum: await checksum(MOBILE_PNG_BYTES), + contentType: "image/png", + id: "mobile", + width: 390, + height: 844, + }, + ], + }; + return release; +} + +function sourceMap(entries: Record) { + return vi.fn(async (url: URL, init: RequestInit) => { + const source = entries[url.pathname]; + if (!source) return new Response(null, { status: 404 }); + const headers = new Headers(); + if (source.contentType) headers.set("content-type", source.contentType); + if (source.contentLength !== undefined) { + headers.set("content-length", String(source.contentLength)); + } + if (url.pathname === "/gallery.tgz") { + expect(new Headers(init.headers).get("accept")).toBe("application/octet-stream"); + } + return new Response(new Uint8Array(source.bytes), { headers }); + }); +} + +function allSources() { + return sourceMap({ + "/gallery.tgz": { bytes: PACKAGE_BYTES, contentType: "application/octet-stream" }, + "/icon.png": { bytes: PNG_BYTES, contentType: "image/png" }, + "/banner.jpg": { bytes: JPEG_BYTES, contentType: "image/jpeg" }, + "/screenshot.webp": { bytes: WEBP_BYTES, contentType: "image/webp" }, + "/screenshot.png": { bytes: MOBILE_PNG_BYTES, contentType: "image/png" }, + }); +} + +function resolveHostname(): Promise { + return Promise.resolve(PUBLIC_ADDRESS); +} + +function persisted(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +describe("release artifact materialization", () => { + it("materializes package, icon, banner, and ordered screenshots into strict blobs", async () => { + const release = await completeRelease(); + const original = structuredClone(release); + const fetch = allSources(); + const uploads: Array<{ bytes: Uint8Array; mimeType: string }> = []; + const uploadBlob = vi.fn(async (bytes: Uint8Array, mimeType: string) => { + uploads.push({ bytes: new Uint8Array(bytes), mimeType }); + return blobFor(bytes, mimeType); + }); + + const staged = await stageReleaseArtifacts(release, { + fetch, + resolveHostname, + }); + const receipts: ArtifactUploadReceipt[] = []; + for (const artifact of staged.artifacts) { + receipts.push(await uploadStagedArtifact(artifact, uploadBlob)); + } + const persistedPlan: ReleaseArtifactMaterializationPlan = persisted(staged.plan); + const persistedReceipts: ArtifactUploadReceipt[] = persisted(receipts); + const materialized = buildMaterializedRelease(persistedPlan, persistedReceipts); + + expect(release).toEqual(original); + expect(JSON.stringify(persistedPlan)).not.toContain("https://assets.example"); + expect(JSON.stringify(persistedPlan)).not.toContain('"bytes"'); + expect(staged.artifacts.map(({ metadata }) => metadata.path)).toEqual([ + "package", + "icon", + "banner", + "screenshots[0]", + "screenshots[1]", + ]); + expect( + staged.plan.artifacts.map(({ path, width, height }) => ({ path, width, height })), + ).toEqual([ + { path: "package", width: undefined, height: undefined }, + { path: "icon", width: 128, height: 128 }, + { path: "banner", width: 1200, height: 400 }, + { path: "screenshots[0]", width: 1440, height: 900 }, + { path: "screenshots[1]", width: 390, height: 844 }, + ]); + expect(safeParse(PackageRelease.mainSchema, materialized, { strict: true }).ok).toBe(true); + expect(fetch.mock.calls.map(([url]) => url.pathname)).toEqual([ + "/gallery.tgz", + "/icon.png", + "/banner.jpg", + "/screenshot.webp", + "/screenshot.png", + ]); + expect(uploads.map((upload) => upload.mimeType)).toEqual([ + "application/gzip", + "image/png", + "image/jpeg", + "image/webp", + "image/png", + ]); + expect(materialized).toMatchObject({ + package: release.package, + version: release.version, + repo: release.repo, + requires: release.requires, + provides: release.provides, + extensions: release.extensions, + artifacts: { + package: { + blob: { $type: "blob", mimeType: "application/gzip", size: PACKAGE_BYTES.byteLength }, + checksum: release.artifacts.package.checksum, + contentType: "application/gzip", + signature: "package-signature", + }, + icon: { + blob: { mimeType: "image/png", size: PNG_BYTES.byteLength }, + id: "primary-icon", + width: 128, + height: 128, + }, + banner: { + blob: { mimeType: "image/jpeg", size: JPEG_BYTES.byteLength }, + width: 1200, + height: 400, + }, + screenshots: [ + { + blob: { mimeType: "image/webp", size: WEBP_BYTES.byteLength }, + id: "desktop", + lang: "en", + width: 1440, + height: 900, + }, + { + blob: { mimeType: "image/png", size: MOBILE_PNG_BYTES.byteLength }, + id: "mobile", + width: 390, + height: 844, + }, + ], + }, + }); + for (const artifact of [ + materialized.artifacts.package, + materialized.artifacts.icon, + materialized.artifacts.banner, + ...(materialized.artifacts.screenshots ?? []), + ]) { + expect(artifact).not.toHaveProperty("url"); + expect(artifact).not.toHaveProperty("requiresAuth"); + expect(artifact).not.toHaveProperty("releaseAsset"); + } + expect(() => buildMaterializedRelease(persistedPlan, persistedReceipts.toReversed())).toThrow( + expect.objectContaining({ code: "ARTIFACT_RECEIPTS_INVALID" }), + ); + const tamperedDimensions = persisted(persistedPlan); + if (!tamperedDimensions.artifacts[1]) throw new Error("Expected icon metadata"); + tamperedDimensions.artifacts[1].width = 127; + expect(() => buildMaterializedRelease(tamperedDimensions, persistedReceipts)).toThrow( + expect.objectContaining({ code: "ARTIFACT_RECEIPTS_INVALID", artifact: "icon" }), + ); + }); + + it.each([ + ["unsafe host", "HOST_REJECTED"], + ["checksum mismatch", "CHECKSUM_MISMATCH"], + ["package size", "RESOURCE_SIZE_EXCEEDED"], + ["package MIME", "ARTIFACT_MIME_INVALID"], + ["unsupported auth", "AUTH_METHOD_UNSUPPORTED"], + ] as const)("fails closed for %s", async (scenario, code) => { + const release = await completeRelease(); + const fetch = allSources(); + if (scenario === "unsafe host") { + release.artifacts.package.url = "https://127.0.0.1/gallery.tgz"; + } + if (scenario === "checksum mismatch") { + release.artifacts.package.checksum = await checksum(PNG_BYTES); + } + if (scenario === "package size") { + fetch.mockImplementationOnce( + async () => + new Response(PACKAGE_BYTES, { + headers: { "content-length": String(262_145) }, + }), + ); + } + if (scenario === "package MIME") { + release.artifacts.package.checksum = await checksum(PNG_BYTES); + fetch.mockImplementationOnce(async () => new Response(PNG_BYTES)); + } + if (scenario === "unsupported auth") { + release.artifacts.package.requiresAuth = true; + } + const uploadBlob = vi.fn(); + + await expect( + materializeReleaseArtifacts(release, { fetch, resolveHostname, uploadBlob }), + ).rejects.toMatchObject({ code, artifact: "package" }); + expect(uploadBlob).not.toHaveBeenCalled(); + }); + + it("uses measured dimensions when the submitted image omits them", async () => { + const release = await completeRelease(); + if (!release.artifacts.icon) throw new Error("Expected icon fixture"); + delete release.artifacts.package.contentType; + delete release.artifacts.icon.contentType; + delete release.artifacts.icon.width; + delete release.artifacts.icon.height; + + const staged = await stageReleaseArtifacts(release, { + fetch: allSources(), + resolveHostname, + }); + + expect(staged.plan.release.artifacts.package).toMatchObject({ + contentType: "application/gzip", + }); + expect(staged.plan.release.artifacts.icon).toMatchObject({ + contentType: "image/png", + width: 128, + height: 128, + }); + expect(staged.plan.artifacts[1]).toMatchObject({ + path: "icon", + width: 128, + height: 128, + }); + }); + + it("rejects submitted dimensions that do not match the image bytes", async () => { + const release = await completeRelease(); + if (!release.artifacts.icon) throw new Error("Expected icon fixture"); + release.artifacts.icon.width = 129; + + await expect( + stageReleaseArtifacts(release, { fetch: allSources(), resolveHostname }), + ).rejects.toMatchObject({ + code: "ARTIFACT_DIMENSIONS_INVALID", + artifact: "icon", + }); + }); + + it("rejects measured dimensions over the image limit", async () => { + const release = await completeRelease(); + if (!release.artifacts.icon) throw new Error("Expected icon fixture"); + const oversized = pngBytes(8193, 1); + release.artifacts.icon.checksum = await checksum(oversized); + delete release.artifacts.icon.width; + delete release.artifacts.icon.height; + const fetch = allSources(); + fetch.mockImplementationOnce(async () => new Response(PACKAGE_BYTES)); + fetch.mockImplementationOnce( + async () => new Response(oversized, { headers: { "content-type": "image/png" } }), + ); + + await expect(stageReleaseArtifacts(release, { fetch, resolveHostname })).rejects.toMatchObject({ + code: "ARTIFACT_DIMENSIONS_INVALID", + artifact: "icon", + }); + }); + + it.each([ + ["CID", async () => blobFor(PNG_BYTES, "application/gzip")], + ["MIME", async () => blobFor(PACKAGE_BYTES, "image/png")], + ["size", async () => ({ ...(await blobFor(PACKAGE_BYTES, "application/gzip")), size: 999 })], + ] as const)("rejects an uploaded blob with mismatched %s", async (_field, returnedBlob) => { + const release = await completeRelease(); + await expect( + materializeReleaseArtifacts(release, { + fetch: allSources(), + resolveHostname, + uploadBlob: returnedBlob, + }), + ).rejects.toMatchObject({ + code: "ARTIFACT_BLOB_INVALID", + artifact: "package", + }); + }); + + it("rejects blob-only inputs because their bytes cannot be verified in this boundary", async () => { + const release = await completeRelease(); + release.artifacts.package = { + blob: await blobFor(PACKAGE_BYTES, "application/gzip"), + checksum: await checksum(PACKAGE_BYTES), + contentType: "application/gzip", + }; + const fetch = vi.fn(); + const uploadBlob = vi.fn(); + + await expect( + materializeReleaseArtifacts(release, { fetch, resolveHostname, uploadBlob }), + ).rejects.toBeInstanceOf(ArtifactMaterializationError); + await expect( + materializeReleaseArtifacts(release, { fetch, resolveHostname, uploadBlob }), + ).rejects.toMatchObject({ + code: "ARTIFACT_SOURCE_UNVERIFIABLE", + artifact: "package", + }); + expect(fetch).not.toHaveBeenCalled(); + expect(uploadBlob).not.toHaveBeenCalled(); + }); + + it("retries deterministically after an uploader fails partway through", async () => { + const release = await completeRelease(); + const uploadedMimeTypes: string[] = []; + let failed = false; + const uploadBlob = vi.fn(async (bytes: Uint8Array, mimeType: string) => { + uploadedMimeTypes.push(mimeType); + if (!failed && mimeType === "image/jpeg") { + failed = true; + throw new Error("provider detail that must not escape"); + } + return blobFor(bytes, mimeType); + }); + + await expect( + materializeReleaseArtifacts(release, { + fetch: allSources(), + resolveHostname, + uploadBlob, + }), + ).rejects.toMatchObject({ + code: "ARTIFACT_UPLOAD_FAILED", + message: "ARTIFACT_UPLOAD_FAILED", + artifact: "banner", + }); + const retried = await materializeReleaseArtifacts(release, { + fetch: allSources(), + resolveHostname, + uploadBlob, + }); + const expected = await materializeReleaseArtifacts(release, { + fetch: allSources(), + resolveHostname, + uploadBlob: blobFor, + }); + + expect(retried).toEqual(expected); + expect(uploadedMimeTypes).toEqual([ + "application/gzip", + "image/png", + "image/jpeg", + "application/gzip", + "image/png", + "image/jpeg", + "image/webp", + "image/png", + ]); + }); + + it("re-materializes a mixed URL and blob descriptor from its verified URL bytes", async () => { + const release = await completeRelease(); + const previousBlob = await blobFor(PACKAGE_BYTES, "application/gzip"); + release.artifacts.package.blob = previousBlob; + const uploaded = await blobFor(PACKAGE_BYTES, "application/gzip"); + const uploadBlob = vi.fn(async (bytes: Uint8Array, mimeType: string) => + mimeType === "application/gzip" ? uploaded : blobFor(bytes, mimeType), + ); + + const result = await materializeReleaseArtifacts(release, { + fetch: allSources(), + resolveHostname, + uploadBlob, + }); + + expect(uploadBlob).toHaveBeenCalled(); + expect(result.artifacts.package.blob).toEqual(uploaded); + expect(result.artifacts.package).not.toHaveProperty("url"); + }); + + it("applies the image descriptor limit before uploading any artifacts", async () => { + const release = await completeRelease(); + const fetch = allSources(); + fetch.mockImplementationOnce(async () => new Response(PACKAGE_BYTES)); + fetch.mockImplementationOnce( + async () => + new Response(PNG_BYTES, { + headers: { "content-length": String(1_048_577) }, + }), + ); + const uploadBlob = vi.fn(); + + await expect( + materializeReleaseArtifacts(release, { fetch, resolveHostname, uploadBlob }), + ).rejects.toMatchObject({ code: "RESOURCE_SIZE_EXCEEDED", artifact: "icon" }); + expect(uploadBlob).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/release-service/test/config.test.ts b/apps/release-service/test/config.test.ts index 213280f656..8f0409fab6 100644 --- a/apps/release-service/test/config.test.ts +++ b/apps/release-service/test/config.test.ts @@ -10,16 +10,16 @@ describe("release-service OAuth configuration", () => { const metadata = getClientMetadata(configuration.oauth); expect(metadata).toEqual({ - client_id: "https://release.example.invalid/.well-known/atproto-client-metadata.json", + client_id: "https://release.example.com/.well-known/atproto-client-metadata.json", client_name: "EmDash delegated release service", - client_uri: "https://release.example.invalid", + client_uri: "https://release.example.com", application_type: "web", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], - redirect_uris: ["https://release.example.invalid/oauth/callback"], + redirect_uris: ["https://release.example.com/oauth/callback"], scope: "atproto repo:com.emdashcms.experimental.package.release?action=create blob:application/gzip blob:image/*", - jwks_uri: "https://release.example.invalid/oauth/jwks.json", + jwks_uri: "https://release.example.com/oauth/jwks.json", dpop_bound_access_tokens: true, token_endpoint_auth_method: "private_key_jwt", token_endpoint_auth_signing_alg: "ES256", @@ -29,13 +29,30 @@ describe("release-service OAuth configuration", () => { ASSERTION_KEY_1.kid, ]); expect(JSON.stringify(getPublicJwks(configuration.oauth))).not.toContain('"d"'); + expect(configuration.access).toEqual({ + teamDomain: TEST_BINDINGS.ACCESS_TEAM_DOMAIN, + audiences: { + viewer: TEST_BINDINGS.ACCESS_VIEWER_AUD, + reviewer: TEST_BINDINGS.ACCESS_REVIEWER_AUD, + admin: TEST_BINDINGS.ACCESS_ADMIN_AUD, + }, + }); + }); + + it("accepts a custom Access issuer hostname", async () => { + const configuration = await loadConfiguration({ + ...TEST_BINDINGS, + ACCESS_TEAM_DOMAIN: "https://access.example.com", + }); + + expect(configuration.access.teamDomain).toBe("https://access.example.com"); }); it.each([ ["empty origin", { ...TEST_BINDINGS, PUBLIC_ORIGIN: "" }], ["empty deployment ID", { ...TEST_BINDINGS, DEPLOYMENT_ID: "" }], - ["HTTP origin", { ...TEST_BINDINGS, PUBLIC_ORIGIN: "http://release.example.invalid" }], - ["origin path", { ...TEST_BINDINGS, PUBLIC_ORIGIN: "https://release.example.invalid/path" }], + ["HTTP origin", { ...TEST_BINDINGS, PUBLIC_ORIGIN: "http://release.example.com" }], + ["origin path", { ...TEST_BINDINGS, PUBLIC_ORIGIN: "https://release.example.com/path" }], [ "redirect mismatch", { ...TEST_BINDINGS, OAUTH_REDIRECT_URIS: '["https://other.example/callback"]' }, @@ -43,6 +60,15 @@ describe("release-service OAuth configuration", () => { ["empty redirects", { ...TEST_BINDINGS, OAUTH_REDIRECT_URIS: "[]" }], ["malformed keyset", { ...TEST_BINDINGS, OAUTH_ASSERTION_KEYSET: "not-json" }], ["malformed encryption keyring", { ...TEST_BINDINGS, ENCRYPTION_KEYRING: "not-json" }], + [ + "Access team domain with a port", + { ...TEST_BINDINGS, ACCESS_TEAM_DOMAIN: "https://emdash-test.cloudflareaccess.com:8443" }, + ], + ["malformed Access audience", { ...TEST_BINDINGS, ACCESS_ADMIN_AUD: "not-an-aud" }], + [ + "duplicate Access audiences", + { ...TEST_BINDINGS, ACCESS_ADMIN_AUD: TEST_BINDINGS.ACCESS_REVIEWER_AUD }, + ], [ "missing active key", { diff --git a/apps/release-service/test/control-routes.test.ts b/apps/release-service/test/control-routes.test.ts new file mode 100644 index 0000000000..7823472db9 --- /dev/null +++ b/apps/release-service/test/control-routes.test.ts @@ -0,0 +1,205 @@ +import { reset } from "cloudflare:test"; +import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT, type JWTVerifyGetKey } from "jose"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import type { AccessRole } from "../src/access/auth.js"; +import { handleRequest } from "../src/index.js"; +import { ROUTES } from "../src/routes.js"; +import { TEST_ACCESS_AUDIENCES, TEST_BINDINGS } from "./fixtures/oauth.js"; + +const ACCESS_KEY_ID = "control-route-access-key"; +const OPERATOR_SUBJECT = "7335d417-61da-459d-899c-0a01c76a2f94"; +const DID = "did:plc:publisher"; + +let privateKey: CryptoKey; +let keyResolver: JWTVerifyGetKey; + +beforeAll(async () => { + const keys = await generateKeyPair("RS256", { extractable: true }); + privateKey = keys.privateKey; + const publicJwk = await exportJWK(keys.publicKey); + publicJwk.kid = ACCESS_KEY_ID; + publicJwk.alg = "RS256"; + publicJwk.use = "sig"; + keyResolver = createLocalJWKSet({ keys: [publicJwk] }); +}); + +afterEach(async () => { + await reset(); +}); + +async function accessToken(role: AccessRole): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ type: "app", email: "operator@example.com" }) + .setProtectedHeader({ alg: "RS256", kid: ACCESS_KEY_ID, typ: "JWT" }) + .setIssuer(TEST_BINDINGS.ACCESS_TEAM_DOMAIN) + .setAudience(TEST_ACCESS_AUDIENCES[role]) + .setSubject(OPERATOR_SUBJECT) + .setIssuedAt(now) + .setNotBefore(now - 1) + .setExpirationTime(now + 300) + .sign(privateKey); +} + +async function operatorRequest( + path: string, + role: AccessRole, + init: RequestInit = {}, +): Promise { + const headers = new Headers(init.headers); + headers.set("cf-access-jwt-assertion", await accessToken(role)); + if (init.method && init.method !== "GET") { + headers.set("origin", TEST_BINDINGS.PUBLIC_ORIGIN); + headers.set("x-emdash-request", "1"); + if (!headers.has("idempotency-key")) { + headers.set("idempotency-key", "operator-request-0001"); + } + } + return handleRequest( + new Request(`${TEST_BINDINGS.PUBLIC_ORIGIN}${path}`, { ...init, headers }), + TEST_BINDINGS, + ROUTES, + keyResolver, + ); +} + +describe("Access service-control routes", () => { + it("returns service status only for the viewer audience", async () => { + const response = await operatorRequest("/admin/api/status", "viewer"); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + data: { state: { mode: "active", epoch: 1 } }, + }); + + const wrongAudience = await operatorRequest("/admin/api/pause", "viewer", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "publication-paused", reasonCode: "MAINTENANCE" }), + }); + expect(wrongAudience.status).toBe(403); + expect(await wrongAudience.json()).toMatchObject({ error: { code: "ACCESS_AUTH_INVALID" } }); + }); + + it("changes service mode and replays the normalized idempotent request", async () => { + const request = { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "publication-paused", reasonCode: "MAINTENANCE" }), + }; + const first = await operatorRequest("/admin/api/pause", "admin", request); + expect(first.status).toBe(200); + expect(await first.json()).toMatchObject({ + data: { + state: { mode: "publication-paused", epoch: 2, reasonCode: "MAINTENANCE" }, + replayed: false, + }, + }); + + const replay = await operatorRequest("/admin/api/pause", "admin", request); + expect(replay.status).toBe(200); + expect(await replay.json()).toMatchObject({ data: { replayed: true } }); + + const conflict = await operatorRequest("/admin/api/pause", "admin", { + ...request, + body: JSON.stringify({ mode: "admission-paused", reasonCode: "MAINTENANCE" }), + }); + expect(conflict.status).toBe(409); + expect(await conflict.json()).toMatchObject({ + error: { code: "IDEMPOTENCY_CONFLICT" }, + }); + }); + + it("sets and reads a publisher suspension without exposing operator email", async () => { + const changed = await operatorRequest( + `/admin/api/publishers/${encodeURIComponent(DID)}/suspend`, + "admin", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + suspended: true, + reasonCode: "SECURITY_REVIEW", + }), + }, + ); + expect(changed.status).toBe(200); + + const read = await operatorRequest( + `/admin/api/publishers/${encodeURIComponent(DID)}`, + "viewer", + ); + const text = await read.text(); + expect(read.status).toBe(200); + expect(JSON.parse(text)).toMatchObject({ + data: { + publisher: { + did: DID, + control: { + publisherDid: DID, + status: "suspended", + reasonCode: "SECURITY_REVIEW", + changedBy: OPERATOR_SUBJECT, + }, + }, + }, + }); + expect(text).not.toContain("operator@example.com"); + }); + + it("paginates sanitized control audit events", async () => { + await operatorRequest("/admin/api/pause", "admin", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "admission-paused", reasonCode: "MAINTENANCE" }), + }); + await operatorRequest(`/admin/api/publishers/${encodeURIComponent(DID)}/suspend`, "admin", { + method: "POST", + headers: { + "content-type": "application/json", + "idempotency-key": "operator-request-0002", + }, + body: JSON.stringify({ + suspended: true, + reasonCode: "SECURITY_REVIEW", + }), + }); + + const first = await operatorRequest("/admin/api/audit?limit=1", "viewer"); + expect(await first.json()).toMatchObject({ + data: { items: [{ sequence: 1 }], nextCursor: "1" }, + }); + + const second = await operatorRequest("/admin/api/audit?after=1&limit=1", "viewer"); + expect(await second.json()).toMatchObject({ data: { items: [{ sequence: 2 }] } }); + }); + + it("rejects invalid control bodies and query parameters", async () => { + const body = await operatorRequest("/admin/api/pause", "admin", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "active", reasonCode: "STALE_REASON" }), + }); + expect(body.status).toBe(400); + expect(await body.json()).toMatchObject({ error: { code: "INVALID_REQUEST" } }); + + const query = await operatorRequest("/admin/api/audit?unexpected=1", "viewer"); + expect(query.status).toBe(400); + }); + + it.each([ + ["GET", "/admin/api/viewer/status", "viewer"], + ["GET", `/admin/api/viewer/publisher-control?did=${encodeURIComponent(DID)}`, "viewer"], + ["GET", "/admin/api/viewer/audit", "viewer"], + ["POST", "/admin/api/admin/service-mode", "admin"], + ["POST", "/admin/api/admin/publisher-control", "admin"], + ] as const)("does not expose the legacy %s %s operator route", async (method, path, role) => { + const response = await operatorRequest(path, role, { + method, + headers: method === "POST" ? { "content-type": "application/json" } : undefined, + body: method === "POST" ? "{}" : undefined, + }); + + expect(response.status).toBe(404); + }); +}); diff --git a/apps/release-service/test/create-only.test.ts b/apps/release-service/test/create-only.test.ts new file mode 100644 index 0000000000..0f044ff282 --- /dev/null +++ b/apps/release-service/test/create-only.test.ts @@ -0,0 +1,56 @@ +import type { FetchHandlerObject } from "@atcute/client"; +import type { PackageRelease } from "@emdash-cms/registry-lexicons"; +import { NSID } from "@emdash-cms/registry-lexicons"; +import { describe, expect, it, vi } from "vitest"; + +import releaseFixture from "../../../packages/registry-verification/fixtures/records/release.json"; +import { createReleaseRecord, uploadReleaseBlob } from "../src/publishing/create-only.js"; + +describe("create-only release client", () => { + it("calls only createRecord with validation enabled", async () => { + const handle = vi.fn(async (_pathname: string, _init: RequestInit) => + Response.json({ + uri: `at://did:plc:publisher/${NSID.packageRelease}/gallery:1.2.3`, + cid: "bafyreigh2akiscaildc4mscz4uzpcbap5jxg26eecmrf6cmnvkzkjmoixe", + }), + ); + const session: FetchHandlerObject = { handle }; + await expect( + createReleaseRecord(session, { + publisherDid: "did:plc:publisher", + rkey: "gallery:1.2.3", + record: structuredClone(releaseFixture) as PackageRelease.Main, + }), + ).resolves.toMatchObject({ cid: expect.any(String) }); + expect(handle).toHaveBeenCalledOnce(); + expect(handle.mock.calls[0]?.[0]).toBe("/xrpc/com.atproto.repo.createRecord"); + const init = handle.mock.calls[0]?.[1]; + expect(init?.method).toBe("post"); + expect(typeof init?.body).toBe("string"); + if (typeof init?.body !== "string") throw new Error("Expected serialized createRecord body"); + expect(JSON.parse(init.body)).toMatchObject({ + repo: "did:plc:publisher", + collection: NSID.packageRelease, + rkey: "gallery:1.2.3", + validate: true, + }); + }); + + it("uploads blob bytes with their verified content type", async () => { + const bytes = new Uint8Array([0x1f, 0x8b, 0x08]); + const blob = { + $type: "blob" as const, + ref: { $link: "bafkreia6n3lf256wgzhov3k2orn2lreyllrloag5qxl467ycpppsssrt7q" }, + mimeType: "application/gzip", + size: bytes.byteLength, + }; + const handle = vi.fn(async (_pathname: string, _init: RequestInit) => Response.json({ blob })); + + await expect(uploadReleaseBlob({ handle }, bytes, "application/gzip")).resolves.toEqual(blob); + expect(handle).toHaveBeenCalledOnce(); + expect(handle.mock.calls[0]?.[0]).toBe("/xrpc/com.atproto.repo.uploadBlob"); + const init = handle.mock.calls[0]?.[1]; + expect(init?.headers).toEqual({ "content-type": "application/gzip" }); + expect(init?.body).toEqual(bytes); + }); +}); diff --git a/apps/release-service/test/fixtures/oauth.ts b/apps/release-service/test/fixtures/oauth.ts index b99c53e181..9196486e33 100644 --- a/apps/release-service/test/fixtures/oauth.ts +++ b/apps/release-service/test/fixtures/oauth.ts @@ -27,10 +27,20 @@ export const TEST_ASSERTION_KEYSET = JSON.stringify({ keys: [ASSERTION_KEY_1, ASSERTION_KEY_2], }); +export const TEST_ACCESS_AUDIENCES = { + viewer: "a".repeat(64), + reviewer: "b".repeat(64), + admin: "c".repeat(64), +} as const; + export const TEST_BINDINGS = { - PUBLIC_ORIGIN: "https://release.example.invalid", + PUBLIC_ORIGIN: "https://release.example.com", DEPLOYMENT_ID: "test-release-service", - OAUTH_REDIRECT_URIS: '["https://release.example.invalid/oauth/callback"]', + ACCESS_TEAM_DOMAIN: "https://emdash-test.cloudflareaccess.com", + ACCESS_VIEWER_AUD: TEST_ACCESS_AUDIENCES.viewer, + ACCESS_REVIEWER_AUD: TEST_ACCESS_AUDIENCES.reviewer, + ACCESS_ADMIN_AUD: TEST_ACCESS_AUDIENCES.admin, + OAUTH_REDIRECT_URIS: '["https://release.example.com/oauth/callback"]', OAUTH_ASSERTION_KEYSET: TEST_ASSERTION_KEYSET, ENCRYPTION_KEYRING: '{"current":1,"keys":[{"version":1,"key":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"}]}', diff --git a/apps/release-service/test/fixtures/publication-proofs.json b/apps/release-service/test/fixtures/publication-proofs.json new file mode 100644 index 0000000000..b65add2bd1 --- /dev/null +++ b/apps/release-service/test/fixtures/publication-proofs.json @@ -0,0 +1,5 @@ +{ + "signingKey": "zDnaeVuZeVRqvscGkiEoR9PFFra2xZUMp97ZPuGFK1VLU7iYN", + "exactProof": "OqJlcm9vdHOB2CpYJQABcRIg1C4D5yRkSej3XekJihJLJ5TsD7lLaJhRRfzTU1wfMYJndmVyc2lvbgHdAQFxEiDULgPnJGRJ6Pdd6QmKEksnlOwPuUtomFFF/NNTXB8xgqZjZGlkeB1kaWQ6d2ViOnB1Ymxpc2hlci5leGFtcGxlLmNvbWNyZXZtM211NjM0bnY0ZWsyNGNzaWdYQGay83y4pkkdh8v6YLwX8mqwuiBkL+xdMIzfrx20Lf83UEmlYB4YYFkQaS4jDha/vOcEwjT6ivFF0+kRZvfdOkNkZGF0YdgqWCUAAXESIOF2tzY2wV1F0WyyGp8LstUO8lMU8VmgBiUVNUWaAnc+ZHByZXb2Z3ZlcnNpb24DwQEBcRIg4Xa3NjbBXUXRbLIanwuy1Q7yUxTxWaAGJRU1RZoCdz6iYWWBpGFrWDhjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnJlbGVhc2UvZ2FsbGVyeToxLjIuM2FwAGF09mF22CpYJQABcRIg6XoqNfYDu6mYClOhh3olve2S5Fcno0jN2v6H49bnZQlhbNgqWCUAAXESICPWWGKAvX12s+8YBNB6iLwFl8YMr6smSZpFoaG8aBsnvwYBcRIg6XoqNfYDu6mYClOhh3olve2S5Fcno0jN2v6H49bnZQmlZSR0eXBleCpjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnJlbGVhc2VncGFja2FnZWdnYWxsZXJ5Z3ZlcnNpb25lMS4yLjNpYXJ0aWZhY3RzoWdwYWNrYWdlo2RibG9ipGNyZWahZSRsaW5reDtiYWZrcmVpZHFteHY2M25pcXA2bmd0ZXNtM2x4bW9oNzZoNWNtZWN6d3d0NGJqaGNxZzNnYnlzbXVqNGRzaXplBWUkdHlwZWRibG9iaG1pbWVUeXBlcGFwcGxpY2F0aW9uL2d6aXBoY2hlY2tzdW14OGJjaXFoYXpwbDV3MnJhNzQybmdqZXp3eG95NHA3NHAyZXlpZnRubmh5Y3NvZmFud21kcmV6aXR5a2NvbnRlbnRUeXBlcGFwcGxpY2F0aW9uL2d6aXBqZXh0ZW5zaW9uc6F4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucmVsZWFzZUV4dGVuc2lvbqNlJHR5cGV4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucmVsZWFzZUV4dGVuc2lvbmpwcm92ZW5hbmNlpWN1cmx4PGh0dHBzOi8vZ2l0aHViLmNvbS9leGFtcGxlL2dhbGxlcnkvYXR0ZXN0YXRpb24uc2lnc3RvcmUuanNvbmhjaGVja3N1bXg4YmNpcWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFpYnVpbGRlcklkeFBodHRwczovL2dpdGh1Yi5jb20vZXhhbXBsZS9nYWxsZXJ5Ly5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sQHJlZnMvaGVhZHMvbWFpbm1wcmVkaWNhdGVUeXBleB5odHRwczovL3Nsc2EuZGV2L3Byb3ZlbmFuY2UvdjFwc291cmNlUmVwb3NpdG9yeXgiaHR0cHM6Ly9naXRodWIuY29tL2V4YW1wbGUvZ2FsbGVyeW5kZWNsYXJlZEFjY2Vzc6A=", + "conflictProof": "OqJlcm9vdHOB2CpYJQABcRIg43RlD4kifWjBbLC824gvqz4xKl6Nv6XJBeQdP8XbyB1ndmVyc2lvbgHdAQFxEiDjdGUPiSJ9aMFssLzbiC+rPjEqXo2/pckF5B0/xdvIHaZjZGlkeB1kaWQ6d2ViOnB1Ymxpc2hlci5leGFtcGxlLmNvbWNyZXZtM211NjM0bnY1ZHMyNGNzaWdYQATyDPvP6cC4aaO++jorB/2mjUb05jD9VTJYVcbUTYT3DU7SoNPqh81xFUia8VyT/jH2w5cfE7mhCr9Fulp7HkNkZGF0YdgqWCUAAXESILx3dgFGDawAjoUPBVywU/DPLiVTfiWFyLqBFPUd3TpSZHByZXb2Z3ZlcnNpb24DwQEBcRIgvHd2AUYNrACOhQ8FXLBT8M8uJVN+JYXIuoEU9R3dOlKiYWWBpGFrWDhjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnJlbGVhc2UvZ2FsbGVyeToxLjIuM2FwAGF09mF22CpYJQABcRIgH1DcFhrVzQjsulxZzwJwQVh1etK8ygjZtaJj/81e9JlhbNgqWCUAAXESICPWWGKAvX12s+8YBNB6iLwFl8YMr6smSZpFoaG8aBsnvwYBcRIgH1DcFhrVzQjsulxZzwJwQVh1etK8ygjZtaJj/81e9JmlZSR0eXBleCpjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnJlbGVhc2VncGFja2FnZWdnYWxsZXJ5Z3ZlcnNpb25lOS45LjlpYXJ0aWZhY3RzoWdwYWNrYWdlo2RibG9ipGNyZWahZSRsaW5reDtiYWZrcmVpZHFteHY2M25pcXA2bmd0ZXNtM2x4bW9oNzZoNWNtZWN6d3d0NGJqaGNxZzNnYnlzbXVqNGRzaXplBWUkdHlwZWRibG9iaG1pbWVUeXBlcGFwcGxpY2F0aW9uL2d6aXBoY2hlY2tzdW14OGJjaXFoYXpwbDV3MnJhNzQybmdqZXp3eG95NHA3NHAyZXlpZnRubmh5Y3NvZmFud21kcmV6aXR5a2NvbnRlbnRUeXBlcGFwcGxpY2F0aW9uL2d6aXBqZXh0ZW5zaW9uc6F4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucmVsZWFzZUV4dGVuc2lvbqNlJHR5cGV4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucmVsZWFzZUV4dGVuc2lvbmpwcm92ZW5hbmNlpWN1cmx4PGh0dHBzOi8vZ2l0aHViLmNvbS9leGFtcGxlL2dhbGxlcnkvYXR0ZXN0YXRpb24uc2lnc3RvcmUuanNvbmhjaGVja3N1bXg4YmNpcWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFpYnVpbGRlcklkeFBodHRwczovL2dpdGh1Yi5jb20vZXhhbXBsZS9nYWxsZXJ5Ly5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sQHJlZnMvaGVhZHMvbWFpbm1wcmVkaWNhdGVUeXBleB5odHRwczovL3Nsc2EuZGV2L3Byb3ZlbmFuY2UvdjFwc291cmNlUmVwb3NpdG9yeXgiaHR0cHM6Ly9naXRodWIuY29tL2V4YW1wbGUvZ2FsbGVyeW5kZWNsYXJlZEFjY2Vzc6A=" +} diff --git a/apps/release-service/test/github-oidc.test.ts b/apps/release-service/test/github-oidc.test.ts index 86e9318865..41bd9cf01e 100644 --- a/apps/release-service/test/github-oidc.test.ts +++ b/apps/release-service/test/github-oidc.test.ts @@ -3,7 +3,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import { GITHUB_ACTIONS_ISSUER, verifyGitHubActionsToken } from "../src/workload/github-oidc.js"; -const AUDIENCE = "https://release.example.invalid"; +const AUDIENCE = "https://release.example.com"; const KEY_ID = "github-actions-test-key"; const SHA = "a".repeat(40); const WORKFLOW_SHA = "b".repeat(40); diff --git a/apps/release-service/test/image-metadata.test.ts b/apps/release-service/test/image-metadata.test.ts new file mode 100644 index 0000000000..a5a6c06b65 --- /dev/null +++ b/apps/release-service/test/image-metadata.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { readImageDimensions } from "../src/publishing/image-metadata.js"; + +function writeUint16LittleEndian(bytes: Uint8Array, offset: number, value: number): void { + bytes[offset] = value & 0xff; + bytes[offset + 1] = (value >>> 8) & 0xff; +} + +function writeUint24LittleEndian(bytes: Uint8Array, offset: number, value: number): void { + bytes[offset] = value & 0xff; + bytes[offset + 1] = (value >>> 8) & 0xff; + bytes[offset + 2] = (value >>> 16) & 0xff; +} + +function writeUint32LittleEndian(bytes: Uint8Array, offset: number, value: number): void { + bytes[offset] = value & 0xff; + bytes[offset + 1] = (value >>> 8) & 0xff; + bytes[offset + 2] = (value >>> 16) & 0xff; + bytes[offset + 3] = (value >>> 24) & 0xff; +} + +function webpChunk(type: string, data: Uint8Array): Uint8Array { + const paddedLength = data.byteLength + (data.byteLength % 2); + const bytes = new Uint8Array(20 + paddedLength); + bytes.set([0x52, 0x49, 0x46, 0x46], 0); + writeUint32LittleEndian(bytes, 4, bytes.byteLength - 8); + bytes.set([0x57, 0x45, 0x42, 0x50], 8); + bytes.set( + Array.from(type, (character) => character.charCodeAt(0)), + 12, + ); + writeUint32LittleEndian(bytes, 16, data.byteLength); + bytes.set(data, 20); + return bytes; +} + +function vp8(width: number, height: number): Uint8Array { + const data = new Uint8Array(10); + data.set([0x9d, 0x01, 0x2a], 3); + writeUint16LittleEndian(data, 6, width); + writeUint16LittleEndian(data, 8, height); + return webpChunk("VP8 ", data); +} + +function vp8l(width: number, height: number): Uint8Array { + const data = new Uint8Array(5); + data[0] = 0x2f; + writeUint32LittleEndian(data, 1, (width - 1) | ((height - 1) << 14)); + return webpChunk("VP8L", data); +} + +function vp8x(width: number, height: number): Uint8Array { + const data = new Uint8Array(10); + writeUint24LittleEndian(data, 4, width - 1); + writeUint24LittleEndian(data, 7, height - 1); + return webpChunk("VP8X", data); +} + +describe("image metadata", () => { + it.each([ + ["VP8", vp8(640, 360), { width: 640, height: 360 }], + ["VP8L", vp8l(390, 844), { width: 390, height: 844 }], + ["VP8X", vp8x(1440, 900), { width: 1440, height: 900 }], + ] as const)("reads %s WebP dimensions", (_format, bytes, expected) => { + expect(readImageDimensions(bytes, "image/webp")).toEqual(expected); + }); + + it("rejects a truncated WebP chunk", () => { + const bytes = vp8x(1440, 900).subarray(0, 24); + expect(readImageDimensions(bytes, "image/webp")).toBeNull(); + }); +}); diff --git a/apps/release-service/test/intent-routes.test.ts b/apps/release-service/test/intent-routes.test.ts new file mode 100644 index 0000000000..61c7ecaa6e --- /dev/null +++ b/apps/release-service/test/intent-routes.test.ts @@ -0,0 +1,386 @@ +import { NSID, type PackageRelease } from "@emdash-cms/registry-lexicons"; +import { reset, runInDurableObject } from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT, type JWTVerifyGetKey } from "jose"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import releaseFixture from "../../../packages/registry-verification/fixtures/records/release.json"; +import { loadConfiguration } from "../src/config.js"; +import { SERVICE_CONTROL_OBJECT_NAME } from "../src/control-do/service-control-do.js"; +import { + handleCancelReleaseIntent, + handleGetReleaseIntent, + handleSubmitReleaseIntent, +} from "../src/intents/routes.js"; +import { GITHUB_ACTIONS_ISSUER } from "../src/workload/github-oidc.js"; +import { TEST_BINDINGS } from "./fixtures/oauth.js"; + +const PUBLISHER_DID = "did:web:publisher.example.com"; +const INTENT_ID = "01JABCDEFGHJKMNPQRSTVWXYZ0"; +const NOW = 1_800_000_000_000; +const KEY_ID = "github-actions-route-test"; +const SHA = "a".repeat(40); +const WORKFLOW_SHA = "b".repeat(40); +const CHECKSUM = "bciqcz4snxjp3biyoe3udwkwfxhrj4gywdzob7j2clzzqim3csofzqja"; + +let privateKey: CryptoKey; +let keyResolver: JWTVerifyGetKey; + +beforeAll(async () => { + const keys = await generateKeyPair("RS256", { extractable: true }); + privateKey = keys.privateKey; + const publicJwk = await exportJWK(keys.publicKey); + publicJwk.kid = KEY_ID; + publicJwk.alg = "RS256"; + publicJwk.use = "sig"; + keyResolver = createLocalJWKSet({ keys: [publicJwk] }); +}); + +function claims(overrides: Record = {}): Record { + return { + jti: crypto.randomUUID(), + repository: "example/gallery", + repository_id: "123456789", + repository_owner: "example", + repository_owner_id: "987654321", + workflow_ref: "example/gallery/.github/workflows/release.yml@refs/heads/main", + workflow_sha: WORKFLOW_SHA, + run_id: "10000000001", + run_attempt: "1", + actor: "release-bot", + actor_id: "11223344", + event_name: "workflow_dispatch", + ref: "refs/heads/main", + ref_type: "branch", + sha: SHA, + repository_visibility: "public", + runner_environment: "github-hosted", + ...overrides, + }; +} + +async function token(overrides: Record = {}): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT(claims(overrides)) + .setProtectedHeader({ alg: "RS256", kid: KEY_ID, typ: "JWT" }) + .setIssuer(GITHUB_ACTIONS_ISSUER) + .setAudience(TEST_BINDINGS.PUBLIC_ORIGIN) + .setSubject("repo:example/gallery:ref:refs/heads/main") + .setIssuedAt(now) + .setNotBefore(now - 1) + .setExpirationTime(now + 300) + .sign(privateKey); +} + +function release(): PackageRelease.Main { + const value = structuredClone(releaseFixture) as PackageRelease.Main; + value.artifacts.package.checksum = CHECKSUM; + value.extensions = { + [NSID.packageReleaseExtension]: { + $type: NSID.packageReleaseExtension, + declaredAccess: {}, + provenance: { + url: "https://example.com/provenance.json", + checksum: CHECKSUM, + predicateType: "https://slsa.dev/provenance/v1", + sourceRepository: "https://github.com/example/gallery", + builderId: + "https://github.com/example/gallery/.github/workflows/release.yml@refs/heads/main", + }, + }, + }; + return value; +} + +function request( + path: string, + workloadToken: string, + init: { body?: unknown; idempotencyKey?: string; method?: string } = {}, +): Request { + const headers = new Headers({ authorization: `Bearer ${workloadToken}` }); + if (init.body !== undefined) headers.set("content-type", "application/json"); + if (init.idempotencyKey) headers.set("idempotency-key", init.idempotencyKey); + return new Request(`${TEST_BINDINGS.PUBLIC_ORIGIN}${path}`, { + method: init.method ?? "GET", + headers, + ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }); +} + +async function putPolicy() { + await env.PUBLISHER_DO.getByName(PUBLISHER_DID).putWorkloadPolicy({ + publisherDid: PUBLISHER_DID, + packageSlug: "gallery", + repository: "example/gallery", + repositoryId: "123456789", + repositoryOwnerId: "987654321", + workflowRef: "example/gallery/.github/workflows/release.yml@refs/heads/main", + allowedRefs: ["refs/heads/main"], + allowedEnvironments: [], + active: true, + expectedVersion: null, + now: NOW - 1, + }); +} + +const submitDependencies = { + get keyResolver() { + return keyResolver; + }, + now: () => NOW, + intentId: () => INTENT_ID, + startWorkflow: async () => ({ ok: true, workflowId: INTENT_ID, created: true }) as const, +}; + +afterEach(async () => { + await reset(); +}); + +describe("release intent API", () => { + it("rejects invalid source records before reserving or starting a Workflow", async () => { + const invalidRelease = release(); + Object.assign(invalidRelease.artifacts.package, { + blob: { + $type: "blob", + ref: { $link: "bafkreicoew2cifs6fwqhqpkvkezdokuvpquj6p7aosznuf7jhxkehsltpe" }, + mimeType: "application/gzip", + size: 128, + }, + }); + let workflowStarted = false; + const response = await handleSubmitReleaseIntent( + request("/v1/release-intents", await token(), { + method: "POST", + body: { + publisherDid: PUBLISHER_DID, + packageSlug: "gallery", + version: "1.2.3", + release: invalidRelease, + }, + idempotencyKey: "github-run-100-attempt-1", + }), + "request-invalid-source", + await loadConfiguration(TEST_BINDINGS), + { + ...submitDependencies, + startWorkflow: async () => { + workflowStarted = true; + return { ok: true, workflowId: INTENT_ID, created: true }; + }, + }, + ); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code: "INVALID_REQUEST" } }); + expect(workflowStarted).toBe(false); + await expect( + runInDurableObject(env.PUBLISHER_DO.getByName(PUBLISHER_DID), (_instance, state) => + state.storage.sql + .exec<{ intents: number; reservations: number }>( + `SELECT + (SELECT COUNT(*) FROM intents) AS intents, + (SELECT COUNT(*) FROM release_reservations) AS reservations`, + ) + .one(), + ), + ).resolves.toEqual({ intents: 0, reservations: 0 }); + }); + + it("submits asynchronously, replays with a fresh matching token, and never stores the token", async () => { + await putPolicy(); + const configuration = await loadConfiguration(TEST_BINDINGS); + const firstToken = await token(); + const body = { + publisherDid: PUBLISHER_DID, + packageSlug: "gallery", + version: "1.2.3", + release: release(), + }; + const first = await handleSubmitReleaseIntent( + request("/v1/release-intents", firstToken, { + method: "POST", + body, + idempotencyKey: "github-run-100-attempt-1", + }), + "request-1", + configuration, + submitDependencies, + ); + expect(first.status).toBe(202); + expect(await first.json()).toMatchObject({ + data: { intent: { id: INTENT_ID, state: "received" }, replayed: false }, + }); + await env.PUBLISHER_DO.getByName(PUBLISHER_DID).putWorkloadPolicy({ + publisherDid: PUBLISHER_DID, + packageSlug: "gallery", + repository: "example/gallery", + repositoryId: "123456789", + repositoryOwnerId: "987654321", + workflowRef: "example/gallery/.github/workflows/release.yml@refs/heads/main", + allowedRefs: ["refs/heads/main"], + allowedEnvironments: [], + active: false, + expectedVersion: 1, + now: NOW + 1, + }); + + const secondToken = await token({ run_attempt: "2" }); + const replay = await handleSubmitReleaseIntent( + request("/v1/release-intents", secondToken, { + method: "POST", + body, + idempotencyKey: "github-run-100-attempt-1", + }), + "request-2", + configuration, + submitDependencies, + ); + expect(replay.status).toBe(200); + expect(await replay.json()).toMatchObject({ + data: { intent: { id: INTENT_ID }, replayed: true }, + }); + + const stored = await env.PUBLISHER_DO.getByName(PUBLISHER_DID).getIntent( + PUBLISHER_DID, + INTENT_ID, + ); + expect(stored).not.toBeNull(); + expect(JSON.stringify(stored)).not.toContain(firstToken); + expect(JSON.stringify(stored)).not.toContain(secondToken); + }); + + it("rejects a changed request under the same workload and idempotency key", async () => { + await putPolicy(); + const configuration = await loadConfiguration(TEST_BINDINGS); + const workloadToken = await token(); + const body = { + publisherDid: PUBLISHER_DID, + packageSlug: "gallery", + version: "1.2.3", + release: release(), + }; + await handleSubmitReleaseIntent( + request("/v1/release-intents", workloadToken, { + method: "POST", + body, + idempotencyKey: "github-run-100-attempt-1", + }), + "request-1", + configuration, + submitDependencies, + ); + const changed = structuredClone(body); + changed.release.artifacts.package.url = "https://example.com/changed.tgz"; + const response = await handleSubmitReleaseIntent( + request("/v1/release-intents", await token(), { + method: "POST", + body: changed, + idempotencyKey: "github-run-100-attempt-1", + }), + "request-2", + configuration, + submitDependencies, + ); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + error: { code: "IDEMPOTENCY_CONFLICT" }, + }); + }); + + it("reads and cancels only with the same normalized workload identity", async () => { + await putPolicy(); + const configuration = await loadConfiguration(TEST_BINDINGS); + const body = { + publisherDid: PUBLISHER_DID, + packageSlug: "gallery", + version: "1.2.3", + release: release(), + }; + await handleSubmitReleaseIntent( + request("/v1/release-intents", await token(), { + method: "POST", + body, + idempotencyKey: "github-run-100-attempt-1", + }), + "request-1", + configuration, + submitDependencies, + ); + + const status = await handleGetReleaseIntent( + request( + `/v1/release-intents/${INTENT_ID}?publisher=${encodeURIComponent(PUBLISHER_DID)}`, + await token({ run_attempt: "2" }), + ), + "request-2", + configuration, + { intentId: INTENT_ID }, + keyResolver, + ); + expect(status.status).toBe(200); + expect(await status.json()).toMatchObject({ data: { intent: { state: "received" } } }); + + const denied = await handleGetReleaseIntent( + request( + `/v1/release-intents/${INTENT_ID}?publisher=${encodeURIComponent(PUBLISHER_DID)}`, + await token({ run_id: "20000000002" }), + ), + "request-3", + configuration, + { intentId: INTENT_ID }, + keyResolver, + ); + expect(denied.status).toBe(403); + + const cancelled = await handleCancelReleaseIntent( + request( + `/v1/release-intents/${INTENT_ID}/cancel?publisher=${encodeURIComponent(PUBLISHER_DID)}`, + await token({ run_attempt: "2" }), + { method: "POST", body: {}, idempotencyKey: "cancel-run-100-attempt-1" }, + ), + "request-4", + configuration, + { intentId: INTENT_ID }, + keyResolver, + ); + expect(cancelled.status).toBe(200); + expect(await cancelled.json()).toMatchObject({ + data: { intent: { state: "cancelled", reasonCode: "CANCELLED" } }, + }); + }); + + it("fails closed when admission is paused", async () => { + await putPolicy(); + const configuration = await loadConfiguration(TEST_BINDINGS); + await env.SERVICE_CONTROL_DO.getByName(SERVICE_CONTROL_OBJECT_NAME).setServiceMode({ + actor: { + realm: "access", + identity: "admin@example.com", + email: "admin@example.com", + role: "admin", + }, + idempotencyKey: "pause-release-intents", + requestDigest: "P".repeat(43), + mode: "admission-paused", + reasonCode: "MAINTENANCE", + now: NOW, + }); + const response = await handleSubmitReleaseIntent( + request("/v1/release-intents", await token(), { + method: "POST", + body: { + publisherDid: PUBLISHER_DID, + packageSlug: "gallery", + version: "1.2.3", + release: release(), + }, + idempotencyKey: "github-run-100-attempt-1", + }), + "request-1", + configuration, + submitDependencies, + ); + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ error: { code: "SERVICE_PAUSED" } }); + }); +}); diff --git a/apps/release-service/test/oauth-custody.test.ts b/apps/release-service/test/oauth-custody.test.ts index 45d55e2332..bba8e23480 100644 --- a/apps/release-service/test/oauth-custody.test.ts +++ b/apps/release-service/test/oauth-custody.test.ts @@ -2,6 +2,7 @@ import type { ActorResolver } from "@atcute/identity-resolver"; import type { StoredSession, StoredState } from "@atcute/oauth-node-client"; import { reset, runInDurableObject } from "cloudflare:test"; import { env } from "cloudflare:workers"; +import { base64url } from "jose"; import { afterEach, describe, expect, it } from "vitest"; import { loadConfiguration } from "../src/config.js"; @@ -32,7 +33,7 @@ function state(userState: unknown, overrides: Partial = {}): Stored authMethod: { method: "private_key_jwt", kid: ASSERTION_KEY_2.kid }, pkceVerifier: PKCE_VERIFIER, issuer: "https://authorization.example", - redirectUri: "https://release.example.invalid/oauth/callback", + redirectUri: "https://release.example.com/oauth/callback", sub: DID, userState, expiresAt: Date.now() + 10 * 60_000, @@ -76,19 +77,22 @@ describe("Durable Object OAuth custody", () => { }, ); await custody.stores.states.set(RAW_STATE, state(custody.userState)); + const stateHash = base64url.encode( + new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(RAW_STATE))), + ); const persisted = await runInDurableObject( - env.PUBLISHER_DO.getByName(DID), + env.OAUTH_STATE_DO.getByName(stateHash), (_instance, durableState) => durableState.storage.sql .exec<{ state_hash: string; encrypted_state: string; encryption_key_version: number; - }>("SELECT state_hash, encrypted_state, encryption_key_version FROM oauth_states") + }>("SELECT state_hash, encrypted_state, encryption_key_version FROM oauth_state") .one(), ); - expect(persisted.state_hash).not.toBe(RAW_STATE); + expect(persisted.state_hash).toBe(stateHash); expect(persisted.encryption_key_version).toBe(configuration.encryption.currentKeyVersion); expect(JSON.stringify(persisted)).not.toContain(RAW_STATE); expect(JSON.stringify(persisted)).not.toContain(PKCE_VERIFIER); @@ -168,6 +172,7 @@ describe("Durable Object OAuth custody", () => { const delegatedSession = session(configuration.oauth.releaseScope); await custody.stores.sessions.set(DID, delegatedSession); await expect(custody.stores.sessions.get(DID)).resolves.toEqual(delegatedSession); + expect(custody.sessionVersion?.(DID)).toBe(1); await expect(custody.stores.sessions.set(DID, delegatedSession)).rejects.toMatchObject({ code: "OAUTH_DELEGATION_CAS_REQUIRED", }); @@ -224,6 +229,7 @@ describe("Durable Object OAuth custody", () => { await expect(env.PUBLISHER_DO.getByName(DID).getDelegation(DID)).resolves.toMatchObject({ stateVersion: 2, }); + expect(custody.sessionVersion?.(DID)).toBe(2); }); it("revokes authority and rejects assertion-key reuse as DPoP", async () => { @@ -415,9 +421,9 @@ describe("OAuth redirect targets", () => { it.each(["https://evil.example", "//evil.example", "/\\evil", "/path\nnext"])( "rejects %j", (value) => { - expect(() => - canonicalizeRedirectTarget(value, "https://release.example.invalid"), - ).toThrowError(expect.objectContaining({ code: "OAUTH_REDIRECT_INVALID" })); + expect(() => canonicalizeRedirectTarget(value, "https://release.example.com")).toThrowError( + expect.objectContaining({ code: "OAUTH_REDIRECT_INVALID" }), + ); }, ); @@ -425,7 +431,7 @@ describe("OAuth redirect targets", () => { expect( canonicalizeRedirectTarget( "/publisher/../publisher?done=1#result", - "https://release.example.invalid", + "https://release.example.com", ), ).toBe("/publisher?done=1#result"); }); diff --git a/apps/release-service/test/oauth-routes.test.ts b/apps/release-service/test/oauth-routes.test.ts index 97426c74f9..10da23359a 100644 --- a/apps/release-service/test/oauth-routes.test.ts +++ b/apps/release-service/test/oauth-routes.test.ts @@ -1,4 +1,4 @@ -import { reset } from "cloudflare:test"; +import { reset, runInDurableObject } from "cloudflare:test"; import { env } from "cloudflare:workers"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -110,6 +110,33 @@ afterEach(async () => { }); describe("publisher OAuth routes", () => { + it("returns an authorization URL envelope for SPA navigation", async () => { + const network = oauthNetwork(); + vi.stubGlobal("fetch", network.fetch); + const response = await handlePublisherIdentityAuthorize( + new Request(`${ORIGIN}/v1/publisher/session/authorize`, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + origin: ORIGIN, + "x-emdash-request": "1", + }, + body: JSON.stringify({ identifier: DID, redirectTarget: "/publisher" }), + }), + "route-json", + await configuration(), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + data: { + authorizationUrl: expect.stringContaining("https://authorization.example/authorize"), + }, + }); + expect(response.headers.get("set-cookie")).toContain("__Host-emdash_oauth_route="); + }); + it("starts identity authorization and completes a bound callback into an app session", async () => { const network = oauthNetwork(); vi.stubGlobal("fetch", network.fetch); @@ -196,6 +223,68 @@ describe("publisher OAuth routes", () => { ); }); + it("keeps attacker-triggerable publisher authorization state out of the publisher shard", async () => { + const network = oauthNetwork(); + vi.stubGlobal("fetch", network.fetch); + const config = await configuration(); + for (let attempt = 0; attempt < 3; attempt += 1) { + const response = await handlePublisherIdentityAuthorize( + new Request(`${ORIGIN}/v1/publisher/session/authorize`, { + method: "POST", + headers: { + "content-type": "application/json", + origin: ORIGIN, + "x-emdash-request": "1", + }, + body: JSON.stringify({ identifier: DID, redirectTarget: "/publisher" }), + }), + `publisher-state-${attempt}`, + config, + ); + expect(response.status).toBe(303); + } + + await expect( + runInDurableObject(env.PUBLISHER_DO.getByName(DID), (_instance, state) => + state.storage.sql + .exec<{ count: number }>("SELECT COUNT(*) AS count FROM oauth_states") + .one(), + ), + ).resolves.toEqual({ count: 0 }); + }); + + it("cannot exhaust approver authorization by filling the approver shard", async () => { + const network = oauthNetwork(); + vi.stubGlobal("fetch", network.fetch); + const config = await configuration(); + for (let attempt = 0; attempt < 21; attempt += 1) { + const response = await handleApproverIdentityAuthorize( + new Request(`${ORIGIN}/v1/approver/session/authorize`, { + method: "POST", + headers: { + "content-type": "application/json", + origin: ORIGIN, + "x-emdash-request": "1", + }, + body: JSON.stringify({ identifier: DID, redirectTarget: "/approvals/intent-1" }), + }), + `approver-state-${attempt}`, + config, + ); + expect(response.status).toBe(303); + } + + await expect( + runInDurableObject(env.APPROVER_DO.getByName(DID), (_instance, state) => + state.storage.sql + .exec<{ count: number }>( + "SELECT COUNT(*) AS count FROM identity_transactions WHERE completed_at IS NULL", + ) + .one(), + ), + ).resolves.toEqual({ count: 0 }); + }); + it("requires same-origin authorization and rejects oversized bodies before resolution", async () => { const config = await configuration(); for (const request of [ diff --git a/apps/release-service/test/operator-routes.test.ts b/apps/release-service/test/operator-routes.test.ts new file mode 100644 index 0000000000..992791e9a8 --- /dev/null +++ b/apps/release-service/test/operator-routes.test.ts @@ -0,0 +1,293 @@ +import { reset, runInDurableObject } from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { AccessActor } from "../src/access/auth.js"; +import { loadConfiguration } from "../src/config.js"; +import { SERVICE_CONTROL_OBJECT_NAME } from "../src/control-do/service-control-do.js"; +import { + handleCancelOperatorIntent, + handleGetOperatorPublisher, + handleReconcileOperatorIntent, + handleRevokeOperatorPublisher, + handleSetOperatorPublisherSuspension, +} from "../src/operator/routes.js"; +import { createPublisherApplicationSession } from "../src/publisher-session/session.js"; +import { TEST_BINDINGS } from "./fixtures/oauth.js"; + +const PUBLISHER_DID = "did:web:publisher.example.com"; +const INTENT_ID = "01JABCDEFGHJKMNPQRSTVWXYZ0"; +const NOW = 1_800_000_000_000; +const VIEWER: AccessActor = { + realm: "access", + identity: "viewer@example.com", + email: "viewer@example.com", + role: "viewer", +}; +const REVIEWER: AccessActor = { + realm: "access", + identity: "reviewer@example.com", + email: "reviewer@example.com", + role: "reviewer", +}; +const ADMIN: AccessActor = { + realm: "access", + identity: "admin@example.com", + email: "admin@example.com", + role: "admin", +}; + +function request(path: string, body: unknown, idempotencyKey: string): Request { + return new Request(`${TEST_BINDINGS.PUBLIC_ORIGIN}${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + "idempotency-key": idempotencyKey, + }, + body: JSON.stringify(body), + }); +} + +async function createIntent(state: "ready" | "received" = "received") { + const publisher = env.PUBLISHER_DO.getByName(PUBLISHER_DID); + await publisher.putWorkloadPolicy({ + publisherDid: PUBLISHER_DID, + packageSlug: "gallery", + repository: "example/gallery", + repositoryId: "123456789", + repositoryOwnerId: "987654321", + workflowRef: "example/gallery/.github/workflows/release.yml@refs/heads/main", + allowedRefs: ["refs/heads/main"], + allowedEnvironments: [], + active: true, + expectedVersion: null, + now: NOW, + }); + await publisher.createIntent({ + publisherDid: PUBLISHER_DID, + intentId: INTENT_ID, + packageSlug: "gallery", + version: "1.2.3", + workloadPolicyVersion: 1, + workloadIdentityDigest: "A".repeat(43), + workloadIdempotencyDigest: "I".repeat(43), + idempotencyKey: "github-run-100-attempt-1", + requestDigest: "B".repeat(43), + workloadIdentityJson: '{"issuer":"github-actions"}', + releaseInputJson: '{"release":{"package":"gallery","version":"1.2.3"}}', + expiresAt: NOW + 60_000, + now: NOW + 1, + }); + if (state === "ready") { + await publisher.transitionIntent({ + publisherDid: PUBLISHER_DID, + intentId: INTENT_ID, + expectedState: "received", + expectedGeneration: 1, + toState: "verifying", + transitionDigest: "C".repeat(43), + actorRealm: "system", + actorIdentity: "release-service", + reasonCode: null, + stateDataJson: "{}", + workflowId: INTENT_ID, + now: NOW + 2, + }); + await publisher.transitionIntent({ + publisherDid: PUBLISHER_DID, + intentId: INTENT_ID, + expectedState: "verifying", + expectedGeneration: 2, + toState: "verified", + transitionDigest: "D".repeat(43), + actorRealm: "system", + actorIdentity: "release-service", + reasonCode: null, + stateDataJson: "{}", + now: NOW + 3, + }); + await publisher.transitionIntent({ + publisherDid: PUBLISHER_DID, + intentId: INTENT_ID, + expectedState: "verified", + expectedGeneration: 3, + toState: "ready", + transitionDigest: "E".repeat(43), + actorRealm: "system", + actorIdentity: "release-service", + reasonCode: null, + stateDataJson: "{}", + now: NOW + 4, + }); + } + return publisher; +} + +afterEach(async () => { + vi.restoreAllMocks(); + await reset(); +}); + +describe("Access operator API", () => { + it("suspends both global admission and the publisher shard before restoring either", async () => { + const configuration = await loadConfiguration(TEST_BINDINGS); + const createdSession = await createPublisherApplicationSession( + env.PUBLISHER_DO, + PUBLISHER_DID, + NOW, + ); + const suspended = await handleSetOperatorPublisherSuspension( + request( + `/admin/api/publishers/${PUBLISHER_DID}/suspend`, + { suspended: true, reasonCode: "ABUSE_REVIEW" }, + "suspend-publisher-test", + ), + "request-1", + configuration, + { publisherDid: PUBLISHER_DID }, + ADMIN, + ); + expect(suspended.status).toBe(200); + expect(await suspended.json()).toMatchObject({ + data: { publisher: { control: { status: "suspended" } } }, + }); + await expect( + env.PUBLISHER_DO.getByName(PUBLISHER_DID).validatePublisherSession( + PUBLISHER_DID, + "A".repeat(43), + null, + ), + ).resolves.toMatchObject({ ok: false, code: "PUBLISHER_SUSPENDED" }); + expect(createdSession.session.publisherDid).toBe(PUBLISHER_DID); + + const restored = await handleSetOperatorPublisherSuspension( + request( + `/admin/api/publishers/${PUBLISHER_DID}/suspend`, + { suspended: false, reasonCode: null }, + "restore-publisher-test", + ), + "request-2", + configuration, + { publisherDid: PUBLISHER_DID }, + ADMIN, + ); + expect(restored.status).toBe(200); + expect(await restored.json()).toMatchObject({ + data: { publisher: { control: { status: "allowed" } } }, + }); + await expect( + env.SERVICE_CONTROL_DO.getByName(SERVICE_CONTROL_OBJECT_NAME).getAdmissionDecision( + PUBLISHER_DID, + ), + ).resolves.toMatchObject({ allowed: true }); + }); + + it("returns sanitized state and revokes retained authority and publisher sessions", async () => { + const configuration = await loadConfiguration(TEST_BINDINGS); + await createPublisherApplicationSession(env.PUBLISHER_DO, PUBLISHER_DID, NOW); + const publisher = env.PUBLISHER_DO.getByName(PUBLISHER_DID); + await publisher.putDelegation({ + publisherDid: PUBLISHER_DID, + releaseNsid: configuration.oauth.releaseNsid, + scope: configuration.oauth.releaseScope, + clientKeyId: configuration.oauth.activeAssertionKeyId, + encryptedSession: "encrypted-session-secret", + encryptionKeyVersion: 1, + issuer: "https://authorization.example.com", + pdsUrl: "https://pds.example.com", + expiresAt: null, + refreshBefore: null, + expectedVersion: null, + }); + const read = await handleGetOperatorPublisher( + new Request(`${TEST_BINDINGS.PUBLIC_ORIGIN}/admin/api/publishers/${PUBLISHER_DID}`), + "request-1", + configuration, + { publisherDid: PUBLISHER_DID }, + VIEWER, + ); + const readValue = await read.json(); + expect(readValue).toMatchObject({ + data: { publisher: { delegation: { status: "active" } } }, + }); + expect(JSON.stringify(readValue)).not.toContain("encrypted-session-secret"); + + const revoked = await handleRevokeOperatorPublisher( + request(`/admin/api/publishers/${PUBLISHER_DID}/revoke`, {}, "revoke-publisher-test"), + "request-2", + configuration, + { publisherDid: PUBLISHER_DID }, + ADMIN, + ); + expect(revoked.status).toBe(200); + expect(await revoked.json()).toMatchObject({ + data: { publisher: { delegation: { status: "revoked", stateVersion: 2 } } }, + }); + const audit = await runInDurableObject(publisher, (_instance, state) => + state.storage.sql + .exec<{ actor_identity: string; actor_realm: string; event_type: string }>( + `SELECT event_type, actor_realm, actor_identity FROM audit_events + WHERE event_type IN ('delegation-revoked', 'publisher-sessions-revoked') + ORDER BY sequence`, + ) + .toArray(), + ); + expect(audit).toEqual([ + { event_type: "delegation-revoked", actor_realm: "access", actor_identity: ADMIN.identity }, + { + event_type: "publisher-sessions-revoked", + actor_realm: "access", + actor_identity: ADMIN.identity, + }, + ]); + }); + + it("cancels an unpublished intent with an Access audit identity", async () => { + const configuration = await loadConfiguration(TEST_BINDINGS); + const publisher = await createIntent(); + const response = await handleCancelOperatorIntent( + request( + `/admin/api/intents/${INTENT_ID}/cancel`, + { publisherDid: PUBLISHER_DID }, + "cancel-intent-test", + ), + "request-1", + configuration, + { intentId: INTENT_ID }, + REVIEWER, + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + data: { intent: { state: "cancelled", reasonCode: "OPERATOR_CANCELLED" } }, + }); + await expect(publisher.listIntentTransitions(PUBLISHER_DID, INTENT_ID)).resolves.toMatchObject([ + {}, + { actorRealm: "access", actorIdentity: REVIEWER.identity, toState: "cancelled" }, + ]); + }); + + it("starts bounded reconciliation only for a recoverable intent", async () => { + const configuration = await loadConfiguration(TEST_BINDINGS); + await createIntent("ready"); + const restartWorkflow = vi.fn(async () => ({ + ok: true as const, + workflowId: INTENT_ID, + restarted: true, + })); + const response = await handleReconcileOperatorIntent( + request( + `/admin/api/intents/${INTENT_ID}/reconcile`, + { publisherDid: PUBLISHER_DID }, + "reconcile-intent-test", + ), + "request-1", + configuration, + { intentId: INTENT_ID }, + REVIEWER, + { restartWorkflow }, + ); + expect(response.status).toBe(202); + expect(await response.json()).toMatchObject({ data: { restarted: true } }); + expect(restartWorkflow).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/release-service/test/publication-materialization.test.ts b/apps/release-service/test/publication-materialization.test.ts index bb86feef4c..482bd3a0a4 100644 --- a/apps/release-service/test/publication-materialization.test.ts +++ b/apps/release-service/test/publication-materialization.test.ts @@ -8,6 +8,35 @@ const DID = "did:plc:publisher"; const INTENT_ID = "01JABCDEFGHJKMNPQRSTVWXYZ0"; const SOURCE_DIGEST = "B".repeat(43); const NOW = 1_800_000_000_000; +const CHECKSUM = "bciqb43wwlv35mnso5lwvu5c3uxcjqwxcw4an3boxz57qe667fffdh7a"; +const BLOB_CID = "bafkreia6n3lf256wgzhov3k2orn2lreyllrloag5qxl467ycpppsssrt7q"; + +type TestArtifactSlot = "icon" | "package" | "screenshots[0]" | "screenshots[1]"; + +interface TestArtifact { + url?: string; + checksum: string; + contentType?: string; + width?: number; + height?: number; + blob?: { + $type: "blob"; + ref: { $link: string }; + mimeType: string; + size: number; + }; +} + +interface TestRelease { + $type: "com.emdashcms.experimental.package.release"; + package: string; + version: string; + artifacts: { + package: TestArtifact; + icon?: TestArtifact; + screenshots?: TestArtifact[]; + }; +} function publisher() { return env.PUBLISHER_DO.getByName(DID); @@ -29,7 +58,71 @@ function policy(): PutWorkloadPolicyInput { }; } -async function prepareReadyIntent() { +function sourceUrl(slot: TestArtifactSlot): string { + return `https://example.com/${slot.replaceAll("[", "-").replaceAll("]", "")}`; +} + +function sourceRelease(slots: readonly TestArtifactSlot[]): TestRelease { + const descriptor = (slot: TestArtifactSlot): TestArtifact => ({ + url: sourceUrl(slot), + checksum: CHECKSUM, + contentType: slot === "package" ? "application/gzip" : "image/png", + ...(slot === "package" ? {} : { width: 640, height: 480 }), + }); + return { + $type: "com.emdashcms.experimental.package.release" as const, + package: "gallery", + version: "1.2.3", + artifacts: { + package: descriptor("package"), + ...(slots.includes("icon") ? { icon: descriptor("icon") } : {}), + ...(slots.includes("screenshots[0]") + ? { + screenshots: slots + .filter((slot) => slot.startsWith("screenshots")) + .map((slot) => descriptor(slot)), + } + : {}), + }, + }; +} + +function materializedRelease(slots: readonly TestArtifactSlot[]): TestRelease { + const release = structuredClone(sourceRelease(slots)); + const withBlob = (slot: TestArtifactSlot): TestArtifact => { + const descriptor = structuredClone( + slot === "package" + ? release.artifacts.package + : slot === "icon" + ? release.artifacts.icon! + : release.artifacts.screenshots![Number(slot.at(-2))], + ); + if (!descriptor) throw new Error("Missing test artifact descriptor"); + delete descriptor.url; + return { + ...descriptor, + blob: { + $type: "blob" as const, + ref: { $link: BLOB_CID }, + mimeType: slot === "package" ? "application/gzip" : "image/png", + size: slot === "package" ? 32_768 : 4_096, + }, + }; + }; + release.artifacts.package = withBlob("package"); + if (release.artifacts.icon) release.artifacts.icon = withBlob("icon"); + if (release.artifacts.screenshots) { + release.artifacts.screenshots = release.artifacts.screenshots.map((_, index) => + withBlob(`screenshots[${index}]` as TestArtifactSlot), + ); + } + return release; +} + +async function prepareReadyIntent( + slots: readonly TestArtifactSlot[] = ["package"], + release: TestRelease = sourceRelease(slots), +) { const stub = publisher(); await stub.putWorkloadPolicy(policy()); await stub.createIntent({ @@ -39,10 +132,11 @@ async function prepareReadyIntent() { version: "1.2.3", workloadPolicyVersion: 1, workloadIdentityDigest: "A".repeat(43), + workloadIdempotencyDigest: "I".repeat(43), idempotencyKey: "github-run-100-attempt-1", requestDigest: SOURCE_DIGEST, workloadIdentityJson: '{"issuer":"github-actions"}', - releaseInputJson: '{"package":"gallery","version":"1.2.3"}', + releaseInputJson: JSON.stringify({ release }), expiresAt: NOW + 60_000, now: NOW + 1, }); @@ -70,15 +164,15 @@ async function prepareReadyIntent() { return stub; } -function stage(slot: "icon" | "package" | "screenshots[0]" | "screenshots[1]") { +async function stage(slot: TestArtifactSlot) { const image = slot !== "package"; return { publisherDid: DID, intentId: INTENT_ID, sourceDigest: SOURCE_DIGEST, slot, - sourceUrlDigest: `${slot[0]!.toUpperCase()}${"U".repeat(42)}`, - checksum: "bciqb43wwlv35mnso5lwvu5c3uxcjqwxcw4an3boxz57qe667fffdh7a", + sourceUrlDigest: await digest(sourceUrl(slot)), + checksum: CHECKSUM, stagingKey: `publication/${INTENT_ID}/${slot.replace("[", "-").replace("]", "")}`, mimeType: image ? ("image/png" as const) : ("application/gzip" as const), size: image ? 4_096 : 32_768, @@ -88,8 +182,8 @@ function stage(slot: "icon" | "package" | "screenshots[0]" | "screenshots[1]") { }; } -function receipt(slot: "icon" | "package" | "screenshots[0]" | "screenshots[1]") { - const staged = stage(slot); +async function receipt(slot: TestArtifactSlot) { + const staged = await stage(slot); return { publisherDid: DID, intentId: INTENT_ID, @@ -97,7 +191,7 @@ function receipt(slot: "icon" | "package" | "screenshots[0]" | "screenshots[1]") slot, blob: { $type: "blob" as const, - ref: { $link: "bafkreia6n3lf256wgzhov3k2orn2lreyllrloag5qxl467ycpppsssrt7q" }, + ref: { $link: BLOB_CID }, mimeType: staged.mimeType, size: staged.size, }, @@ -120,7 +214,7 @@ afterEach(async () => { describe("publisher publication materialization", () => { it("replays exact mutations, rejects conflicts, and lists slots canonically", async () => { - const stub = await prepareReadyIntent(); + const stub = await prepareReadyIntent(["package", "icon", "screenshots[0]", "screenshots[1]"]); await expect( stub.beginPublicationMaterialization(DID, INTENT_ID, SOURCE_DIGEST, NOW + 4), ).resolves.toEqual({ ok: true, replayed: false }); @@ -132,31 +226,35 @@ describe("publisher publication materialization", () => { ).resolves.toEqual({ ok: false, code: "MATERIALIZATION_CONFLICT" }); for (const slot of ["screenshots[1]", "package", "icon", "screenshots[0]"] as const) { - await expect(stub.putPublicationArtifactStage(stage(slot))).resolves.toEqual({ + const staged = await stage(slot); + const blobReceipt = await receipt(slot); + await expect(stub.putPublicationArtifactStage(staged)).resolves.toEqual({ ok: true, replayed: false, }); - await expect(stub.putPublicationArtifactStage(stage(slot))).resolves.toEqual({ + await expect(stub.putPublicationArtifactStage(staged)).resolves.toEqual({ ok: true, replayed: true, }); - await expect(stub.putPublicationBlobReceipt(receipt(slot))).resolves.toEqual({ + await expect(stub.putPublicationBlobReceipt(blobReceipt)).resolves.toEqual({ ok: true, replayed: false, }); - await expect(stub.putPublicationBlobReceipt(receipt(slot))).resolves.toEqual({ + await expect(stub.putPublicationBlobReceipt(blobReceipt)).resolves.toEqual({ ok: true, replayed: true, }); } + const packageStage = await stage("package"); + const packageReceipt = await receipt("package"); await expect( - stub.putPublicationArtifactStage({ ...stage("package"), size: 32_769 }), + stub.putPublicationArtifactStage({ ...packageStage, size: 32_769 }), ).resolves.toEqual({ ok: false, code: "MATERIALIZATION_CONFLICT" }); await runInDurableObject(stub, (instance) => { expect(() => instance.putPublicationBlobReceipt({ - ...receipt("package"), - blob: { ...receipt("package").blob, size: 1 }, + ...packageReceipt, + blob: { ...packageReceipt.blob, size: 1 }, }), ).toThrowError(expect.objectContaining({ code: "PUBLICATION_MATERIALIZATION_INVALID" })); }); @@ -177,8 +275,8 @@ describe("publisher publication materialization", () => { it("writes one bounded canonical final record after every slot has a receipt", async () => { const stub = await prepareReadyIntent(); await stub.beginPublicationMaterialization(DID, INTENT_ID, SOURCE_DIGEST, NOW + 4); - await stub.putPublicationArtifactStage(stage("package")); - const recordJson = '{"package":"gallery","version":"1.2.3"}'; + await stub.putPublicationArtifactStage(await stage("package")); + const recordJson = JSON.stringify(materializedRelease(["package"])); const recordDigest = await digest(recordJson); await expect( stub.completePublicationMaterialization({ @@ -190,12 +288,13 @@ describe("publisher publication materialization", () => { now: NOW + 12, }), ).resolves.toEqual({ ok: false, code: "MATERIALIZATION_INCOMPLETE" }); + const packageReceipt = await receipt("package"); await runInDurableObject(stub, (instance) => { expect(() => instance.putPublicationBlobReceipt({ - ...receipt("package"), + ...packageReceipt, blob: { - ...receipt("package").blob, + ...packageReceipt.blob, ref: { $link: "bafkreibm6jg3ux5qu5wzvikphw4qjzx6i7htc4w4e4c4pv7a7uynxqevmy", }, @@ -203,7 +302,7 @@ describe("publisher publication materialization", () => { }), ).toThrowError(expect.objectContaining({ code: "PUBLICATION_MATERIALIZATION_INVALID" })); }); - await stub.putPublicationBlobReceipt(receipt("package")); + await stub.putPublicationBlobReceipt(packageReceipt); const complete = { publisherDid: DID, @@ -224,8 +323,10 @@ describe("publisher publication materialization", () => { await expect( stub.completePublicationMaterialization({ ...complete, - recordJson: '{"package":"other","version":"1.2.3"}', - recordDigest: await digest('{"package":"other","version":"1.2.3"}'), + recordJson: JSON.stringify({ ...materializedRelease(["package"]), package: "other" }), + recordDigest: await digest( + JSON.stringify({ ...materializedRelease(["package"]), package: "other" }), + ), }), ).resolves.toEqual({ ok: false, code: "MATERIALIZATION_CONFLICT" }); await expect(stub.getPublicationMaterialization(DID, INTENT_ID)).resolves.toMatchObject({ @@ -235,23 +336,137 @@ describe("publisher publication materialization", () => { }); }); + it("rejects a final record whose blob does not match its staged receipt", async () => { + const stub = await prepareReadyIntent(); + await stub.beginPublicationMaterialization(DID, INTENT_ID, SOURCE_DIGEST, NOW + 4); + await stub.putPublicationArtifactStage(await stage("package")); + await stub.putPublicationBlobReceipt(await receipt("package")); + const substituted = materializedRelease(["package"]); + substituted.artifacts.package.blob!.ref.$link = + "bafkreibm6jg3ux5qu5wzvikphw4qjzx6i7htc4w4e4c4pv7a7uynxqevmy"; + const recordJson = JSON.stringify(substituted); + + await expect( + stub.completePublicationMaterialization({ + publisherDid: DID, + intentId: INTENT_ID, + sourceDigest: SOURCE_DIGEST, + recordJson, + recordDigest: await digest(recordJson), + }), + ).resolves.toEqual({ ok: false, code: "MATERIALIZATION_CONFLICT" }); + }); + + it("requires the staged and final slots to equal the immutable source slots", async () => { + let stub = await prepareReadyIntent(["package", "icon"]); + await stub.beginPublicationMaterialization(DID, INTENT_ID, SOURCE_DIGEST, NOW + 4); + await stub.putPublicationArtifactStage(await stage("package")); + await stub.putPublicationBlobReceipt(await receipt("package")); + let recordJson = JSON.stringify(materializedRelease(["package", "icon"])); + await expect( + stub.completePublicationMaterialization({ + publisherDid: DID, + intentId: INTENT_ID, + sourceDigest: SOURCE_DIGEST, + recordJson, + recordDigest: await digest(recordJson), + }), + ).resolves.toEqual({ ok: false, code: "MATERIALIZATION_INCOMPLETE" }); + await stub.putPublicationArtifactStage(await stage("icon")); + await stub.putPublicationBlobReceipt(await receipt("icon")); + recordJson = JSON.stringify(materializedRelease(["package"])); + await expect( + stub.completePublicationMaterialization({ + publisherDid: DID, + intentId: INTENT_ID, + sourceDigest: SOURCE_DIGEST, + recordJson, + recordDigest: await digest(recordJson), + }), + ).resolves.toEqual({ ok: false, code: "MATERIALIZATION_CONFLICT" }); + + await reset(); + stub = await prepareReadyIntent(); + await stub.beginPublicationMaterialization(DID, INTENT_ID, SOURCE_DIGEST, NOW + 4); + for (const slot of ["package", "icon"] as const) { + await stub.putPublicationArtifactStage(await stage(slot)); + await stub.putPublicationBlobReceipt(await receipt(slot)); + } + recordJson = JSON.stringify(materializedRelease(["package", "icon"])); + await expect( + stub.completePublicationMaterialization({ + publisherDid: DID, + intentId: INTENT_ID, + sourceDigest: SOURCE_DIGEST, + recordJson, + recordDigest: await digest(recordJson), + }), + ).resolves.toEqual({ ok: false, code: "MATERIALIZATION_CONFLICT" }); + }); + + it("requires the verified MIME type in the canonical record", async () => { + const source = sourceRelease(["package"]); + delete source.artifacts.package.contentType; + const stub = await prepareReadyIntent(["package"], source); + await stub.beginPublicationMaterialization(DID, INTENT_ID, SOURCE_DIGEST, NOW + 4); + await stub.putPublicationArtifactStage(await stage("package")); + await stub.putPublicationBlobReceipt(await receipt("package")); + const missingContentType = materializedRelease(["package"]); + delete missingContentType.artifacts.package.contentType; + let recordJson = JSON.stringify(missingContentType); + await expect( + stub.completePublicationMaterialization({ + publisherDid: DID, + intentId: INTENT_ID, + sourceDigest: SOURCE_DIGEST, + recordJson, + recordDigest: await digest(recordJson), + }), + ).resolves.toEqual({ ok: false, code: "MATERIALIZATION_CONFLICT" }); + + recordJson = JSON.stringify(materializedRelease(["package"])); + await expect( + stub.completePublicationMaterialization({ + publisherDid: DID, + intentId: INTENT_ID, + sourceDigest: SOURCE_DIGEST, + recordJson, + recordDigest: await digest(recordJson), + }), + ).resolves.toEqual({ ok: true, replayed: false }); + }); + it("rejects out-of-range slots, staged sizes, and final JSON", async () => { const stub = await prepareReadyIntent(); await stub.beginPublicationMaterialization(DID, INTENT_ID, SOURCE_DIGEST, NOW + 4); + const packageStage = await stage("package"); + const screenshotStage = await stage("screenshots[0]"); await runInDurableObject(stub, (instance) => { expect(() => - instance.putPublicationArtifactStage({ ...stage("package"), size: 262_145 }), + instance.putPublicationArtifactStage({ ...packageStage, size: 262_145 }), ).toThrowError(expect.objectContaining({ code: "PUBLICATION_MATERIALIZATION_INVALID" })); expect(() => instance.putPublicationArtifactStage({ - ...stage("screenshots[0]"), + ...screenshotStage, // @ts-expect-error - verifies runtime rejection outside the static slot union slot: "screenshots[8]", }), ).toThrowError(expect.objectContaining({ code: "PUBLICATION_MATERIALIZATION_INVALID" })); }); - await stub.putPublicationArtifactStage(stage("package")); - await stub.putPublicationBlobReceipt(receipt("package")); + await stub.putPublicationArtifactStage(packageStage); + await stub.putPublicationBlobReceipt(await receipt("package")); + const invalidRecordJson = '{"package":"gallery","version":"1.2.3"}'; + await runInDurableObject(stub, async (instance) => { + await expect( + instance.completePublicationMaterialization({ + publisherDid: DID, + intentId: INTENT_ID, + sourceDigest: SOURCE_DIGEST, + recordJson: invalidRecordJson, + recordDigest: await digest(invalidRecordJson), + }), + ).rejects.toMatchObject({ code: "PUBLICATION_MATERIALIZATION_INVALID" }); + }); const oversizedJson = JSON.stringify({ value: "x".repeat(128 * 1024) }); await runInDurableObject(stub, async (instance) => { await expect( diff --git a/apps/release-service/test/publication-operation.test.ts b/apps/release-service/test/publication-operation.test.ts index 7632d536fd..77544d9468 100644 --- a/apps/release-service/test/publication-operation.test.ts +++ b/apps/release-service/test/publication-operation.test.ts @@ -7,6 +7,45 @@ import type { IntentState, PutWorkloadPolicyInput } from "../src/publisher-do/pu const DID = "did:plc:publisher"; const INTENT_ID = "01JABCDEFGHJKMNPQRSTVWXYZ0"; const NOW = 1_800_000_000_000; +const OPERATION_CREDENTIAL = "C".repeat(43); +const ATTEMPT_KEY = "K".repeat(43); +const ATTEMPT_TOKEN = "T".repeat(43); +const CHECKSUM = "bciqb43wwlv35mnso5lwvu5c3uxcjqwxcw4an3boxz57qe667fffdh7a"; +const BLOB_CID = "bafkreia6n3lf256wgzhov3k2orn2lreyllrloag5qxl467ycpppsssrt7q"; +const SOURCE_URL = "https://example.com/gallery.tar.gz"; + +function sourceRelease() { + return { + $type: "com.emdashcms.experimental.package.release" as const, + package: "gallery", + version: "1.2.3", + artifacts: { + package: { + url: SOURCE_URL, + checksum: CHECKSUM, + contentType: "application/gzip", + }, + }, + }; +} + +function materializedRelease() { + return { + ...sourceRelease(), + artifacts: { + package: { + checksum: CHECKSUM, + contentType: "application/gzip", + blob: { + $type: "blob" as const, + ref: { $link: BLOB_CID }, + mimeType: "application/gzip", + size: 32_768, + }, + }, + }, + }; +} function publisher() { return env.PUBLISHER_DO.getByName(DID); @@ -38,10 +77,11 @@ async function preparePublishing() { version: "1.2.3", workloadPolicyVersion: 1, workloadIdentityDigest: "A".repeat(43), + workloadIdempotencyDigest: "I".repeat(43), idempotencyKey: "github-run-100-attempt-1", requestDigest: "B".repeat(43), workloadIdentityJson: '{"issuer":"github-actions"}', - releaseInputJson: '{"package":"gallery","version":"1.2.3"}', + releaseInputJson: JSON.stringify({ release: sourceRelease() }), expiresAt: NOW + 60_000, now: NOW + 1, }); @@ -69,6 +109,16 @@ async function preparePublishing() { return stub; } +function beginPublicationOperation( + stub: ReturnType, + leaseMs: number, + now: number, + attemptKey = ATTEMPT_KEY, + token = ATTEMPT_TOKEN, +) { + return stub.beginPublicationOperation(DID, INTENT_ID, 5, leaseMs, attemptKey, token, now); +} + async function digest(value: string): Promise { const bytes = new Uint8Array( await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)), @@ -86,8 +136,8 @@ async function materialize(stub: ReturnType): Promise intentId: INTENT_ID, sourceDigest, slot: "package", - sourceUrlDigest: "U".repeat(43), - checksum: "bciqb43wwlv35mnso5lwvu5c3uxcjqwxcw4an3boxz57qe667fffdh7a", + sourceUrlDigest: await digest(SOURCE_URL), + checksum: CHECKSUM, stagingKey: `publication/${INTENT_ID}/package`, mimeType: "application/gzip", size: 32_768, @@ -102,13 +152,13 @@ async function materialize(stub: ReturnType): Promise slot: "package", blob: { $type: "blob", - ref: { $link: "bafkreia6n3lf256wgzhov3k2orn2lreyllrloag5qxl467ycpppsssrt7q" }, + ref: { $link: BLOB_CID }, mimeType: "application/gzip", size: 32_768, }, now: NOW + 8, }); - const recordJson = '{"package":"gallery","version":"1.2.3"}'; + const recordJson = JSON.stringify(materializedRelease()); const recordDigest = await digest(recordJson); await stub.completePublicationMaterialization({ publisherDid: DID, @@ -165,36 +215,44 @@ afterEach(async () => { }); describe("publisher publication operations", () => { - it("serializes publication with a generation-bound hashed lease", async () => { + it("replays a committed begin after response loss and serializes other attempts", async () => { const stub = await preparePublishing(); - const first = await stub.beginPublicationOperation(DID, INTENT_ID, 5, 5_000, NOW + 10); + const first = await beginPublicationOperation(stub, 5_000, NOW + 10); expect(first).toMatchObject({ ok: true, + replayed: false, lease: { intentId: INTENT_ID, generation: 1, expectedIntentGeneration: 5 }, }); if (!first.ok) return; + await expect(beginPublicationOperation(stub, 5_000, NOW + 11)).resolves.toEqual({ + ok: true, + lease: first.lease, + replayed: true, + }); await expect( - stub.beginPublicationOperation(DID, INTENT_ID, 5, 5_000, NOW + 11), + beginPublicationOperation(stub, 5_000, NOW + 11, OPERATION_CREDENTIAL, "U".repeat(43)), ).resolves.toEqual({ ok: false, code: "PUBLICATION_BUSY", retryAt: first.lease.expiresAt, }); + await expect(stub.getIntent(DID, INTENT_ID)).resolves.toMatchObject({ state: "publishing" }); const persisted = await runInDurableObject(stub, (_instance, state) => state.storage.sql - .exec<{ token_hash: string }>( - "SELECT token_hash FROM publication_operations WHERE intent_id = ?", + .exec<{ attempt_key: string; token_hash: string }>( + "SELECT attempt_key, token_hash FROM publication_operations WHERE intent_id = ?", INTENT_ID, ) .one(), ); + expect(persisted.attempt_key).toBe(ATTEMPT_KEY); expect(persisted.token_hash).not.toBe(first.lease.token); }); it("advances materialized and creating phases only for the active lease", async () => { const stub = await preparePublishing(); - const started = await stub.beginPublicationOperation(DID, INTENT_ID, 5, 5_000, NOW + 10); + const started = await beginPublicationOperation(stub, 5_000, NOW + 10); expect(started.ok).toBe(true); if (!started.ok) return; await expect( @@ -248,7 +306,7 @@ describe("publisher publication operations", () => { it("completes a confirmed write atomically and replays the exact completion", async () => { const stub = await preparePublishing(); - const started = await stub.beginPublicationOperation(DID, INTENT_ID, 5, 5_000, NOW + 10); + const started = await beginPublicationOperation(stub, 5_000, NOW + 10); expect(started.ok).toBe(true); if (!started.ok) return; await advanceToCreating(stub, started.lease); @@ -277,6 +335,9 @@ describe("publisher publication operations", () => { stateGeneration: 6, replayed: true, }); + await expect( + stub.completePublicationOperation({ ...completion, resultCid: "bafyother" }), + ).resolves.toEqual({ ok: false, code: "PUBLICATION_CAS_REQUIRED" }); await expect(stub.getIntent(DID, INTENT_ID)).resolves.toMatchObject({ state: "published", stateGeneration: 6, @@ -289,7 +350,7 @@ describe("publisher publication operations", () => { it("rejects stale tokens and records ambiguous outcomes for reconciliation", async () => { const stub = await preparePublishing(); - const started = await stub.beginPublicationOperation(DID, INTENT_ID, 5, 5_000, NOW + 10); + const started = await beginPublicationOperation(stub, 5_000, NOW + 10); expect(started.ok).toBe(true); if (!started.ok) return; @@ -326,7 +387,7 @@ describe("publisher publication operations", () => { it("records a repository conflict as a terminal conflict outcome", async () => { const stub = await preparePublishing(); - const started = await stub.beginPublicationOperation(DID, INTENT_ID, 5, 5_000, NOW + 10); + const started = await beginPublicationOperation(stub, 5_000, NOW + 10); expect(started.ok).toBe(true); if (!started.ok) return; await advanceToCreating(stub, started.lease); @@ -338,8 +399,9 @@ describe("publisher publication operations", () => { generation: started.lease.generation, token: started.lease.token, expectedIntentGeneration: 5, - completionDigest: "X".repeat(43), + completionDigest: "W".repeat(43), outcome: "conflict", + reasonCode: null, resultUri: null, resultCid: null, now: NOW + 11, @@ -350,10 +412,6 @@ describe("publisher publication operations", () => { stateGeneration: 6, replayed: false, }); - await expect(stub.getIntent(DID, INTENT_ID)).resolves.toMatchObject({ - state: "conflict", - stateGeneration: 6, - }); const transitions = await stub.listIntentTransitions(DID, INTENT_ID); expect(transitions.at(-1)).toMatchObject({ fromState: "publishing", @@ -362,13 +420,83 @@ describe("publisher publication operations", () => { }); }); + it.each([ + ["blocked", "ready", "PUBLICATION_PAUSED"], + ["failed", "failed", "OAUTH_DELEGATION_UNAVAILABLE"], + ] as const)( + "closes an expired pre-write lease as %s without entering ambiguous reconciliation", + async (outcome, state, reasonCode) => { + const stub = await preparePublishing(); + const started = await beginPublicationOperation(stub, 1, NOW + 10); + expect(started.ok).toBe(true); + if (!started.ok) return; + + const completion = { + publisherDid: DID, + intentId: INTENT_ID, + generation: started.lease.generation, + token: started.lease.token, + expectedIntentGeneration: 5, + completionDigest: "X".repeat(43), + outcome, + reasonCode, + resultUri: null, + resultCid: null, + now: NOW + 12, + } as const; + await expect(stub.completePublicationOperation(completion)).resolves.toEqual({ + ok: true, + state, + stateGeneration: 6, + replayed: false, + }); + await expect( + stub.completePublicationOperation({ ...completion, reasonCode: "DIFFERENT_REASON" }), + ).resolves.toEqual({ ok: false, code: "PUBLICATION_CAS_REQUIRED" }); + await expect(stub.getIntent(DID, INTENT_ID)).resolves.toMatchObject({ + state, + stateGeneration: 6, + }); + const transitions = await stub.listIntentTransitions(DID, INTENT_ID); + expect(transitions.at(-1)).toMatchObject({ reasonCode, toState: state }); + }, + ); + + it.each([ + ["blocked", "PUBLICATION_PAUSED"], + ["failed", "OAUTH_DELEGATION_UNAVAILABLE"], + ] as const)("rejects a %s completion after the create boundary", async (outcome, reasonCode) => { + const stub = await preparePublishing(); + const started = await beginPublicationOperation(stub, 5_000, NOW + 10); + expect(started.ok).toBe(true); + if (!started.ok) return; + await advanceToCreating(stub, started.lease); + + await expect( + stub.completePublicationOperation({ + publisherDid: DID, + intentId: INTENT_ID, + generation: started.lease.generation, + token: started.lease.token, + expectedIntentGeneration: started.lease.expectedIntentGeneration, + completionDigest: "X".repeat(43), + outcome, + reasonCode, + resultUri: null, + resultCid: null, + now: NOW + 12, + }), + ).resolves.toEqual({ ok: false, code: "PUBLICATION_CAS_REQUIRED" }); + await expect(stub.getIntent(DID, INTENT_ID)).resolves.toMatchObject({ state: "publishing" }); + }); + it("requires reconciliation and re-arms recovery for an expired write lease", async () => { const stub = await preparePublishing(); - await stub.beginPublicationOperation(DID, INTENT_ID, 5, 1, NOW + 10); + await beginPublicationOperation(stub, 1, NOW + 10); await runInDurableObject(stub, (_instance, state) => state.storage.deleteAlarm()); await expect( - stub.beginPublicationOperation(DID, INTENT_ID, 5, 5_000, NOW + 12), + beginPublicationOperation(stub, 5_000, NOW + 12, "L".repeat(43), "U".repeat(43)), ).resolves.toEqual({ ok: false, code: "PUBLICATION_RECOVERY_REQUIRED" }); await expect( runInDurableObject(stub, (_instance, state) => state.storage.getAlarm()), @@ -378,7 +506,7 @@ describe("publisher publication operations", () => { it("recovers an expired upload phase back to ready via the alarm", async () => { const stub = await preparePublishing(); const alarmNow = Date.now() - 1_000; - await stub.beginPublicationOperation(DID, INTENT_ID, 5, 1, alarmNow); + await beginPublicationOperation(stub, 1, alarmNow); await runDurableObjectAlarm(stub); await expect(stub.getIntent(DID, INTENT_ID)).resolves.toMatchObject({ @@ -403,7 +531,7 @@ describe("publisher publication operations", () => { it("retains materialization and reconciles only after the creating phase expires", async () => { const stub = await preparePublishing(); - const started = await stub.beginPublicationOperation(DID, INTENT_ID, 5, 5_000, NOW + 10); + const started = await beginPublicationOperation(stub, 5_000, NOW + 10); expect(started.ok).toBe(true); if (!started.ok) return; const materializationDigest = await advanceToCreating(stub, started.lease); @@ -423,7 +551,7 @@ describe("publisher publication operations", () => { it("retains materialization but returns an expired materialized phase to ready", async () => { const stub = await preparePublishing(); - const started = await stub.beginPublicationOperation(DID, INTENT_ID, 5, 5_000, NOW + 10); + const started = await beginPublicationOperation(stub, 5_000, NOW + 10); expect(started.ok).toBe(true); if (!started.ok) return; const materializationDigest = await materialize(stub); diff --git a/apps/release-service/test/publication-staging.test.ts b/apps/release-service/test/publication-staging.test.ts new file mode 100644 index 0000000000..bc982c9c85 --- /dev/null +++ b/apps/release-service/test/publication-staging.test.ts @@ -0,0 +1,89 @@ +import { computeMultihash } from "@emdash-cms/registry-verification"; +import { env } from "cloudflare:workers"; +import { describe, expect, it } from "vitest"; + +import { + loadStagedArtifact, + persistStagedArtifact, + PublicationStagingError, +} from "../src/publishing/staging.js"; + +const PUBLISHER_DID = "did:plc:publisher"; +const INTENT_ID = "01JABCDEFGHJKMNPQRSTVWXYZ0"; +const BYTES = new Uint8Array([0x1f, 0x8b, 0x08, 0x00, 0x01]); + +async function artifact() { + const checksum = await computeMultihash(BYTES); + if (!checksum.success) throw new Error(checksum.error.code); + return { + metadata: { + path: "package" as const, + checksum: checksum.value, + mimeType: "application/gzip", + size: BYTES.byteLength, + }, + bytes: BYTES, + }; +} + +describe("publication artifact staging", () => { + it("writes deterministic create-only objects and replays matching bytes", async () => { + const input = { + publisherDid: PUBLISHER_DID, + intentId: INTENT_ID, + sourceUrl: "https://example.com/gallery.tar.gz", + artifact: await artifact(), + }; + const first = await persistStagedArtifact(env.PUBLICATION_STAGING, input); + const replay = await persistStagedArtifact(env.PUBLICATION_STAGING, input); + + expect(replay).toEqual(first); + await expect(loadStagedArtifact(env.PUBLICATION_STAGING, first)).resolves.toEqual({ + metadata: first.metadata, + bytes: BYTES, + }); + }); + + it("rejects an existing object whose bytes do not match the staged checksum", async () => { + const input = { + publisherDid: PUBLISHER_DID, + intentId: INTENT_ID, + sourceUrl: "https://example.com/gallery.tar.gz", + artifact: await artifact(), + }; + const staged = await persistStagedArtifact(env.PUBLICATION_STAGING, input); + await env.PUBLICATION_STAGING.put(staged.key, new Uint8Array(BYTES.byteLength)); + + await expect(persistStagedArtifact(env.PUBLICATION_STAGING, input)).rejects.toMatchObject({ + code: "PUBLICATION_STAGING_CONFLICT", + }); + await expect(loadStagedArtifact(env.PUBLICATION_STAGING, staged)).rejects.toBeInstanceOf( + PublicationStagingError, + ); + }); + + it("uses a staging key that remains valid for screenshot slots", async () => { + const screenshotBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + const checksum = await computeMultihash(screenshotBytes); + if (!checksum.success) throw new Error(checksum.error.code); + const staged = await persistStagedArtifact(env.PUBLICATION_STAGING, { + publisherDid: PUBLISHER_DID, + intentId: "01JABCDEFGHJKMNPQRSTVWXYZ1", + sourceUrl: "https://example.com/screenshot.png", + artifact: { + metadata: { + path: "screenshots[0]", + checksum: checksum.value, + mimeType: "image/png", + size: screenshotBytes.byteLength, + width: 1, + height: 1, + }, + bytes: screenshotBytes, + }, + }); + + expect(staged.key).toContain("/screenshots-0/"); + expect(staged.key).not.toContain("["); + }); +}); diff --git a/apps/release-service/test/publisher-intent-state.test.ts b/apps/release-service/test/publisher-intent-state.test.ts index 5c1511112c..c985bbe0eb 100644 --- a/apps/release-service/test/publisher-intent-state.test.ts +++ b/apps/release-service/test/publisher-intent-state.test.ts @@ -42,6 +42,7 @@ function intent(overrides: Partial = {}): CreateIntentInput { version: "1.2.3", workloadPolicyVersion: 1, workloadIdentityDigest: "A".repeat(43), + workloadIdempotencyDigest: "I".repeat(43), idempotencyKey: "github-run-100-attempt-1", requestDigest: "B".repeat(43), workloadIdentityJson: JSON.stringify({ issuer: "github-actions", runId: "100" }), @@ -113,7 +114,9 @@ describe("publisher release intents", () => { await stub.putWorkloadPolicy(policy()); const first = await stub.createIntent(intent()); - await expect(stub.createIntent(intent({ intentId: INTENT_2 }))).resolves.toEqual({ + await expect( + stub.createIntent(intent({ intentId: INTENT_2, workloadIdentityDigest: "C".repeat(43) })), + ).resolves.toEqual({ ...(first.ok ? first : {}), replayed: true, }); @@ -122,6 +125,24 @@ describe("publisher release intents", () => { ).resolves.toEqual({ ok: false, code: "IDEMPOTENCY_CONFLICT" }); }); + it("lists newest intents with an exclusive ULID cursor", async () => { + const stub = publisher(); + await stub.putWorkloadPolicy(policy()); + await stub.createIntent(intent()); + await stub.createIntent( + intent({ + intentId: INTENT_2, + version: "1.2.4", + workloadIdentityDigest: "D".repeat(43), + workloadIdempotencyDigest: "J".repeat(43), + idempotencyKey: "github-run-101-attempt-1", + }), + ); + + await expect(stub.listIntents(DID, null, 1)).resolves.toMatchObject([{ id: INTENT_2 }]); + await expect(stub.listIntents(DID, INTENT_2, 1)).resolves.toMatchObject([{ id: INTENT_1 }]); + }); + it("returns the existing owner when another identity reserves the same version", async () => { const stub = publisher(); await stub.putWorkloadPolicy(policy()); @@ -132,6 +153,7 @@ describe("publisher release intents", () => { intent({ intentId: INTENT_2, workloadIdentityDigest: "D".repeat(43), + workloadIdempotencyDigest: "J".repeat(43), idempotencyKey: "github-run-101-attempt-1", }), ), diff --git a/apps/release-service/test/publisher-routes.test.ts b/apps/release-service/test/publisher-routes.test.ts new file mode 100644 index 0000000000..8a5898a241 --- /dev/null +++ b/apps/release-service/test/publisher-routes.test.ts @@ -0,0 +1,220 @@ +import { reset } from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { afterEach, describe, expect, it } from "vitest"; + +import { loadConfiguration } from "../src/config.js"; +import { createPublisherApplicationSession } from "../src/publisher-session/session.js"; +import { + handleDisablePublisherWorkload, + handleGetPublisher, + handleListPublisherIntents, + handleListPublisherWorkloads, + handlePutPublisherWorkload, + handleRevokePublisherDelegation, +} from "../src/publisher/routes.js"; +import { TEST_BINDINGS } from "./fixtures/oauth.js"; + +const PUBLISHER_DID = "did:web:publisher.example.com"; +const INTENT_ID = "01JABCDEFGHJKMNPQRSTVWXYZ0"; +const NOW = 1_800_000_000_000; + +function cookieValue(header: string): string { + return header.split(";", 1)[0] ?? ""; +} + +async function sessionHeaders(mutation = false): Promise { + const session = await createPublisherApplicationSession(env.PUBLISHER_DO, PUBLISHER_DID, NOW); + const headers = new Headers({ + cookie: session.setCookieHeaders.map(cookieValue).join("; "), + }); + if (mutation) { + const csrf = cookieValue(session.setCookieHeaders[1]).split("=", 2)[1] ?? ""; + headers.set("content-type", "application/json"); + headers.set("idempotency-key", "publisher-route-mutation"); + headers.set("origin", TEST_BINDINGS.PUBLIC_ORIGIN); + headers.set("x-emdash-request", "1"); + headers.set("x-emdash-csrf", csrf); + } + return headers; +} + +function request(path: string, headers: Headers, method = "GET", body?: unknown): Request { + return new Request(`${TEST_BINDINGS.PUBLIC_ORIGIN}${path}`, { + method, + headers, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +function policyBody(expectedVersion: number | null = null) { + return { + packageSlug: "gallery", + repository: "example/gallery", + repositoryId: "123456789", + repositoryOwnerId: "987654321", + workflowRef: "example/gallery/.github/workflows/release.yml@refs/heads/main", + allowedRefs: ["refs/heads/main"], + allowedEnvironments: [], + expectedVersion, + }; +} + +afterEach(async () => { + await reset(); +}); + +describe("publisher API", () => { + it("returns only sanitized publisher and delegation state", async () => { + const configuration = await loadConfiguration(TEST_BINDINGS); + const headers = await sessionHeaders(); + await env.PUBLISHER_DO.getByName(PUBLISHER_DID).putDelegation({ + publisherDid: PUBLISHER_DID, + releaseNsid: configuration.oauth.releaseNsid, + scope: configuration.oauth.releaseScope, + clientKeyId: configuration.oauth.activeAssertionKeyId, + encryptedSession: "encrypted-session-secret", + encryptionKeyVersion: 1, + issuer: "https://authorization.example.com", + pdsUrl: "https://pds.example.com", + expiresAt: NOW + 60_000, + refreshBefore: NOW + 30_000, + expectedVersion: null, + }); + + const response = await handleGetPublisher( + request("/v1/publisher", headers), + "request-1", + configuration, + ); + expect(response.status).toBe(200); + const value = await response.json(); + expect(value).toMatchObject({ + data: { + publisher: { + did: PUBLISHER_DID, + delegation: { status: "active", stateVersion: 1 }, + }, + }, + }); + expect(JSON.stringify(value)).not.toContain("encrypted-session-secret"); + expect(JSON.stringify(value)).not.toContain(configuration.oauth.activeAssertionKeyId); + }); + + it("creates, replays, lists, and disables a workload policy with CSRF and CAS", async () => { + const configuration = await loadConfiguration(TEST_BINDINGS); + const createHeaders = await sessionHeaders(true); + const created = await handlePutPublisherWorkload( + request("/v1/publisher/workloads", createHeaders, "POST", policyBody()), + "request-1", + configuration, + ); + expect(created.status).toBe(201); + expect(await created.json()).toMatchObject({ + data: { policy: { packageSlug: "gallery", active: true, stateVersion: 1 }, replayed: false }, + }); + + const replayHeaders = await sessionHeaders(true); + const replay = await handlePutPublisherWorkload( + request("/v1/publisher/workloads", replayHeaders, "POST", policyBody()), + "request-2", + configuration, + ); + expect(replay.status).toBe(200); + expect(await replay.json()).toMatchObject({ data: { replayed: true } }); + + const list = await handleListPublisherWorkloads( + request("/v1/publisher/workloads?limit=1", await sessionHeaders()), + "request-3", + configuration, + ); + expect(await list.json()).toMatchObject({ + data: { items: [{ packageSlug: "gallery", active: true }] }, + }); + + const disableHeaders = await sessionHeaders(true); + const disabled = await handleDisablePublisherWorkload( + request("/v1/publisher/workloads/gallery", disableHeaders, "DELETE", { + expectedVersion: 1, + }), + "request-4", + configuration, + { packageSlug: "gallery" }, + ); + expect(disabled.status).toBe(200); + expect(await disabled.json()).toMatchObject({ + data: { policy: { active: false, stateVersion: 2 }, replayed: false }, + }); + }); + + it("lists only intents from the authenticated publisher shard", async () => { + const configuration = await loadConfiguration(TEST_BINDINGS); + const publisher = env.PUBLISHER_DO.getByName(PUBLISHER_DID); + await publisher.putWorkloadPolicy({ + publisherDid: PUBLISHER_DID, + ...policyBody(), + active: true, + now: NOW, + }); + await publisher.createIntent({ + publisherDid: PUBLISHER_DID, + intentId: INTENT_ID, + packageSlug: "gallery", + version: "1.2.3", + workloadPolicyVersion: 1, + workloadIdentityDigest: "A".repeat(43), + workloadIdempotencyDigest: "I".repeat(43), + idempotencyKey: "github-run-100-attempt-1", + requestDigest: "B".repeat(43), + workloadIdentityJson: '{"issuer":"github-actions"}', + releaseInputJson: '{"release":{"package":"gallery","version":"1.2.3"}}', + expiresAt: NOW + 60_000, + now: NOW + 1, + }); + + const response = await handleListPublisherIntents( + request("/v1/publisher/intents", await sessionHeaders()), + "request-1", + configuration, + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + data: { items: [{ id: INTENT_ID, publisherDid: PUBLISHER_DID }] }, + }); + }); + + it("revokes retained authority idempotently without exposing OAuth errors", async () => { + const configuration = await loadConfiguration(TEST_BINDINGS); + const publisher = env.PUBLISHER_DO.getByName(PUBLISHER_DID); + await sessionHeaders(); + await publisher.putDelegation({ + publisherDid: PUBLISHER_DID, + releaseNsid: configuration.oauth.releaseNsid, + scope: configuration.oauth.releaseScope, + clientKeyId: configuration.oauth.activeAssertionKeyId, + encryptedSession: "encrypted-session-secret", + encryptionKeyVersion: 1, + issuer: "https://authorization.example.com", + pdsUrl: "https://pds.example.com", + expiresAt: null, + refreshBefore: null, + expectedVersion: null, + }); + const headers = await sessionHeaders(true); + const response = await handleRevokePublisherDelegation( + request("/v1/publisher/delegation", headers, "DELETE", {}), + "request-1", + configuration, + { + revokeDelegation: async (publisherDid) => { + const current = await publisher.getDelegation(publisherDid); + if (!current) throw new Error("Expected delegation"); + await publisher.revokeDelegation(publisherDid, current.stateVersion); + }, + }, + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + data: { publisher: { delegation: { status: "revoked", stateVersion: 2 } } }, + }); + }); +}); diff --git a/apps/release-service/test/reconciliation.test.ts b/apps/release-service/test/reconciliation.test.ts new file mode 100644 index 0000000000..00f2e4483c --- /dev/null +++ b/apps/release-service/test/reconciliation.test.ts @@ -0,0 +1,34 @@ +import type { PackageRelease } from "@emdash-cms/registry-lexicons"; +import { NSID } from "@emdash-cms/registry-lexicons"; +import { describe, expect, it } from "vitest"; + +import releaseFixture from "../../../packages/registry-verification/fixtures/records/release.json"; +import { reconcileReleaseRecord } from "../src/publishing/reconcile.js"; + +const DID = "did:plc:publisher"; +const PACKAGE = "gallery"; +const VERSION = "1.2.3"; +const URI = `at://${DID}/${NSID.packageRelease}/${PACKAGE}:${VERSION}`; + +describe("release reconciliation", () => { + it("distinguishes absence, exact semantic replay, and conflict", () => { + const expected = structuredClone(releaseFixture) as PackageRelease.Main; + expect(reconcileReleaseRecord(DID, PACKAGE, VERSION, expected, null)).toEqual({ + outcome: "absent", + }); + expect( + reconcileReleaseRecord(DID, PACKAGE, VERSION, expected, { + uri: URI, + cid: "bafyexact", + value: { ...structuredClone(expected) }, + }), + ).toEqual({ outcome: "exact", uri: URI, cid: "bafyexact" }); + expect( + reconcileReleaseRecord(DID, PACKAGE, VERSION, expected, { + uri: URI, + cid: "bafyconflict", + value: { ...structuredClone(expected), version: "9.9.9" }, + }), + ).toEqual({ outcome: "conflict" }); + }); +}); diff --git a/apps/release-service/test/release-intent-workflow.test.ts b/apps/release-service/test/release-intent-workflow.test.ts index 3cea2f4f6f..287dc93d55 100644 --- a/apps/release-service/test/release-intent-workflow.test.ts +++ b/apps/release-service/test/release-intent-workflow.test.ts @@ -1,16 +1,112 @@ +import type { StoredSession } from "@atcute/oauth-node-client"; import type { PackageRelease } from "@emdash-cms/registry-lexicons"; import { NSID } from "@emdash-cms/registry-lexicons"; -import { introspectWorkflowInstance, reset } from "cloudflare:test"; -import { env } from "cloudflare:workers"; +import { computeMultihash } from "@emdash-cms/registry-verification/checksum"; +import { introspectWorkflowInstance, reset, runInDurableObject } from "cloudflare:test"; +import { env, type WorkflowStep } from "cloudflare:workers"; import { afterEach, describe, expect, it, vi } from "vitest"; import releaseFixture from "../../../packages/registry-verification/fixtures/records/release.json"; -import { startReleaseIntentWorkflow } from "../src/workflows/start.js"; +import type ReleaseVerifier from "../../release-verifier/src/index.js"; +import { decodeAwaitingApprovalState } from "../src/approvals/digest.js"; +import { loadConfiguration } from "../src/config.js"; +import { SERVICE_CONTROL_OBJECT_NAME } from "../src/control-do/service-control-do.js"; +import { createPublisherOAuthStores } from "../src/oauth/custody.js"; +import { publishVerifiedIntent } from "../src/publishing/workflow.js"; +import type { AuthoritativeRecord } from "../src/verification/pds.js"; +import { + restartReleaseIntentWorkflow, + startReleaseIntentWorkflow, +} from "../src/workflows/start.js"; +import { ASSERTION_KEY_2, TEST_BINDINGS } from "./fixtures/oauth.js"; +import publicationProofs from "./fixtures/publication-proofs.json"; const PUBLISHER_DID = "did:web:publisher.example.com"; const INTENT_ID = "01JABCDEFGHJKMNPQRSTVWXYZ0"; const NOW = 1_800_000_000_000; -const ARTIFACT_CHECKSUM = "bciqcz4snxjp3biyoe3udwkwfxhrj4gywdzob7j2clzzqim3csofzqja"; +const CREATED_URI = `at://${PUBLISHER_DID}/${NSID.packageRelease}/gallery:1.2.3`; +const CREATED_CID = "bafyreigh2akiscaildc4mscz4uzpcbap5jxg26eecmrf6cmnvkzkjmoixe"; +const PACKAGE_BYTES = new Uint8Array([0x1f, 0x8b, 0x08, 0x00, 0x01]); +const ARTIFACT_CHECKSUM = "bciqhazpl5w2ra742ngjezwxoy4p74p2eyiftnnhycsofanwmdrezity"; +const ARTIFACT_BLOB_CID = "bafkreidqmxv63niqp6ngtesm3lxmoh76h5cmeczwwt4bjhcqg3gbysmuj4"; +const DEFAULT_SIGNING_KEY = "zDnaeq9feE9D74uYD5jynoyyQPbhhWU2vStcmC8W1xQHG3fWe"; + +function writeUint24LittleEndian(bytes: Uint8Array, offset: number, value: number): void { + bytes[offset] = value & 0xff; + bytes[offset + 1] = (value >>> 8) & 0xff; + bytes[offset + 2] = (value >>> 16) & 0xff; +} + +function writeUint32BigEndian(bytes: Uint8Array, offset: number, value: number): void { + bytes[offset] = (value >>> 24) & 0xff; + bytes[offset + 1] = (value >>> 16) & 0xff; + bytes[offset + 2] = (value >>> 8) & 0xff; + bytes[offset + 3] = value & 0xff; +} + +function pngBytes(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(33); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); + writeUint32BigEndian(bytes, 8, 13); + bytes.set([0x49, 0x48, 0x44, 0x52], 12); + writeUint32BigEndian(bytes, 16, width); + writeUint32BigEndian(bytes, 20, height); + bytes.set([8, 6, 0, 0, 0], 24); + return bytes; +} + +function jpegBytes(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(23); + bytes.set([0xff, 0xd8, 0xff, 0xc0, 0x00, 0x11, 0x08], 0); + bytes[7] = (height >>> 8) & 0xff; + bytes[8] = height & 0xff; + bytes[9] = (width >>> 8) & 0xff; + bytes[10] = width & 0xff; + bytes[11] = 3; + bytes.set([1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0], 12); + bytes.set([0xff, 0xd9], 21); + return bytes; +} + +function webpBytes(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(30); + bytes.set([0x52, 0x49, 0x46, 0x46, 22, 0, 0, 0, 0x57, 0x45, 0x42, 0x50], 0); + bytes.set([0x56, 0x50, 0x38, 0x58, 10, 0, 0, 0], 12); + writeUint24LittleEndian(bytes, 24, width - 1); + writeUint24LittleEndian(bytes, 27, height - 1); + return bytes; +} + +async function checksumFor(bytes: Uint8Array): Promise { + const result = await computeMultihash(bytes); + if (!result.success) throw new Error("Unable to compute test checksum"); + return result.value; +} + +function encodeBase32(bytes: Uint8Array): string { + const alphabet = "abcdefghijklmnopqrstuvwxyz234567"; + let result = ""; + let buffer = 0; + let bits = 0; + for (const byte of bytes) { + buffer = (buffer << 8) | byte; + bits += 8; + while (bits >= 5) { + result += alphabet[(buffer >>> (bits - 5)) & 31] ?? ""; + bits -= 5; + } + } + if (bits > 0) result += alphabet[(buffer << (5 - bits)) & 31] ?? ""; + return result; +} + +async function blobCidFor(bytes: Uint8Array): Promise { + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); + const cid = new Uint8Array(4 + digest.byteLength); + cid.set([0x01, 0x55, 0x12, 0x20]); + cid.set(digest, 4); + return `b${encodeBase32(cid)}`; +} const PROFILE_PROOF = "OqJlcm9vdHOB2CpYJQABcRIgDvmOi+nZTPwAHpDNlC2y2J7fUQ1ApZKJRa48jp934NBndmVyc2lvbgHdAQFxEiAO+Y6L6dlM/AAekM2ULbLYnt9RDUClkolFrjyOn3fg0KZjZGlkeB1kaWQ6d2ViOnB1Ymxpc2hlci5leGFtcGxlLmNvbWNyZXZtM211NXFhZHRwazIybWNzaWdYQKq7vfiaEIAWBU/mBxVb+dRselfs/o/vLWgXiiWtBrrBIT9LTKTG8Ylh5LuryHBu1Xx0m0Zu/FeAL7dzSrbBs9tkZGF0YdgqWCUAAXESICPWWGKAvX12s+8YBNB6iLwFl8YMr6smSZpFoaG8aBsnZHByZXb2Z3ZlcnNpb24DkwEBcRIgI9ZYYoC9fXaz7xgE0HqIvAWXxgyvqyZJmkWhobxoGyeiYWWBpGFrWDJjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGUvZ2FsbGVyeWFwAGF09mF22CpYJQABcRIg75HAxLI29zFxT2IAMP+6xED3Uxy3mslLTuujJkBV1nphbPbQAwFxEiDvkcDEsjb3MXFPYgAw/7rEQPdTHLeayUtO66MmQFXWeqhiaWR4VWF0Oi8vZGlkOndlYjpwdWJsaXNoZXIuZXhhbXBsZS5jb20vY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlL2dhbGxlcnlkbmFtZWdHYWxsZXJ5ZHR5cGVtZW1kYXNoLXBsdWdpbmUkdHlwZXgqY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlZ2F1dGhvcnOBoWRuYW1lcUV4YW1wbGUgUHVibGlzaGVyZ2xpY2Vuc2VjTUlUaHNlY3VyaXR5gaFlZW1haWx0c2VjdXJpdHlAZXhhbXBsZS5jb21qZXh0ZW5zaW9uc6F4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZUV4dGVuc2lvbqJlJHR5cGV4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZUV4dGVuc2lvbmpyZXBvc2l0b3J5eCJodHRwczovL2dpdGh1Yi5jb20vZXhhbXBsZS9nYWxsZXJ5"; const APPROVAL_PROFILE_PROOF = @@ -18,10 +114,63 @@ const APPROVAL_PROFILE_PROOF = const PROVENANCE = { predicateType: "https://slsa.dev/provenance/v1", url: "https://github.com/example/gallery/attestation.sigstore.json", - checksum: "bciqkkpvkbtfcwq6kjkbq3kgjxe5j6ihzkxlfxkzqhwzaaaa3wkbq3a", + checksum: "bciqaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", sourceRepository: "https://github.com/example/gallery", builderId: "https://github.com/example/gallery/.github/workflows/release.yml@refs/heads/main", } as const; +const CONTROL_ACTOR = { + realm: "access", + identity: "admin@example.com", + email: "admin@example.com", + role: "admin", +} as const; +async function createDpopKey(): Promise { + const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ + "sign", + "verify", + ]); + if (!("privateKey" in pair)) throw new Error("Failed to generate DPoP test key pair"); + const jwk = await crypto.subtle.exportKey("jwk", pair.privateKey); + if ( + jwk instanceof ArrayBuffer || + jwk.kty !== "EC" || + jwk.crv !== "P-256" || + typeof jwk.x !== "string" || + typeof jwk.y !== "string" || + typeof jwk.d !== "string" + ) { + throw new Error("Failed to generate DPoP test key"); + } + return { kty: "EC", crv: "P-256", alg: "ES256", x: jwk.x, y: jwk.y, d: jwk.d }; +} + +async function storeDelegation() { + const configuration = await loadConfiguration(TEST_BINDINGS); + const custody = createPublisherOAuthStores( + env.PUBLISHER_DO, + configuration.encryption, + configuration.oauth, + { + purpose: "release_delegation", + expectedDid: PUBLISHER_DID, + redirectTarget: "/", + }, + ); + await custody.stores.sessions.set(PUBLISHER_DID, { + dpopKey: await createDpopKey(), + authMethod: { method: "private_key_jwt", kid: ASSERTION_KEY_2.kid }, + tokenSet: { + iss: "https://authorization.example", + sub: PUBLISHER_DID, + aud: "https://pds.example.com", + scope: configuration.oauth.releaseScope, + access_token: "access-token", + refresh_token: "refresh-token", + token_type: "DPoP", + expires_at: Date.now() + 60 * 60_000, + }, + }); +} function releaseRecord() { const release = structuredClone(releaseFixture) as PackageRelease.Main & { @@ -35,8 +184,71 @@ function releaseRecord() { return release; } -function workflowNetwork(profileProof = PROFILE_PROOF) { - return async (input: RequestInfo | URL): Promise => { +async function fullReleaseRecord() { + const release = releaseRecord(); + const icon = pngBytes(128, 128); + const banner = jpegBytes(1200, 400); + const desktop = webpBytes(1440, 900); + const mobile = pngBytes(390, 844); + release.artifacts = { + package: { + url: "https://github.com/example/gallery/releases/download/v1.2.3/gallery.tar.gz", + checksum: ARTIFACT_CHECKSUM, + releaseAsset: true, + }, + icon: { + url: "https://assets.example/icon.png", + checksum: await checksumFor(icon), + id: "icon", + }, + banner: { + url: "https://assets.example/banner.jpg", + checksum: await checksumFor(banner), + }, + screenshots: [ + { + url: "https://assets.example/desktop.webp", + checksum: await checksumFor(desktop), + id: "desktop", + lang: "en", + }, + { + url: "https://assets.example/mobile.png", + checksum: await checksumFor(mobile), + id: "mobile", + }, + ], + }; + return { + release, + sources: new Map([ + ["https://assets.example/icon.png", { bytes: icon, mimeType: "image/png" }], + ["https://assets.example/banner.jpg", { bytes: banner, mimeType: "image/jpeg" }], + ["https://assets.example/desktop.webp", { bytes: desktop, mimeType: "image/webp" }], + ["https://assets.example/mobile.png", { bytes: mobile, mimeType: "image/png" }], + ]), + }; +} + +function proofBytes(value: string): Uint8Array { + return Uint8Array.from(atob(value), (character) => character.charCodeAt(0)); +} + +interface WorkflowNetworkOptions { + artifactSources?: ReadonlyMap; + profileProof?: string; + listedReleases?: () => readonly AuthoritativeRecord[]; + authoritativeProof?: () => Uint8Array | null; + signingKey?: () => string; + onArtifactFetch?: () => Response | void | Promise; + onAuthorizationMetadata?: () => void | Promise; + onUploadBlob?: (request: Request) => Response | void | Promise; + onCreateRecord?: (request: Request) => Response | Promise; +} + +function workflowNetwork(options: WorkflowNetworkOptions = {}) { + const profileProof = options.profileProof ?? PROFILE_PROOF; + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { const url = new URL(input instanceof Request ? input.url : input.toString()); if (url.hostname === "cloudflare-dns.com") { return Response.json({ @@ -52,7 +264,7 @@ function workflowNetwork(profileProof = PROFILE_PROOF) { id: `${PUBLISHER_DID}#atproto`, type: "Multikey", controller: PUBLISHER_DID, - publicKeyMultibase: "zDnaeq9feE9D74uYD5jynoyyQPbhhWU2vStcmC8W1xQHG3fWe", + publicKeyMultibase: options.signingKey?.() ?? DEFAULT_SIGNING_KEY, }, ], service: [ @@ -65,16 +277,99 @@ function workflowNetwork(profileProof = PROFILE_PROOF) { }); } if (url.hostname === "pds.example.com" && url.pathname === "/xrpc/com.atproto.sync.getRecord") { + if (url.searchParams.get("collection") === NSID.packageRelease) { + const proof = options.authoritativeProof?.() ?? null; + return proof + ? new Response(proof, { + headers: { "content-type": "application/vnd.ipld.car" }, + }) + : Response.json({ error: "RecordNotFound" }, { status: 404 }); + } return new Response( Uint8Array.from(atob(profileProof), (character) => character.charCodeAt(0)), { headers: { "content-type": "application/vnd.ipld.car" } }, ); } + if ( + url.hostname === "pds.example.com" && + url.pathname === "/.well-known/oauth-protected-resource" + ) { + return Response.json({ + resource: "https://pds.example.com", + authorization_servers: ["https://authorization.example"], + }); + } + if ( + url.hostname === "authorization.example" && + url.pathname === "/.well-known/oauth-authorization-server" + ) { + await options.onAuthorizationMetadata?.(); + return Response.json({ + issuer: "https://authorization.example", + authorization_endpoint: "https://authorization.example/authorize", + token_endpoint: "https://authorization.example/token", + pushed_authorization_request_endpoint: "https://authorization.example/par", + client_id_metadata_document_supported: true, + dpop_signing_alg_values_supported: ["ES256"], + response_types_supported: ["code"], + authorization_response_iss_parameter_supported: true, + }); + } + if ( + url.hostname === "github.com" && + url.pathname === "/example/gallery/releases/download/v1.2.3/gallery.tar.gz" + ) { + const response = await options.onArtifactFetch?.(); + if (response) return response; + return new Response(PACKAGE_BYTES, { headers: { "content-type": "application/gzip" } }); + } + const artifactSource = options.artifactSources?.get(url.toString()); + if (artifactSource) { + return new Response(artifactSource.bytes, { + headers: { "content-type": artifactSource.mimeType }, + }); + } + if ( + url.hostname === "pds.example.com" && + url.pathname === "/xrpc/com.atproto.repo.uploadBlob" + ) { + const request = input instanceof Request ? input : new Request(url, init); + const response = await options.onUploadBlob?.(request); + if (response) return response; + return Response.json({ + blob: { + $type: "blob", + ref: { $link: ARTIFACT_BLOB_CID }, + mimeType: "application/gzip", + size: PACKAGE_BYTES.byteLength, + }, + }); + } + if (url.hostname === "pds.example.com" && url.pathname === "/xrpc/com.atproto.repo.getRecord") { + const collection = url.searchParams.get("collection"); + if (collection === NSID.packageRelease) { + expect(url.searchParams.get("rkey")).toBe("gallery:1.2.3"); + return options.authoritativeProof?.() + ? Response.json({ uri: CREATED_URI, cid: CREATED_CID, value: {} }) + : Response.json({ error: "RecordNotFound" }, { status: 400 }); + } + } if ( url.hostname === "pds.example.com" && url.pathname === "/xrpc/com.atproto.repo.listRecords" ) { - return Response.json({ records: [] }); + return Response.json({ records: options.listedReleases?.() ?? [] }); + } + if ( + url.hostname === "pds.example.com" && + url.pathname === "/xrpc/com.atproto.repo.createRecord" + ) { + const request = input instanceof Request ? input : new Request(url, init); + if (options.onCreateRecord) return options.onCreateRecord(request); + return Response.json({ + uri: CREATED_URI, + cid: CREATED_CID, + }); } throw new Error(`Unexpected request: ${url.toString()}`); }; @@ -105,6 +400,7 @@ async function createVerifyingIntent( version: "1.2.3", workloadPolicyVersion: 1, workloadIdentityDigest: "A".repeat(43), + workloadIdempotencyDigest: "I".repeat(43), idempotencyKey: "github-run-100-attempt-1", requestDigest: "B".repeat(43), workloadIdentityJson: JSON.stringify({ issuer: "github-actions", runId: "100" }), @@ -112,6 +408,7 @@ async function createVerifyingIntent( expiresAt: NOW + 60_000, now: NOW + 1, }); + await storeDelegation(); if (!transitionToVerifying) return; await publisher.transitionIntent({ publisherDid: PUBLISHER_DID, @@ -129,6 +426,17 @@ async function createVerifyingIntent( }); } +function immediateWorkflowStep(): WorkflowStep { + return { + do: async (...args: unknown[]) => { + const callback: unknown = args.findLast((value) => typeof value === "function"); + if (typeof callback !== "function") throw new Error("Workflow step callback is missing"); + const result: unknown = await callback(); + return result; + }, + } as WorkflowStep; +} + afterEach(async () => { vi.unstubAllGlobals(); await reset(); @@ -183,7 +491,7 @@ describe("ReleaseIntentWorkflow", () => { }); }); - it("persists every verification stage and makes a valid non-escalating intent ready", async () => { + it("persists every verification stage and publishes a valid non-escalating intent", async () => { vi.stubGlobal("fetch", workflowNetwork()); await createVerifyingIntent(); await using introspector = await introspectWorkflowInstance( @@ -198,12 +506,12 @@ describe("ReleaseIntentWorkflow", () => { await expect(introspector.getOutput()).resolves.toEqual({ intentId: INTENT_ID, - state: "ready", + state: "published", reasonCode: null, }); await expect( env.PUBLISHER_DO.getByName(PUBLISHER_DID).getIntent(PUBLISHER_DID, INTENT_ID), - ).resolves.toMatchObject({ state: "ready", stateGeneration: 4 }); + ).resolves.toMatchObject({ state: "published", stateGeneration: 6 }); await expect( env.PUBLISHER_DO.getByName(PUBLISHER_DID).listVerificationSteps(PUBLISHER_DID, INTENT_ID), ).resolves.toMatchObject([ @@ -212,21 +520,698 @@ describe("ReleaseIntentWorkflow", () => { { name: "access-baseline" }, { name: "artifact-provenance" }, { name: "policy-decision" }, + { name: "final-verification" }, + ]); + }); + + it("uploads every artifact before permitting a blob-only canonical create", async () => { + const full = await fullReleaseRecord(); + const events: string[] = []; + const uploadSlots = ["package", "icon", "banner", "screenshots[0]", "screenshots[1]"]; + let uploadIndex = 0; + const createdRecords: PackageRelease.Main[] = []; + const publisher = env.PUBLISHER_DO.getByName(PUBLISHER_DID); + const control = env.SERVICE_CONTROL_DO.getByName(SERVICE_CONTROL_OBJECT_NAME); + vi.stubGlobal( + "fetch", + workflowNetwork({ + artifactSources: full.sources, + onUploadBlob: async (request) => { + const bytes = new Uint8Array(await request.arrayBuffer()); + const mimeType = request.headers.get("content-type"); + if (!mimeType) throw new Error("Expected upload MIME type"); + const slot = uploadSlots[uploadIndex]; + if (!slot) throw new Error("Unexpected extra upload"); + uploadIndex += 1; + events.push(`upload:${slot}`); + return Response.json({ + blob: { + $type: "blob", + ref: { $link: await blobCidFor(bytes) }, + mimeType, + size: bytes.byteLength, + }, + }); + }, + onCreateRecord: async (request) => { + const materialization = await env.PUBLISHER_DO.getByName( + PUBLISHER_DID, + ).getPublicationMaterialization(PUBLISHER_DID, INTENT_ID); + expect(materialization?.status).toBe("complete"); + events.push("materialized"); + events.push("permit:issued", "permit:consumed", "create"); + const body = await request.json<{ record: PackageRelease.Main }>(); + createdRecords.push(body.record); + return Response.json({ uri: CREATED_URI, cid: CREATED_CID }); + }, + }), + ); + await createVerifyingIntent(true, JSON.stringify({ release: full.release })); + await using introspector = await introspectWorkflowInstance( + env.RELEASE_INTENT_WORKFLOW, + INTENT_ID, + ); + await env.RELEASE_INTENT_WORKFLOW.create({ + id: INTENT_ID, + params: { publisherDid: PUBLISHER_DID, intentId: INTENT_ID }, + }); + await introspector.waitForStatus("complete"); + + expect(events).toEqual([ + "upload:package", + "upload:icon", + "upload:banner", + "upload:screenshots[0]", + "upload:screenshots[1]", + "materialized", + "permit:issued", + "permit:consumed", + "create", ]); + const createdRecord = createdRecords[0]; + if (!createdRecord) throw new Error("Expected created release record"); + expect(createdRecord).toMatchObject({ + artifacts: { + package: { contentType: "application/gzip", blob: { mimeType: "application/gzip" } }, + icon: { contentType: "image/png", width: 128, height: 128 }, + banner: { contentType: "image/jpeg", width: 1200, height: 400 }, + screenshots: [ + { contentType: "image/webp", width: 1440, height: 900 }, + { contentType: "image/png", width: 390, height: 844 }, + ], + }, + }); + for (const artifact of [ + createdRecord.artifacts.package, + createdRecord.artifacts.icon, + createdRecord.artifacts.banner, + ...(createdRecord.artifacts.screenshots ?? []), + ]) { + expect(artifact).toHaveProperty("blob"); + expect(artifact).not.toHaveProperty("url"); + expect(artifact).not.toHaveProperty("releaseAsset"); + expect(artifact).not.toHaveProperty("requiresAuth"); + } + const materialization = await publisher.getPublicationMaterialization(PUBLISHER_DID, INTENT_ID); + if (!materialization) throw new Error("Expected materialization state"); + const latestUpload = Math.max( + ...materialization.slots.map((artifact) => artifact.uploadedAt ?? 0), + ); + const permit = await runInDurableObject(control, (_instance, state) => + state.storage.sql + .exec<{ consumed_at: number; created_at: number }>( + "SELECT created_at, consumed_at FROM publication_permits", + ) + .one(), + ); + expect(permit.created_at).toBeGreaterThanOrEqual(latestUpload); + expect(permit.created_at).toBeGreaterThanOrEqual(materialization.updatedAt); + expect(permit.consumed_at).toBeGreaterThanOrEqual(permit.created_at); + const operation = await runInDurableObject(publisher, (_instance, state) => + state.storage.sql + .exec<{ phase: string }>( + "SELECT phase FROM publication_operations WHERE intent_id = ?", + INTENT_ID, + ) + .one(), + ); + expect(operation.phase).toBe("creating"); + await expect(env.PUBLICATION_STAGING.list()).resolves.toMatchObject({ objects: [] }); + }, 15_000); + + it("retries an ambiguous blob upload without entering release reconciliation", async () => { + let uploadAttempts = 0; + let createAttempts = 0; + vi.stubGlobal( + "fetch", + workflowNetwork({ + onUploadBlob: () => { + uploadAttempts += 1; + if (uploadAttempts === 1) throw new Error("Simulated lost upload response"); + }, + onCreateRecord: () => { + createAttempts += 1; + return Response.json({ uri: CREATED_URI, cid: CREATED_CID }); + }, + }), + ); + await createVerifyingIntent(); + await using introspector = await introspectWorkflowInstance( + env.RELEASE_INTENT_WORKFLOW, + INTENT_ID, + ); + await env.RELEASE_INTENT_WORKFLOW.create({ + id: INTENT_ID, + params: { publisherDid: PUBLISHER_DID, intentId: INTENT_ID }, + }); + await introspector.waitForStatus("complete"); + + expect(uploadAttempts).toBe(2); + expect(createAttempts).toBe(1); + await expect(introspector.getOutput()).resolves.toMatchObject({ state: "published" }); + const operation = await runInDurableObject( + env.PUBLISHER_DO.getByName(PUBLISHER_DID), + (_instance, state) => + state.storage.sql + .exec<{ outcome: string; phase: string }>( + "SELECT outcome, phase FROM publication_operations WHERE intent_id = ?", + INTENT_ID, + ) + .one(), + ); + expect(operation).toEqual({ outcome: "published", phase: "creating" }); + }, 10_000); + + it("converges a timeout after createRecord to the exact authoritative release", async () => { + let createAttempts = 0; + let authoritativeVisible = false; + const authoritative = { + proof: proofBytes(publicationProofs.exactProof), + signingKey: publicationProofs.signingKey, + }; + vi.stubGlobal( + "fetch", + workflowNetwork({ + authoritativeProof: () => (authoritativeVisible ? authoritative.proof : null), + signingKey: () => (authoritativeVisible ? authoritative.signingKey : DEFAULT_SIGNING_KEY), + onCreateRecord: () => { + createAttempts += 1; + authoritativeVisible = true; + throw new Error("Simulated timeout after the PDS committed the record"); + }, + }), + ); + await createVerifyingIntent(); + await using introspector = await introspectWorkflowInstance( + env.RELEASE_INTENT_WORKFLOW, + INTENT_ID, + ); + await env.RELEASE_INTENT_WORKFLOW.create({ + id: INTENT_ID, + params: { publisherDid: PUBLISHER_DID, intentId: INTENT_ID }, + }); + await introspector.waitForStatus("complete"); + + await expect(introspector.getOutput()).resolves.toEqual({ + intentId: INTENT_ID, + state: "published", + reasonCode: null, + }); + expect(createAttempts).toBe(1); + await expect( + env.PUBLISHER_DO.getByName(PUBLISHER_DID).getIntent(PUBLISHER_DID, INTENT_ID), + ).resolves.toMatchObject({ state: "published", stateGeneration: 7 }); + }); + + it("makes a different record at the deterministic key a terminal conflict", async () => { + let createAttempts = 0; + let authoritativeVisible = false; + const authoritative = { + proof: proofBytes(publicationProofs.conflictProof), + signingKey: publicationProofs.signingKey, + }; + vi.stubGlobal( + "fetch", + workflowNetwork({ + authoritativeProof: () => (authoritativeVisible ? authoritative.proof : null), + signingKey: () => (authoritativeVisible ? authoritative.signingKey : DEFAULT_SIGNING_KEY), + onCreateRecord: () => { + createAttempts += 1; + authoritativeVisible = true; + throw new Error("Simulated ambiguous create response"); + }, + }), + ); + await createVerifyingIntent(); + await using introspector = await introspectWorkflowInstance( + env.RELEASE_INTENT_WORKFLOW, + INTENT_ID, + ); + await env.RELEASE_INTENT_WORKFLOW.create({ + id: INTENT_ID, + params: { publisherDid: PUBLISHER_DID, intentId: INTENT_ID }, + }); + await introspector.waitForStatus("complete"); + + await expect(introspector.getOutput()).resolves.toEqual({ + intentId: INTENT_ID, + state: "conflict", + reasonCode: "RELEASE_CONFLICT", + }); + expect(createAttempts).toBe(1); + await expect( + env.PUBLISHER_DO.getByName(PUBLISHER_DID).getIntent(PUBLISHER_DID, INTENT_ID), + ).resolves.toMatchObject({ state: "conflict", stateGeneration: 7 }); + }); + + it("makes a release that appears before final verification a terminal conflict", async () => { + let snapshotReads = 0; + let createAttempts = 0; + vi.stubGlobal( + "fetch", + workflowNetwork({ + listedReleases: () => { + snapshotReads += 1; + return snapshotReads < 4 + ? [] + : [{ uri: CREATED_URI, cid: CREATED_CID, value: releaseRecord() }]; + }, + onCreateRecord: () => { + createAttempts += 1; + return Response.json({ uri: CREATED_URI, cid: CREATED_CID }); + }, + }), + ); + await createVerifyingIntent(); + await using introspector = await introspectWorkflowInstance( + env.RELEASE_INTENT_WORKFLOW, + INTENT_ID, + ); + await env.RELEASE_INTENT_WORKFLOW.create({ + id: INTENT_ID, + params: { publisherDid: PUBLISHER_DID, intentId: INTENT_ID }, + }); + await introspector.waitForStatus("complete"); + + await expect(introspector.getOutput()).resolves.toEqual({ + intentId: INTENT_ID, + state: "conflict", + reasonCode: "RELEASE_EXISTS", + }); + expect(createAttempts).toBe(0); + await expect( + env.PUBLISHER_DO.getByName(PUBLISHER_DID).getIntent(PUBLISHER_DID, INTENT_ID), + ).resolves.toMatchObject({ state: "conflict" }); + }, 15_000); + + it("invalidates an intent when the final publisher snapshot is malformed", async () => { + let snapshotReads = 0; + vi.stubGlobal( + "fetch", + workflowNetwork({ + listedReleases: () => { + snapshotReads += 1; + return snapshotReads < 4 + ? [] + : [{ uri: "not-an-at-uri", cid: CREATED_CID, value: releaseRecord() }]; + }, + }), + ); + await createVerifyingIntent(); + await using introspector = await introspectWorkflowInstance( + env.RELEASE_INTENT_WORKFLOW, + INTENT_ID, + ); + await env.RELEASE_INTENT_WORKFLOW.create({ + id: INTENT_ID, + params: { publisherDid: PUBLISHER_DID, intentId: INTENT_ID }, + }); + await introspector.waitForStatus("complete"); + + await expect(introspector.getOutput()).resolves.toEqual({ + intentId: INTENT_ID, + state: "invalid", + reasonCode: "RELEASE_LIST_INVALID", + }); + await expect( + env.PUBLISHER_DO.getByName(PUBLISHER_DID).getIntent(PUBLISHER_DID, INTENT_ID), + ).resolves.toMatchObject({ state: "invalid" }); + }, 15_000); + + it("uses a fresh permit and publication generation after each confirmed absence", async () => { + let createAttempts = 0; + vi.stubGlobal( + "fetch", + workflowNetwork({ + onCreateRecord: () => { + createAttempts += 1; + throw new Error("Simulated timeout before the PDS committed the record"); + }, + }), + ); + await createVerifyingIntent(); + await using introspector = await introspectWorkflowInstance( + env.RELEASE_INTENT_WORKFLOW, + INTENT_ID, + ); + await env.RELEASE_INTENT_WORKFLOW.create({ + id: INTENT_ID, + params: { publisherDid: PUBLISHER_DID, intentId: INTENT_ID }, + }); + await introspector.waitForStatus("complete"); + + await expect(introspector.getOutput()).resolves.toEqual({ + intentId: INTENT_ID, + state: "failed", + reasonCode: "PDS_RETRY_EXHAUSTED", + }); + expect(createAttempts).toBe(3); + await expect( + env.PUBLISHER_DO.getByName(PUBLISHER_DID).getIntent(PUBLISHER_DID, INTENT_ID), + ).resolves.toMatchObject({ state: "failed", stateGeneration: 13 }); + + const operation = await runInDurableObject( + env.PUBLISHER_DO.getByName(PUBLISHER_DID), + (_instance, state) => + state.storage.sql + .exec<{ generation: number; outcome: string; status: string }>( + "SELECT generation, outcome, status FROM publication_operations WHERE intent_id = ?", + INTENT_ID, + ) + .one(), + ); + expect(operation).toEqual({ generation: 3, outcome: "ambiguous", status: "completed" }); + const permits = await runInDurableObject( + env.SERVICE_CONTROL_DO.getByName(SERVICE_CONTROL_OBJECT_NAME), + (_instance, state) => + state.storage.sql + .exec<{ consumed: number; distinct_ids: number; total: number }>( + `SELECT COUNT(*) AS total, COUNT(DISTINCT id) AS distinct_ids, + SUM(CASE WHEN consumed_at IS NOT NULL THEN 1 ELSE 0 END) AS consumed + FROM publication_permits`, + ) + .one(), + ); + expect(permits).toEqual({ total: 3, distinct_ids: 3, consumed: 3 }); + }); + + it.each([ + ["publication pause", "pause", "ready", "PUBLICATION_PAUSED"], + ["publisher suspension", "suspend", "ready", "PUBLISHER_SUSPENDED"], + ["delegation revocation", "revoke", "failed", "OAUTH_DELEGATION_UNAVAILABLE"], + ] as const)( + "blocks publication after a permit when %s wins the pre-write race", + async (_name, controlAction, expectedState, expectedReason) => { + let controlApplied = false; + let createAttempts = 0; + vi.stubGlobal( + "fetch", + workflowNetwork({ + onAuthorizationMetadata: async () => { + if (controlApplied) return; + controlApplied = true; + if (controlAction === "revoke") { + const publisher = env.PUBLISHER_DO.getByName(PUBLISHER_DID); + const delegation = await publisher.getDelegation(PUBLISHER_DID); + if (!delegation) throw new Error("Expected stored delegation"); + await publisher.revokeDelegation(PUBLISHER_DID, delegation.stateVersion); + return; + } + const control = env.SERVICE_CONTROL_DO.getByName(SERVICE_CONTROL_OBJECT_NAME); + if (controlAction === "pause") { + await control.setServiceMode({ + actor: CONTROL_ACTOR, + idempotencyKey: "publication-pause-test", + requestDigest: "P".repeat(43), + mode: "publication-paused", + reasonCode: "TEST_PAUSE", + }); + return; + } + await control.setPublisherControl({ + actor: CONTROL_ACTOR, + idempotencyKey: "publisher-suspend-test", + requestDigest: "S".repeat(43), + publisherDid: PUBLISHER_DID, + status: "suspended", + reasonCode: "TEST_SUSPEND", + }); + }, + onCreateRecord: () => { + createAttempts += 1; + return Response.json({ uri: CREATED_URI, cid: CREATED_CID }); + }, + }), + ); + await createVerifyingIntent(); + await using introspector = await introspectWorkflowInstance( + env.RELEASE_INTENT_WORKFLOW, + INTENT_ID, + ); + await env.RELEASE_INTENT_WORKFLOW.create({ + id: INTENT_ID, + params: { publisherDid: PUBLISHER_DID, intentId: INTENT_ID }, + }); + await introspector.waitForStatus("complete"); + + await expect(introspector.getOutput()).resolves.toEqual({ + intentId: INTENT_ID, + state: expectedState, + reasonCode: expectedReason, + }); + expect(createAttempts).toBe(0); + await expect( + env.PUBLISHER_DO.getByName(PUBLISHER_DID).getIntent(PUBLISHER_DID, INTENT_ID), + ).resolves.toMatchObject({ state: expectedState }); + }, + ); + + it("restarts a completed ready Workflow after publication is unpaused", async () => { + let paused = false; + let createAttempts = 0; + vi.stubGlobal( + "fetch", + workflowNetwork({ + onAuthorizationMetadata: async () => { + if (paused) return; + paused = true; + await env.SERVICE_CONTROL_DO.getByName(SERVICE_CONTROL_OBJECT_NAME).setServiceMode({ + actor: CONTROL_ACTOR, + idempotencyKey: "publication-restart-pause", + requestDigest: "R".repeat(43), + mode: "publication-paused", + reasonCode: "TEST_PAUSE", + }); + }, + onCreateRecord: () => { + createAttempts += 1; + return Response.json({ uri: CREATED_URI, cid: CREATED_CID }); + }, + }), + ); + await createVerifyingIntent(); + await using introspector = await introspectWorkflowInstance( + env.RELEASE_INTENT_WORKFLOW, + INTENT_ID, + ); + await env.RELEASE_INTENT_WORKFLOW.create({ + id: INTENT_ID, + params: { publisherDid: PUBLISHER_DID, intentId: INTENT_ID }, + }); + await introspector.waitForStatus("complete"); + await expect(introspector.getOutput()).resolves.toMatchObject({ state: "ready" }); + + await env.SERVICE_CONTROL_DO.getByName(SERVICE_CONTROL_OBJECT_NAME).setServiceMode({ + actor: CONTROL_ACTOR, + idempotencyKey: "publication-restart-active", + requestDigest: "A".repeat(43), + mode: "active", + reasonCode: null, + }); + await expect( + restartReleaseIntentWorkflow( + env.RELEASE_INTENT_WORKFLOW, + env.PUBLISHER_DO, + PUBLISHER_DID, + INTENT_ID, + ), + ).resolves.toEqual({ ok: true, workflowId: INTENT_ID, restarted: true }); + await introspector.waitForStepResult({ name: "recovery-policy-decision" }); + await introspector.waitForStatus("complete"); + await expect(introspector.getOutput()).resolves.toEqual({ + intentId: INTENT_ID, + state: "published", + reasonCode: null, + }); + expect(createAttempts).toBe(1); }); + it("resumes publication when the publishing transition committed without an operation", async () => { + vi.stubGlobal("fetch", workflowNetwork({ profileProof: APPROVAL_PROFILE_PROOF })); + await createVerifyingIntent(); + const publisher = env.PUBLISHER_DO.getByName(PUBLISHER_DID); + const originalIntent = await publisher.getIntent(PUBLISHER_DID, INTENT_ID); + if (!originalIntent) throw new Error("Expected a stored intent"); + await using introspector = await introspectWorkflowInstance( + env.RELEASE_INTENT_WORKFLOW, + INTENT_ID, + ); + await env.RELEASE_INTENT_WORKFLOW.create({ + id: INTENT_ID, + params: { publisherDid: PUBLISHER_DID, intentId: INTENT_ID }, + }); + await introspector.waitForStepResult({ name: "await-approval" }); + const awaiting = await publisher.getIntent(PUBLISHER_DID, INTENT_ID); + if (!awaiting) throw new Error("Expected an awaiting intent"); + const approval = await decodeAwaitingApprovalState(awaiting.stateDataJson); + const ready = await publisher.transitionIntent({ + publisherDid: PUBLISHER_DID, + intentId: INTENT_ID, + expectedState: "awaiting_approval", + expectedGeneration: awaiting.stateGeneration, + toState: "ready", + transitionDigest: "Y".repeat(43), + actorRealm: "approver", + actorIdentity: "did:plc:approver", + reasonCode: "APPROVED", + stateDataJson: JSON.stringify({ approved: true }), + }); + expect(ready.ok).toBe(true); + if (!ready.ok) return; + await publisher.transitionIntent({ + publisherDid: PUBLISHER_DID, + intentId: INTENT_ID, + expectedState: "ready", + expectedGeneration: ready.intent.stateGeneration, + toState: "publishing", + transitionDigest: "X".repeat(43), + actorRealm: "system", + actorIdentity: "release-service", + reasonCode: null, + stateDataJson: JSON.stringify({ attempt: 1 }), + }); + const control = env.SERVICE_CONTROL_DO.getByName(SERVICE_CONTROL_OBJECT_NAME); + await control.setServiceMode({ + actor: CONTROL_ACTOR, + idempotencyKey: "publishing-retry-paused", + requestDigest: "V".repeat(43), + mode: "publication-paused", + reasonCode: "TEST_PAUSE", + }); + + await expect( + publishVerifiedIntent( + { + ...env, + RELEASE_VERIFIER: env.RELEASE_VERIFIER as Service, + }, + immediateWorkflowStep(), + PUBLISHER_DID, + originalIntent, + approval.approvalEvidence, + ), + ).resolves.toEqual({ + intentId: INTENT_ID, + state: "ready", + reasonCode: "PUBLICATION_PAUSED", + }); + await expect(publisher.getIntent(PUBLISHER_DID, INTENT_ID)).resolves.toMatchObject({ + state: "ready", + }); + await control.setServiceMode({ + actor: CONTROL_ACTOR, + idempotencyKey: "publishing-retry-active", + requestDigest: "U".repeat(43), + mode: "active", + reasonCode: null, + }); + await expect( + publishVerifiedIntent( + { + ...env, + RELEASE_VERIFIER: env.RELEASE_VERIFIER as Service, + }, + immediateWorkflowStep(), + PUBLISHER_DID, + originalIntent, + approval.approvalEvidence, + ), + ).resolves.toEqual({ intentId: INTENT_ID, state: "published", reasonCode: null }); + await expect(publisher.getIntent(PUBLISHER_DID, INTENT_ID)).resolves.toMatchObject({ + state: "published", + }); + const operation = await runInDurableObject(publisher, (_instance, state) => + state.storage.sql + .exec<{ lease_ms: number }>( + `SELECT expires_at - started_at AS lease_ms + FROM publication_operations WHERE intent_id = ?`, + INTENT_ID, + ) + .one(), + ); + expect(operation.lease_ms).toBe(5 * 60_000); + }); + + it("restarts an errored reconciliation and accepts the exact authoritative record", async () => { + let reconciliationAvailable = false; + let sourceAvailable = true; + let sourceFetches = 0; + let createAttempts = 0; + let authoritativeVisible = false; + const authoritative = { + proof: proofBytes(publicationProofs.exactProof), + signingKey: publicationProofs.signingKey, + }; + vi.stubGlobal( + "fetch", + workflowNetwork({ + authoritativeProof: () => { + if (!reconciliationAvailable) throw new Error("Simulated PDS read outage"); + return authoritativeVisible ? authoritative.proof : null; + }, + signingKey: () => (authoritativeVisible ? authoritative.signingKey : DEFAULT_SIGNING_KEY), + onArtifactFetch: () => { + sourceFetches += 1; + return sourceAvailable ? undefined : new Response(null, { status: 503 }); + }, + onCreateRecord: () => { + createAttempts += 1; + authoritativeVisible = true; + throw new Error("Simulated timeout after commit"); + }, + }), + ); + await createVerifyingIntent(); + await using introspector = await introspectWorkflowInstance( + env.RELEASE_INTENT_WORKFLOW, + INTENT_ID, + ); + await env.RELEASE_INTENT_WORKFLOW.create({ + id: INTENT_ID, + params: { publisherDid: PUBLISHER_DID, intentId: INTENT_ID }, + }); + await introspector.waitForStatus("errored"); + await expect( + env.PUBLISHER_DO.getByName(PUBLISHER_DID).getIntent(PUBLISHER_DID, INTENT_ID), + ).resolves.toMatchObject({ state: "reconciling" }); + + reconciliationAvailable = true; + sourceAvailable = false; + await expect( + restartReleaseIntentWorkflow( + env.RELEASE_INTENT_WORKFLOW, + env.PUBLISHER_DO, + PUBLISHER_DID, + INTENT_ID, + ), + ).resolves.toEqual({ ok: true, workflowId: INTENT_ID, restarted: true }); + await introspector.waitForStepResult({ name: "recovery-reconciliation" }); + await introspector.waitForStatus("complete"); + await expect(introspector.getOutput()).resolves.toEqual({ + intentId: INTENT_ID, + state: "published", + reasonCode: null, + }); + expect(createAttempts).toBe(1); + expect(sourceFetches).toBe(1); + }, 15_000); + it("waits for a canonical approval transition and resumes from its event", async () => { - vi.stubGlobal("fetch", workflowNetwork(APPROVAL_PROFILE_PROOF)); + vi.stubGlobal("fetch", workflowNetwork({ profileProof: APPROVAL_PROFILE_PROOF })); await createVerifyingIntent(); await using introspector = await introspectWorkflowInstance( env.RELEASE_INTENT_WORKFLOW, INTENT_ID, ); + await introspector.modify((modifier) => + modifier.forceEventTimeout({ name: "approval-decision" }), + ); const instance = await env.RELEASE_INTENT_WORKFLOW.create({ id: INTENT_ID, params: { publisherDid: PUBLISHER_DID, intentId: INTENT_ID }, }); await introspector.waitForStepResult({ name: "await-approval" }); + await introspector.waitForStepResult({ name: "approval-timeout-state" }); const awaiting = await env.PUBLISHER_DO.getByName(PUBLISHER_DID).getIntent( PUBLISHER_DID, INTENT_ID, @@ -249,7 +1234,7 @@ describe("ReleaseIntentWorkflow", () => { await introspector.waitForStatus("complete"); await expect(introspector.getOutput()).resolves.toEqual({ intentId: INTENT_ID, - state: "ready", + state: "published", reasonCode: null, }); }); diff --git a/apps/release-service/test/service-control-do.test.ts b/apps/release-service/test/service-control-do.test.ts new file mode 100644 index 0000000000..382db8df7a --- /dev/null +++ b/apps/release-service/test/service-control-do.test.ts @@ -0,0 +1,280 @@ +import { reset, runDurableObjectAlarm, runInDurableObject } from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { AccessActor } from "../src/access/auth.js"; +import { + SERVICE_CONTROL_OBJECT_NAME, + type SetServiceModeInput, +} from "../src/control-do/service-control-do.js"; + +const DID = "did:plc:publisher"; +const INTENT_ID = "intent-01JABCDEFGHJKMNPQRSTVWXYZ"; +const NOW = 1_800_000_000_000; +const VIEWER = { + realm: "access", + identity: "7335d417-61da-459d-899c-0a01c76a2f94", + email: "viewer@example.com", + role: "viewer", +} as const satisfies AccessActor; +const ADMIN = { + ...VIEWER, + email: "admin@example.com", + role: "admin", +} as const satisfies AccessActor; + +function control() { + return env.SERVICE_CONTROL_DO.getByName(SERVICE_CONTROL_OBJECT_NAME); +} + +function modeInput(overrides: Partial = {}): SetServiceModeInput { + return { + actor: ADMIN, + idempotencyKey: "operator-request-0001", + requestDigest: "A".repeat(43), + mode: "publication-paused", + reasonCode: "MAINTENANCE", + now: NOW, + ...overrides, + }; +} + +afterEach(async () => { + await reset(); +}); + +describe("ServiceControlDurableObject", () => { + it("starts active and admits an unsuspended publisher", async () => { + const stub = control(); + + await expect(stub.readServiceState(VIEWER)).resolves.toEqual({ + mode: "active", + epoch: 1, + reasonCode: null, + changedBy: "system:bootstrap", + changedAt: 0, + }); + await expect(stub.getAdmissionDecision(DID)).resolves.toEqual({ + allowed: true, + mode: "active", + modeEpoch: 1, + code: null, + }); + await expect(stub.readPublisherControl(VIEWER, DID)).resolves.toEqual({ + publisherDid: DID, + status: "allowed", + reasonCode: null, + changedBy: "system:default", + changedAt: 0, + }); + }); + + it("changes mode atomically and replays an operator mutation once", async () => { + const stub = control(); + const input = modeInput(); + + const first = await stub.setServiceMode(input); + expect(first).toEqual({ + ok: true, + replayed: false, + value: { + mode: "publication-paused", + epoch: 2, + reasonCode: "MAINTENANCE", + changedBy: ADMIN.identity, + changedAt: NOW, + }, + }); + await expect(stub.setServiceMode(input)).resolves.toEqual({ ...first, replayed: true }); + await expect(stub.setServiceMode({ ...input, requestDigest: "B".repeat(43) })).resolves.toEqual( + { ok: false, code: "IDEMPOTENCY_CONFLICT" }, + ); + + const audit = await stub.listAudit(VIEWER); + expect(audit).toHaveLength(1); + expect(audit[0]).toMatchObject({ + eventType: "service-mode-changed", + actorRealm: "access", + actorIdentity: ADMIN.identity, + actorRole: "admin", + subject: "publication-paused", + reasonCode: "MAINTENANCE", + }); + }); + + it("rejects insufficient operators and incomplete pause reasons", async () => { + const stub = control(); + + await runInDurableObject(stub, async (instance) => { + await expect(instance.setServiceMode(modeInput({ actor: VIEWER }))).rejects.toMatchObject({ + code: "CONTROL_ACTOR_INVALID", + }); + await expect(instance.setServiceMode(modeInput({ reasonCode: null }))).rejects.toMatchObject({ + code: "CONTROL_INPUT_INVALID", + }); + await expect( + instance.setServiceMode(modeInput({ mode: "active", reasonCode: "MAINTENANCE" })), + ).rejects.toMatchObject({ code: "CONTROL_INPUT_INVALID" }); + }); + }); + + it("applies admission and publication pauses independently", async () => { + const stub = control(); + await stub.setServiceMode(modeInput({ mode: "admission-paused", reasonCode: "MAINTENANCE" })); + + await expect(stub.getAdmissionDecision(DID)).resolves.toMatchObject({ + allowed: false, + mode: "admission-paused", + code: "ADMISSION_PAUSED", + }); + const admittedPermit = await stub.issuePublicationPermit(DID, INTENT_ID, 5_000, NOW + 1); + expect(admittedPermit).toMatchObject({ ok: true, permit: { modeEpoch: 2 } }); + + await stub.setServiceMode( + modeInput({ + idempotencyKey: "operator-request-0002", + requestDigest: "B".repeat(43), + mode: "publication-paused", + now: NOW + 2, + }), + ); + await expect(stub.getAdmissionDecision(DID)).resolves.toMatchObject({ + allowed: true, + mode: "publication-paused", + code: null, + }); + await expect(stub.issuePublicationPermit(DID, INTENT_ID, 5_000, NOW + 3)).resolves.toEqual({ + ok: false, + code: "PUBLICATION_PAUSED", + }); + }); + + it("issues a bound permit that can be consumed exactly once", async () => { + const stub = control(); + const issued = await stub.issuePublicationPermit(DID, INTENT_ID, 5_000, NOW); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + + await expect( + stub.consumePublicationPermit({ + ...issued.permit, + now: NOW + 1, + }), + ).resolves.toEqual({ ok: true, modeEpoch: 1 }); + await expect( + stub.consumePublicationPermit({ + ...issued.permit, + now: NOW + 2, + }), + ).resolves.toEqual({ ok: false, code: "PERMIT_CONSUMED" }); + await expect( + stub.consumePublicationPermit({ + ...issued.permit, + token: `${"A".repeat(42)}B`, + now: NOW + 2, + }), + ).resolves.toEqual({ ok: false, code: "PERMIT_INVALID" }); + }); + + it("invalidates a cached permit when the service mode epoch changes", async () => { + const stub = control(); + const issued = await stub.issuePublicationPermit(DID, INTENT_ID, 5_000, NOW); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + await stub.setServiceMode( + modeInput({ mode: "admission-paused", reasonCode: "MAINTENANCE", now: NOW + 1 }), + ); + + await expect( + stub.consumePublicationPermit({ ...issued.permit, now: NOW + 2 }), + ).resolves.toEqual({ ok: false, code: "PERMIT_STALE" }); + }); + + it("suspends publisher admission and invalidates outstanding permits", async () => { + const stub = control(); + const issued = await stub.issuePublicationPermit(DID, INTENT_ID, 5_000, NOW); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + + await expect( + stub.setPublisherControl({ + actor: ADMIN, + idempotencyKey: "operator-request-0001", + requestDigest: "A".repeat(43), + publisherDid: DID, + status: "suspended", + reasonCode: "SECURITY_REVIEW", + now: NOW + 1, + }), + ).resolves.toMatchObject({ + ok: true, + value: { publisherDid: DID, status: "suspended", reasonCode: "SECURITY_REVIEW" }, + }); + await expect(stub.getAdmissionDecision(DID)).resolves.toMatchObject({ + allowed: false, + code: "PUBLISHER_SUSPENDED", + }); + await expect(stub.issuePublicationPermit(DID, "intent-2", 5_000, NOW + 2)).resolves.toEqual({ + ok: false, + code: "PUBLISHER_SUSPENDED", + }); + await expect( + stub.consumePublicationPermit({ ...issued.permit, now: NOW + 2 }), + ).resolves.toEqual({ ok: false, code: "PUBLISHER_SUSPENDED" }); + }); + + it("never persists a plaintext permit token", async () => { + const stub = control(); + const issued = await stub.issuePublicationPermit(DID, INTENT_ID, 5_000, NOW); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + + const persisted = await runInDurableObject(stub, (_instance, state) => ({ + permit: state.storage.sql + .exec<{ token_hash: string }>( + "SELECT token_hash FROM publication_permits WHERE id = ?", + issued.permit.id, + ) + .one(), + audit: state.storage.sql + .exec<{ public_payload: string }>("SELECT public_payload FROM audit_events") + .toArray(), + })); + expect(persisted.permit.token_hash).not.toBe(issued.permit.token); + expect(JSON.stringify(persisted)).not.toContain(issued.permit.token); + }); + + it("cleans expired permits and operator idempotency with its alarm", async () => { + const stub = control(); + const oldNow = Date.now() - 24 * 60 * 60_000 - 1_000; + await stub.issuePublicationPermit(DID, INTENT_ID, 1, oldNow); + await stub.setServiceMode( + modeInput({ + mode: "admission-paused", + reasonCode: "MAINTENANCE", + now: oldNow, + }), + ); + + await runDurableObjectAlarm(stub); + const counts = await runInDurableObject(stub, (_instance, state) => ({ + permits: state.storage.sql + .exec<{ count: number }>("SELECT COUNT(*) AS count FROM publication_permits") + .one().count, + idempotency: state.storage.sql + .exec<{ count: number }>("SELECT COUNT(*) AS count FROM operator_idempotency") + .one().count, + })); + expect(counts).toEqual({ permits: 0, idempotency: 0 }); + }); + + it("rejects calls routed to a non-canonical control object", async () => { + const unnamed = env.SERVICE_CONTROL_DO.get(env.SERVICE_CONTROL_DO.newUniqueId()); + + await runInDurableObject(unnamed, async (instance) => { + await expect(instance.readServiceState(VIEWER)).rejects.toMatchObject({ + code: "CONTROL_OBJECT_MISMATCH", + }); + }); + }); +}); diff --git a/apps/release-service/test/ui-assets.test.ts b/apps/release-service/test/ui-assets.test.ts new file mode 100644 index 0000000000..ce1497b3ed --- /dev/null +++ b/apps/release-service/test/ui-assets.test.ts @@ -0,0 +1,72 @@ +import { SELF } from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT, type JWTVerifyGetKey } from "jose"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { handleUiRequest } from "../src/index.js"; +import { TEST_ACCESS_AUDIENCES, TEST_BINDINGS } from "./fixtures/oauth.js"; + +const ACCESS_SUBJECT = "7335d417-61da-459d-899c-0a01c76a2f94"; +let privateKey: CryptoKey; +let keyResolver: JWTVerifyGetKey; + +beforeAll(async () => { + const keys = await generateKeyPair("RS256", { extractable: true }); + privateKey = keys.privateKey; + const publicJwk = await exportJWK(keys.publicKey); + publicJwk.kid = "access-ui-test"; + publicJwk.alg = "RS256"; + publicJwk.use = "sig"; + keyResolver = createLocalJWKSet({ keys: [publicJwk] }); +}); + +async function accessToken(): Promise { + const now = Math.floor(Date.now() / 1000); + return await new SignJWT({ email: "operator@example.com", type: "app" }) + .setProtectedHeader({ alg: "RS256", kid: "access-ui-test", typ: "JWT" }) + .setIssuer(TEST_BINDINGS.ACCESS_TEAM_DOMAIN) + .setAudience(TEST_ACCESS_AUDIENCES.viewer) + .setSubject(ACCESS_SUBJECT) + .setIssuedAt(now) + .setNotBefore(now - 1) + .setExpirationTime(now + 300) + .sign(privateKey); +} + +describe("release-service UI assets", () => { + it("serves publisher SPA navigation with strict security headers", async () => { + const response = await handleUiRequest( + new Request("https://release.example.com/publisher"), + env, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/html"); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("content-security-policy")).toContain("frame-ancestors 'none'"); + expect(await response.text()).toContain('
'); + }); + + it("requires a verified Access audience before serving operator navigation", async () => { + const routed = await SELF.fetch("https://release.example.com/admin"); + expect(routed.status).toBe(401); + expect(routed.headers.get("content-type")).toContain("application/json"); + + const denied = await handleUiRequest( + new Request("https://release.example.com/admin"), + env, + keyResolver, + ); + expect(denied.status).toBe(401); + + const allowed = await handleUiRequest( + new Request("https://release.example.com/admin", { + headers: { "cf-access-jwt-assertion": await accessToken() }, + }), + env, + keyResolver, + ); + expect(allowed.status).toBe(200); + expect(allowed.headers.get("content-type")).toContain("text/html"); + }); +}); diff --git a/apps/release-service/test/verification-evaluate.test.ts b/apps/release-service/test/verification-evaluate.test.ts index 093dd9da05..cff027c354 100644 --- a/apps/release-service/test/verification-evaluate.test.ts +++ b/apps/release-service/test/verification-evaluate.test.ts @@ -18,7 +18,7 @@ const ARTIFACT_CHECKSUM = "bciqcz4snxjp3biyoe3udwkwfxhrj4gywdzob7j2clzzqim3csofz const PROVENANCE = { predicateType: "https://slsa.dev/provenance/v1", url: "https://github.com/example/gallery/attestation.sigstore.json", - checksum: "bciqkkpvkbtfcwq6kjkbq3kgjxe5j6ihzkxlfxkzqhwzaaaa3wkbq3a", + checksum: "bciqaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", sourceRepository: "https://github.com/example/gallery", builderId: "https://github.com/example/gallery/.github/workflows/release.yml@refs/heads/main", } as const; @@ -45,6 +45,7 @@ function intent(release = proposedRelease()): StoredIntent { stateGeneration: 2, workloadPolicyVersion: 1, workloadIdentityDigest: "A".repeat(43), + workloadIdempotencyDigest: "I".repeat(43), requestDigest: "B".repeat(43), workloadIdentityJson: JSON.stringify({ issuer: "github-actions" }), releaseInputJson: JSON.stringify({ release }), diff --git a/apps/release-service/test/verification-pds.test.ts b/apps/release-service/test/verification-pds.test.ts index 75c35390d2..1c249177bf 100644 --- a/apps/release-service/test/verification-pds.test.ts +++ b/apps/release-service/test/verification-pds.test.ts @@ -4,6 +4,7 @@ import { NSID } from "@emdash-cms/registry-lexicons"; import { describe, expect, it } from "vitest"; import { + findAuthoritativeRelease, PublisherSnapshotError, readPublisherVerificationSnapshot, } from "../src/verification/pds.js"; @@ -99,6 +100,25 @@ function snapshotFetch( }; } +function releaseFetch(record: ReturnType | null, options: { error?: string } = {}) { + return async (input: RequestInfo | URL): Promise => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + if (url.hostname === "cloudflare-dns.com") { + return Response.json({ + Status: 0, + Answer: url.searchParams.get("type") === "A" ? [{ type: 1, data: "93.184.216.34" }] : [], + }); + } + expect(url.pathname).toBe("/xrpc/com.atproto.repo.getRecord"); + expect(url.searchParams.get("repo")).toBe(PUBLISHER_DID); + expect(url.searchParams.get("collection")).toBe(NSID.packageRelease); + expect(url.searchParams.get("rkey")).toBe("gallery:2.0.0"); + return record + ? Response.json(record) + : Response.json({ error: options.error ?? "RecordNotFound" }, { status: 400 }); + }; +} + describe("publisher verification snapshot", () => { it("uses a signed repository proof instead of an unverified profile response", async () => { const fetch: typeof globalThis.fetch = async (input, init) => { @@ -181,3 +201,30 @@ describe("publisher verification snapshot", () => { ).rejects.toBeInstanceOf(PublisherSnapshotError); }); }); + +describe("authoritative release reconciliation read", () => { + it("reads only the deterministic release key and returns its authoritative CID", async () => { + await expect( + findAuthoritativeRelease(PUBLISHER_DID, "gallery", "2.0.0", { + actorResolver: resolver(), + fetch: releaseFetch(release("2.0.0")), + }), + ).resolves.toEqual(release("2.0.0")); + }); + + it("accepts only the explicit RecordNotFound response as confirmed absence", async () => { + await expect( + findAuthoritativeRelease(PUBLISHER_DID, "gallery", "2.0.0", { + actorResolver: resolver(), + fetch: releaseFetch(null), + }), + ).resolves.toBeNull(); + + await expect( + findAuthoritativeRelease(PUBLISHER_DID, "gallery", "2.0.0", { + actorResolver: resolver(), + fetch: releaseFetch(null, { error: "InvalidRequest" }), + }), + ).rejects.toMatchObject({ code: "RELEASE_RECORD_INVALID" }); + }); +}); diff --git a/apps/release-service/test/verification-step.test.ts b/apps/release-service/test/verification-step.test.ts index 1aa89e7a27..42ebc28786 100644 --- a/apps/release-service/test/verification-step.test.ts +++ b/apps/release-service/test/verification-step.test.ts @@ -32,6 +32,7 @@ async function createVerifyingIntent() { version: "1.2.3", workloadPolicyVersion: 1, workloadIdentityDigest: "A".repeat(43), + workloadIdempotencyDigest: "I".repeat(43), idempotencyKey: "github-run-100-attempt-1", requestDigest: "B".repeat(43), workloadIdentityJson: JSON.stringify({ issuer: "github-actions", runId: "100" }), diff --git a/apps/release-service/test/worker.test.ts b/apps/release-service/test/worker.test.ts index 0e4c6a0cbe..90354858ed 100644 --- a/apps/release-service/test/worker.test.ts +++ b/apps/release-service/test/worker.test.ts @@ -4,11 +4,11 @@ import { describe, expect, it, vi } from "vitest"; import type { ConfigurationBindings } from "../src/config.js"; import { handleRequest } from "../src/index.js"; import type { RouteDefinition } from "../src/routes.js"; -import { TEST_ASSERTION_KEYSET } from "./fixtures/oauth.js"; +import { TEST_BINDINGS } from "./fixtures/oauth.js"; describe("release-service Worker", () => { it("serves health with a stable JSON envelope and request ID", async () => { - const response = await SELF.fetch("https://release.example.invalid/health", { + const response = await SELF.fetch("https://release.example.com/health", { headers: { "x-request-id": "health-check-1" }, }); expect(response.status).toBe(200); @@ -20,6 +20,27 @@ describe("release-service Worker", () => { }); }); + it("serves liveness without loading service configuration", async () => { + const response = await handleRequest(new Request("https://test/health"), { + ...TEST_BINDINGS, + PUBLIC_ORIGIN: "", + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ data: { status: "ok" } }); + expect( + (await handleRequest(new Request("https://test/health", { method: "POST" }), TEST_BINDINGS)) + .status, + ).toBe(405); + }); + + it("serves readiness only after configuration and control storage initialize", async () => { + const response = await SELF.fetch("https://release.example.com/ready"); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ data: { status: "ready" } }); + }); + it("serves public-only OAuth metadata and overlapping keys", async () => { const metadata = await SELF.fetch( "https://untrusted.invalid/.well-known/atproto-client-metadata.json", @@ -27,14 +48,14 @@ describe("release-service Worker", () => { expect(metadata.status).toBe(200); expect(metadata.headers.get("cache-control")).toBe("public, max-age=300"); expect(await metadata.json()).toMatchObject({ - client_id: "https://release.example.invalid/.well-known/atproto-client-metadata.json", - redirect_uris: ["https://release.example.invalid/oauth/callback"], - jwks_uri: "https://release.example.invalid/oauth/jwks.json", + client_id: "https://release.example.com/.well-known/atproto-client-metadata.json", + redirect_uris: ["https://release.example.com/oauth/callback"], + jwks_uri: "https://release.example.com/oauth/jwks.json", scope: "atproto repo:com.emdashcms.experimental.package.release?action=create blob:application/gzip blob:image/*", }); - const jwks = await SELF.fetch("https://release.example.invalid/oauth/jwks.json"); + const jwks = await SELF.fetch("https://release.example.com/oauth/jwks.json"); const text = await jwks.text(); expect(JSON.parse(text).keys).toHaveLength(2); expect(text).not.toContain('"d"'); @@ -42,14 +63,11 @@ describe("release-service Worker", () => { it("fails configuration closed without exposing binding names", async () => { const bindings = { + ...TEST_BINDINGS, PUBLIC_ORIGIN: "", - DEPLOYMENT_ID: "test-release-service", OAUTH_REDIRECT_URIS: "[]", - OAUTH_ASSERTION_KEYSET: TEST_ASSERTION_KEYSET, - ENCRYPTION_KEYRING: - '{"current":1,"keys":[{"version":1,"key":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"}]}', } satisfies ConfigurationBindings; - const response = await handleRequest(new Request("https://test/health"), bindings); + const response = await handleRequest(new Request("https://test/ready"), bindings); expect(response.status).toBe(503); const body = await response.text(); expect(body).toContain("CONFIGURATION_ERROR"); @@ -58,9 +76,9 @@ describe("release-service Worker", () => { it("returns method and route errors without exposing internal failures", async () => { expect( - (await SELF.fetch("https://release.example.invalid/health", { method: "POST" })).status, + (await SELF.fetch("https://release.example.com/health", { method: "POST" })).status, ).toBe(405); - expect((await SELF.fetch("https://release.example.invalid/missing")).status).toBe(404); + expect((await SELF.fetch("https://release.example.com/v1/missing")).status).toBe(404); const internalMessage = "assertion private key leaked"; const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -74,15 +92,8 @@ describe("release-service Worker", () => { }; try { const response = await handleRequest( - new Request("https://release.example.invalid/__test/failure"), - { - PUBLIC_ORIGIN: "https://release.example.invalid", - DEPLOYMENT_ID: "test-release-service", - OAUTH_REDIRECT_URIS: '["https://release.example.invalid/oauth/callback"]', - OAUTH_ASSERTION_KEYSET: TEST_ASSERTION_KEYSET, - ENCRYPTION_KEYRING: - '{"current":1,"keys":[{"version":1,"key":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"}]}', - }, + new Request("https://release.example.com/__test/failure"), + TEST_BINDINGS, [route], ); expect(response.status).toBe(500); @@ -95,7 +106,7 @@ describe("release-service Worker", () => { it("registers OAuth mutation routes behind their origin and session checks", async () => { const identity = await SELF.fetch( - "https://release.example.invalid/v1/publisher/session/authorize", + "https://release.example.com/v1/publisher/session/authorize", { method: "POST", headers: { "content-type": "application/json" }, @@ -104,8 +115,7 @@ describe("release-service Worker", () => { ); expect(identity.status).toBe(403); expect( - (await SELF.fetch("https://release.example.invalid/v1/publisher/delegation/authorize")) - .status, + (await SELF.fetch("https://release.example.com/v1/publisher/delegation/authorize")).status, ).toBe(405); }); }); diff --git a/apps/release-service/test/workload-policy-evaluation.test.ts b/apps/release-service/test/workload-policy-evaluation.test.ts index 512c8e3a4a..41bd96bb34 100644 --- a/apps/release-service/test/workload-policy-evaluation.test.ts +++ b/apps/release-service/test/workload-policy-evaluation.test.ts @@ -147,6 +147,14 @@ describe("workload policy evaluation", () => { "gallery", "1.2.3", ), + ).toBe(idempotencyDigest); + expect( + await digestWorkloadIdempotencyIdentity( + { ...identity, run: { ...identity.run, id: "101" } }, + "did:plc:publisher", + "gallery", + "1.2.3", + ), ).not.toBe(idempotencyDigest); }); }); diff --git a/apps/release-service/tsconfig.json b/apps/release-service/tsconfig.json index 7dcabbcc62..7c026bcb1f 100644 --- a/apps/release-service/tsconfig.json +++ b/apps/release-service/tsconfig.json @@ -5,5 +5,6 @@ "verbatimModuleSyntax": true, "noEmit": true }, - "include": ["src/**/*", "test/**/*", "worker-configuration.d.ts"] + "include": ["src/**/*", "test/**/*", "worker-configuration.d.ts"], + "exclude": ["src/ui/**/*"] } diff --git a/apps/release-service/tsconfig.ui.json b/apps/release-service/tsconfig.ui.json new file mode 100644 index 0000000000..18eb048da5 --- /dev/null +++ b/apps/release-service/tsconfig.ui.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["vite/client", "node", "react", "react-dom"], + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src/ui/**/*"] +} diff --git a/apps/release-service/vite.config.ts b/apps/release-service/vite.config.ts index f6268f8e95..aaed97345c 100644 --- a/apps/release-service/vite.config.ts +++ b/apps/release-service/vite.config.ts @@ -1,6 +1,8 @@ import { cloudflare } from "@cloudflare/vite-plugin"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig({ - plugins: [cloudflare()], + plugins: [react(), tailwindcss(), cloudflare()], }); diff --git a/apps/release-service/vitest.config.ts b/apps/release-service/vitest.config.ts index 6c6a3463da..7b4baf4d0b 100644 --- a/apps/release-service/vitest.config.ts +++ b/apps/release-service/vitest.config.ts @@ -1,13 +1,14 @@ import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; -import { defineConfig } from "vitest/config"; +import { configDefaults, defineConfig } from "vitest/config"; -import { TEST_ASSERTION_KEYSET } from "./test/fixtures/oauth.js"; +import { TEST_ACCESS_AUDIENCES, TEST_ASSERTION_KEYSET } from "./test/fixtures/oauth.js"; process.env["OAUTH_ASSERTION_KEYSET"] ??= TEST_ASSERTION_KEYSET; process.env["ENCRYPTION_KEYRING"] ??= '{"current":1,"keys":[{"version":1,"key":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"}]}'; export default defineConfig({ + test: { exclude: [...configDefaults.exclude, "src/ui/**/*.test.{ts,tsx}"] }, plugins: [ cloudflareTest({ wrangler: { configPath: "./wrangler.jsonc" }, @@ -52,9 +53,13 @@ export default defineConfig({ }, ], bindings: { - PUBLIC_ORIGIN: "https://release.example.invalid", + PUBLIC_ORIGIN: "https://release.example.com", DEPLOYMENT_ID: "test-release-service", - OAUTH_REDIRECT_URIS: '["https://release.example.invalid/oauth/callback"]', + ACCESS_TEAM_DOMAIN: "https://emdash-test.cloudflareaccess.com", + ACCESS_VIEWER_AUD: TEST_ACCESS_AUDIENCES.viewer, + ACCESS_REVIEWER_AUD: TEST_ACCESS_AUDIENCES.reviewer, + ACCESS_ADMIN_AUD: TEST_ACCESS_AUDIENCES.admin, + OAUTH_REDIRECT_URIS: '["https://release.example.com/oauth/callback"]', OAUTH_ASSERTION_KEYSET: TEST_ASSERTION_KEYSET, ENCRYPTION_KEYRING: '{"current":1,"keys":[{"version":1,"key":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"}]}', diff --git a/apps/release-service/vitest.ui.config.ts b/apps/release-service/vitest.ui.config.ts new file mode 100644 index 0000000000..102a23366f --- /dev/null +++ b/apps/release-service/vitest.ui.config.ts @@ -0,0 +1,12 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + environmentOptions: { jsdom: { url: "https://release.example.com" } }, + include: ["src/ui/**/*.test.{ts,tsx}"], + setupFiles: ["./src/ui/test-setup.ts"], + }, +}); diff --git a/apps/release-service/worker-configuration.d.ts b/apps/release-service/worker-configuration.d.ts index 9d7c55111d..9240b99ecd 100644 --- a/apps/release-service/worker-configuration.d.ts +++ b/apps/release-service/worker-configuration.d.ts @@ -1,13 +1,21 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 62c6d34a0585fbc4cf59c500f8ad457c) +// Generated by Wrangler by running `wrangler types` (hash: f7554f803601bb371e79597019eba45d) // Runtime types generated with workerd@1.20260815.1 2026-05-14 nodejs_compat interface __BaseEnv_Env { + PUBLICATION_STAGING: R2Bucket; + ASSETS: Fetcher; PUBLIC_ORIGIN: ""; DEPLOYMENT_ID: ""; + ACCESS_TEAM_DOMAIN: ""; + ACCESS_VIEWER_AUD: ""; + ACCESS_REVIEWER_AUD: ""; + ACCESS_ADMIN_AUD: ""; OAUTH_REDIRECT_URIS: "[]"; OAUTH_ASSERTION_KEYSET: string; ENCRYPTION_KEYRING: string; APPROVER_DO: DurableObjectNamespace; + OAUTH_STATE_DO: DurableObjectNamespace; + SERVICE_CONTROL_DO: DurableObjectNamespace; PUBLISHER_DO: DurableObjectNamespace; RELEASE_VERIFIER: Fetcher /* emdash-release-verifier */; RELEASE_INTENT_WORKFLOW: Workflow[0]['payload']>; @@ -15,7 +23,7 @@ interface __BaseEnv_Env { declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/index"); - durableNamespaces: "ApproverDurableObject" | "PublisherDurableObject"; + durableNamespaces: "ApproverDurableObject" | "OAuthStateDurableObject" | "PublisherDurableObject" | "ServiceControlDurableObject"; } interface Env extends __BaseEnv_Env {} } @@ -24,7 +32,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/apps/release-service/wrangler.jsonc b/apps/release-service/wrangler.jsonc index 5a0ccfbddc..ac1ac703a8 100644 --- a/apps/release-service/wrangler.jsonc +++ b/apps/release-service/wrangler.jsonc @@ -12,6 +12,14 @@ "name": "APPROVER_DO", "class_name": "ApproverDurableObject", }, + { + "name": "OAUTH_STATE_DO", + "class_name": "OAuthStateDurableObject", + }, + { + "name": "SERVICE_CONTROL_DO", + "class_name": "ServiceControlDurableObject", + }, { "name": "PUBLISHER_DO", "class_name": "PublisherDurableObject", @@ -21,7 +29,12 @@ "migrations": [ { "tag": "v1", - "new_sqlite_classes": ["ApproverDurableObject", "PublisherDurableObject"], + "new_sqlite_classes": [ + "ApproverDurableObject", + "OAuthStateDurableObject", + "PublisherDurableObject", + "ServiceControlDurableObject", + ], }, ], "services": [ @@ -30,6 +43,12 @@ "service": "emdash-release-verifier", }, ], + "r2_buckets": [ + { + "binding": "PUBLICATION_STAGING", + "bucket_name": "emdash-release-service-publication-staging", + }, + ], "workflows": [ { "binding": "RELEASE_INTENT_WORKFLOW", @@ -37,9 +56,31 @@ "class_name": "ReleaseIntentWorkflow", }, ], + "assets": { + "directory": "./dist/client", + "binding": "ASSETS", + "not_found_handling": "single-page-application", + "html_handling": "none", + "run_worker_first": [ + "/", + "/.well-known/*", + "/admin", + "/admin/*", + "/approvals/*", + "/health", + "/oauth/*", + "/publisher*", + "/ready", + "/v1/*", + ], + }, "vars": { "PUBLIC_ORIGIN": "", "DEPLOYMENT_ID": "", + "ACCESS_TEAM_DOMAIN": "", + "ACCESS_VIEWER_AUD": "", + "ACCESS_REVIEWER_AUD": "", + "ACCESS_ADMIN_AUD": "", "OAUTH_REDIRECT_URIS": "[]", }, "secrets": { diff --git a/packages/plugin-cli/README.md b/packages/plugin-cli/README.md index 6a1cf0ed54..797c02df0d 100644 --- a/packages/plugin-cli/README.md +++ b/packages/plugin-cli/README.md @@ -25,6 +25,9 @@ emdash-plugin build Build dist/ artifacts (plugin.mjs, emdash-plugin dev Watch sources and rebuild on change emdash-plugin bundle Pack dist/ + assets into a registry tarball emdash-plugin publish Build, upload, and publish a release +emdash-plugin release submit Submit a delegated release with GitHub OIDC +emdash-plugin release status Read a delegated release intent +emdash-plugin release cancel Cancel an unpublished delegated release intent emdash-plugin validate [path] Validate emdash-plugin.jsonc against the v1 schema emdash-plugin login Interactive atproto OAuth login emdash-plugin logout [--did ] Revoke the active session @@ -34,7 +37,7 @@ emdash-plugin search Free-text search emdash-plugin info Show package details ``` -The non-interactive output commands (`whoami`, `validate`, `search`, `info`, `login`, `publish`) accept `--json` for machine-readable output. Discovery commands (`search`, `info`) accept `--registry-url ` (or `EMDASH_REGISTRY_URL`). +The non-interactive output commands (`whoami`, `validate`, `search`, `info`, `login`, `publish`, `release submit`, `release status`, `release cancel`) accept `--json` for machine-readable output. Discovery commands (`search`, `info`) accept `--registry-url ` (or `EMDASH_REGISTRY_URL`). ## Development @@ -85,6 +88,29 @@ Pass `--url https://example.com/foo-1.0.0.tar.gz` to use an externally hosted bu On first publish, pass `--license` and `--security-email` (or `--security-url`) to bootstrap the package profile — or keep them in `emdash-plugin.jsonc` (see below). +## Delegated releases + +The `release` commands authenticate with the current GitHub Actions OpenID Connect (OIDC) identity. Grant the job `id-token: write`; the CLI requests a token whose audience is the release-service origin for every API call. + +The following command submits a generated package release record and waits for publication or an approval request: + +```sh +emdash-plugin release submit release.json \ + --service-url https://release.example.com \ + --publisher-did did:web:publisher.example.com +``` + +Set `EMDASH_RELEASE_SERVICE_URL` and `EMDASH_PUBLISHER_DID` to omit the two target flags. The default idempotency key uses the GitHub run ID, so a re-run reuses the existing intent. Pass `--idempotency-key` when separate runs or jobs must replay the same submission. + +Use `--no-wait` to return after the service accepts the intent. The status and cancellation commands require the same publisher and GitHub workload identity: + +```sh +emdash-plugin release status 01JABCDEFGHJKMNPQRSTVWXYZ0 +emdash-plugin release cancel 01JABCDEFGHJKMNPQRSTVWXYZ0 +``` + +These commands fail outside GitHub Actions because no OIDC request endpoint is available. Use the delegated release Action when the workflow only needs submission and outputs. + ## `emdash-plugin.jsonc` Drop an `emdash-plugin.jsonc` file next to your plugin's `package.json`. The CLI reads it automatically from the current directory. Schema-driven IDE completion works via the bundled JSON Schema: diff --git a/packages/plugin-cli/src/commands/release.ts b/packages/plugin-cli/src/commands/release.ts new file mode 100644 index 0000000000..09b9a7e648 --- /dev/null +++ b/packages/plugin-cli/src/commands/release.ts @@ -0,0 +1,186 @@ +import type { ReleaseIntentResource } from "@emdash-cms/registry-client/release-service"; +import { defineCommand } from "citty"; +import { consola } from "consola"; +import pc from "picocolors"; + +import { + cancelDelegatedReleaseIntent, + getDelegatedReleaseIntent, + submitDelegatedRelease, +} from "../release-service/operations.js"; + +const POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/; +const FAILURE_STATES = new Set([ + "invalid", + "rejected", + "cancelled", + "expired", + "failed", + "conflict", +]); + +function requiredTarget(args: { "publisher-did"?: string; "service-url"?: string }) { + const serviceUrl = args["service-url"] || process.env["EMDASH_RELEASE_SERVICE_URL"]; + const publisherDid = args["publisher-did"] || process.env["EMDASH_PUBLISHER_DID"]; + if (!serviceUrl) throw new Error("Release service URL is required"); + if (!publisherDid) throw new Error("Publisher DID is required"); + return { serviceUrl, publisherDid }; +} + +function positiveInteger(value: string, name: string, maximum: number): number { + if (!POSITIVE_INTEGER_PATTERN.test(value)) throw new Error(`${name} must be a positive integer`); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed > maximum) { + throw new Error(`${name} is outside the supported range`); + } + return parsed; +} + +function printIntent(intent: ReleaseIntentResource, json: boolean): void { + if (json) { + console.log(JSON.stringify(intent, null, 2)); + return; + } + console.log(`${pc.bold(intent.packageSlug)} ${pc.dim(intent.version)}`); + console.log(` Intent: ${intent.id}`); + console.log(` State: ${intent.state}`); + if (intent.approvalUrl) console.log(` Approve: ${intent.approvalUrl}`); + if (intent.result) { + console.log(` URI: ${intent.result.uri}`); + console.log(` CID: ${intent.result.cid}`); + } + if (intent.reasonCode) console.log(` Reason: ${intent.reasonCode}`); +} + +const commonArgs = { + "service-url": { + type: "string" as const, + description: "Release service origin (or EMDASH_RELEASE_SERVICE_URL)", + }, + "publisher-did": { + type: "string" as const, + description: "Publisher DID (or EMDASH_PUBLISHER_DID)", + }, + json: { + type: "boolean" as const, + description: "Output the intent as JSON", + }, +}; + +export const releaseSubmitCommand = defineCommand({ + meta: { + name: "submit", + description: "Submit a delegated release from GitHub Actions OIDC", + }, + args: { + "release-file": { + type: "positional", + description: "Package release record JSON file", + required: true, + }, + ...commonArgs, + "idempotency-key": { + type: "string", + description: "Stable submission key (defaults to the GitHub run identity)", + }, + "no-wait": { + type: "boolean", + description: "Return after the service accepts the intent", + }, + "wait-for-approval": { + type: "boolean", + description: "Keep polling while the intent awaits approval", + default: false, + }, + "poll-interval-seconds": { + type: "string", + description: "Seconds between status requests", + default: "5", + }, + "timeout-minutes": { + type: "string", + description: "Maximum polling time", + default: "30", + }, + }, + async run({ args }) { + const target = requiredTarget(args); + let previousState: string | null = null; + const intent = await submitDelegatedRelease({ + ...target, + releaseFile: args["release-file"], + idempotencyKey: args["idempotency-key"], + wait: !args["no-wait"], + waitForApproval: args["wait-for-approval"], + pollIntervalMs: + positiveInteger(args["poll-interval-seconds"], "poll-interval-seconds", 300) * 1000, + maxWaitMs: positiveInteger(args["timeout-minutes"], "timeout-minutes", 360) * 60_000, + onUpdate: args.json + ? undefined + : (current) => { + if (current.state !== previousState) { + previousState = current.state; + consola.info(`Release intent ${current.id} entered ${current.state}`); + } + }, + }); + printIntent(intent, args.json ?? false); + if (FAILURE_STATES.has(intent.state)) { + throw new Error( + `Release intent ended in ${intent.state}${intent.reasonCode ? ` (${intent.reasonCode})` : ""}`, + ); + } + }, +}); + +export const releaseStatusCommand = defineCommand({ + meta: { name: "status", description: "Read a delegated release intent" }, + args: { + "intent-id": { + type: "positional", + description: "Release intent ULID", + required: true, + }, + ...commonArgs, + }, + async run({ args }) { + const intent = await getDelegatedReleaseIntent({ + ...requiredTarget(args), + intentId: args["intent-id"], + }); + printIntent(intent, args.json ?? false); + }, +}); + +export const releaseCancelCommand = defineCommand({ + meta: { name: "cancel", description: "Cancel an unpublished delegated release intent" }, + args: { + "intent-id": { + type: "positional", + description: "Release intent ULID", + required: true, + }, + ...commonArgs, + "idempotency-key": { + type: "string", + description: "Stable cancellation key (defaults to the GitHub run identity)", + }, + }, + async run({ args }) { + const intent = await cancelDelegatedReleaseIntent({ + ...requiredTarget(args), + intentId: args["intent-id"], + idempotencyKey: args["idempotency-key"], + }); + printIntent(intent, args.json ?? false); + }, +}); + +export const releaseCommand = defineCommand({ + meta: { name: "release", description: "Manage delegated release intents" }, + subCommands: { + submit: releaseSubmitCommand, + status: releaseStatusCommand, + cancel: releaseCancelCommand, + }, +}); diff --git a/packages/plugin-cli/src/index.ts b/packages/plugin-cli/src/index.ts index 010b2ae9b5..5b46aa52d8 100644 --- a/packages/plugin-cli/src/index.ts +++ b/packages/plugin-cli/src/index.ts @@ -33,6 +33,7 @@ import { loginCommand } from "./commands/login.js"; import { logoutCommand } from "./commands/logout.js"; import { pdsConformanceCommand } from "./commands/pds-conformance.js"; import { publishCommand } from "./commands/publish.js"; +import { releaseCommand } from "./commands/release.js"; import { searchCommand } from "./commands/search.js"; import { switchCommand } from "./commands/switch.js"; import { updatePackageCommand } from "./commands/update-package.js"; @@ -58,6 +59,7 @@ const main = defineCommand({ dev: devCommand, bundle: bundleCommand, publish: publishCommand, + release: releaseCommand, "update-package": updatePackageCommand, validate: validateCommand, }, diff --git a/packages/plugin-cli/src/release-service/operations.ts b/packages/plugin-cli/src/release-service/operations.ts new file mode 100644 index 0000000000..f677f3baf5 --- /dev/null +++ b/packages/plugin-cli/src/release-service/operations.ts @@ -0,0 +1,171 @@ +import { readFile, stat } from "node:fs/promises"; + +import { + ReleaseServiceClient, + createReleaseIdempotencyKey, + parseDelegatedReleaseSourceRecord, + type ReleaseIntentResource, +} from "@emdash-cms/registry-client/release-service"; + +const POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/; +const MAX_RELEASE_FILE_BYTES = 128 * 1024; +const MAX_OIDC_TOKEN_CHARS = 16 * 1024; + +export interface ReleaseServiceEnvironment { + readonly [key: string]: string | undefined; +} + +export interface ReleaseServiceOperationDependencies { + fetch?: typeof fetch; + environment?: ReleaseServiceEnvironment; + readReleaseRecord?: (path: string) => Promise; +} + +export interface ReleaseServiceTarget { + serviceUrl: string; + publisherDid: string; +} + +export interface SubmitDelegatedReleaseOptions extends ReleaseServiceTarget { + releaseFile: string; + idempotencyKey?: string; + wait?: boolean; + waitForApproval?: boolean; + pollIntervalMs?: number; + maxWaitMs?: number; + onUpdate?: (intent: ReleaseIntentResource) => void | Promise; +} + +export interface MutateReleaseIntentOptions extends ReleaseServiceTarget { + intentId: string; + idempotencyKey?: string; +} + +async function defaultReadReleaseRecord(path: string): Promise { + try { + const metadata = await stat(path); + if (!metadata.isFile() || metadata.size > MAX_RELEASE_FILE_BYTES) { + throw new Error("invalid release file"); + } + return JSON.parse(await readFile(path, "utf8")); + } catch { + throw new Error("Release record file could not be read"); + } +} + +function defaultIdempotencyKey(environment: ReleaseServiceEnvironment): string { + const runId = environment["GITHUB_RUN_ID"]; + if (runId && POSITIVE_INTEGER_PATTERN.test(runId)) { + return `github-run-${runId}`; + } + return createReleaseIdempotencyKey("emdash-plugin-release"); +} + +export async function requestGithubOidcToken( + audience: string, + dependencies: ReleaseServiceOperationDependencies = {}, +): Promise { + const environment = dependencies.environment ?? process.env; + const requestUrl = environment["ACTIONS_ID_TOKEN_REQUEST_URL"]; + const requestToken = environment["ACTIONS_ID_TOKEN_REQUEST_TOKEN"]; + if (!requestUrl || !requestToken) { + throw new Error("GitHub Actions OIDC is unavailable"); + } + let url: URL; + try { + url = new URL(requestUrl); + if (url.protocol !== "https:" || url.username !== "" || url.password !== "") { + throw new Error("invalid OIDC URL"); + } + url.searchParams.set("audience", audience); + } catch { + throw new Error("GitHub Actions OIDC is unavailable"); + } + const response = await (dependencies.fetch ?? globalThis.fetch)(url, { + headers: { authorization: `Bearer ${requestToken}` }, + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) throw new Error("GitHub Actions OIDC request failed"); + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new Error("GitHub Actions OIDC response is invalid"); + } + if ( + payload === null || + typeof payload !== "object" || + Array.isArray(payload) || + !("value" in payload) || + typeof payload.value !== "string" || + payload.value.length === 0 || + payload.value.length > MAX_OIDC_TOKEN_CHARS + ) { + throw new Error("GitHub Actions OIDC response is invalid"); + } + return payload.value; +} + +function releaseClient( + target: ReleaseServiceTarget, + dependencies: ReleaseServiceOperationDependencies, +): ReleaseServiceClient { + return new ReleaseServiceClient({ + serviceUrl: target.serviceUrl, + fetch: dependencies.fetch, + workloadToken: () => requestGithubOidcToken(target.serviceUrl, dependencies), + }); +} + +export async function submitDelegatedRelease( + options: SubmitDelegatedReleaseOptions, + dependencies: ReleaseServiceOperationDependencies = {}, +): Promise { + const rawRelease = await (dependencies.readReleaseRecord ?? defaultReadReleaseRecord)( + options.releaseFile, + ); + const release = parseDelegatedReleaseSourceRecord(rawRelease); + if (!release) throw new Error("Release record file is invalid"); + const environment = dependencies.environment ?? process.env; + const client = releaseClient(options, dependencies); + const submitted = await client.submitIntent( + { + publisherDid: options.publisherDid, + packageSlug: release.package, + version: release.version, + release, + }, + { idempotencyKey: options.idempotencyKey ?? defaultIdempotencyKey(environment) }, + ); + if (options.wait === false) return submitted.intent; + return await client.waitForIntent(options.publisherDid, submitted.intent.id, { + pollIntervalMs: options.pollIntervalMs, + maxWaitMs: options.maxWaitMs, + stopOnApproval: !(options.waitForApproval ?? false), + onUpdate: options.onUpdate, + }); +} + +export async function getDelegatedReleaseIntent( + options: ReleaseServiceTarget & { intentId: string }, + dependencies: ReleaseServiceOperationDependencies = {}, +): Promise { + return await releaseClient(options, dependencies).getIntent( + options.publisherDid, + options.intentId, + ); +} + +export async function cancelDelegatedReleaseIntent( + options: MutateReleaseIntentOptions, + dependencies: ReleaseServiceOperationDependencies = {}, +): Promise { + const environment = dependencies.environment ?? process.env; + return await releaseClient(options, dependencies).cancelIntent( + options.publisherDid, + options.intentId, + { + idempotencyKey: options.idempotencyKey ?? defaultIdempotencyKey(environment), + }, + ); +} diff --git a/packages/plugin-cli/tests/release-service-operations.test.ts b/packages/plugin-cli/tests/release-service-operations.test.ts new file mode 100644 index 0000000000..269d5e2e13 --- /dev/null +++ b/packages/plugin-cli/tests/release-service-operations.test.ts @@ -0,0 +1,196 @@ +import { NSID, type PackageRelease } from "@emdash-cms/registry-lexicons"; +import { describe, expect, it } from "vitest"; + +import releaseFixture from "../../registry-verification/fixtures/records/release.json"; +import { + cancelDelegatedReleaseIntent, + getDelegatedReleaseIntent, + requestGithubOidcToken, + submitDelegatedRelease, +} from "../src/release-service/operations.js"; + +const SERVICE = "https://release.example.com"; +const PUBLISHER_DID = "did:web:publisher.example.com"; +const INTENT_ID = "01JABCDEFGHJKMNPQRSTVWXYZ0"; +const CHECKSUM = "bciqcz4snxjp3biyoe3udwkwfxhrj4gywdzob7j2clzzqim3csofzqja"; +const ENVIRONMENT = { + ACTIONS_ID_TOKEN_REQUEST_URL: "https://token.actions.example/id-token?api-version=1", + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "runner-request-token", + GITHUB_RUN_ID: "10000000001", + GITHUB_RUN_ATTEMPT: "2", +}; + +function sourceRelease(): PackageRelease.Main { + const release = structuredClone(releaseFixture) as PackageRelease.Main; + release.artifacts.package.checksum = CHECKSUM; + release.extensions = { + [NSID.packageReleaseExtension]: { + $type: NSID.packageReleaseExtension, + declaredAccess: {}, + provenance: { + url: "https://example.com/provenance.json", + checksum: CHECKSUM, + predicateType: "https://slsa.dev/provenance/v1", + sourceRepository: "https://github.com/example/gallery", + builderId: + "https://github.com/example/gallery/.github/workflows/release.yml@refs/heads/main", + }, + }, + }; + return release; +} + +function intent(state: string) { + return { + id: INTENT_ID, + publisherDid: PUBLISHER_DID, + packageSlug: "gallery", + version: "1.2.3", + state, + stateGeneration: 2, + reasonCode: null, + workflowId: INTENT_ID, + expiresAt: 1_800_000_000_000, + createdAt: 1_799_999_000_000, + updatedAt: 1_799_999_500_000, + result: null, + approvalUrl: null, + }; +} + +function success(data: unknown, status = 200): Response { + return Response.json({ data, requestId: "request-1" }, { status }); +} + +describe("delegated release CLI operations", () => { + it("requests a GitHub OIDC token for the release-service audience", async () => { + const calls: Array<{ headers: Headers; url: URL }> = []; + const token = await requestGithubOidcToken(SERVICE, { + environment: ENVIRONMENT, + fetch: async (input, init) => { + calls.push({ + url: new URL(input instanceof Request ? input.url : input.toString()), + headers: new Headers(init?.headers), + }); + return Response.json({ value: "header.payload.signature" }); + }, + }); + + expect(token).toBe("header.payload.signature"); + expect(calls[0]?.url.searchParams.get("audience")).toBe(SERVICE); + expect(calls[0]?.headers.get("authorization")).toBe("Bearer runner-request-token"); + }); + + it("submits with the stable GitHub run idempotency identity", async () => { + const serviceRequests: Request[] = []; + const result = await submitDelegatedRelease( + { + serviceUrl: SERVICE, + publisherDid: PUBLISHER_DID, + releaseFile: "release.json", + wait: false, + }, + { + environment: ENVIRONMENT, + readReleaseRecord: async () => sourceRelease(), + fetch: async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + if (url.hostname === "token.actions.example") { + return Response.json({ value: "header.payload.signature" }); + } + serviceRequests.push(new Request(url, init)); + return success({ intent: intent("received"), replayed: false }, 202); + }, + }, + ); + + expect(result.state).toBe("received"); + expect(serviceRequests).toHaveLength(1); + expect(serviceRequests[0]?.headers.get("idempotency-key")).toBe("github-run-10000000001"); + expect(serviceRequests[0]?.headers.get("authorization")).toBe( + "Bearer header.payload.signature", + ); + }); + + it("uses fresh OIDC tokens for status and cancellation", async () => { + let tokenCount = 0; + const serviceRequests: Request[] = []; + const fetch: typeof globalThis.fetch = async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + if (url.hostname === "token.actions.example") { + return Response.json({ value: `header.payload.signature-${++tokenCount}` }); + } + serviceRequests.push(new Request(url, init)); + return success({ intent: intent(url.pathname.endsWith("/cancel") ? "cancelled" : "ready") }); + }; + const dependencies = { environment: ENVIRONMENT, fetch }; + + await getDelegatedReleaseIntent( + { serviceUrl: SERVICE, publisherDid: PUBLISHER_DID, intentId: INTENT_ID }, + dependencies, + ); + await cancelDelegatedReleaseIntent( + { serviceUrl: SERVICE, publisherDid: PUBLISHER_DID, intentId: INTENT_ID }, + dependencies, + ); + + expect(tokenCount).toBe(2); + expect(serviceRequests.map((request) => request.headers.get("authorization"))).toEqual([ + "Bearer header.payload.signature-1", + "Bearer header.payload.signature-2", + ]); + }); + + it("rejects an invalid release record before requesting OIDC", async () => { + let fetched = false; + await expect( + submitDelegatedRelease( + { + serviceUrl: SERVICE, + publisherDid: PUBLISHER_DID, + releaseFile: "release.json", + }, + { + environment: ENVIRONMENT, + readReleaseRecord: async () => ({ package: "gallery" }), + fetch: async () => { + fetched = true; + throw new Error("unexpected fetch"); + }, + }, + ), + ).rejects.toThrow("Release record file is invalid"); + expect(fetched).toBe(false); + }); + + it("rejects a blob-bearing source record before requesting OIDC", async () => { + const release = sourceRelease(); + Object.assign(release.artifacts.package, { + blob: { + $type: "blob", + ref: { $link: "bafkreicoew2cifs6fwqhqpkvkezdokuvpquj6p7aosznuf7jhxkehsltpe" }, + mimeType: "application/gzip", + size: 128, + }, + }); + let fetched = false; + await expect( + submitDelegatedRelease( + { + serviceUrl: SERVICE, + publisherDid: PUBLISHER_DID, + releaseFile: "release.json", + }, + { + environment: ENVIRONMENT, + readReleaseRecord: async () => release, + fetch: async () => { + fetched = true; + throw new Error("unexpected fetch"); + }, + }, + ), + ).rejects.toThrow("Release record file is invalid"); + expect(fetched).toBe(false); + }); +}); diff --git a/packages/registry-client/package.json b/packages/registry-client/package.json index 674dd87d9e..2469c72b24 100644 --- a/packages/registry-client/package.json +++ b/packages/registry-client/package.json @@ -17,6 +17,10 @@ "types": "./dist/publishing/index.d.ts", "default": "./dist/publishing/index.js" }, + "./release-service": { + "types": "./dist/release-service/index.d.ts", + "default": "./dist/release-service/index.js" + }, "./internal/conformance": { "types": "./dist/conformance/index.d.ts", "default": "./dist/conformance/index.js" diff --git a/packages/registry-client/src/index.ts b/packages/registry-client/src/index.ts index 4dcfbbdddf..1832cc5d14 100644 --- a/packages/registry-client/src/index.ts +++ b/packages/registry-client/src/index.ts @@ -75,6 +75,35 @@ export { DirectPdsReadError, } from "./direct-pds/index.js"; +export { + type CursorPage, + type DelegationResource, + type MutationOptions as ReleaseServiceMutationOptions, + type MutationResult, + type OperatorClientOptions, + type OperatorPublisherResource, + type PublisherControlResource, + type PublisherResource, + type PutWorkloadPolicyInput, + type ReleaseIntentResource, + type ReleaseIntentResult, + type ReleaseIntentState, + type ReleaseServiceApiErrorCode, + type ReleaseServiceClientErrorCode, + type ReleaseServiceClientOptions, + type RequestOptions as ReleaseServiceRequestOptions, + type ServiceControlState, + type SubmitReleaseIntentInput, + type SubmitReleaseIntentResult, + type WaitForIntentOptions, + type WorkloadPolicyResource, + ReleaseServiceClient, + ReleaseServiceError, + ReleaseServiceOperatorClient, + TERMINAL_RELEASE_INTENT_STATES, + createReleaseIdempotencyKey, +} from "./release-service/index.js"; + export { type EnvMismatch, type HostEnv, diff --git a/packages/registry-client/src/release-service/index.ts b/packages/registry-client/src/release-service/index.ts new file mode 100644 index 0000000000..07c6374f00 --- /dev/null +++ b/packages/registry-client/src/release-service/index.ts @@ -0,0 +1,980 @@ +import type { PackageRelease } from "@emdash-cms/registry-lexicons"; + +import { parseDelegatedReleaseSourceRecord } from "./source-record.js"; +import { + TERMINAL_RELEASE_INTENT_STATES, + type CursorPage, + type DelegationResource, + type MutationResult, + type OperatorPublisherResource, + type PublisherControlResource, + type PublisherResource, + type PutWorkloadPolicyInput, + type ReleaseIntentResource, + type ReleaseIntentResult, + type ReleaseIntentState, + type ReleaseServiceApiErrorCode, + type ReleaseServiceClientErrorCode, + type ServiceControlState, + type SubmitReleaseIntentInput, + type SubmitReleaseIntentResult, + type WorkloadPolicyResource, +} from "./types.js"; + +export type { + DelegatedReleaseSourceArtifact, + DelegatedReleaseSourceArtifacts, + DelegatedReleaseSourceEnvelope, + DelegatedReleaseSourceExtension, + DelegatedReleaseSourceImageArtifact, + DelegatedReleaseSourceRecord, +} from "./source-record.js"; +export { parseDelegatedReleaseSourceRecord } from "./source-record.js"; + +export type { + CursorPage, + DelegationResource, + MutationResult, + OperatorPublisherResource, + PublisherControlResource, + PublisherResource, + PutWorkloadPolicyInput, + ReleaseIntentResource, + ReleaseIntentResult, + ReleaseIntentState, + ReleaseServiceApiErrorCode, + ReleaseServiceClientErrorCode, + ServiceControlState, + SubmitReleaseIntentInput, + SubmitReleaseIntentResult, + WorkloadPolicyResource, +} from "./types.js"; +export { TERMINAL_RELEASE_INTENT_STATES } from "./types.js"; + +const DID_PATTERN = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/; +const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/; +const PACKAGE_SLUG_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; +const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z.-]{0,127}$/; +const CID_PATTERN = /^[A-Za-z0-9]+$/; +const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$/; +const IDEMPOTENCY_PREFIX_PATTERN = /[^A-Za-z0-9._:-]/g; +const DIGITS_PATTERN = /^[0-9]+$/; +const CSRF_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const API_ERROR_CODES: Readonly> = { + ACCESS_DENIED: true, + ACCESS_AUTH_INVALID: true, + ACCESS_AUTH_REQUIRED: true, + APPROVAL_INVALID: true, + APPROVER_SESSION_INVALID: true, + APPROVER_SUSPENDED: true, + AUTH_INVALID: true, + CONFIGURATION_ERROR: true, + CREDENTIAL_LIMIT_REACHED: true, + CREDENTIAL_NOT_FOUND: true, + CREDENTIAL_REVOKED: true, + CSRF_INVALID: true, + DELEGATION_REQUIRED: true, + IDEMPOTENCY_KEY_INVALID: true, + IDEMPOTENCY_CONFLICT: true, + INTERNAL_ERROR: true, + INVALID_REQUEST: true, + INTENT_NOT_APPROVABLE: true, + INTENT_NOT_CANCELLABLE: true, + METHOD_NOT_ALLOWED: true, + NOT_FOUND: true, + OAUTH_AUTHORIZATION_FAILED: true, + OAUTH_CALLBACK_INVALID: true, + PROFILE_CHANGED: true, + PROFILE_FETCH_FAILED: true, + PUBLISHER_SESSION_INVALID: true, + PUBLISHER_SUSPENDED: true, + RELEASE_EXISTS: true, + SERVICE_PAUSED: true, + SERVICE_UNAVAILABLE: true, + VERSION_RESERVED: true, + WORKFLOW_UNAVAILABLE: true, + WORKLOAD_NOT_ALLOWED: true, +}; +const RETRYABLE_ERROR_CODES: ReadonlySet = new Set([ + "CONFIGURATION_ERROR", + "INTERNAL_ERROR", + "NETWORK_ERROR", + "PROFILE_FETCH_FAILED", + "PUBLISHER_SUSPENDED", + "SERVICE_PAUSED", + "SERVICE_UNAVAILABLE", + "WORKFLOW_UNAVAILABLE", +]); +const INTENT_STATES: Readonly> = { + received: true, + verifying: true, + verified: true, + awaiting_approval: true, + ready: true, + publishing: true, + reconciling: true, + published: true, + invalid: true, + rejected: true, + cancelled: true, + expired: true, + failed: true, + conflict: true, +}; + +type WorkloadTokenProvider = () => string | Promise; +type CsrfTokenProvider = () => string | Promise; + +export interface ReleaseServiceClientOptions { + serviceUrl: string; + fetch?: typeof fetch; + workloadToken?: string | WorkloadTokenProvider; + csrfToken?: string | CsrfTokenProvider; +} + +export interface RequestOptions { + signal?: AbortSignal; +} + +export interface MutationOptions extends RequestOptions { + idempotencyKey: string; +} + +export interface WaitForIntentOptions extends RequestOptions { + pollIntervalMs?: number; + maxWaitMs?: number; + stopOnApproval?: boolean; + onUpdate?: (intent: ReleaseIntentResource) => void | Promise; +} + +export interface OperatorClientOptions { + serviceUrl: string; + fetch?: typeof fetch; +} + +export class ReleaseServiceError extends Error { + readonly code: ReleaseServiceClientErrorCode; + readonly status: number; + readonly requestId: string | null; + readonly retryable: boolean; + readonly retryAfterMs: number | null; + + constructor(input: { + code: ReleaseServiceClientErrorCode; + message: string; + status?: number; + requestId?: string | null; + retryAfterMs?: number | null; + }) { + super(input.message); + this.name = "ReleaseServiceError"; + this.code = input.code; + this.status = input.status ?? 0; + this.requestId = input.requestId ?? null; + this.retryable = RETRYABLE_ERROR_CODES.has(input.code); + this.retryAfterMs = input.retryAfterMs ?? null; + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isIntentState(value: unknown): value is ReleaseIntentState { + return typeof value === "string" && Object.hasOwn(INTENT_STATES, value); +} + +function isApiErrorCode(value: unknown): value is ReleaseServiceApiErrorCode { + return typeof value === "string" && Object.hasOwn(API_ERROR_CODES, value); +} + +function serviceOrigin(value: string): string { + try { + const url = new URL(value); + const loopback = + url.protocol === "http:" && + (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"); + if ( + (url.protocol !== "https:" && !loopback) || + url.username !== "" || + url.password !== "" || + url.pathname !== "/" || + url.search !== "" || + url.hash !== "" || + url.origin !== value + ) { + throw new Error("invalid origin"); + } + return url.origin; + } catch { + throw new ReleaseServiceError({ + code: "CLIENT_RESPONSE_INVALID", + message: "Release service URL must be an HTTPS origin or a loopback development origin", + }); + } +} + +function requireIdempotencyKey(value: string): string { + if (!IDEMPOTENCY_KEY_PATTERN.test(value)) { + throw new ReleaseServiceError({ + code: "IDEMPOTENCY_KEY_INVALID", + message: "Idempotency key is invalid", + }); + } + return value; +} + +export function createReleaseIdempotencyKey(prefix = "emdash"): string { + const normalized = prefix.replaceAll(IDEMPOTENCY_PREFIX_PATTERN, "-").slice(0, 64); + const value = `${normalized || "emdash"}-${crypto.randomUUID()}`; + return requireIdempotencyKey(value); +} + +function stringValue(value: Record, key: string): string | null { + const item = value[key]; + return typeof item === "string" ? item : null; +} + +function nullableString(value: Record, key: string): string | null | undefined { + const item = value[key]; + return item === null || typeof item === "string" ? item : undefined; +} + +function safeInteger(value: Record, key: string): number | null { + const item = value[key]; + return Number.isSafeInteger(item) ? Number(item) : null; +} + +function parseIntentResult(value: unknown): ReleaseIntentResult | null | undefined { + if (value === null) return null; + if (!isRecord(value)) return undefined; + const uri = stringValue(value, "uri"); + const cid = stringValue(value, "cid"); + return uri && cid ? { uri, cid } : undefined; +} + +function parseIntent(value: unknown, serviceUrl?: string): ReleaseIntentResource { + if (!isRecord(value)) throw invalidResponse(); + const id = stringValue(value, "id"); + const publisherDid = stringValue(value, "publisherDid"); + const packageSlug = stringValue(value, "packageSlug"); + const version = stringValue(value, "version"); + const state = value["state"]; + const stateGeneration = safeInteger(value, "stateGeneration"); + const reasonCode = nullableString(value, "reasonCode"); + const workflowId = nullableString(value, "workflowId"); + const expiresAt = safeInteger(value, "expiresAt"); + const createdAt = safeInteger(value, "createdAt"); + const updatedAt = safeInteger(value, "updatedAt"); + const result = parseIntentResult(value["result"]); + const approvalUrl = nullableString(value, "approvalUrl"); + if ( + !id || + !ULID_PATTERN.test(id) || + !publisherDid || + !DID_PATTERN.test(publisherDid) || + !packageSlug || + !PACKAGE_SLUG_PATTERN.test(packageSlug) || + !version || + !VERSION_PATTERN.test(version) || + !isIntentState(state) || + stateGeneration === null || + stateGeneration < 1 || + reasonCode === undefined || + workflowId === undefined || + expiresAt === null || + createdAt === null || + updatedAt === null || + result === undefined || + approvalUrl === undefined || + (workflowId !== null && !ULID_PATTERN.test(workflowId)) || + createdAt > updatedAt || + (result !== null && + (result.uri !== + `at://${publisherDid}/com.emdashcms.experimental.package.release/${packageSlug}:${version}` || + !CID_PATTERN.test(result.cid))) + ) { + throw invalidResponse(); + } + if (approvalUrl !== null && serviceUrl) { + let parsedApproval: URL; + try { + parsedApproval = new URL(approvalUrl); + } catch { + throw invalidResponse(); + } + if (parsedApproval.origin !== serviceUrl || parsedApproval.protocol !== "https:") { + throw invalidResponse(); + } + } + return { + id, + publisherDid, + packageSlug, + version, + state, + stateGeneration, + reasonCode, + workflowId, + expiresAt, + createdAt, + updatedAt, + result, + approvalUrl, + }; +} + +function parseStringArray(value: unknown): readonly string[] | null { + return Array.isArray(value) && value.every((item) => typeof item === "string") + ? [...value] + : null; +} + +function parsePolicy(value: unknown): WorkloadPolicyResource { + if (!isRecord(value)) throw invalidResponse(); + const packageSlug = stringValue(value, "packageSlug"); + const repository = stringValue(value, "repository"); + const repositoryId = stringValue(value, "repositoryId"); + const repositoryOwnerId = stringValue(value, "repositoryOwnerId"); + const workflowRef = stringValue(value, "workflowRef"); + const allowedRefs = parseStringArray(value["allowedRefs"]); + const allowedEnvironments = parseStringArray(value["allowedEnvironments"]); + const stateVersion = safeInteger(value, "stateVersion"); + const authorizedBy = stringValue(value, "authorizedBy"); + const createdAt = safeInteger(value, "createdAt"); + const updatedAt = safeInteger(value, "updatedAt"); + if ( + !packageSlug || + !repository || + !repositoryId || + !repositoryOwnerId || + !workflowRef || + !allowedRefs || + !allowedEnvironments || + typeof value["active"] !== "boolean" || + stateVersion === null || + !authorizedBy || + createdAt === null || + updatedAt === null + ) { + throw invalidResponse(); + } + return { + packageSlug, + repository, + repositoryId, + repositoryOwnerId, + workflowRef, + allowedRefs, + allowedEnvironments, + active: value["active"], + stateVersion, + authorizedBy, + createdAt, + updatedAt, + }; +} + +function parseDelegation(value: unknown): DelegationResource | null { + if (value === null) return null; + if (!isRecord(value)) throw invalidResponse(); + const releaseNsid = stringValue(value, "releaseNsid"); + const scope = stringValue(value, "scope"); + const issuer = nullableString(value, "issuer"); + const pdsUrl = nullableString(value, "pdsUrl"); + const expiresAt = value["expiresAt"]; + const refreshBefore = value["refreshBefore"]; + const status = value["status"]; + const stateVersion = safeInteger(value, "stateVersion"); + if ( + !releaseNsid || + !scope || + issuer === undefined || + pdsUrl === undefined || + (expiresAt !== null && !Number.isSafeInteger(expiresAt)) || + (refreshBefore !== null && !Number.isSafeInteger(refreshBefore)) || + (status !== "active" && status !== "revoked" && status !== "reauthorization_required") || + stateVersion === null + ) { + throw invalidResponse(); + } + return { + releaseNsid, + scope, + issuer, + pdsUrl, + expiresAt: expiresAt === null ? null : Number(expiresAt), + refreshBefore: refreshBefore === null ? null : Number(refreshBefore), + status, + stateVersion, + }; +} + +function parsePublisher(value: unknown): PublisherResource { + if (!isRecord(value)) throw invalidResponse(); + const did = stringValue(value, "did"); + const delegation = parseDelegation(value["delegation"]); + const sessionExpiresAt = value["sessionExpiresAt"]; + if ( + !did || + !DID_PATTERN.test(did) || + (sessionExpiresAt !== undefined && !Number.isSafeInteger(sessionExpiresAt)) + ) { + throw invalidResponse(); + } + return { + did, + delegation, + ...(sessionExpiresAt === undefined ? {} : { sessionExpiresAt: Number(sessionExpiresAt) }), + }; +} + +function parseServiceState(value: unknown): ServiceControlState { + if (!isRecord(value)) throw invalidResponse(); + const mode = value["mode"]; + const epoch = safeInteger(value, "epoch"); + const reasonCode = nullableString(value, "reasonCode"); + const changedBy = stringValue(value, "changedBy"); + const changedAt = safeInteger(value, "changedAt"); + if ( + (mode !== "active" && mode !== "admission-paused" && mode !== "publication-paused") || + epoch === null || + reasonCode === undefined || + !changedBy || + changedAt === null + ) { + throw invalidResponse(); + } + return { mode, epoch, reasonCode, changedBy, changedAt }; +} + +function parsePublisherControl(value: unknown): PublisherControlResource { + if (!isRecord(value)) throw invalidResponse(); + const publisherDid = stringValue(value, "publisherDid"); + const status = value["status"]; + const reasonCode = nullableString(value, "reasonCode"); + const changedBy = stringValue(value, "changedBy"); + const changedAt = safeInteger(value, "changedAt"); + if ( + !publisherDid || + (status !== "allowed" && status !== "suspended") || + reasonCode === undefined || + !changedBy || + changedAt === null + ) { + throw invalidResponse(); + } + return { publisherDid, status, reasonCode, changedBy, changedAt }; +} + +function invalidResponse(requestId: string | null = null): ReleaseServiceError { + return new ReleaseServiceError({ + code: "CLIENT_RESPONSE_INVALID", + message: "Release service returned an invalid response", + status: 502, + requestId, + }); +} + +function retryAfterMs(response: Response): number | null { + const value = response.headers.get("retry-after"); + if (!value) return null; + if (DIGITS_PATTERN.test(value)) return Number(value) * 1000; + const date = Date.parse(value); + return Number.isFinite(date) ? Math.max(0, date - Date.now()) : null; +} + +function parseErrorPayload( + value: unknown, + response: Response, +): { code: ReleaseServiceApiErrorCode; message: string; requestId: string | null } { + if (!isRecord(value) || !isRecord(value["error"])) throw invalidResponse(); + const code = stringValue(value["error"], "code"); + const message = stringValue(value["error"], "message"); + const requestId = nullableString(value, "requestId"); + if (!isApiErrorCode(code) || !message || requestId === undefined) { + throw invalidResponse(response.headers.get("x-request-id")); + } + return { code, message, requestId }; +} + +async function responseJson(response: Response): Promise { + const mediaType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); + if (mediaType !== "application/json") throw invalidResponse(response.headers.get("x-request-id")); + try { + return await response.json(); + } catch { + throw invalidResponse(response.headers.get("x-request-id")); + } +} + +async function sleep(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) throw signal.reason; + await new Promise((resolve, reject) => { + const complete = () => { + signal?.removeEventListener("abort", abort); + resolve(); + }; + const timer = setTimeout(complete, ms); + const abort = () => { + clearTimeout(timer); + reject(signal?.reason); + }; + signal?.addEventListener("abort", abort, { once: true }); + }); +} + +class BaseReleaseServiceClient { + readonly serviceUrl: string; + readonly fetch: typeof fetch; + + constructor(options: { serviceUrl: string; fetch?: typeof fetch }) { + this.serviceUrl = serviceOrigin(options.serviceUrl); + this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis); + } + + protected async call( + path: string, + init: RequestInit, + parse: (value: unknown) => T, + ): Promise { + let response: Response; + try { + response = await this.fetch(new URL(path, this.serviceUrl), init); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") throw error; + throw new ReleaseServiceError({ + code: "NETWORK_ERROR", + message: "Release service request failed", + }); + } + const payload = await responseJson(response); + if (!response.ok) { + const error = parseErrorPayload(payload, response); + throw new ReleaseServiceError({ + ...error, + status: response.status, + retryAfterMs: retryAfterMs(response), + }); + } + if (!isRecord(payload) || !("data" in payload)) { + throw invalidResponse(response.headers.get("x-request-id")); + } + return parse(payload["data"]); + } +} + +export class ReleaseServiceClient extends BaseReleaseServiceClient { + readonly #workloadToken: string | WorkloadTokenProvider | undefined; + readonly #csrfToken: string | CsrfTokenProvider | undefined; + + constructor(options: ReleaseServiceClientOptions) { + super(options); + this.#workloadToken = options.workloadToken; + this.#csrfToken = options.csrfToken; + } + + async #token(): Promise { + const token = + typeof this.#workloadToken === "function" ? await this.#workloadToken() : this.#workloadToken; + if (!token || token.length > 16 * 1024 || token.includes(" ")) { + throw new ReleaseServiceError({ + code: "AUTH_INVALID", + message: "Workload token is unavailable", + }); + } + return token; + } + + async #csrf(): Promise { + const token = typeof this.#csrfToken === "function" ? await this.#csrfToken() : this.#csrfToken; + if (!token || !CSRF_TOKEN_PATTERN.test(token)) { + throw new ReleaseServiceError({ + code: "CSRF_INVALID", + message: "Publisher CSRF token is unavailable", + }); + } + return token; + } + + async #workloadHeaders(idempotencyKey?: string): Promise { + const headers = new Headers({ authorization: `Bearer ${await this.#token()}` }); + if (idempotencyKey) headers.set("idempotency-key", requireIdempotencyKey(idempotencyKey)); + return headers; + } + + async #publisherMutationHeaders(idempotencyKey: string): Promise { + return new Headers({ + "content-type": "application/json", + "idempotency-key": requireIdempotencyKey(idempotencyKey), + "x-emdash-request": "1", + "x-emdash-csrf": await this.#csrf(), + }); + } + + async submitIntent( + input: SubmitReleaseIntentInput, + options: MutationOptions, + ): Promise { + const release = parseDelegatedReleaseSourceRecord(input.release, { + packageSlug: input.packageSlug, + version: input.version, + }); + if (!release) { + throw new ReleaseServiceError({ + code: "INVALID_REQUEST", + message: "Delegated release source record is invalid", + }); + } + const headers = await this.#workloadHeaders(options.idempotencyKey); + headers.set("content-type", "application/json"); + return await this.call( + "/v1/release-intents", + { + method: "POST", + headers, + body: JSON.stringify({ ...input, release }), + signal: options.signal, + }, + (value) => { + if (!isRecord(value) || typeof value["replayed"] !== "boolean") { + throw invalidResponse(); + } + return { + intent: parseIntent(value["intent"], this.serviceUrl), + replayed: value["replayed"], + }; + }, + ); + } + + async getIntent( + publisherDid: string, + intentId: string, + options: RequestOptions = {}, + ): Promise { + const headers = await this.#workloadHeaders(); + return await this.call( + `/v1/release-intents/${encodeURIComponent(intentId)}?publisher=${encodeURIComponent(publisherDid)}`, + { method: "GET", headers, signal: options.signal }, + (value) => { + if (!isRecord(value)) throw invalidResponse(); + return parseIntent(value["intent"], this.serviceUrl); + }, + ); + } + + async cancelIntent( + publisherDid: string, + intentId: string, + options: MutationOptions, + ): Promise { + const headers = await this.#workloadHeaders(options.idempotencyKey); + headers.set("content-type", "application/json"); + return await this.call( + `/v1/release-intents/${encodeURIComponent(intentId)}/cancel?publisher=${encodeURIComponent(publisherDid)}`, + { method: "POST", headers, body: "{}", signal: options.signal }, + (value) => { + if (!isRecord(value)) throw invalidResponse(); + return parseIntent(value["intent"], this.serviceUrl); + }, + ); + } + + async waitForIntent( + publisherDid: string, + intentId: string, + options: WaitForIntentOptions = {}, + ): Promise { + const pollIntervalMs = options.pollIntervalMs ?? 1_000; + const maxWaitMs = options.maxWaitMs ?? 15 * 60_000; + if ( + !Number.isSafeInteger(pollIntervalMs) || + pollIntervalMs < 0 || + !Number.isSafeInteger(maxWaitMs) || + maxWaitMs < 1 + ) { + throw new ReleaseServiceError({ + code: "INVALID_REQUEST", + message: "Polling options are invalid", + }); + } + const deadline = Date.now() + maxWaitMs; + for (;;) { + const intent = await this.getIntent(publisherDid, intentId, { signal: options.signal }); + await options.onUpdate?.(intent); + if ( + TERMINAL_RELEASE_INTENT_STATES.has(intent.state) || + ((options.stopOnApproval ?? true) && intent.state === "awaiting_approval") + ) { + return intent; + } + if (Date.now() >= deadline) { + throw new ReleaseServiceError({ + code: "POLL_TIMEOUT", + message: "Timed out waiting for release intent", + }); + } + await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())), options.signal); + } + } + + async getPublisher(options: RequestOptions = {}): Promise { + return await this.call( + "/v1/publisher", + { method: "GET", credentials: "include", signal: options.signal }, + (value) => { + if (!isRecord(value)) throw invalidResponse(); + return parsePublisher(value["publisher"]); + }, + ); + } + + async revokeDelegation(options: MutationOptions): Promise { + return await this.call( + "/v1/publisher/delegation", + { + method: "DELETE", + credentials: "include", + headers: await this.#publisherMutationHeaders(options.idempotencyKey), + body: "{}", + signal: options.signal, + }, + (value) => { + if (!isRecord(value)) throw invalidResponse(); + return parsePublisher(value["publisher"]); + }, + ); + } + + async listWorkloads( + options: RequestOptions & { cursor?: string; limit?: number } = {}, + ): Promise> { + const url = new URL("/v1/publisher/workloads", this.serviceUrl); + if (options.cursor) url.searchParams.set("cursor", options.cursor); + if (options.limit !== undefined) url.searchParams.set("limit", String(options.limit)); + return await this.call( + `${url.pathname}${url.search}`, + { method: "GET", credentials: "include", signal: options.signal }, + (value) => parsePage(value, parsePolicy), + ); + } + + async putWorkload( + input: PutWorkloadPolicyInput, + options: MutationOptions, + ): Promise> { + return await this.call( + "/v1/publisher/workloads", + { + method: "POST", + credentials: "include", + headers: await this.#publisherMutationHeaders(options.idempotencyKey), + body: JSON.stringify(input), + signal: options.signal, + }, + (value) => { + if (!isRecord(value) || typeof value["replayed"] !== "boolean") { + throw invalidResponse(); + } + return { value: parsePolicy(value["policy"]), replayed: value["replayed"] }; + }, + ); + } + + async disableWorkload( + packageSlug: string, + expectedVersion: number, + options: MutationOptions, + ): Promise> { + return await this.call( + `/v1/publisher/workloads/${encodeURIComponent(packageSlug)}`, + { + method: "DELETE", + credentials: "include", + headers: await this.#publisherMutationHeaders(options.idempotencyKey), + body: JSON.stringify({ expectedVersion }), + signal: options.signal, + }, + (value) => { + if (!isRecord(value) || typeof value["replayed"] !== "boolean") { + throw invalidResponse(); + } + return { value: parsePolicy(value["policy"]), replayed: value["replayed"] }; + }, + ); + } + + async listPublisherIntents( + options: RequestOptions & { cursor?: string; limit?: number } = {}, + ): Promise> { + const url = new URL("/v1/publisher/intents", this.serviceUrl); + if (options.cursor) url.searchParams.set("cursor", options.cursor); + if (options.limit !== undefined) url.searchParams.set("limit", String(options.limit)); + return await this.call( + `${url.pathname}${url.search}`, + { method: "GET", credentials: "include", signal: options.signal }, + (value) => parsePage(value, (item) => parseIntent(item, this.serviceUrl)), + ); + } +} + +function parsePage(value: unknown, parseItem: (item: unknown) => T): CursorPage { + if (!isRecord(value) || !Array.isArray(value["items"])) throw invalidResponse(); + const nextCursor = value["nextCursor"]; + if (nextCursor !== undefined && typeof nextCursor !== "string") throw invalidResponse(); + return { + items: value["items"].map(parseItem), + ...(nextCursor ? { nextCursor } : {}), + }; +} + +export class ReleaseServiceOperatorClient extends BaseReleaseServiceClient { + #mutationHeaders(idempotencyKey: string): Headers { + return new Headers({ + "content-type": "application/json", + "idempotency-key": requireIdempotencyKey(idempotencyKey), + "x-emdash-request": "1", + }); + } + + async getStatus(options: RequestOptions = {}): Promise { + return await this.call( + "/admin/api/status", + { method: "GET", credentials: "include", signal: options.signal }, + (value) => { + if (!isRecord(value)) throw invalidResponse(); + return parseServiceState(value["state"]); + }, + ); + } + + async setMode( + mode: ServiceControlState["mode"], + reasonCode: string | null, + options: MutationOptions, + ): Promise> { + return await this.call( + "/admin/api/pause", + { + method: "POST", + credentials: "include", + headers: this.#mutationHeaders(options.idempotencyKey), + body: JSON.stringify({ mode, reasonCode }), + signal: options.signal, + }, + (value) => { + if (!isRecord(value) || typeof value["replayed"] !== "boolean") { + throw invalidResponse(); + } + return { value: parseServiceState(value["state"]), replayed: value["replayed"] }; + }, + ); + } + + async getPublisher( + publisherDid: string, + options: RequestOptions = {}, + ): Promise { + return await this.call( + `/admin/api/publishers/${encodeURIComponent(publisherDid)}`, + { method: "GET", credentials: "include", signal: options.signal }, + (value) => { + if (!isRecord(value) || !isRecord(value["publisher"])) throw invalidResponse(); + const publisher = parsePublisher(value["publisher"]); + return { ...publisher, control: parsePublisherControl(value["publisher"]["control"]) }; + }, + ); + } + + async setPublisherSuspended( + publisherDid: string, + suspended: boolean, + reasonCode: string | null, + options: MutationOptions, + ): Promise { + return await this.call( + `/admin/api/publishers/${encodeURIComponent(publisherDid)}/suspend`, + { + method: "POST", + credentials: "include", + headers: this.#mutationHeaders(options.idempotencyKey), + body: JSON.stringify({ suspended, reasonCode }), + signal: options.signal, + }, + (value) => { + if (!isRecord(value) || !isRecord(value["publisher"])) throw invalidResponse(); + return parsePublisherControl(value["publisher"]["control"]); + }, + ); + } + + async revokePublisher( + publisherDid: string, + options: MutationOptions, + ): Promise { + return await this.call( + `/admin/api/publishers/${encodeURIComponent(publisherDid)}/revoke`, + { + method: "POST", + credentials: "include", + headers: this.#mutationHeaders(options.idempotencyKey), + body: "{}", + signal: options.signal, + }, + (value) => { + if (!isRecord(value)) throw invalidResponse(); + return parsePublisher(value["publisher"]); + }, + ); + } + + async cancelIntent( + publisherDid: string, + intentId: string, + options: MutationOptions, + ): Promise { + return await this.call( + `/admin/api/intents/${encodeURIComponent(intentId)}/cancel`, + { + method: "POST", + credentials: "include", + headers: this.#mutationHeaders(options.idempotencyKey), + body: JSON.stringify({ publisherDid }), + signal: options.signal, + }, + (value) => { + if (!isRecord(value)) throw invalidResponse(); + return parseIntent(value["intent"], this.serviceUrl); + }, + ); + } + + async reconcileIntent( + publisherDid: string, + intentId: string, + options: MutationOptions, + ): Promise<{ intent: ReleaseIntentResource; restarted: boolean }> { + return await this.call( + `/admin/api/intents/${encodeURIComponent(intentId)}/reconcile`, + { + method: "POST", + credentials: "include", + headers: this.#mutationHeaders(options.idempotencyKey), + body: JSON.stringify({ publisherDid }), + signal: options.signal, + }, + (value) => { + if (!isRecord(value) || typeof value["restarted"] !== "boolean") { + throw invalidResponse(); + } + return { + intent: parseIntent(value["intent"], this.serviceUrl), + restarted: value["restarted"], + }; + }, + ); + } +} + +export type ReleaseRecord = PackageRelease.Main; diff --git a/packages/registry-client/src/release-service/source-record.ts b/packages/registry-client/src/release-service/source-record.ts new file mode 100644 index 0000000000..a679b2b85a --- /dev/null +++ b/packages/registry-client/src/release-service/source-record.ts @@ -0,0 +1,165 @@ +import { safeParse } from "@atcute/lexicons"; +import { fromBase32, toBase32 } from "@atcute/multibase"; +import { + NSID, + PackageRelease, + PackageReleaseExtension, + type PackageRelease as PackageReleaseTypes, + type PackageReleaseExtension as PackageReleaseExtensionTypes, +} from "@emdash-cms/registry-lexicons"; + +const SOURCE_ARTIFACT_KEYS = new Set(["$type", "package", "icon", "banner", "screenshots"]); +const IMAGE_CONTENT_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]); + +export type DelegatedReleaseSourceArtifact = Omit< + PackageReleaseTypes.Artifact, + "blob" | "requiresAuth" | "url" +> & { + url: NonNullable; + blob?: never; + requiresAuth?: never; +}; + +export type DelegatedReleaseSourceImageArtifact = Omit< + PackageReleaseTypes.ImageArtifact, + "blob" | "requiresAuth" | "url" +> & { + url: NonNullable; + blob?: never; + requiresAuth?: never; +}; + +export interface DelegatedReleaseSourceArtifacts extends Omit< + PackageReleaseTypes.Artifacts, + "banner" | "icon" | "package" | "screenshots" +> { + package: DelegatedReleaseSourceArtifact; + icon?: DelegatedReleaseSourceImageArtifact; + banner?: DelegatedReleaseSourceImageArtifact; + screenshots?: DelegatedReleaseSourceImageArtifact[]; +} + +export type DelegatedReleaseSourceExtension = Omit< + PackageReleaseExtensionTypes.Main, + "provenance" +> & { + provenance: PackageReleaseExtensionTypes.Provenance; +}; + +export interface DelegatedReleaseSourceRecord extends Omit< + PackageReleaseTypes.Main, + "artifacts" | "auth" | "extensions" +> { + artifacts: DelegatedReleaseSourceArtifacts; + auth?: never; + extensions: Record & { + [NSID.packageReleaseExtension]: DelegatedReleaseSourceExtension; + }; +} + +export interface DelegatedReleaseSourceEnvelope { + packageSlug: string; + version: string; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isHttpsUrl(value: unknown): value is string { + if (typeof value !== "string") return false; + try { + const url = new URL(value); + return ( + url.protocol === "https:" && url.username === "" && url.password === "" && url.hash === "" + ); + } catch { + return false; + } +} + +function isCanonicalSha256Multihash(value: unknown): value is string { + if (typeof value !== "string" || !value.startsWith("b")) return false; + try { + const bytes = fromBase32(value.slice(1)); + return ( + bytes.length === 34 && + bytes[0] === 0x12 && + bytes[1] === 0x20 && + `b${toBase32(bytes)}` === value + ); + } catch { + return false; + } +} + +function validSourceArtifact(value: unknown, image: boolean): boolean { + if ( + !isRecord(value) || + Object.hasOwn(value, "blob") || + Object.hasOwn(value, "requiresAuth") || + !isHttpsUrl(value["url"]) || + !isCanonicalSha256Multihash(value["checksum"]) + ) { + return false; + } + const contentType = value["contentType"]; + return contentType === undefined + ? true + : image + ? typeof contentType === "string" && IMAGE_CONTENT_TYPES.has(contentType) + : contentType === "application/gzip"; +} + +function validSourceArtifacts(value: unknown): boolean { + if ( + !isRecord(value) || + Object.keys(value).some((key) => !SOURCE_ARTIFACT_KEYS.has(key)) || + !validSourceArtifact(value["package"], false) || + (value["icon"] !== undefined && !validSourceArtifact(value["icon"], true)) || + (value["banner"] !== undefined && !validSourceArtifact(value["banner"], true)) + ) { + return false; + } + const screenshots = value["screenshots"]; + return ( + screenshots === undefined || + (Array.isArray(screenshots) && + screenshots.every((artifact) => validSourceArtifact(artifact, true))) + ); +} + +function isDelegatedReleaseSourceRecord( + release: PackageReleaseTypes.Main, + envelope?: DelegatedReleaseSourceEnvelope, +): release is DelegatedReleaseSourceRecord { + if ( + Object.hasOwn(release, "auth") || + !validSourceArtifacts(release.artifacts) || + (envelope !== undefined && + (release.package !== envelope.packageSlug || release.version !== envelope.version)) || + !isRecord(release.extensions) + ) { + return false; + } + const extension = safeParse( + PackageReleaseExtension.mainSchema, + release.extensions[NSID.packageReleaseExtension], + ); + return ( + extension.ok && + extension.value.provenance !== undefined && + isHttpsUrl(extension.value.provenance.url) && + isCanonicalSha256Multihash(extension.value.provenance.checksum) + ); +} + +export function parseDelegatedReleaseSourceRecord( + value: unknown, + envelope?: DelegatedReleaseSourceEnvelope, +): DelegatedReleaseSourceRecord | null { + const release = safeParse(PackageRelease.mainSchema, value); + return release.ok && isDelegatedReleaseSourceRecord(release.value, envelope) + ? release.value + : null; +} diff --git a/packages/registry-client/src/release-service/types.ts b/packages/registry-client/src/release-service/types.ts new file mode 100644 index 0000000000..6e26dcd910 --- /dev/null +++ b/packages/registry-client/src/release-service/types.ts @@ -0,0 +1,174 @@ +import type { DelegatedReleaseSourceRecord } from "./source-record.js"; + +export type ReleaseIntentState = + | "received" + | "verifying" + | "verified" + | "awaiting_approval" + | "ready" + | "publishing" + | "reconciling" + | "published" + | "invalid" + | "rejected" + | "cancelled" + | "expired" + | "failed" + | "conflict"; + +export type ReleaseServiceApiErrorCode = + | "ACCESS_DENIED" + | "ACCESS_AUTH_INVALID" + | "ACCESS_AUTH_REQUIRED" + | "APPROVAL_INVALID" + | "APPROVER_SESSION_INVALID" + | "APPROVER_SUSPENDED" + | "AUTH_INVALID" + | "CONFIGURATION_ERROR" + | "CREDENTIAL_LIMIT_REACHED" + | "CREDENTIAL_NOT_FOUND" + | "CREDENTIAL_REVOKED" + | "CSRF_INVALID" + | "DELEGATION_REQUIRED" + | "IDEMPOTENCY_KEY_INVALID" + | "IDEMPOTENCY_CONFLICT" + | "INTERNAL_ERROR" + | "INVALID_REQUEST" + | "INTENT_NOT_APPROVABLE" + | "INTENT_NOT_CANCELLABLE" + | "METHOD_NOT_ALLOWED" + | "NOT_FOUND" + | "OAUTH_AUTHORIZATION_FAILED" + | "OAUTH_CALLBACK_INVALID" + | "PROFILE_CHANGED" + | "PROFILE_FETCH_FAILED" + | "PUBLISHER_SESSION_INVALID" + | "PUBLISHER_SUSPENDED" + | "RELEASE_EXISTS" + | "SERVICE_PAUSED" + | "SERVICE_UNAVAILABLE" + | "VERSION_RESERVED" + | "WORKFLOW_UNAVAILABLE" + | "WORKLOAD_NOT_ALLOWED"; + +export type ReleaseServiceClientErrorCode = + | ReleaseServiceApiErrorCode + | "CLIENT_RESPONSE_INVALID" + | "NETWORK_ERROR" + | "POLL_TIMEOUT"; + +export interface ReleaseIntentResult { + uri: string; + cid: string; +} + +export interface ReleaseIntentResource { + id: string; + publisherDid: string; + packageSlug: string; + version: string; + state: ReleaseIntentState; + stateGeneration: number; + reasonCode: string | null; + workflowId: string | null; + expiresAt: number; + createdAt: number; + updatedAt: number; + result: ReleaseIntentResult | null; + approvalUrl: string | null; +} + +export interface SubmitReleaseIntentInput { + publisherDid: string; + packageSlug: string; + version: string; + release: DelegatedReleaseSourceRecord; +} + +export interface SubmitReleaseIntentResult { + intent: ReleaseIntentResource; + replayed: boolean; +} + +export interface WorkloadPolicyResource { + packageSlug: string; + repository: string; + repositoryId: string; + repositoryOwnerId: string; + workflowRef: string; + allowedRefs: readonly string[]; + allowedEnvironments: readonly string[]; + active: boolean; + stateVersion: number; + authorizedBy: string; + createdAt: number; + updatedAt: number; +} + +export interface PutWorkloadPolicyInput { + packageSlug: string; + repository: string; + repositoryId: string; + repositoryOwnerId: string; + workflowRef: string; + allowedRefs: readonly string[]; + allowedEnvironments: readonly string[]; + expectedVersion: number | null; +} + +export interface DelegationResource { + releaseNsid: string; + scope: string; + issuer: string | null; + pdsUrl: string | null; + expiresAt: number | null; + refreshBefore: number | null; + status: "active" | "revoked" | "reauthorization_required"; + stateVersion: number; +} + +export interface PublisherResource { + did: string; + delegation: DelegationResource | null; + sessionExpiresAt?: number; +} + +export interface ServiceControlState { + mode: "active" | "admission-paused" | "publication-paused"; + epoch: number; + reasonCode: string | null; + changedBy: string; + changedAt: number; +} + +export interface PublisherControlResource { + publisherDid: string; + status: "allowed" | "suspended"; + reasonCode: string | null; + changedBy: string; + changedAt: number; +} + +export interface OperatorPublisherResource extends PublisherResource { + control: PublisherControlResource; +} + +export interface CursorPage { + items: T[]; + nextCursor?: string; +} + +export interface MutationResult { + value: T; + replayed: boolean; +} + +export const TERMINAL_RELEASE_INTENT_STATES: ReadonlySet = new Set([ + "published", + "invalid", + "rejected", + "cancelled", + "expired", + "failed", + "conflict", +]); diff --git a/packages/registry-client/tests/release-service.test.ts b/packages/registry-client/tests/release-service.test.ts new file mode 100644 index 0000000000..5d2f173b35 --- /dev/null +++ b/packages/registry-client/tests/release-service.test.ts @@ -0,0 +1,360 @@ +import { NSID } from "@emdash-cms/registry-lexicons"; +import { describe, expect, it, vi } from "vitest"; + +import { + ReleaseServiceClient, + ReleaseServiceError, + ReleaseServiceOperatorClient, + createReleaseIdempotencyKey, +} from "../src/release-service/index.js"; + +const SERVICE = "https://release.example.com"; +const PUBLISHER_DID = "did:web:publisher.example.com"; +const INTENT_ID = "01JABCDEFGHJKMNPQRSTVWXYZ0"; +const CSRF = "C".repeat(43); +const CHECKSUM = "bciqcz4snxjp3biyoe3udwkwfxhrj4gywdzob7j2clzzqim3csofzqja"; + +function sourceRelease() { + return { + $type: "com.emdashcms.experimental.package.release" as const, + package: "gallery", + version: "1.2.3", + artifacts: { + package: { url: "https://example.com/gallery.tgz", checksum: CHECKSUM }, + }, + extensions: { + [NSID.packageReleaseExtension]: { + $type: NSID.packageReleaseExtension, + declaredAccess: {}, + provenance: { + url: "https://example.com/provenance.json", + checksum: CHECKSUM, + predicateType: "https://slsa.dev/provenance/v1", + sourceRepository: "https://github.com/example/gallery", + builderId: + "https://github.com/example/gallery/.github/workflows/release.yml@refs/heads/main", + }, + }, + }, + }; +} + +function intent(state = "received") { + return { + id: INTENT_ID, + publisherDid: PUBLISHER_DID, + packageSlug: "gallery", + version: "1.2.3", + state, + stateGeneration: 2, + reasonCode: null, + workflowId: INTENT_ID, + expiresAt: 1_800_000_000_000, + createdAt: 1_799_999_000_000, + updatedAt: 1_799_999_500_000, + result: null, + approvalUrl: + state === "awaiting_approval" + ? `${SERVICE}/approvals/${INTENT_ID}?publisher=${encodeURIComponent(PUBLISHER_DID)}` + : null, + }; +} + +function policy() { + return { + packageSlug: "gallery", + repository: "example/gallery", + repositoryId: "123456789", + repositoryOwnerId: "987654321", + workflowRef: "example/gallery/.github/workflows/release.yml@refs/heads/main", + allowedRefs: ["refs/heads/main"], + allowedEnvironments: [], + active: true, + stateVersion: 1, + authorizedBy: PUBLISHER_DID, + createdAt: 1_799_999_000_000, + updatedAt: 1_799_999_000_000, + }; +} + +function success(data: unknown, status = 200): Response { + return Response.json( + { data, requestId: "request-1" }, + { status, headers: { "x-request-id": "request-1" } }, + ); +} + +describe("ReleaseServiceClient", () => { + it("allows only explicit loopback HTTP origins for local development", () => { + expect( + new ReleaseServiceClient({ + serviceUrl: "http://127.0.0.1:5175", + workloadToken: "header.payload.signature", + }), + ).toBeInstanceOf(ReleaseServiceClient); + expect( + () => + new ReleaseServiceClient({ + serviceUrl: "http://release.example.com", + workloadToken: "header.payload.signature", + }), + ).toThrow("HTTPS origin or a loopback"); + }); + + it("submits a typed intent without retaining or exposing the workload token", async () => { + const calls: Array<{ init: RequestInit | undefined; url: string }> = []; + const workloadToken = "header.payload.signature"; + const fetch: typeof globalThis.fetch = vi.fn(async (input, init) => { + calls.push({ url: input instanceof Request ? input.url : input.toString(), init }); + return success({ intent: intent(), replayed: false }, 202); + }); + const client = new ReleaseServiceClient({ serviceUrl: SERVICE, fetch, workloadToken }); + const release = sourceRelease(); + const result = await client.submitIntent( + { publisherDid: PUBLISHER_DID, packageSlug: "gallery", version: "1.2.3", release }, + { idempotencyKey: "github-run-100-attempt-1" }, + ); + + expect(result).toMatchObject({ intent: { id: INTENT_ID }, replayed: false }); + expect(calls).toHaveLength(1); + expect(new URL(calls[0]!.url).pathname).toBe("/v1/release-intents"); + const headers = new Headers(calls[0]!.init?.headers); + expect(headers.get("authorization")).toBe(`Bearer ${workloadToken}`); + expect(headers.get("idempotency-key")).toBe("github-run-100-attempt-1"); + expect(JSON.stringify(result)).not.toContain(workloadToken); + }); + + it("rejects invalid source records before acquiring a workload token", async () => { + const release = sourceRelease(); + Object.assign(release.artifacts.package, { + blob: { + $type: "blob", + ref: { $link: "bafkreicoew2cifs6fwqhqpkvkezdokuvpquj6p7aosznuf7jhxkehsltpe" }, + mimeType: "application/gzip", + size: 128, + }, + }); + const token = vi.fn(() => "header.payload.signature"); + const fetch = vi.fn(); + const client = new ReleaseServiceClient({ serviceUrl: SERVICE, fetch, workloadToken: token }); + + await expect( + client.submitIntent( + { publisherDid: PUBLISHER_DID, packageSlug: "gallery", version: "1.2.3", release }, + { idempotencyKey: "github-run-100-attempt-1" }, + ), + ).rejects.toMatchObject({ code: "INVALID_REQUEST" }); + expect(token).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("maps stable server errors, retry hints, and network failures", async () => { + const workloadToken = "header.payload.signature"; + const pausedFetch: typeof globalThis.fetch = async () => + Response.json( + { + error: { code: "SERVICE_PAUSED", message: "Release admission is paused" }, + requestId: "request-paused", + }, + { status: 503, headers: { "retry-after": "2" } }, + ); + const client = new ReleaseServiceClient({ + serviceUrl: SERVICE, + fetch: pausedFetch, + workloadToken, + }); + await expect(client.getIntent(PUBLISHER_DID, INTENT_ID)).rejects.toMatchObject({ + code: "SERVICE_PAUSED", + status: 503, + requestId: "request-paused", + retryable: true, + retryAfterMs: 2_000, + }); + try { + await client.getIntent(PUBLISHER_DID, INTENT_ID); + expect.fail("expected release service error"); + } catch (error) { + expect(error).toBeInstanceOf(ReleaseServiceError); + expect(JSON.stringify(error)).not.toContain(workloadToken); + } + + const offline = new ReleaseServiceClient({ + serviceUrl: SERVICE, + fetch: async () => { + throw new TypeError("offline with sensitive provider details"); + }, + workloadToken, + }); + await expect(offline.getIntent(PUBLISHER_DID, INTENT_ID)).rejects.toMatchObject({ + code: "NETWORK_ERROR", + message: "Release service request failed", + retryable: true, + }); + }); + + it("polls with a fresh token and stops at approval by default", async () => { + const tokens: string[] = []; + let responseIndex = 0; + const fetch: typeof globalThis.fetch = async (_input, init) => { + tokens.push(new Headers(init?.headers).get("authorization") ?? ""); + const state = responseIndex++ === 0 ? "verifying" : "awaiting_approval"; + return success({ intent: intent(state) }); + }; + let tokenIndex = 0; + const client = new ReleaseServiceClient({ + serviceUrl: SERVICE, + fetch, + workloadToken: () => `token-${++tokenIndex}`, + }); + const updates: string[] = []; + const result = await client.waitForIntent(PUBLISHER_DID, INTENT_ID, { + pollIntervalMs: 0, + maxWaitMs: 1_000, + onUpdate: (value) => { + updates.push(value.state); + }, + }); + + expect(result.state).toBe("awaiting_approval"); + expect(result.approvalUrl).toContain(INTENT_ID); + expect(tokens).toEqual(["Bearer token-1", "Bearer token-2"]); + expect(updates).toEqual(["verifying", "awaiting_approval"]); + }); + + it("parses expired intents whose state was updated after the deadline", async () => { + const expired = { + ...intent("expired"), + reasonCode: "APPROVAL_EXPIRED", + updatedAt: 1_800_000_001_000, + }; + const client = new ReleaseServiceClient({ + serviceUrl: SERVICE, + fetch: async () => success({ intent: expired }), + workloadToken: "header.payload.signature", + }); + + await expect(client.getIntent(PUBLISHER_DID, INTENT_ID)).resolves.toMatchObject({ + state: "expired", + reasonCode: "APPROVAL_EXPIRED", + updatedAt: expired.updatedAt, + }); + }); + + it("rejects malformed success envelopes at the client trust boundary", async () => { + const client = new ReleaseServiceClient({ + serviceUrl: SERVICE, + fetch: async () => success({ intent: { id: INTENT_ID } }), + workloadToken: "header.payload.signature", + }); + await expect(client.getIntent(PUBLISHER_DID, INTENT_ID)).rejects.toMatchObject({ + code: "CLIENT_RESPONSE_INVALID", + status: 502, + }); + + const unsafeLink = new ReleaseServiceClient({ + serviceUrl: SERVICE, + fetch: async () => + success({ + intent: { + ...intent("awaiting_approval"), + approvalUrl: "https://attacker.example/approve", + }, + }), + workloadToken: "header.payload.signature", + }); + await expect(unsafeLink.getIntent(PUBLISHER_DID, INTENT_ID)).rejects.toMatchObject({ + code: "CLIENT_RESPONSE_INVALID", + }); + }); + + it("uses cookie credentials and double-submit CSRF only for publisher mutations", async () => { + const calls: RequestInit[] = []; + const fetch: typeof globalThis.fetch = async (input, init = {}) => { + calls.push(init); + const path = new URL(input instanceof Request ? input.url : input.toString()).pathname; + if (path === "/v1/publisher") { + return success({ publisher: { did: PUBLISHER_DID, delegation: null } }); + } + return success({ policy: policy(), replayed: false }, 201); + }; + const client = new ReleaseServiceClient({ serviceUrl: SERVICE, fetch, csrfToken: CSRF }); + await client.getPublisher(); + await client.putWorkload( + { + packageSlug: "gallery", + repository: "example/gallery", + repositoryId: "123456789", + repositoryOwnerId: "987654321", + workflowRef: "example/gallery/.github/workflows/release.yml@refs/heads/main", + allowedRefs: ["refs/heads/main"], + allowedEnvironments: [], + expectedVersion: null, + }, + { idempotencyKey: "publisher-workload-0001" }, + ); + + expect(calls[0]?.credentials).toBe("include"); + expect(new Headers(calls[0]?.headers).has("authorization")).toBe(false); + expect(calls[1]?.credentials).toBe("include"); + const mutationHeaders = new Headers(calls[1]?.headers); + expect(mutationHeaders.get("x-emdash-request")).toBe("1"); + expect(mutationHeaders.get("x-emdash-csrf")).toBe(CSRF); + expect(mutationHeaders.has("authorization")).toBe(false); + }); + + it("creates valid collision-resistant idempotency keys", () => { + const first = createReleaseIdempotencyKey("github action"); + const second = createReleaseIdempotencyKey("github action"); + expect(first).toMatch(/^github-action-[0-9a-f-]{36}$/); + expect(second).not.toBe(first); + }); +}); + +describe("ReleaseServiceOperatorClient", () => { + it("uses Access cookie credentials and roleless operator paths", async () => { + const calls: Array<{ init: RequestInit | undefined; url: string }> = []; + const fetch: typeof globalThis.fetch = async (input, init) => { + calls.push({ url: input instanceof Request ? input.url : input.toString(), init }); + return success({ + publisher: { + did: PUBLISHER_DID, + delegation: null, + control: { + publisherDid: PUBLISHER_DID, + status: "suspended", + reasonCode: "ABUSE_REVIEW", + changedBy: "admin@example.com", + changedAt: 1_800_000_000_000, + }, + }, + }); + }; + const client = new ReleaseServiceOperatorClient({ serviceUrl: SERVICE, fetch }); + const result = await client.getPublisher(PUBLISHER_DID); + + expect(result.control.status).toBe("suspended"); + expect(new URL(calls[0]!.url).pathname).toBe( + `/admin/api/publishers/${encodeURIComponent(PUBLISHER_DID)}`, + ); + expect(calls[0]!.init?.credentials).toBe("include"); + }); + + it("adds idempotency and mutation headers to reconciliation", async () => { + let captured: { init: RequestInit | undefined; url: string } | null = null; + const fetch: typeof globalThis.fetch = async (input, init) => { + captured = { url: input instanceof Request ? input.url : input.toString(), init }; + return success({ intent: intent("reconciling"), restarted: true }, 202); + }; + const client = new ReleaseServiceOperatorClient({ serviceUrl: SERVICE, fetch }); + const result = await client.reconcileIntent(PUBLISHER_DID, INTENT_ID, { + idempotencyKey: "operator-reconcile-0001", + }); + + expect(result.restarted).toBe(true); + expect(new URL(captured!.url).pathname).toBe(`/admin/api/intents/${INTENT_ID}/reconcile`); + const headers = new Headers(captured!.init?.headers); + expect(headers.get("idempotency-key")).toBe("operator-reconcile-0001"); + expect(headers.get("x-emdash-request")).toBe("1"); + expect(captured!.init?.credentials).toBe("include"); + }); +}); diff --git a/packages/registry-client/tests/release-source-record.test.ts b/packages/registry-client/tests/release-source-record.test.ts new file mode 100644 index 0000000000..2d3f85c3a1 --- /dev/null +++ b/packages/registry-client/tests/release-source-record.test.ts @@ -0,0 +1,185 @@ +import { NSID, type PackageRelease } from "@emdash-cms/registry-lexicons"; +import { describe, expect, it } from "vitest"; + +import releaseFixture from "../../registry-verification/fixtures/records/release.json"; +import { parseDelegatedReleaseSourceRecord } from "../src/release-service/index.js"; + +const CHECKSUM = "bciqcz4snxjp3biyoe3udwkwfxhrj4gywdzob7j2clzzqim3csofzqja"; + +function sourceRecord(): PackageRelease.Main { + const release = structuredClone(releaseFixture) as PackageRelease.Main; + release.artifacts.package.checksum = CHECKSUM; + release.artifacts.icon = { + url: "https://example.com/icon.png", + checksum: CHECKSUM, + contentType: "image/png", + width: 64, + height: 64, + }; + release.artifacts.banner = { + url: "https://example.com/banner.webp", + checksum: CHECKSUM, + contentType: "image/webp", + width: 1200, + height: 400, + }; + release.artifacts.screenshots = [ + { + url: "https://example.com/screenshot.jpg", + checksum: CHECKSUM, + contentType: "image/jpeg", + width: 800, + height: 600, + }, + ]; + release.extensions = { + [NSID.packageReleaseExtension]: { + $type: NSID.packageReleaseExtension, + declaredAccess: {}, + provenance: { + url: "https://example.com/provenance.json", + checksum: CHECKSUM, + predicateType: "https://slsa.dev/provenance/v1", + sourceRepository: "https://github.com/example/gallery", + builderId: + "https://github.com/example/gallery/.github/workflows/release.yml@refs/heads/main", + }, + }, + }; + return release; +} + +function blob() { + return { + $type: "blob" as const, + ref: { $link: "bafkreicoew2cifs6fwqhqpkvkezdokuvpquj6p7aosznuf7jhxkehsltpe" }, + mimeType: "application/gzip", + size: 128, + }; +} + +describe("delegated release source records", () => { + it("accepts URL-only package and listing artifacts with required provenance", () => { + const release = sourceRecord(); + + expect( + parseDelegatedReleaseSourceRecord(release, { + packageSlug: "gallery", + version: "1.2.3", + }), + ).toEqual(release); + }); + + it.each([ + [ + "package blob", + (release: PackageRelease.Main) => Object.assign(release.artifacts.package, { blob: blob() }), + ], + [ + "image blob", + (release: PackageRelease.Main) => + Object.assign(release.artifacts.icon!, { + blob: { ...blob(), mimeType: "image/png" }, + }), + ], + [ + "blob-only package", + (release: PackageRelease.Main) => { + delete release.artifacts.package.url; + Object.assign(release.artifacts.package, { blob: blob() }); + }, + ], + [ + "blob-only image", + (release: PackageRelease.Main) => { + delete release.artifacts.icon!.url; + Object.assign(release.artifacts.icon!, { + blob: { ...blob(), mimeType: "image/png" }, + }); + }, + ], + ["top-level auth", (release: PackageRelease.Main) => Object.assign(release, { auth: {} })], + [ + "requiresAuth false", + (release: PackageRelease.Main) => + Object.assign(release.artifacts.package, { requiresAuth: false }), + ], + [ + "custom artifact slot", + (release: PackageRelease.Main) => + Object.assign(release.artifacts, { "x-signature": { ...release.artifacts.package } }), + ], + [ + "non-HTTPS package URL", + (release: PackageRelease.Main) => { + release.artifacts.package.url = "http://example.com/gallery.tgz"; + }, + ], + [ + "missing image URL", + (release: PackageRelease.Main) => { + delete release.artifacts.icon!.url; + }, + ], + [ + "unsupported package MIME declaration", + (release: PackageRelease.Main) => { + release.artifacts.package.contentType = "application/zip"; + }, + ], + [ + "unsupported image MIME declaration", + (release: PackageRelease.Main) => { + release.artifacts.icon!.contentType = "image/svg+xml"; + }, + ], + [ + "non-canonical checksum", + (release: PackageRelease.Main) => { + release.artifacts.package.checksum = "bciqexample"; + }, + ], + [ + "missing provenance", + (release: PackageRelease.Main) => { + const extensions = release.extensions as Record; + delete extensions[NSID.packageReleaseExtension]!.provenance; + }, + ], + [ + "non-HTTPS provenance URL", + (release: PackageRelease.Main) => { + const extensions = release.extensions as Record; + extensions[NSID.packageReleaseExtension]!.provenance.url = + "http://example.com/provenance.json"; + }, + ], + [ + "non-canonical provenance checksum", + (release: PackageRelease.Main) => { + const extensions = release.extensions as Record< + string, + { provenance: { checksum: string } } + >; + extensions[NSID.packageReleaseExtension]!.provenance.checksum = "bciqexample"; + }, + ], + ])("rejects %s", (_name, mutate) => { + const release = sourceRecord(); + mutate(release); + + expect(parseDelegatedReleaseSourceRecord(release)).toBeNull(); + }); + + it.each([ + ["another-package", "1.2.3"], + ["gallery", "2.0.0"], + ])("rejects request envelope mismatch for %s at %s", (packageSlug, version) => { + expect( + parseDelegatedReleaseSourceRecord(sourceRecord(), { + packageSlug, + version, + }), + ).toBeNull(); + }); +}); diff --git a/packages/registry-client/tsdown.config.ts b/packages/registry-client/tsdown.config.ts index bb4f716bb7..9bd2c4da79 100644 --- a/packages/registry-client/tsdown.config.ts +++ b/packages/registry-client/tsdown.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ "src/discovery/index.ts", "src/env/index.ts", "src/publishing/index.ts", + "src/release-service/index.ts", ], format: ["esm"], outExtensions: () => ({ js: ".js" }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88ab1292a2..e97a26191d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -560,8 +560,39 @@ importers: specifier: 'catalog:' version: 4.124.0(@cloudflare/workers-types@4.20260305.1) + apps/release-action: + dependencies: + '@atcute/lexicons': + specifier: 'catalog:' + version: 2.0.0 + '@emdash-cms/registry-client': + specifier: workspace:* + version: link:../../packages/registry-client + '@emdash-cms/registry-lexicons': + specifier: workspace:* + version: link:../../packages/registry-lexicons + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.10.13 + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3))(publint@0.3.17)(typescript@6.0.3) + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + apps/release-service: dependencies: + '@atcute/atproto': + specifier: 'catalog:' + version: 4.0.2(@atcute/lexicons@2.0.0) + '@atcute/client': + specifier: 'catalog:' + version: 5.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3) '@atcute/identity-resolver': specifier: 'catalog:' version: 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3) @@ -571,6 +602,9 @@ importers: '@atcute/oauth-node-client': specifier: 'catalog:' version: 2.0.0(@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3) + '@cloudflare/kumo': + specifier: 'catalog:' + version: 2.6.0(@date-fns/tz@1.4.1)(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(date-fns@4.1.0)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1) '@emdash-cms/auth': specifier: workspace:* version: link:../../packages/auth @@ -586,12 +620,27 @@ importers: '@emdash-cms/registry-verification': specifier: workspace:* version: link:../../packages/registry-verification + '@lingui/core': + specifier: 'catalog:' + version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3)) + '@lingui/react': + specifier: 'catalog:' + version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.2.4) jose: specifier: ^6.1.3 version: 6.1.3 + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) semver: specifier: 'catalog:' version: 7.7.4 + ulidx: + specifier: ^2.4.1 + version: 2.4.1 devDependencies: '@cloudflare/vite-plugin': specifier: 'catalog:' @@ -599,12 +648,33 @@ importers: '@cloudflare/vitest-pool-workers': specifier: 'catalog:' version: 0.16.3(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/node': specifier: 'catalog:' version: 24.10.13 + '@types/react': + specifier: 'catalog:' + version: 19.2.14 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.14) '@types/semver': specifier: 'catalog:' version: 7.7.1 + '@vitejs/plugin-react': + specifier: ^4.6.0 + version: 4.7.0(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + tailwindcss: + specifier: ^4.1.10 + version: 4.3.3 typescript: specifier: 'catalog:' version: 6.0.3