From 6fbab22979424fcedebd6879790ca0b286926b90 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 11:00:57 +0100 Subject: [PATCH 01/24] docs: add delegated release service implementation plan --- .gitignore | 4 + .../implementation-plan.md | 1075 ++++++++++++++++ .../plans/delegated-release-service/spec.md | 1135 +++++++++++++++++ 3 files changed, 2214 insertions(+) create mode 100644 .opencode/plans/delegated-release-service/implementation-plan.md create mode 100644 .opencode/plans/delegated-release-service/spec.md diff --git a/.gitignore b/.gitignore index 5e4e771b8b..d755b20bf2 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,10 @@ examples/wp-theme-unit-test/ # pattern so we can re-include the agents subdirectory. .opencode/* !.opencode/agents/ +!.opencode/plans/ +.opencode/plans/* +!.opencode/plans/delegated-release-service/ +!.opencode/plans/delegated-release-service/** # .claude is local-only EXCEPT for the symlinks pointing at AGENTS.md and skills/ .claude/* diff --git a/.opencode/plans/delegated-release-service/implementation-plan.md b/.opencode/plans/delegated-release-service/implementation-plan.md new file mode 100644 index 0000000000..17e2962c15 --- /dev/null +++ b/.opencode/plans/delegated-release-service/implementation-plan.md @@ -0,0 +1,1075 @@ +# Delegated Release Service Implementation Plan + +Companion: [Implementation spec](./spec.md) + +Status: execution plan pending Gate 0 decisions and feasibility results + +This plan turns the delegated release service spec into independently deliverable workstreams. It defines ownership boundaries, dependencies, integration gates, and completion criteria. It intentionally contains no time estimates. + +## Outcomes + +The implementation is complete when: + +1. A publisher can establish an atproto OAuth grant restricted to create-only release records. +2. GitHub Actions can submit an attested release without storing an atproto credential. +3. The service validates the artifact, manifest access, provenance, source, workflow, and signed profile policy. +4. Releases requiring confirmation are held until a currently authorized approver uses a previously enrolled, UV-capable passkey. +5. The service publishes exactly one immutable release record and reconciles ambiguous PDS responses. +6. An EmDash installer independently repeats integrity, provenance, and policy verification. +7. The aggregator records policy history, verifies provenance, and excludes policy-violating releases from default discovery. +8. Publishers can manage delegation, workflow policies, approvers, notification endpoints, and audit history through the web console. +9. The hosted service and a fresh Workers/D1 self-host pass the same conformance suite. + +## Execution Rules + +- Protocol and security semantics live in shared packages, not independently in the service, installer, and aggregator. +- Every workstream lands tests with its implementation. Tests are not a cleanup phase. +- New public record fields remain optional until the stable namespace migration. +- No component accepts `transition:generic` as a fallback for delegated publishing. +- No implementation path may overwrite a release record. +- Every asynchronous consumer is idempotent before it is connected to a real queue. +- User-facing console work uses Kumo, Lingui, and RTL-safe layout from its first PR. +- Public beta may begin after Gate 4. Production launch remains blocked on accurate aggregator policy history in Gate 5. +- Work behind an incomplete integration must be unreachable by default, not merely undocumented. + +## Dependency Model + +### Workstream IDs + +| ID | Workstream | Primary output | +| --- | --- | --- | +| `W0` | Decisions and feasibility | Ratified contracts and proved platform assumptions | +| `W1` | Protocol and lexicons | Profile policy and release provenance records | +| `W2` | Shared verification | One verification implementation for all consumers | +| `W3` | OAuth, crypto, and passkeys | Secure identity, grant custody, and approval primitives | +| `W4` | Workload identity | GitHub OIDC verification and typed workflow policies | +| `W5` | Service persistence and orchestration | D1 state machine, queues, publication, reconciliation | +| `W6` | Publisher and approver console | Full management and approval UI | +| `W7` | Notifications | Email, signed webhooks, outbox delivery | +| `W8` | CLI and GitHub Action | Author and CI clients | +| `W9` | Installer enforcement | Independent install-time verification | +| `W10` | Aggregator enforcement | Historical policy and discovery filtering | +| `W11` | Operations and self-hosting | Production config, observability, abuse controls, runbooks | +| `W12` | Conformance and security | Cross-component, browser, adversarial, and production tests | + +### High-Level Graph + +```mermaid +flowchart TD + W0[W0 Decisions and feasibility] + W1[W1 Protocol and lexicons] + W2[W2 Shared verification] + W3[W3 OAuth, crypto, passkeys] + W4[W4 GitHub workload identity] + W5[W5 Service orchestration] + W6[W6 Web console] + W7[W7 Notifications] + W8[W8 CLI and GitHub Action] + W9[W9 Installer enforcement] + W10[W10 Aggregator enforcement] + W11[W11 Operations and self-hosting] + W12[W12 Conformance and security] + + W0 --> W1 + W0 --> W2 + W0 --> W3 + W0 --> W4 + W0 --> W10 + W1 --> W2 + W1 --> W5 + W1 --> W8 + W1 --> W9 + W1 --> W10 + W2 --> W5 + W2 --> W9 + W2 --> W10 + W3 --> W5 + W3 --> W6 + W4 --> W5 + W4 --> W6 + W5 --> W6 + W5 --> W7 + W5 --> W8 + W5 --> W11 + W7 --> W6 + W8 --> W12 + W9 --> W12 + W10 --> W12 + W11 --> W12 +``` + +### Critical Path + +```text +W0 record/auth/provenance feasibility +-> W1 record contracts +-> W2 shared artifact and provenance verification +-> W5 intent validation and PDS publication +-> W9 independent installer enforcement +-> W10 accurate aggregator policy history +-> W12 end-to-end conformance and production launch gate +``` + +`W3` and `W4` run in parallel with `W1` and `W2` after their respective Gate 0 spikes. `W6`, `W7`, and most of `W8` begin once the service API and lifecycle contracts are frozen, without waiting for aggregator history work. + +## Integration Gates + +### Gate 0: Design and Platform Feasibility + +Required before protocol or service implementation is treated as production work: + +- Package profile extension and repository anchor are ratified. +- Release baseline and declared-access escalation semantics are ratified. +- Public CLI spelling is decided. +- Create-only repo scope works on each supported PDS without broad fallback. +- Confidential OAuth sessions can be restored and refreshed safely in workerd. +- The selected Sigstore implementation verifies a real GitHub provenance bundle in workerd. +- An aggregator event source can recover intermediate profile values with verifiable ordering. + +Gate owner: `W0`. + +### Gate 1: Protocol and Verification Foundation + +- New profile and release extension fixtures round-trip through generated lexicon types. +- Existing profile writers preserve unknown and policy extensions. +- Shared checksum, fetch, bundle, access-diff, and provenance tests pass in Node and workerd. +- Installer, service, and aggregator can consume the same fixture corpus. + +Gate owners: `W1`, `W2`. + +### Gate 2: Secure Service Core + +- A publisher can establish and revoke an exact create-only delegation. +- GitHub OIDC submission creates one D1 intent and survives Queue redelivery. +- Automatic and passkey-approved paths publish through the same final verification function. +- Ambiguous PDS writes reconcile exact match, confirmed absence, and conflict. +- No profile write or release overwrite operation exists in the retained-session path. + +Gate owners: `W3`, `W4`, `W5`. + +### Gate 3: Independent Consumer Enforcement + +- A clean EmDash site independently blocks absent-required, failed, and unverifiable provenance. +- The installer does not trust release-service or aggregator verification status. +- The aggregator marks unsupported or unverified policy state and excludes it from default discovery. + +Gate owners: `W9`, minimum `W10`. + +### Gate 4: Hosted Beta Readiness + +- Full console, multiple passkeys, email, webhooks, audit, recovery, and delegation health work without operator database edits. +- Official CLI and GitHub Action pass the service conformance suite. +- Abuse limits, encryption rotation, alerts, backup/export, and self-hosting docs are complete. +- External security review has no unresolved critical or high findings. + +Gate owners: `W6`, `W7`, `W8`, `W11`, `W12`. + +### Gate 5: Production Registry Launch + +- Aggregator policy-at-publication history is accurate under rapid profile changes, event reordering, queue delay, and replay. +- Downgrade cooldown and notification behavior pass conformance tests. +- Default discovery cannot recommend a release whose required provenance is pending, invalid, or unverifiable. +- End-to-end production smoke succeeds from real GitHub OIDC through PDS, aggregator, and clean-site installation. + +Gate owners: `W10`, `W12`. + +## Workstream W0: Decisions and Feasibility + +This workstream closes the implementation blockers in the spec. Its outputs are executable fixtures and recorded decisions, not exploratory prose alone. + +### `W0.1` Ratify the record shape + +Decide and update RFC #1870 with: + +- Exact profile extension NSID. +- Exact location and canonical form of the signed repository URL. +- Release-policy object shape and defaults. +- Provenance reference shape. +- Stable treatment of unknown provenance predicates. +- Experimental-to-stable NSID migration consequences for OAuth grants. + +Output: accepted RFC text and matching JSON examples. + +Dependencies: none. + +### `W0.2` Ratify escalation semantics + +Decide: + +- Highest-semver current release as the baseline. +- First release uses empty access. +- Out-of-order publication behavior. +- `allowedHosts` containment rules. +- Unknown constraint changes are conservatively escalating. +- Which baseline changes invalidate an approval. + +Output: decision table consumed directly by `W2.4` tests. + +Dependencies: none. + +### `W0.3` Prove create-only PDS support + +Build a minimal confidential client and test: + +- `atproto repo:?action=create` authorization. +- Successful release create. +- Rejected update, delete, profile create/update, and unrelated collection writes. +- Revocation endpoint behavior. +- Key-removal behavior before and after access-token expiry. + +Targets: Bluesky-hosted PDS and at least one alternative implementation intended for support. + +Output: committed integration fixture or reproducible test harness plus compatibility matrix. + +Dependencies: `W0.1` draft NSID. + +### `W0.4` Prove confidential OAuth custody in workerd + +Exercise real `@atcute/oauth-node-client` behavior with: + +- Private-key JWT and published JWKS. +- Separate client assertion and DPoP keys. +- D1-backed session restore. +- Nonce retry. +- Concurrent refresh attempts under a D1 lease. +- Rotating refresh tokens and client assertion keys. + +Output: service-auth prototype and exact persisted session requirements. + +Dependencies: none. + +### `W0.5` Prove Sigstore verification in workerd + +Use a real `actions/attest-build-provenance` bundle to map and verify: + +- Sigstore signature and transparency evidence. +- Artifact subject digest. +- Repository ID and URL. +- Commit SHA and ref. +- Workflow identity and SLSA builder fields. +- RFC `sourceRepository` and `builderId` mapping. + +Output: Workers-compatible verifier choice, fixture, and field-mapping contract. + +Dependencies: `W0.1` provenance draft. + +### `W0.6` Prove historical aggregator input + +Publish profile states `strict -> relaxed -> strict` before the aggregator queue drains, with a release between transitions. Prove the chosen source can recover all values, ordering keys, CIDs, revisions, and commit proof material. + +Output: selected event source and ingest prototype. If no source can provide this, return to the RFC before implementing cooldown semantics. + +Dependencies: none. + +### `W0.7` Decide public CLI shape + +Resolve `emdash-plugin` versus `emdash plugin`, including compatibility policy and documentation naming. + +Output: one command spelling used by `W8` and RFC examples. + +Dependencies: none. + +### W0 Completion + +Gate 0 passes. Any failed feasibility spike changes the RFC or architecture before downstream work proceeds. + +## Workstream W1: Protocol and Lexicons + +### `W1.1` Add package profile extension + +Files: + +- `packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/profile.json` +- New `profileExtension.json` +- Generated exports and types. + +Deliver: + +- Optional `extensions` container. +- Signed repository anchor. +- `requireProvenance`, `confirmation`, and `approvers`. +- Semantic validation for DID uniqueness, policy values, and URL canonicalization. + +Dependencies: `W0.1`. + +### `W1.2` Add release provenance + +Extend `releaseExtension.json` with the ratified provenance reference and add semantic validation for predicate, URL, checksum, source, and builder. + +Dependencies: `W0.1`, `W0.5` field mapping. + +### `W1.3` Regenerate and publish typed contracts + +- Regenerate atcute types. +- Export value and type symbols from the package barrel. +- Add valid, absent-policy, unknown-predicate, and invalid fixture tests. +- Document experimental/stable NSID lookup through exported constants. + +Dependencies: `W1.1`, `W1.2`. + +### `W1.4` Preserve extensions in all profile writers + +Update interactive publish and profile update paths, especially: + +- `ProfileInput`. +- `buildProfileRecord`. +- `stampLastUpdated`. +- Profile update validation and CLI serialization. + +Regression test: write strict policy, run a later ordinary interactive publish, and verify the extension survives unchanged. + +Dependencies: `W1.1`. + +### `W1.5` Add profile-policy editing to the local CLI core + +Provide a profile-scoped, interactive-only operation that: + +- Fetches and validates the current profile. +- Preserves unrelated extension data. +- Applies one policy edit. +- Uses `swapRecord` with the fetched CID. +- Never shares its OAuth session with the release service. + +Dependencies: `W1.3`, `W1.4`. + +### `W1.6` Update permission-set and namespace migration contracts + +- Publish or update the experimental release permission set if applicable. +- Document that stable NSID migration requires reauthorization. +- Add a typed helper that returns the active release collection and scope string. + +Dependencies: `W0.3`, `W1.3`. + +### `W1.7` Add create-only release publishing helper + +Extend `registry-client` with a narrow delegated-release helper that: + +- Constructs and validates the deterministic `:` rkey. +- Serializes the canonical release record from verified inputs. +- Performs exactly one create through `createRecord` or a single-create `applyWrites` call. +- Exposes no update, delete, profile write, `putRecord`, or overwrite option. +- Returns the AT URI and CID needed for reconciliation. + +Dependencies: `W1.3`, `W1.6`. + +### W1 Completion + +The protocol can represent every signed fact required by the service and installer, and existing tools cannot accidentally strip policy. + +## Workstream W2: Shared Verification + +Create `packages/registry-verification` and make its APIs usable in Workers and Node. + +### `W2.1` Package scaffold and fixture corpus + +- Add package build, exports, tests, and workerd compatibility job. +- Establish canonical fixture directories for records, tarballs, checksums, and Sigstore bundles. +- Define stable verification error codes shared by all consumers. + +Dependencies: Gate 0. + +### `W2.2` Checksums and safe resource fetching + +Extract and reconcile existing checksum behavior. Add the dedicated verifier-Worker boundary for untrusted outbound fetches, manual redirects, byte/time limits, URL validation, DNS defense-in-depth, and injectable test transport. + +Dependencies: `W2.1`. + +### `W2.3` Canonical plugin bundle validation + +Unify CLI and core tar readers. Reject traversal, duplicate normalized paths, links, devices, duplicate manifests, gzip bombs, and limit violations. Return the validated manifest and canonical access. + +Dependencies: `W2.1`. + +### `W2.4` Declared-access canonicalization and escalation + +Implement in `packages/plugin-types/src/declared-access.ts`: + +- Canonical form. +- Equality. +- Structured diff. +- Escalation predicate. +- Stable digest input. + +Drive implementation from the ratified `W0.2` table. + +Dependencies: `W0.2`, `W1.3`. + +### `W2.5` Provenance verification + +Implement the `ProvenanceVerifier` interface and GitHub/SLSA v1 adapter proved in `W0.5`. Include trust-root update strategy and no partial-success state. + +Dependencies: `W0.5`, `W1.2`, `W2.2`. + +### `W2.6` Record and policy verification + +Add helpers that: + +- Validate profile and release lexicons. +- Normalize absent policy defaults. +- Match release package/rkey/version. +- Resolve the signed repository anchor. +- Verify required/optional/failed provenance semantics. +- Produce a structured verification report suitable for console, installer, and aggregator. + +Dependencies: `W1.3`, `W2.2`, `W2.4`, `W2.5`. + +### `W2.7` Direct PDS read helpers + +Extend `registry-client` with unauthenticated direct-PDS profile/release reads, bounded rkey enumeration, lexicon validation, and semver baseline selection. Do not route these helpers through the aggregator. + +Dependencies: `W1.3`, `W0.2`. + +### W2 Completion + +Given the same profile, release, artifact, and provenance fixtures, service, installer, and aggregator receive the same verification result and error code. + +## Workstream W3: OAuth, Crypto, and Passkeys + +### `W3.1` Service encryption + +Implement versioned AES-GCM envelope encryption with HKDF-derived purpose keys and associated row identity. Cover OAuth session blobs, DPoP keys, emails, webhook destinations, and webhook secrets. + +Dependencies: Gate 0. + +### `W3.2` Confidential client metadata and JWKS + +Serve stable metadata and JWKS routes, support overlapping assertion keys, and validate deployment-derived client ID, redirects, scope declaration, and public origin. + +Dependencies: `W0.4`, `W1.6` for final scope. + +### `W3.3` D1 OAuth stores + +Implement separate logical stores for: + +- Console identity. +- Approver identity proof. +- Durable release delegation. + +Persist only the release delegation after callback. Encrypt all sensitive state. + +Dependencies: `W3.1`, `W3.2`. + +### `W3.4` Session refresh coordination + +Implement `PublisherCoordinator` with D1 leases, rotated-session CAS persistence, proactive refresh, jitter, reauthorization state, revocation, and ambiguous refresh recovery. + +Dependencies: `W0.4`, `W3.3`, `W5.2` repository primitives. + +### `W3.5` Console and approver identity sessions + +- Convert successful `atproto` OAuth into short-lived, hashed, same-origin service sessions. +- Delete unneeded OAuth session material. +- Bind approver identity proof to invitation or requested DID. +- Add CSRF and session rotation. + +Dependencies: `W3.3`, `W5.1` app scaffold. + +### `W3.6` Required-UV passkey primitives + +Extend `packages/auth` additively with configurable user verification and typed challenge context. Preserve existing CMS behavior by default. + +Dependencies: none after Gate 0. + +### `W3.7` Service passkey repository and ceremonies + +- Multiple named credentials per DID. +- OAuth-before-registration. +- Required UV. +- Bound approval/rejection challenges. +- Atomic challenge consumption and counter update. +- Individual revocation requires fresh atproto identity proof and, when another active credential exists, an assertion from another credential. +- Last-credential recovery may proceed from fresh atproto proof alone, but emits a high-severity audit event and notifications to every affected publisher. + +Dependencies: `W3.5`, `W3.6`, `W5.2`. + +### W3 Completion + +The service can prove publisher and approver DIDs, hold only the exact writer grant, serialize refresh, and verify replay-resistant UV passkey ceremonies. + +## Workstream W4: GitHub Workload Identity + +### `W4.1` Issuer-neutral interfaces + +Define `WorkloadIssuer`, `VerifiedWorkload`, policy matcher, and stable failure codes without GitHub-specific types leaking into intent orchestration. + +Dependencies: Gate 0. + +### `W4.2` GitHub JWT verification + +Verify discovery, remote JWKS, issuer, audience, token times, immutable repository/owner IDs, workflow identity, ref, SHA, run ID, run attempt, and optional environment. + +Dependencies: `W4.1`. + +### `W4.3` Typed workload-policy model + +Implement D1 repository and semantic matcher for repository, workflow, refs, and environments. No arbitrary expressions. + +Dependencies: `W4.1`, `W5.2`. + +### `W4.4` Replay and cancellation identity + +- Hash and reserve each raw JWT until expiry. +- Bind submission evidence to policy ID/version. +- Require matching repository, workflow, run ID, and run attempt for workload cancellation. +- Allow separately audited publisher-console cancellation. + +Dependencies: `W4.2`, `W4.3`, `W5.3`. + +### W4 Completion + +A verified token maps to exactly one normalized workload identity and either one authorized package policy or a stable rejection. + +## Workstream W5: Service Persistence and Orchestration + +### `W5.1` Worker application scaffold + +Create `apps/release-service` using the aggregator's Cloudflare Vite and workers-vitest patterns. Add D1, Queues, DLQs, cron, static assets, generated Worker types, health route, and fail-closed configuration validation. + +Dependencies: Gate 0. + +### `W5.2` D1 schema and repositories + +Land migrations and typed repositories for: + +- Publisher accounts and delegations. +- Console/OAuth transactions. +- Workload policies and JWT replay. +- Approver identities, credentials, invitations, and challenges. +- Release targets, intents, and approvals. +- Notification endpoints, outbox, deliveries, and audit. + +Include unique constraints, owner columns, indexes, CAS state version, expiring leases, and a cryptographically random `public_intent_id` distinct from any internal row identifier. Public APIs, approval URLs, CLI output, notifications, and audit links use only the opaque public ID. + +Dependencies: `W5.1`, data contracts from `W1`, `W3`, and `W4` may land incrementally. + +### `W5.3` Intent submission + +- Verify OIDC before remote fetch. +- Reject publishers excluded by the deployment's allowed-publisher policy before creating state or fetching user-controlled URLs. +- Validate request and reserve JWT, idempotency key, and release target atomically. +- Create intent, audit event, and validation outbox row in one D1 batch. +- Return stable `202`, duplicate, and conflict responses. + +Dependencies: `W1.3`, `W4.2`, `W4.3`, `W5.2`. + +### `W5.4` Validation worker + +- Resolve DID and PDS. +- Fetch signed profile and baseline releases. +- Validate artifacts, manifest access, and provenance. +- Derive signed policy, escalation, approval requirement, approval digest inputs, and expiry. +- Transition by CAS and write outbox events. + +Dependencies: `W2.3`, `W2.6`, `W2.7`, `W5.3`. + +### `W5.5` Approval and rejection lifecycle + +- Revalidate profile and baseline before challenge creation. +- Before returning substantive approval details, issuing a challenge, or accepting approval/rejection, fetch the current profile policy and require the authenticated approver DID to remain listed. An unauthenticated approval URL reveals only minimal package/status data and the login action. +- Recompute approval digest before challenge verification. +- Store append-only approval/rejection evidence. +- Invalidate approval when any bound fact changes. +- Transition approved intent to publish queue. + +Dependencies: `W3.7`, `W5.4`. + +### `W5.6` Final verification and PDS publication + +- Re-run full verification. +- Acquire publisher publication/session lease. +- Create one deterministic release record. +- Reconcile timeout or transport ambiguity by direct read and canonical comparison. +- Distinguish published, confirmed absent/retryable, immutable conflict, and reauthorization-required. + +Dependencies: `W1.7`, `W3.4`, `W5.4`, `W5.5`. + +### `W5.7` Outbox, Queues, cron, and recovery + +- Transactional outbox drainer. +- Idempotent Queue consumers. +- DLQ forensics. +- Stage expiry. +- Lease reclamation. +- Publishing reconciliation. +- Proactive OAuth refresh. +- Bounded pruning of consumed tokens and challenges. + +Dependencies: `W5.2`, `W5.4`, `W5.6`. + +### `W5.8` Versioned JSON API + +Implement CI, publisher, and approver endpoints from the spec with shared response envelopes, stable error codes, request IDs, owner checks, pagination, CSRF protection, and generated API-client types. Enforce the deployment's allowed-publisher policy on publisher login/delegation and every CI submission. Expose only `public_intent_id` for intent addressing; internal row IDs never cross the API boundary. + +Dependencies: endpoint-specific service operations from `W3` through `W5.7`. + +### W5 Completion + +Gate 2 passes with real D1, Queue redelivery, and fake-PDS integration tests. + +## Workstream W6: Publisher and Approver Console + +### `W6.1` Console foundation + +- React SPA through Worker static assets. +- Kumo component setup. +- Lingui extraction and locale loading. +- `LocaleDirectionProvider` and logical classes. +- Authenticated router, API client, error boundaries, and session expiry behavior. + +Dependencies: `W3.5`, initial `W5.8` session endpoints. + +### `W6.2` Publisher overview and delegation + +Show delegation scope, PDS, status, refresh health, assertion key, revoke, and reauthorize flows. Never imply key removal revokes current access tokens immediately. + +Dependencies: `W3.4`, delegation endpoints in `W5.8`. + +### `W6.3` Packages and workload policies + +- Read-only signed profile policy. +- Listed-versus-enrolled approver matrix. +- Typed GitHub repository/workflow/ref/environment editor. +- Generate local CLI command for signed profile-policy changes. + +Dependencies: `W4.3`, package/policy endpoints, `W8.3` command contract. + +### `W6.4` Intent and approval views + +- Lifecycle timeline. +- Workload evidence. +- Artifact and provenance checks. +- Baseline and structured access diff. +- Approval, rejection, blocked, expired, conflict, and revalidation states. + +Dependencies: `W5.4`, `W5.5`, approval endpoints. + +### `W6.5` Passkey and enrolment UI + +- Invitation acceptance. +- atproto identity callback. +- Multiple credential registration, naming, listing, and revocation. +- High-severity recovery warnings. +- No enrol-and-approve combined action. + +Dependencies: `W3.7`, passkey endpoints. + +### `W6.6` Notifications and audit UI + +- Verified email configuration. +- Webhook creation, one-time secret display, event selection, test, rotation, and failure state. +- Paginated audit filters and export. + +Dependencies: `W7`, audit endpoints. + +### `W6.7` Localization, accessibility, and RTL completion + +- No hard-coded user-facing strings. +- Keyboard and screen-reader pass. +- Arabic end-to-end pass. +- WebAuthn status and error announcements. +- Mobile approval review remains usable without hiding security details. + +Dependencies: all `W6` screens. + +### W6 Completion + +Every service-local publisher and approver operation is available through the console without direct D1 access. + +## Workstream W7: Notifications + +### `W7.1` Notification event contract + +Define versioned event types and safe payloads. Separate internal event data from the intentionally minimal email/webhook payload. + +Dependencies: lifecycle contract from `W5.4` through `W5.7`. + +### `W7.2` Endpoint ownership and verification + +- Explicit publisher owner on every endpoint. +- Optional approver recipient. +- Email verification. +- Webhook URL validation and test delivery through the shared verifier/egress boundary. +- Event allowlists and disabled state. + +Dependencies: `W2.2`, `W3.1`, `W5.2`. + +### `W7.3` Email adapter + +Implement Cloudflare Email Service behind `Mailer`, with text and HTML templates, localized copy where recipient locale is known, and no sensitive intent detail in mail. + +Dependencies: `W7.1`, `W7.2`. + +### `W7.4` Signed webhook adapter + +- Stable delivery ID and event schema version. +- Timestamped HMAC over raw body. +- Dedicated untrusted-egress boundary. +- Retry, jitter, DLQ, disable threshold, and secret rotation overlap. + +Dependencies: `W2.2`, `W7.1`, `W7.2`. + +### `W7.5` Delivery dispatcher + +Consume outbox events, materialize per-endpoint deliveries, deduplicate, record attempts, and surface failures to console and audit. + +Dependencies: `W5.7`, `W7.3`, `W7.4`. + +### W7 Completion + +Every lifecycle and credential-security event reaches configured destinations with at-least-once, auditable delivery. + +## Workstream W8: CLI and GitHub Action + +### `W8.1` Shared delegated-service API client + +Add `packages/registry-client/src/delegated` with typed requests, envelopes, polling, idempotency, stable errors, and no browser-specific dependency. + +Dependencies: API schemas from `W5.8`. + +### `W8.2` Delegation command + +Open the service authorization flow, wait for completion, display exact granted scope and service origin, and report reauthorization requirements. + +Dependencies: `W3.2` through `W3.5`, `W8.1`. + +### `W8.3` Profile policy command + +Implement the ratified local profile-policy editor from `W1.5`, including add/remove approver, confirmation, provenance requirement, conflict handling, and JSON output. + +Dependencies: `W1.5`, `W0.7`. + +### `W8.4` Enrol and approve commands + +Open service-hosted browser ceremonies and wait for a terminal result. Do not attempt WebAuthn in the terminal. + +Dependencies: `W3.7`, approval API, `W8.1`. + +### `W8.5` Release submit, status, and cancel commands + +- Build request from local manifest/artifact/provenance inputs. +- Acquire workload token only in CI-supported mode. +- Poll until published, awaiting approval, or terminal failure. +- Emit stable JSON for automation. + +Dependencies: `W5.8`, `W8.1`. + +### `W8.6` Official GitHub Action + +- Request an audience-scoped OIDC token. +- Submit with deterministic idempotency input. +- Poll status. +- Output intent ID, status, approval URL, and release URI. +- Support cancellation from the same run identity. +- Accept no atproto secret. + +Dependencies: `W4`, `W5.8`, `W8.1`. + +### W8 Completion + +First-time delegation and steady-state automated release work through documented commands and the official Action. + +## Workstream W9: Installer Enforcement + +### `W9.1` Replace duplicate integrity helpers + +Move core artifact checksum, tar, and manifest consistency checks to `@emdash-cms/registry-verification` without behavior regression for existing releases. + +Dependencies: `W2.2`, `W2.3`, `W2.4`. + +### `W9.2` Fetch signed profile policy and provenance + +Extend direct-PDS install resolution to validate profile and release extensions and retain the profile CID used for the decision. + +Dependencies: `W1.3`, `W2.6`, `W2.7`. + +### `W9.3` Enforce provenance semantics + +- Optional and absent: install with explicit unattested status. +- Required and absent: block. +- Present and valid: continue. +- Present and failed/unverifiable: block, regardless of policy default. + +Dependencies: `W9.1`, `W9.2`. + +### `W9.4` Admin consent and provenance UI + +Show source, builder, workflow identity, verification status, and precise errors without presenting provenance as a safety guarantee. Localize and test RTL. + +Dependencies: `W9.3`. + +### `W9.5` Historical-policy limitation handling + +Apply current signed policy as a conservative direct-install floor and expose when historical policy-at-publication is unavailable. Never trust the aggregator to relax a current signed requirement. + +Dependencies: `W9.2`, RFC clarification. + +### W9 Completion + +Gate 3 installer criteria pass against valid, absent, tampered, foreign-source, and unknown-predicate fixtures. + +## Workstream W10: Aggregator Enforcement + +### `W10.1` Event-specific ingest source + +Replace or extend current Jetstream/current-record ingestion with the `W0.6` proved source. Carry ordering key, event CID, repo revision, record value, and proof blocks through Queue jobs. + +Dependencies: `W0.6`. + +### `W10.2` Policy history schema and ingest + +- Persist every package policy event. +- Classify tightening, weakening, and approver changes. +- Associate each release with the last preceding profile event. +- Handle profile/release arrival reordering through pending state and retry. + +Dependencies: `W1.3`, `W10.1`. + +### `W10.3` Release provenance verification pipeline + +- Persist provenance reference and policy event. +- Queue shared verification. +- CAS `pending -> valid|invalid|unverifiable`. +- Record stable reasons and verification time. + +Dependencies: `W2.6`, `W10.2`. + +### `W10.4` Minimum default filtering + +Before hosted beta, expose policy status and exclude releases that are pending, invalid, or unverifiable when current signed policy requires provenance. This may use current policy as a conservative floor until historical ingest is complete. + +Dependencies: `W1.3`, `W2.6`; does not wait for `W10.1`. + +### `W10.5` Accurate policy-at-publication views + +Update latest-release, search, package, and audit views to use the associated historical policy event. Explicit audit reads may include violating releases with reasons. + +Dependencies: `W10.2`, `W10.3`. + +### `W10.6` Downgrade cooldown and notification + +- Detect `requireProvenance: true -> false`. +- Continue enforcing the strict prior floor through configured cooldown. +- Notify signed security contacts through the approved channel. +- Audit legitimate expiry and repeated downgrade transitions. + +Dependencies: `W10.2`, `W7` notification adapter or an explicitly separate aggregator notifier. + +### `W10.7` Reconciliation and backfill + +Backfill current records and available historical events, retry pending policy associations, reverify stale provenance, and preserve immutable duplicate-release behavior. + +Dependencies: `W10.2`, `W10.3`, `W10.5`. + +### W10 Completion + +Gate 5 aggregator criteria pass under event reordering, rapid profile transitions, queue delays, retries, and backfill. + +## Workstream W11: Operations and Self-Hosting + +### `W11.1` Deployment configuration + +Define fail-closed Worker bindings and variables for D1, Queues, DLQs, static assets, verifier Worker, email, public origin, OAuth metadata, GitHub audience, encryption keys, and allowed publisher policy. + +Dependencies: `W5.1`, `W7` binding choices. + +### `W11.2` Key and session operations + +Runbooks and tooling for: + +- Client assertion key overlap and removal. +- Application encryption-key migration. +- Webhook secret rotation. +- Publisher revocation and reauthorization. +- Compromised deployment emergency response. + +Dependencies: `W3.1` through `W3.4`, `W7.4`. + +### `W11.3` Observability and alerts + +Implement metrics and structured logs for lifecycle latency, validation failures, OAuth refresh, lease contention, PDS ambiguity, approval expiry, Queue age, DLQs, and delivery failures. Add security alerts listed in the spec. + +Dependencies: service lifecycle stabilized in `W5`, notification lifecycle in `W7`. + +### `W11.4` Abuse controls + +- Rate limits by publisher, policy, package, and source. +- Active-intent and remote-byte quotas. +- OIDC replay and policy-mismatch detection. +- Strict CSP, cookies, origin, and framing policy. +- Log/Sentry redaction tests. + +Dependencies: `W4`, `W5`, `W6`. + +### `W11.5` Backup, restore, and audit export + +Document and test D1 backup/export, encrypted-row restore, intent/audit export, and recovery after Queue loss. Restoration must not duplicate PDS releases. + +Dependencies: `W5.7`, `W3.1`. + +### `W11.6` Workers self-hosting path + +Provide a deployment template and guide that creates D1, Queues, DLQs, cron, verifier Worker, email adapter, OAuth keys, and secrets in another Cloudflare account. Include a post-deploy conformance command. + +Dependencies: `W11.1` through `W11.5`. + +### W11 Completion + +An operator can deploy, monitor, rotate, back up, restore, revoke, and incident-respond without undocumented database edits. + +## Workstream W12: Conformance and Security + +This workstream owns cross-component testing. Unit and workstream integration tests remain with their implementation workstreams. + +### `W12.1` Shared conformance fixtures + +Version fixtures for profiles, releases, access diffs, GitHub tokens, Sigstore bundles, service API responses, aggregator events, and installer outcomes. Every component imports fixtures rather than copying JSON. + +Dependencies: `W1`, `W2`, `W4` contracts. + +### `W12.2` Real workerd integration suite + +Cover D1 migrations, Queues, DLQs, cron, OAuth stores, refresh contention, state CAS, outbox recovery, expiry, encryption, and verifier Worker calls. + +Dependencies: `W3`, `W5`, `W7`. + +### `W12.3` Browser/WebAuthn suite + +Using the existing virtual authenticator pattern, cover multiple credentials, UV rejection, challenge replay, separate enrolment/approval, removal authorized by another active credential, last-credential OAuth recovery, affected-publisher notifications, rejection, Arabic RTL, accessibility, and mobile approval review. + +Dependencies: `W3.7`, `W6`. + +### `W12.4` End-to-end protocol suite + +Run fake GitHub issuer -> release service -> test PDS -> aggregator -> clean EmDash installer. Include automatic, approval-required, tampered, changed-profile, changed-baseline, ambiguous-write, and downgrade cases. + +Dependencies: `W5`, `W8`, `W9`, `W10`. + +### `W12.5` Adversarial and fuzz testing + +- Tar and Sigstore parser fuzzing. +- URL, redirect, DNS, and webhook SSRF cases. +- OIDC claim confusion and replay. +- OAuth refresh race and key rotation. +- Cross-tenant endpoint and intent authorization. +- Allowed-publisher rejection at console login/delegation and CI submission. +- Guessing and internal-row-ID attempts against public intent routes. +- Approval digest mutation and cross-action replay. +- Queue duplicate, reorder, poison, and DLQ behavior. + +Dependencies: feature-complete implementations. + +### `W12.6` External security review + +Review scope includes service compromise blast radius, OAuth custody, DPoP storage, encryption/key rotation, WebAuthn ceremonies, OIDC policy, PDS reconciliation, parser boundaries, notification egress, and tenant isolation. + +Dependencies: Gates 2 and 3. + +### `W12.7` Self-host and production smoke + +First provision a fresh Workers/D1 self-host from `W11.6` and run the same delegated-release conformance suite used for the hosted service. Then use a controlled GitHub repository, real OIDC, supported PDS, hosted service, production aggregator, and disposable EmDash site. Verify rollback disables new submissions without invalidating published records or losing staged audit data. + +Dependencies: `W11.6` and Gates 0 through 4; final hosted production run after Gate 5 implementation. + +### W12 Completion + +No unresolved critical/high security findings, all conformance suites pass, and the production smoke satisfies Gate 5. + +## Recommended Merge Sequence + +The following sequence preserves parallelism while keeping each merge independently coherent. Items on the same row may proceed concurrently after their dependencies land. + +| Sequence | Merge unit | Depends on | +| --- | --- | --- | +| 1 | Gate 0 RFC decisions and CLI naming | None | +| 2 | Create-only OAuth/PDS spike | Draft record NSID | +| 3 | Confidential OAuth/workerd spike | None | +| 4 | Sigstore/workerd spike | Draft provenance shape | +| 5 | Aggregator historical-event prototype | None | +| 6 | Profile and release lexicons plus generated types | Gate 0 record decisions | +| 7 | Profile-extension preservation regression fix | Profile lexicon | +| 8 | Create-only release publishing helper | Generated types, scope contract | +| 9 | Shared package scaffold, checksum, fetch, and bundle validation | Gate 0 | +| 10 | Declared-access canonical diff | Escalation decision, generated types | +| 11 | Shared provenance and record verification | Sigstore spike, lexicons, safe fetch | +| 12 | Passkey required-UV and challenge-context extension | Gate 0 | +| 13 | Release-service scaffold and initial D1 migrations | Gate 0 | +| 14 | Encryption, confidential metadata, and OAuth stores | OAuth spike, scaffold | +| 15 | GitHub verifier and workload-policy repository | Scaffold | +| 16 | Intent submission and validation worker | Shared verification, GitHub policy, D1 repositories | +| 17 | Enrolment and multiple-passkey service flow | OAuth identity, passkey extension, D1 repositories | +| 18 | Approval digest and approval/rejection lifecycle | Validation worker, passkey flow | +| 19 | Publication, refresh coordination, and reconciliation | Create-only helper, approval lifecycle, OAuth store | +| 20 | Outbox, Queues, cron, expiry, and recovery | Core lifecycle | +| 21 | Versioned API client and CI endpoints | Core lifecycle/API | +| 22 | Installer shared-verification migration and enforcement | Shared verification, lexicons | +| 23 | Minimum aggregator policy status and filtering | Shared verification, lexicons | +| 24 | Console foundation, delegation, policy, intent, passkey, approval, and audit pages | Stable service API and service flows | +| 25 | Email, webhook, and delivery workers | Outbox lifecycle | +| 26 | Notification console pages | Notification API and delivery workers | +| 27 | CLI delegation, policy, enrolment, approval, and release commands | API client and service flows | +| 28 | Official GitHub Action | CI API and GitHub verifier | +| 29 | Event-specific aggregator ingest and policy history | Historical-event prototype | +| 30 | Aggregator provenance pipeline, historical views, and cooldown | Policy history, notifications | +| 31 | Operations, self-hosting, and conformance hardening | Feature-complete service | +| 32 | Self-host conformance, production smoke, and launch gate | All prior launch dependencies | + +## Parallelization Map + +After Gate 0: + +- Team A can execute `W1` protocol and lexicons. +- Team B can start `W2.1` through `W2.3` and finish provenance after `W1.2`. +- Team C can execute `W3.1`, `W3.6`, and service OAuth after the OAuth spike. +- Team D can scaffold `W5.1`, define generic repositories, and implement `W4`. +- Team E can implement `W10.1` from the historical-event prototype independently of the service. + +After Gate 1: + +- `W5` validation and publication become the main integration path. +- `W9` installer work can proceed independently against shared fixtures. +- `W10.3` provenance processing can proceed once event-specific policy association exists. + +After Gate 2: + +- `W6`, `W7`, and `W8` can proceed in parallel against the stable API. +- `W11` can add production bindings, observability, and runbooks. +- `W12` can begin full cross-component suites. + +## Dependency Risks + +| Risk | Impacted work | Required response | +| --- | --- | --- | +| Supported PDS rejects create-only scope | `W3`, `W5`, `W8` | Change RFC/support matrix; never add broad fallback. | +| Sigstore verifier cannot run in workerd | `W2`, `W5`, `W9`, `W10` | Resolve runtime compatibility or controlled cryptographic worker design before Gate 1. | +| Historical profile values cannot be recovered | `W10`, production launch | Redesign history source or revise RFC downgrade guarantees before Gate 5. | +| Profile extension shape changes after implementation | `W1`, all consumers | Block downstream schema work until ratification; regenerate fixtures together. | +| D1 refresh lease proves unsafe under real atcute behavior | `W3`, `W5` | Introduce per-publisher DO coordinator behind `PublisherCoordinator`, keeping D1 canonical. | +| Current CLI profile update strips extensions | `W1`, policy security | Land preservation regression fix with the lexicon, before policy can be set. | +| GitHub attestation fields differ from RFC assumptions | `W0`, `W2`, `W4` | Ratify mapping from real bundle and update RFC fields before verifier implementation. | +| Verifier Worker cannot enforce required egress policy | `W2`, `W7`, self-hosting | Require controlled egress proxy for deployments with private connectivity. | + +## Definition of Done for Every Work Item + +- Intended behavior and failure behavior are both tested. +- New persisted state has a forward-only migration and real D1 test. +- New async work is idempotent under duplicate and reordered delivery. +- New API behavior has a stable error code and API-client coverage. +- Security-sensitive comparisons use canonical forms and constant-time comparison where applicable. +- No secrets or private notification data appear in logs, errors, fixtures, or snapshots. +- User-facing strings are localized and new layouts pass RTL review. +- Package changes include an appropriate changeset. +- `pnpm build`, targeted tests, `pnpm lint:quick`, and relevant typechecks pass. +- The workstream's integration gate documentation is updated with actual verification evidence. + +## First Execution Set + +Start these immediately and in parallel: + +1. `W0.1` and `W0.2`: ratify protocol record and escalation contracts. +2. `W0.3`: create-only PDS compatibility spike. +3. `W0.4`: confidential OAuth and D1 refresh-lock spike. +4. `W0.5`: real GitHub Sigstore bundle verification in workerd. +5. `W0.6`: event-specific aggregator history prototype. +6. `W0.7`: settle CLI spelling. + +Do not start the production service state machine until `W0.3`, `W0.4`, and `W0.5` pass. Protocol fixture work may begin from draft shapes, but publishing lexicons waits for `W0.1` and `W0.2` ratification. diff --git a/.opencode/plans/delegated-release-service/spec.md b/.opencode/plans/delegated-release-service/spec.md new file mode 100644 index 0000000000..26f14c45fd --- /dev/null +++ b/.opencode/plans/delegated-release-service/spec.md @@ -0,0 +1,1135 @@ +# Delegated Release Service Implementation Spec + +Status: design draft pending RFC record-shape ratification and Phase 0 feasibility results + +Source: [RFC PR #1870](https://github.com/emdash-cms/emdash/pull/1870), Attested Automated Publishing + +This spec covers the complete feature: protocol records, shared verification, the hosted delegated release service, publisher and approver UI, CLI integration, install-time verification, and aggregator handling. The service is the center of the design, but it is not useful or safe unless the protocol and install-time work land with it. + +## Decisions + +| Area | Decision | +| --- | --- | +| Reference runtime | A standalone Cloudflare Workers app at `apps/release-service`, separate from the aggregator. | +| Canonical state | D1. Use CAS state transitions, unique constraints, leases, and a transactional outbox. | +| Durable Objects | Do not use one in v1. A per-publisher DO is a later optimization for OAuth refresh and PDS write serialization if D1 lease contention becomes material. | +| Workload identity | GitHub Actions OIDC in v1 behind an issuer-neutral verifier interface. | +| Management surface | A full web console for service-local configuration and audit. Signed package policy remains read-only in the service. | +| Approver credentials | Multiple named passkeys per approver DID, individually revocable. | +| Notifications | Email and signed webhooks, both behind adapters. | +| API style | Versioned JSON HTTP API under `/v1`, not XRPC. Protocol records remain atproto lexicons. | +| Provenance predicate | SLSA provenance v1 is the only understood predicate in v1. Other predicates are present-but-unverifiable and fail when provenance is supplied. | +| Approval threshold | One valid approval from any currently listed, enrolled approver. Quorum is out of scope. | +| Stage TTL | 24 hours by default, operator-configurable with a bounded range of 15 minutes to 7 days. | +| Service tenancy | Multi-tenant by default. A v1 self-host deploys the same Worker app in its own Cloudflare account and can restrict allowed publisher DIDs. | + +## Non-Negotiable Security Invariants + +1. CI never receives or stores an atproto account credential, refresh token, DPoP private key, or delegated release session. +2. The durable writer grant is exactly `atproto repo:?action=create`. There is no `transition:generic` fallback. +3. The service never durably acquires profile write scope. The console reads signed package policy from the PDS but cannot change it. +4. A release version is create-only and immutable. The delegated path has no overwrite option. +5. Every decision is based on records fetched directly from the publisher PDS, not aggregator data. +6. Every supplied artifact and provenance document is fetched through the shared SSRF-safe fetcher and checked against its signed checksum. +7. Artifact, provenance, profile policy, and escalation checks run at submission and again immediately before the PDS write. +8. Human approval is bound to an approval digest covering the release, workload evidence, policy, profile CID, baseline release CID, approver DID, and action. +9. Passkey approval requires WebAuthn user verification, not only user presence. +10. A listed but unenrolled DID cannot approve. Enrolment and approval are separate ceremonies. +11. D1 state and a PDS write are never treated as one transaction. Ambiguous PDS results are reconciled by reading the deterministic record key. +12. Secrets at rest are application-encrypted. Database access alone must not expose OAuth sessions, DPoP keys, emails, or webhook secrets. + +## Scope + +### Included + +- Profile policy and release provenance lexicon additions. +- A shared, Workers-compatible registry verification package. +- A standalone hosted release service with D1, Queues, cron, static assets, and a React console. +- Confidential atproto OAuth for durable release delegation. +- atproto login for publisher console sessions and approver DID proof. +- GitHub Actions OIDC policy registration and verification. +- Staged release lifecycle, passkey enrolment, approval, rejection, cancellation, and expiry. +- Multiple passkeys per approver DID. +- Email and webhook notifications. +- CLI commands and a reusable service API client. +- Install-time provenance and signed policy enforcement. +- Aggregator policy status, provenance verification, and downgrade history. +- Local workerd tests, browser WebAuthn tests, and an end-to-end test PDS flow. +- Cloudflare Workers self-hosting documentation and replaceable notification adapters. + +### Excluded + +- Native plugins. +- Non-GitHub workload issuers in the first release. +- Quorum approval. +- Signed approval receipts verifiable by installers. +- The future approver-addition cooldown. +- A profile-policy editor inside the release service. +- Artifact hosting or upload. CI supplies stable URLs. +- Generic configurable OIDC claim expressions in v1. +- A protocol-level recovery identity for lost approver passkeys. + +## Required RFC Clarifications + +These should be corrected in PR #1870 or recorded in a follow-up before implementation is called conforming. + +### OAuth revocation is not immediate + +Removing a confidential-client key from JWKS prevents sessions bound to that key from refreshing after the authorization server observes the removal. Existing access tokens remain usable until expiry. Emergency revocation must also call the authorization server's revocation endpoint for each known session. The service stores the client assertion key ID used to create each session so routine rotation can retain old public keys while those sessions exist. + +### The service uses public PDS reads + +Create-only repo scope grants writes, not reads. Package profiles and prior releases are public records and are read without the delegated session. Reads resolve the current PDS from the publisher DID and never use the aggregator as authority. + +### Automated submission is asynchronous + +Artifact and Sigstore verification can outlive a normal request and must survive retries. `POST /v1/release-intents` returns an intent resource. The GitHub Action and CLI poll or stream status until the result is `published` or `awaiting_approval`. A fast path may include the AT URI in the initial response, but callers cannot require it. + +### "Previous release" needs a deterministic definition + +For escalation evaluation, the baseline is the highest-semver existing release for the package at the time of verification, excluding the proposed `(package, version)` key. If no release exists, the baseline access is `{}`. This is deliberately conservative for out-of-order publication. The baseline is recomputed before publishing; a change invalidates an existing approval. + +### Experimental and stable NSIDs + +During the registry experiment the grant targets `com.emdashcms.experimental.package.release?action=create`. The service derives this from `@emdash-cms/registry-lexicons`; it must not hard-code the eventual `pm.fair.*` collection. Stable namespace migration requires every publisher to establish a new grant because OAuth scope is collection-specific. + +### CLI binary name + +The repository currently ships `emdash-plugin`, while the RFC examples say `emdash plugin`. Implementation and docs must settle on one public spelling. This spec uses the existing `emdash-plugin` binary. + +## Architecture + +```text +GitHub Actions Publisher / approver browser + | OIDC | atproto OAuth + WebAuthn + v v ++----------------------------------------------------------------+ +| apps/release-service | +| Worker API, React console, OAuth, OIDC, policy, orchestration | ++-----------------------------+----------------------------------+ + | + D1 canonical state + | + transactional outbox rows + | + +------------+-------------+ + | | + validation/publish queue notification queue + | | + artifact host, Sigstore Email Service, webhooks + | + publisher PDS createRecord +``` + +The service is not part of `apps/aggregator`. The aggregator is public, read-oriented, and has no publisher capability. The release service is a security-sensitive control plane with encrypted credentials and private user data. They may share published libraries, not bindings, databases, or deployments. + +### D1 Versus Durable Objects + +#### D1-only + +Benefits: + +- One relational source for console queries, package policy, stages, credentials, audit, and deliveries. +- Unique constraints naturally reserve package versions and deduplicate OIDC tokens. +- D1 migrations, local workerd testing, and Queue patterns already exist in `apps/aggregator`. +- The relational model does not prevent a later SQLite or Postgres port, but v1 self-hosting targets Workers and D1. + +Costs: + +- OAuth refresh and publication need explicit leases because multiple Worker isolates may race. +- PDS writes remain external side effects and require reconciliation. + +#### Durable Object authority + +A per-publisher SQLite Durable Object would serialize token refreshes and PDS writes. It does not solve the PDS transaction boundary, and it fragments state needed by the global console, audit queries, approver views, and operator tooling. Adding a D1 projection would introduce a dual-write problem more dangerous than the race it removes. + +#### Hybrid + +A hybrid keeps D1 canonical and routes only publisher-session operations through a per-publisher DO. This is viable later if metrics show meaningful lease contention or repeated refresh races. The DO must remain an execution coordinator with rebuildable state, never the only copy of a grant or release lifecycle. + +#### Decision + +Ship D1-only. Implement `PublisherCoordinator` behind an interface so a later DO adapter can replace D1 leases without changing intent or API code. + +```ts +interface PublisherCoordinator { + withSessionLease(publisherDid: string, operation: () => Promise): Promise; + withPublishLease(publisherDid: string, operation: () => Promise): Promise; +} +``` + +## Repository Layout + +```text +apps/release-service/ + migrations/ + public/ + src/ + api/ + audit/ + console/ + crypto/ + db/ + intents/ + notifications/ + oauth/ + oidc/ + passkeys/ + publishing/ + queues/ + routes/ + index.ts + test/ + package.json + vite.config.ts + vitest.config.ts + wrangler.jsonc + +packages/registry-verification/ + src/ + access.ts + bundle.ts + checksum.ts + fetch.ts + policy.ts + provenance.ts + records.ts + index.ts +``` + +Changes also touch: + +- `packages/registry-lexicons`: profile policy and provenance schemas. +- `packages/registry-client`: direct PDS read/create helpers and release-service API client. +- `packages/plugin-types`: canonical declared-access comparison and diff. +- `packages/auth`: optional required-UV WebAuthn verification and challenge context. +- `packages/plugin-cli`: delegate, policy, enrol, approve, and automated submit commands. +- `packages/core`: install-time provenance and profile policy enforcement. +- `apps/aggregator`: policy history, provenance verification, and discovery filtering. + +## Protocol Changes + +### Package profile extension + +Add `com.emdashcms.experimental.package.profileExtension` and an `extensions` map on the package profile. The extension has this shape: + +```ts +interface PackageProfileExtension { + $type: "com.emdashcms.experimental.package.profileExtension"; + repository: string; + releasePolicy?: { + requireProvenance?: boolean; + confirmation?: "escalation-only" | "always"; + approvers?: string[]; + }; +} +``` + +Constraints: + +- `repository` is a canonical HTTPS source repository URL, maximum 1024 bytes. +- `approvers` contains atproto DIDs only, has no duplicates, and is capped at 32. +- Absence normalizes to `requireProvenance: false`, `confirmation: "escalation-only"`, `approvers: []`. +- Unknown `confirmation` values fail lexicon or semantic validation. +- Policy fields stay optional for backwards compatibility. + +The repository field belongs in the signed profile extension because provenance layer 4 needs a package-level source anchor. The existing per-release `repo` field may remain for FAIR compatibility but cannot replace the profile anchor. + +### Release provenance + +Extend the release extension: + +```ts +interface ReleaseProvenance { + predicateType: "https://slsa.dev/provenance/v1" | string; + url: string; + checksum: string; + sourceRepository: string; + builderId: string; +} + +interface PackageReleaseExtension { + $type: "com.emdashcms.experimental.package.releaseExtension"; + declaredAccess: DeclaredAccess; + provenance?: ReleaseProvenance; +} +``` + +The provenance document is a Sigstore bundle. `checksum` covers the exact fetched document bytes. The attestation subject digest covers the package artifact bytes. + +### Lexicon rollout + +1. Add JSON lexicons and semantic validators. +2. Regenerate types with the existing registry-lexicons generation command. +3. Add round-trip fixtures before publishing schemas. +4. Update the experimental permission set, if one is published, to include the exact create-only collection. +5. Test scope support against Bluesky's PDS and at least one alternative PDS before service beta. + +Update every existing profile writer at the same time. In particular, `packages/plugin-cli/src/publish/api.ts` currently reconstructs profiles from a whitelist when updating `lastUpdated`; `ProfileInput`, `buildProfileRecord`, `stampLastUpdated`, and related validation must preserve `extensions` byte-for-byte unless `emdash-plugin policy` intentionally changes it. Add a regression test that sets strict policy, performs a later interactive publish, and proves the profile extension and CID-derived policy survive. + +## Shared Verification Package + +`@emdash-cms/registry-verification` is runtime-neutral ESM and must run in Workers and Node. It is the only implementation used by the service, installer, and eventually the CLI and aggregator. + +### Safe fetching + +`fetchVerifiedResource()` enforces: + +- HTTPS only. +- No URL credentials. +- Manual redirects, maximum 3. +- Reject IP literals and local/internal hostname forms. Pre-resolve DNS and reject loopback, private, link-local, multicast, unspecified, and metadata ranges before every hop as defense in depth. +- A response header timeout, total timeout, and byte limit. +- Streaming reads that abort on the first byte over the limit. +- No forwarding of authorization, cookies, or caller headers across origins. +- Content length is an early rejection only; the stream limit remains authoritative. +- Injected `fetch` and resolver for deterministic tests. + +Artifact and provenance hosts are untrusted. The same restrictions apply to webhook registration probes and redirects. + +Workers `fetch()` does not expose or pin the IP used for the actual connection, so DNS pre-resolution cannot eliminate rebinding TOCTOU. The hosted deployment performs untrusted fetches in a dedicated verifier Worker with no service bindings, VPC connectivity, credentials, or private origin access. If a deployment exposes private network connectivity, it must route these requests through an egress proxy that resolves, validates, and pins the destination. The threat model and self-hosting docs state this residual explicitly. + +### Checksum handling + +Exports: + +```ts +computeMultihash(bytes, algorithm) +decodeMultihash(value) +verifyMultihash(bytes, expected) +compareDigestBytes(left, right) +``` + +The delegated path accepts multibase multihashes only. Legacy bare hexadecimal checksums may remain install-compatible for old records but are rejected for new delegated releases. + +### Bundle verification + +Consolidate the currently different readers in `packages/plugin-cli/src/commands/publish.ts` and `packages/core/src/plugins/marketplace.ts`. + +The canonical reader: + +- Rejects absolute paths, `..`, normalized-path collisions, duplicate entries, links, devices, and unsupported entry types. +- Requires exactly one root `manifest.json` and one root `backend.js`. +- Enforces compressed, decompressed, file-count, and per-file limits from one exported constants module. +- Parses `manifest.json` with the shared plugin manifest schema. +- Requires manifest slug and version to match the intent. +- Returns canonical `declaredAccess`, never raw manifest capability sugar. + +### Declared access escalation + +Add these exports to `packages/plugin-types/src/declared-access.ts` and re-export from `@emdash-cms/plugin-types`: + +```ts +canonicalizeDeclaredAccess(value): CanonicalDeclaredAccess +declaredAccessEqual(previous, next): boolean +diffDeclaredAccess(previous, next): AccessDiff +isDeclaredAccessEscalation(previous, next): boolean +``` + +Rules: + +- Materialize implied operations, such as write implying read. +- Sort object keys and sort/deduplicate host lists. +- Adding a category or operation is escalation. +- Removing a category or operation is narrowing. +- Missing `network.request.allowedHosts` means unrestricted access. +- Restricted to unrestricted network access is escalation. +- A new host is escalation unless an old exact or wildcard pattern already covers it. +- `*.example.com` covers subdomains, not `example.com` itself. +- Removing a known restricting constraint is escalation. +- Any changed unknown constraint is conservatively escalation because narrowing cannot be proved. +- The first release compares against empty access. +- The result includes a structured, localized-UI-friendly diff, not preformatted English. + +### Provenance verification + +```ts +interface ProvenanceVerifier { + verify(input: { + document: Uint8Array; + reference: ReleaseProvenance; + artifactDigest: Uint8Array; + profileRepository: string; + }): Promise; +} +``` + +The GitHub/Sigstore implementation verifies: + +1. The reference checksum matches the fetched Sigstore bundle. +2. The Sigstore signature, certificate chain, identity, and transparency evidence are valid under current Sigstore trust roots. +3. The attestation predicate is SLSA provenance v1. +4. A subject digest exactly matches the package artifact digest. +5. The attested source repository canonicalizes to `sourceRepository` in the release reference. +6. `sourceRepository` canonicalizes to the signed profile `repository`. +7. The verified GitHub workflow identity exactly matches `builderId` using the authoritative identity fields in the Sigstore certificate and SLSA predicate. + +The implementation must not do partial verification. Unknown predicates and unsupported bundle formats produce `PROVENANCE_UNVERIFIABLE`, never "absent". + +An early spike must prove that the selected Sigstore verifier bundles and runs in workerd. If the upstream package depends on unsupported Node APIs, this blocks the hosted service phase; do not replace cryptographic verification with an external trust API. + +That spike must use an actual bundle from `actions/attest-build-provenance` and document the exact mapping for repository ID, workflow ref, commit SHA, ref, certificate identity, SLSA `builder.id`, and the RFC's `builderId`. Implementation must follow the observed bundle schema rather than assuming the workflow ref is always stored in `builder.id`. + +## Service Data Model + +All IDs are ULIDs. Timestamps are UTC ISO strings. JSON columns contain canonical JSON where hashes or comparisons depend on them. + +### Identity and delegation + +`publisher_accounts` + +| Column | Notes | +| --- | --- | +| `did` | Primary key. | +| `handle` | Display cache only. | +| `pds_url` | Resolved cache, never a permanent authority. | +| `pds_resolved_at` | Cache timestamp. | +| `created_at`, `updated_at` | Audit metadata. | + +`delegations` + +| Column | Notes | +| --- | --- | +| `id` | Primary key. | +| `publisher_did` | Unique active delegation per DID and release NSID. | +| `release_nsid` | Scope-bound collection. | +| `encrypted_session` | atcute session state, refresh token, DPoP private key, and nonce data. | +| `encryption_key_version` | Enables envelope-key rotation. | +| `client_key_id` | Confidential client assertion key that created the session. | +| `scope` | Must equal the expected exact scope. | +| `status` | `active`, `refreshing`, `reauthorization_required`, `revoked`. | +| `lease_owner`, `lease_expires_at` | Distributed refresh lock. | +| `last_refreshed_at`, `refresh_before` | Proactive refresh schedule. | +| `created_at`, `updated_at`, `revoked_at` | Lifecycle. | + +`console_sessions` stores an opaque hashed browser session token, publisher DID, expiry, and CSRF secret. The atproto identity OAuth session is discarded after login; console authentication does not need durable PDS access. + +`oauth_transactions` stores hashed state, PKCE material, purpose, expected DID, encrypted temporary state, redirect target, and expiry. Purposes are `console_login`, `release_delegation`, and `approver_identity`. + +### Workload policy + +`workload_policies` + +| Column | Notes | +| --- | --- | +| `id` | Primary key. | +| `publisher_did`, `package_slug` | Unique package mapping. | +| `provider` | `github-actions` in v1. | +| `issuer` | Exact issuer URL. | +| `audience` | Exact service audience. | +| `repository_id`, `repository_owner_id` | Immutable GitHub identifiers, required. | +| `repository` | Human-readable `owner/name`, checked in addition to IDs. | +| `workflow_ref` | Exact reusable workflow or workflow file identity. | +| `allowed_refs` | JSON list of exact refs or validated prefix patterns. | +| `allowed_environments` | Optional JSON allowlist. | +| `enabled` | Boolean. | +| `version` | Increments on every edit and is captured by an intent. | +| `created_by_did`, `created_at`, `updated_at` | Audit. | + +Policies do not accept arbitrary JavaScript or expression languages. The console presents typed fields. A package may have multiple policies, for example release and prerelease workflows. + +`workload_token_uses` has a primary key over `(issuer, token_hash)`, expiry, intent ID, and first-seen timestamp. Hash the complete raw JWT so replay protection does not depend on an optional `jti` claim. + +### Approvers and passkeys + +`approver_identities` + +| Column | Notes | +| --- | --- | +| `did` | Primary key. | +| `handle` | Display cache. | +| `encrypted_email` | Optional private notification route. | +| `email_verified_at` | Null until verified. | +| `created_at`, `updated_at` | Lifecycle. | + +`approver_credentials` + +| Column | Notes | +| --- | --- | +| `credential_id` | Primary key, base64url credential ID. | +| `approver_did` | Owner. | +| `name` | User-provided authenticator label. | +| `public_key`, `algorithm`, `counter`, `transports` | WebAuthn verification data. | +| `device_type`, `backed_up` | Display and risk metadata. | +| `created_at`, `last_used_at`, `revoked_at` | Lifecycle. | + +`enrolment_invitations` stores a random-token hash, target DID, optional publisher/package context, expiry, consumed time, and inviter DID. Possession does not authorize enrolment; OAuth must return the same target DID. + +`webauthn_challenges` stores only the challenge hash plus purpose, approver DID, credential restrictions, intent ID, approval digest, profile CID, baseline release CID, action, expiry, and consumed timestamp. + +### Release lifecycle + +`release_targets` reserves immutable versions. + +| Column | Notes | +| --- | --- | +| `publisher_did`, `package_slug`, `version` | Composite primary key. | +| `record_digest` | Canonical proposed release record hash. | +| `intent_id` | Owning intent. | +| `at_uri`, `cid` | Populated on publication. | + +`release_intents` + +| Column | Notes | +| --- | --- | +| `id` | Primary key. | +| `publisher_did`, `package_slug`, `version`, `rkey` | Release identity. | +| `idempotency_key` | Unique per workload policy. | +| `record_json`, `record_digest` | Immutable canonical release draft. | +| `approval_digest` | Hash of every fact displayed and authorized by a passkey approval. | +| `artifact_digest` | Raw digest representation for provenance comparison. | +| `workload_policy_id`, `workload_policy_version` | Policy used at submission. | +| `workload_evidence` | Canonical verified claims, never the raw bearer token. | +| `profile_cid`, `profile_policy_json`, `profile_policy_digest` | Submission snapshot. | +| `baseline_release_uri`, `baseline_release_cid`, `baseline_access_json` | Escalation baseline. | +| `access_diff_json`, `is_escalation` | Approval presentation and decision. | +| `requires_approval` | Derived from signed profile policy plus escalation. | +| `status`, `state_version` | CAS state machine. | +| `expires_at` | Stage TTL. | +| `lease_owner`, `lease_expires_at` | Queue consumer lease. | +| `at_uri`, `release_cid` | Publication result. | +| `failure_code`, `failure_detail` | Stable machine code plus safe detail. | +| `created_at`, `updated_at`, `published_at` | Lifecycle. | + +The approval digest is not the release record digest. It is a domain-separated hash over canonical release record JSON, artifact and provenance references, derived declared access, normalized verified workload evidence, workload policy ID and version, profile CID and policy digest, baseline release CID and access, approver DID, and action. Recompute it when issuing and consuming a challenge. Any mismatch invalidates the challenge. + +`intent_approvals` stores intent ID, approver DID, credential ID, approval digest, profile CID, baseline release CID, approved/rejected action, and timestamp. It is append-only. + +### Delivery and audit + +`notification_endpoints` has an explicit `publisher_did` owner, endpoint type, optional `approver_did` recipient, encrypted destination, verification state, event allowlist, enabled state, and last failure. Webhook rows also store an encrypted signing secret. Every management and delivery query includes the owner key; ownership is never inferred from an event payload. + +`outbox_events` stores event ID, type, canonical payload, destination class, creation time, enqueue time, and completion time. + +`delivery_attempts` has a unique key `(event_id, endpoint_id, attempt)`, HTTP status or mail result, retry time, and redacted error. + +`audit_events` is append-only and contains actor type, actor identifier, action, target type/ID, request ID, IP prefix or privacy-preserving hash, structured safe metadata, and timestamp. Never log JWTs, OAuth state, session blobs, passkey challenges, email addresses, or webhook secrets. + +## Release Intent State Machine + +```text +received + -> validating + -> awaiting_approval + -> publish_queued + -> failed + +awaiting_approval + -> publish_queued + -> rejected + -> cancelled + -> expired + -> validating profile/baseline changed and must be re-evaluated + +publish_queued + -> publishing + -> published + -> publish_queued transient failure after confirmed absence + -> conflict different record exists at deterministic rkey + -> failed permanent verification or auth failure +``` + +Every transition uses a conditional update on `(id, status, state_version)`. Queue messages carry only intent ID and expected state version. A consumer that loses the CAS does no external work. + +Terminal statuses are `published`, `rejected`, `cancelled`, `expired`, `conflict`, and `failed`. + +### Submission transaction + +1. Verify the GitHub JWT and workload policy before fetching user-controlled URLs. +2. Validate and canonicalize the release draft. +3. Hash the raw JWT and canonical record. +4. Use one D1 batch to insert the token-use row, reserve the release target, insert the intent, append an audit event, and add a validation outbox row. +5. If the same idempotency key and record digest already exist, return the existing intent. +6. If an idempotency key or release target exists with different content, return `409`. + +Queue send is not atomic with D1. The request attempts to enqueue after commit, and a cron-driven outbox drainer sends any remaining rows. Duplicate sends are expected. + +### Validation + +The worker claims a bounded lease, then: + +1. Re-resolves the publisher DID and fetches the package profile from the current PDS. +2. Validates the profile lexicon and extension semantics. +3. Requires the package's signed profile repository to match the workload policy's repository. +4. Fetches and verifies all supplied artifact checksums. +5. Validates the package bundle and derives its canonical `declaredAccess`. +6. Requires bundle access to equal release-record access. +7. Fetches and verifies provenance when present. +8. Rejects absent provenance when `requireProvenance` is true. +9. Reads package releases directly from the PDS and selects the baseline. +10. Computes the structured access diff and escalation flag. +11. Derives `requiresApproval = confirmation === "always" || isEscalation`. +12. Stores the immutable snapshots and transitions to `awaiting_approval` or `publish_queued`. + +An intent requiring approval remains stageable when no listed approver is enrolled. The console shows it as blocked and notifications explain that enrolment is required. It is never auto-approved. + +### Approval + +The approval page can be viewed only after atproto login proves a DID currently listed in the fetched package policy. It shows: + +- Package, version, publisher DID, and signed source repository. +- GitHub repository, workflow, ref, commit SHA, run ID, run attempt, and environment. +- Artifact URL and checksum. +- Provenance URL, checksum, source, and builder. +- Previous release version and CID. +- Full declared-access diff, with escalation highlighted. +- Intent creation and expiry time. + +`POST .../approval/options` re-fetches profile and baseline state before issuing a challenge. If either CID changed, the intent returns to `validating`. The challenge binds the approval digest, current profile CID, current baseline CID, approver DID, and action. + +Approval verification: + +1. Find the credential by response ID and require it to belong to the OAuth-proven approver DID. +2. Verify origin and RP ID. +3. Require user presence and user verification. +4. Consume the exact single-use challenge. +5. Verify signature and counter. +6. In one D1 batch, update the credential counter, append approval and audit rows, transition the intent, and create outbox rows. + +Rejection uses a passkey assertion bound to action `reject`; it is not a weaker button click. + +### Final verification and publication + +Before PDS write, repeat all validation steps and require unchanged: + +- Canonical release record digest. +- Workload policy version and enabled state. +- Package profile CID and policy digest. +- Baseline release CID. +- Artifact bytes and checksum. +- Provenance document and all four verification layers. + +If the profile or baseline changed, invalidate the pending publication and return to validation. Human approval must be repeated if the recomputed intent still requires it. + +Publication uses `com.atproto.repo.createRecord` or an `applyWrites` batch containing one create. The deterministic rkey is `:`. It never updates the profile. + +If the PDS call times out or returns an ambiguous transport error: + +1. Fetch the deterministic AT URI from the PDS. +2. If it exists and canonical content equals the intended record, mark the intent published. +3. If absent, return to `publish_queued` with bounded exponential backoff. +4. If different content exists, mark `conflict` and alert the publisher. + +Authentication or scope errors mark the delegation `reauthorization_required`; affected intents remain staged until expiry and the publisher is notified. + +## Atproto OAuth + +### Client metadata + +Serve public metadata and JWKS from stable HTTPS URLs. The confidential client declares: + +```json +{ + "application_type": "web", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "dpop_bound_access_tokens": true, + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "ES256" +} +``` + +The exact `client_id`, redirects, scope declaration, and `jwks_uri` are deployment-derived. Client assertion keys and per-session DPoP keys are distinct keypairs. + +### OAuth purposes + +| Purpose | Requested scope | Persistence | +| --- | --- | --- | +| Console login | `atproto` | Delete OAuth session after creating the service cookie. | +| Approver identity proof | `atproto` | Delete immediately after matching the expected DID. | +| Release delegation | `atproto repo:?action=create` | Store encrypted until revoked. | + +Use separate logical atcute stores per purpose so sessions for the same DID cannot overwrite each other. + +### Session refresh + +Refresh tokens are rotating and replay-sensitive. Before restoring or refreshing a writer session, acquire a D1 lease on its delegation row. The lease owner must still match when persisting rotated state. Refresh proactively well before expiry and add jitter. A scheduled sweep refreshes idle sessions so a three-month refresh-token lifetime cannot lapse unnoticed. + +Store the confidential client key ID used at initial authorization. Routine rotation publishes old and new public keys together, uses new keys for new grants, and retains old private keys until their sessions are reauthorized or revoked. + +## GitHub Actions OIDC + +```ts +interface WorkloadIssuer { + verify(token: string, expectedAudience: string): Promise; +} + +interface VerifiedWorkload { + issuer: string; + subject: string; + repository: string; + repositoryId: string; + repositoryOwnerId: string; + workflowRef: string; + jobWorkflowRef?: string; + ref: string; + sha: string; + runId: string; + runAttempt: string; + environment?: string; + expiresAt: number; +} +``` + +The GitHub implementation verifies discovery metadata, remote JWKS signature, exact issuer, exact audience, `exp`, `nbf`, and reasonable `iat`. Authorization matches immutable repository and owner IDs, repository name, exact workflow identity, ref rules, and optional environment. + +Never authorize from request-body repository fields, actor names, branch display names, or a GitHub organization name alone. Store normalized verified claims, not the raw JWT. + +Workload cancellation requires a fresh OIDC token whose repository ID, workflow identity, run ID, and run attempt match the submitting workload evidence. The original bearer token and optional `jti` are not retained. A publisher may separately cancel any pre-publication intent through an authenticated console action; the audit log distinguishes workload and publisher cancellation. + +## WebAuthn and Enrolment + +Extend `@emdash-cms/auth/passkey` additively: + +- `PasskeyConfig.userVerification?: "preferred" | "required"`. +- Verification checks `authenticatorData.userVerified` when required. +- Challenge data accepts an opaque typed context and returns it after atomic consumption. +- Existing CMS callers retain `preferred` as their default. + +Service enrolment flow: + +1. Approver opens an invitation or starts enrolment for their own DID. +2. Service performs atproto OAuth with scope `atproto`. +3. OAuth `sub` must equal the invitation target DID, when present. +4. Service issues WebAuthn registration with UV required and excludes existing credential IDs. +5. Verified credential is inserted with a user-provided name. +6. Enrolment is audited and publisher/approver notifications are queued. +7. The user returns to the intent page but no approval is created automatically. + +An approver can add multiple credentials. Removing a credential requires a fresh atproto login and a passkey assertion from another active credential when one exists. If no credential remains, OAuth proof alone can start recovery, but the event is high-severity audited and notifies all publishers whose profiles currently list the DID. + +Package authorization is always re-evaluated from the current signed profile. Retaining a credential after a DID is removed from one package is safe because the same DID may approve other packages. + +## JSON API + +All responses use: + +```ts +type ApiResponse = + | { data: T; requestId: string } + | { error: { code: string; message: string; details?: unknown }; requestId: string }; +``` + +Errors have stable screaming-snake codes. Secrets and upstream raw errors never appear in `details`. + +### CI endpoints + +#### `POST /v1/release-intents` + +Headers: + +- `Authorization: Bearer ` +- `Idempotency-Key: ` +- `Content-Type: application/json` + +Body: + +```json +{ + "publisherDid": "did:plc:...", + "package": "gallery-plugin", + "version": "1.2.3", + "artifacts": { + "package": { + "url": "https://github.com/.../gallery-plugin.tgz", + "checksum": "b...", + "contentType": "application/gzip" + } + }, + "requires": { "env:emdash": ">=1.0.0" }, + "provenance": { + "predicateType": "https://slsa.dev/provenance/v1", + "url": "https://github.com/.../attestation.sigstore.json", + "checksum": "b...", + "sourceRepository": "https://github.com/example/gallery-plugin", + "builderId": "https://github.com/example/gallery-plugin/.github/workflows/release.yml@refs/heads/main" + } +} +``` + +The service derives `declaredAccess` from the fetched bundle. It may accept a caller-provided copy only to fail fast on disagreement; the service-derived value is authoritative for the record draft. + +Response: `202` with intent ID, status URL, expiry, and approval URL when known. + +#### `GET /v1/release-intents/:id` + +CI authenticates with a fresh matching OIDC token. Browser console sessions may also read authorized intents. Returns status, safe evidence, approval requirement, failure code, and final AT URI/CID. + +#### `POST /v1/release-intents/:id/cancel` + +Requires either a fresh OIDC token matching the original repository, workflow, run ID, and run attempt, or the publisher's authenticated console session. Valid only before `publishing`. + +### Publisher console endpoints + +- `GET /v1/me` +- `GET /v1/delegations` +- `POST /v1/delegations/start` +- `DELETE /v1/delegations/:id` +- `GET /v1/packages` +- `GET /v1/packages/:slug/profile-policy` +- `GET|POST|PATCH|DELETE /v1/workload-policies` +- `GET /v1/release-intents` +- `GET /v1/release-intents/:id` +- `POST /v1/approver-invitations` +- `GET|POST|PATCH|DELETE /v1/notification-endpoints` +- `GET /v1/audit-events` + +Mutations require the console CSRF token and same-origin request headers. A console session may manage only rows whose publisher DID matches its authenticated DID. + +### Approver endpoints + +- `GET /v1/enrolment-invitations/:token` +- `POST /v1/approver/oauth/start` +- `POST /v1/passkeys/registration/options` +- `POST /v1/passkeys/registration/verify` +- `GET /v1/passkeys` +- `PATCH|DELETE /v1/passkeys/:credentialId` +- `GET /v1/release-intents/:id/approval` +- `POST /v1/release-intents/:id/approval/options` +- `POST /v1/release-intents/:id/approve` +- `POST /v1/release-intents/:id/reject` + +Approval details require an atproto-authenticated browser session whose DID is currently listed by the package profile. The untrusted approval URL alone exposes only package name, status, and a login prompt. + +## Web Console + +Build a React SPA served by the Worker static-assets binding. Use Kumo components and Lingui from the beginning. All layouts use logical RTL-safe classes. + +Publisher pages: + +- Overview: delegation health, packages, held intents, failed deliveries. +- Delegation: exact scope, PDS, key/session health, revoke and reauthorize actions. +- Packages: current signed policy, enrolled/listed approver matrix, workload policies. +- Workload policy editor: typed GitHub repository, workflow, ref, and environment controls. +- Intents: searchable lifecycle history and exact verification results. +- Notifications: verified email routes, webhooks, event selection, test delivery, secret rotation. +- Audit: actor, action, target, outcome, and timestamp with filters and export. + +Approver pages: + +- Enrolment invitation and atproto identity proof. +- Passkey list with names, creation, last use, and individual revocation. +- Approval detail with workload, provenance, checksums, and access diff. +- Success, rejection, cancellation, and expiry states. + +The console displays signed profile policy read-only. To change `requireProvenance`, `confirmation`, or `approvers`, it generates the corresponding `emdash-plugin policy` command and refreshes until the new profile CID appears. This preserves the RFC's profile-scope separation. A future browser-only direct-to-PDS editor may be added only if profile tokens never reach the service Worker. + +## Notifications + +Events: + +- `intent.awaiting_approval` +- `intent.approved` +- `intent.rejected` +- `intent.cancelled` +- `intent.expired` +- `intent.published` +- `intent.failed` +- `delegation.reauthorization_required` +- `approver.enrolled` +- `approver.credential_added` +- `approver.credential_removed` +- `approver.recovery` +- `delivery.disabled` + +Email contains the package, version, intent ID, and service-origin URL. It does not include full workload claims, access details, or secrets. Those require authenticated console access. + +Webhook delivery: + +- Stable event ID and schema version. +- `X-EmDash-Event`, `X-EmDash-Delivery`, `X-EmDash-Timestamp`, and HMAC-SHA256 signature headers. +- Signature input is `.`. +- At-least-once semantics; receivers deduplicate by delivery ID. +- Manual redirect handling and SSRF checks on every attempt. +- Exponential retries with jitter, then DLQ and endpoint disable threshold. +- Secret shown once and rotatable with an overlap window. + +Use interfaces for `Mailer` and `WebhookDispatcher`. The hosted adapter uses Cloudflare Email Service and Workers fetch; a Workers self-host may select another HTTP mail adapter. A future Node port can add SMTP without changing the webhook contract. + +## CLI and GitHub Action + +Add a shared client under `packages/registry-client/src/delegated`. + +Commands: + +```text +emdash-plugin delegate --service +emdash-plugin policy set --require-provenance --confirmation always --approver +emdash-plugin enrol --service [--invite ] +emdash-plugin approve --service +emdash-plugin release submit --service --url --provenance +emdash-plugin release status --service [--wait] +emdash-plugin release cancel --service +``` + +`delegate` opens the service OAuth flow and waits for completion. `enrol` and `approve` open the service-hosted WebAuthn page because terminal programs cannot directly perform a platform passkey ceremony. + +Provide an official GitHub Action wrapping OIDC acquisition and intent submission. It outputs: + +- `intent-id` +- `status` +- `approval-url` when held +- `release-uri` when published + +The action waits by default until `published`, `awaiting_approval`, or terminal failure. It never accepts an atproto secret input. + +## Installer Changes + +`packages/core` must enforce the signed policy independently of the service and aggregator. + +Install flow additions: + +1. Fetch the current release and package profile directly from the publisher PDS with record proof as already required by RFC 0001. +2. Validate profile and release extensions. +3. Verify artifact checksum and manifest access as today, using the shared package. +4. If provenance is present, run all provenance checks. +5. If `requireProvenance` was in force for the release and provenance is absent or failed, block installation. +6. If provenance is optional and absent, continue with an explicit "not attested" status. +7. If provenance is present but failed or unverifiable, block. Never downgrade it to absent. +8. Surface source repository, builder identity, workflow, and verified/unverified state without presenting provenance as malware safety. + +Historical policy-at-publication cannot be reconstructed from only the current profile after a legitimate policy change. Until the protocol carries a policy snapshot/receipt, direct installers use the current signed policy as a conservative floor. The aggregator tracks event history for accurate discovery-time status. This limitation must be explicit in UI and RFC text. + +## Aggregator Changes + +The aggregator needs event-history work before it can implement the RFC's "policy in force at publication time" rule. + +### Ingest ordering + +Current Jetstream jobs discard `time_us`, event CID, and repo revision, then fetch the current record by rkey. That loses intermediate profile versions. Merely adding those fields to the job is insufficient because a later PDS read still returns only the current value. Phase 0 must choose and prove an event source that provides the event-specific record value plus a verifiable commit/CAR path, or explicitly document a weaker trust model. Carry its ordering key, event CID, repo revision, and record blocks through the queue and persist every profile-policy version. + +Do not claim downgrade cooldown support until intermediate versions are provably retained. + +### Schema + +Add: + +- Raw profile extension and current policy digest on `packages`. +- `package_policy_events` with DID, slug, event ordering key, profile CID, policy, repository, and transition classification. +- Provenance reference, policy event ID, policy status, reasons, and verification time on `releases`. +- A provenance verification queue/outbox. +- Downgrade notification state and cooldown expiry. + +Release policy status is `pending`, `valid`, `invalid`, or `unverifiable`. Default search, latest-release selection, and update discovery exclude `pending`, `invalid`, and `unverifiable` releases when policy requires provenance. Explicit audit endpoints may return them with reasons. + +Policy weakening transitions include `requireProvenance: true -> false`, `confirmation: always -> escalation-only`, and removal of approvers. Only `requireProvenance` affects install validity; the other transitions are audit signals because confirmation is not installer-verifiable. + +Expensive provenance work runs asynchronously. Structural ingest writes `pending`, queues verification, and updates status with CAS. Any exposed pending/invalid release carries explicit status; it is never silently treated as valid. + +## Encryption and Key Management + +Use AES-256-GCM with a versioned master key supplied by a Worker secret or Secrets Store. Derive purpose-specific data-encryption keys with HKDF. Associated data includes table name, row primary key, publisher DID, and key version. + +Encrypt: + +- atcute OAuth session blobs and DPoP private keys. +- Confidential client private keys if not directly represented as deployment secrets. +- Email addresses. +- Webhook URLs when they contain private paths or tokens. +- Webhook secrets. + +Never encrypt fields that need indexed equality; store a separate keyed hash when lookup is required. Key rotation rewrites rows in bounded queue jobs. Old keys remain available until migration completes and verification reports zero old-version rows. + +## Abuse and Platform Security + +- Rate-limit intent submission by workload policy, publisher, package, and source network. +- Cap active intents per package and publisher. +- Reserve storage before remote fetches and expire abandoned intents. +- Reject URLs before enqueueing when syntax is unsafe; repeat DNS/IP checks during fetch. +- Apply strict CSP, frame ancestors, referrer policy, secure cookies, and origin checks to console and approval pages. +- Use random opaque public intent IDs; do not expose D1 row IDs or sequential identifiers. +- Redact all bearer tokens and OAuth materials from logs and Sentry. +- Treat provenance parser and tar parser input as hostile and fuzz both. +- Keep approval and enrolment pages on one configured RP origin; preview deployments use separate RP IDs and databases. +- Require explicit operator configuration for trusted origins, client ID, audience, email sender, and public base URL. Fail closed when absent. + +## Background Work + +Queues: + +- `release-validation`: validation and publication jobs. +- `release-notifications`: email and webhook jobs. +- A DLQ for each queue. + +Cron tasks: + +- Every minute: drain unsent outbox rows and expire staged intents. +- Every 5 minutes: reclaim expired worker leases and reconcile `publishing` intents. +- Daily: refresh delegations approaching refresh-token expiry, re-resolve stale DIDs, prune consumed OAuth state and challenges, and verify notification endpoint health. + +Every consumer is idempotent and begins with a state/version CAS. DLQ consumers persist a forensics row or audit event before acknowledging. + +## Observability + +Structured logs and metrics include: + +- Intent counts and latency by lifecycle state. +- Validation failure code and layer, without private payloads. +- OIDC failures by reason. +- OAuth refresh success, contention, and reauthorization count. +- PDS write latency, ambiguous outcomes, and reconciliation result. +- Approval wait duration and expiry rate. +- Queue age, retries, and DLQ count. +- Email/webhook delivery success and endpoint disable count. +- D1 query and write latency. +- Active leases and lease-steal count. + +Security alerts: + +- Delegation scope mismatch. +- OAuth client key removal or refresh replay error. +- Re-enrolment or recovery. +- Different record at a reserved release rkey. +- Repeated provenance or artifact mutation between stage and approval. +- Repeated OIDC replay or policy mismatch. +- Webhook SSRF attempt. + +## Testing + +### Protocol and shared package + +- Lexicon valid/invalid fixtures and generated-type tests. +- Checksum vectors for supported and unsupported algorithms. +- URL redirect, DNS rebinding, private IPv4/IPv6, metadata endpoint, timeout, and oversize tests. +- Tar traversal, duplicate normalized path, duplicate manifest, links, devices, gzip bomb, and cap tests. +- Every declared-access widening and narrowing rule. +- Valid Sigstore bundle, bad signature, wrong subject, substituted bundle, wrong repository, wrong builder, unknown predicate, and expired trust material. + +### Service unit and workerd integration + +- Real D1 migrations with `@cloudflare/vitest-pool-workers`. +- OIDC signature, issuer, audience, time, immutable claim, policy, and replay failures. +- Duplicate idempotency and version races. +- First release with non-empty access requires approval. +- `confirmation: always` requires approval for unchanged access. +- Listed but unenrolled approver cannot approve. +- Removed approver cannot approve with a retained credential. +- Required-UV rejects a user-present-only assertion. +- Challenge replay and cross-intent/action replay fail. +- Profile, baseline, artifact, provenance, or workload-policy mutation invalidates approval. +- Concurrent OAuth refresh obtains one lease and preserves the rotated session. +- Ambiguous PDS write reconciles exact match, absence, and conflicting content. +- Queue redelivery, outbox recovery, lease expiry, stage expiry, rejection, and cancellation. +- Email/webhook deduplication, signature, redirect, retry, and disable behavior. +- Encryption round trip and key rotation. + +### Browser E2E + +Use the existing virtual authenticator fixture pattern from `e2e/fixtures/virtual-authenticator.ts`. + +- Publisher signs in and creates a GitHub policy. +- Publisher establishes the create-only delegation. +- Approver accepts invitation through atproto OAuth and registers two passkeys. +- Enrolment does not approve an existing intent. +- Approver reviews and approves an escalating intent. +- A second credential can approve after the first is revoked. +- Arabic locale and RTL layout for every new console flow. + +### End-to-end protocol + +Against a test PDS and fake GitHub issuer: + +1. Publish a signed profile with strict policy. +2. Submit an attested artifact from CI. +3. Approve with a virtual passkey. +4. Create the release record through the scoped session. +5. Ingest it in the aggregator. +6. Install it from a clean EmDash site with independent provenance verification. + +Run a second suite against real GitHub OIDC and a real supported PDS in a controlled repository before production launch. + +## Delivery Plan + +### Phase 0: Feasibility spikes + +- Prove create-only permission support on target PDSes. +- Prove confidential `private_key_jwt` OAuth with persistent DPoP session restoration in workerd. +- Prove distributed refresh locking under concurrent Worker requests. +- Prove Sigstore/SLSA verification in workerd. +- Resolve and prototype the aggregator historical-event source needed for policy-at-publication ordering. The prototype must recover two intermediate profile values changed before queue drain, not only their event metadata. + +Exit criterion: no unresolved platform blocker. These are spikes, not production shortcuts. + +### Phase 1: Protocol and verification foundation + +- Land profile and release lexicon additions. +- Land `@emdash-cms/registry-verification`. +- Land declared-access canonical diff. +- Extend passkey primitives with required UV and bound challenge context. +- Extract create-only release record construction into `registry-client`. +- Switch existing installer integrity checks to shared verification where behavior is equivalent. + +Exit criterion: records can represent the RFC, and service/installer use identical verification fixtures. + +### Phase 2: Secure automated vertical slice + +- Scaffold `apps/release-service` with D1, OAuth, GitHub OIDC, intent state machine, queues, and publication reconciliation. +- Implement delegation and minimal publisher console. +- Implement approver OAuth, multiple passkeys, approval pages, and audit. +- Implement CLI API client and official GitHub Action. +- Implement install-time provenance and policy enforcement. +- Add minimum aggregator policy status and default filtering. Until historical ordering lands, apply the current signed profile as a conservative floor and mark the result accordingly. + +Exit criterion: GitHub Actions can publish an attested release without a stored atproto credential, including an escalation requiring passkey approval, and a clean site independently verifies and installs it. + +### Phase 3: Hosted product completeness + +- Complete the publisher console and localization. +- Add email, webhook management, retries, and delivery UI. +- Add credential recovery, delegation health, key rotation tooling, and audit export. +- Add operator abuse controls and production observability. +- Publish Workers/D1 self-hosting docs and deployment templates. + +Exit criterion: the default hosted instance and a fresh Workers/D1 self-host can complete all service lifecycle operations without database/operator hand edits. Public production launch remains blocked on Phase 4's accurate aggregator history. + +### Phase 4: Aggregator policy enforcement + +- Preserve event-specific policy history. +- Add asynchronous provenance verification. +- Add policy status to read views and default filtering. +- Add downgrade cooldown and security-contact notification. + +Exit criterion: discovery accurately applies policy in force at publication time and survives out-of-order/replayed ingest. + +Phase 4 is a production launch gate for the default public service and registry, not optional post-launch hardening. + +### Phase 5: Hardening and expansion + +- External security review and parser fuzzing. +- Load and failure-injection tests. +- Additional OIDC issuers through the existing interface. +- Evaluate a per-publisher DO coordinator using measured refresh/publish contention. +- Consider signed approval receipts and quorum in a new RFC. + +## Acceptance Criteria + +- No atproto account credential exists in CI configuration or logs. +- The retained grant is demonstrably create-only for the active release NSID. +- The service cannot write profile records with its retained grant. +- Every escalation and every `confirmation: always` release requires a current listed approver's UV passkey assertion. +- Changing any approval-bound input invalidates the approval. +- A PDS timeout cannot create a duplicate or cause a successful release to be reported permanently failed. +- An installer rejects missing required provenance and every failed provenance layer without trusting service or aggregator status. +- The hosted service supports multiple passkeys, email, webhooks, full publisher audit, and self-service delegation. +- The implementation runs under Workers with real D1/Queue tests and has no DO correctness dependency. +- A self-host can deploy the same Worker with its own D1, Queues, cron triggers, Email Service or mail adapter, and webhook configuration without changing protocol or API semantics. + +## Known Residual Risks + +- A compromised release service can use active grants to publish unwanted releases that still satisfy signed profile and provenance constraints. +- Compromise of the service encryption key exposes all retained publisher sessions for that deployment. +- Revoking a confidential client key does not invalidate already-issued access tokens immediately. +- Hosted CI and Sigstore are part of provenance trust; a compromised authorized workflow can produce genuinely attested malicious output. +- Confirmation remains unverifiable at install time until the protocol carries signed approval receipts. +- Current-profile enforcement by a direct installer cannot perfectly reconstruct policy at historical publication time. +- Unknown declared-access constraints must be treated conservatively, which can require approval for a change that is actually narrowing. +- D1 and PDS cannot commit atomically; deterministic keys and reconciliation reduce, but do not remove, operational complexity. + +## Implementation Blockers to Resolve First + +1. Confirm the exact permission NSID and create-only scope against deployed PDS implementations. +2. Select and prove a Workers-compatible Sigstore verifier. +3. Decide the authoritative historical event source for aggregator policy ordering. +4. Ratify the package-profile repository field and extension shape. +5. Ratify the baseline definition and declared-access escalation rules in this spec. +6. Settle the public CLI spelling before adding new command documentation. From 1f0d5ffc6a5759ffa36edb0ef50f09632a364cee Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Fri, 10 Jul 2026 10:03:54 +0000 Subject: [PATCH 02/24] style: format --- .../implementation-plan.md | 118 +++++------ .../plans/delegated-release-service/spec.md | 198 +++++++++--------- 2 files changed, 158 insertions(+), 158 deletions(-) diff --git a/.opencode/plans/delegated-release-service/implementation-plan.md b/.opencode/plans/delegated-release-service/implementation-plan.md index 17e2962c15..176fd9bf8c 100644 --- a/.opencode/plans/delegated-release-service/implementation-plan.md +++ b/.opencode/plans/delegated-release-service/implementation-plan.md @@ -36,21 +36,21 @@ The implementation is complete when: ### Workstream IDs -| ID | Workstream | Primary output | -| --- | --- | --- | -| `W0` | Decisions and feasibility | Ratified contracts and proved platform assumptions | -| `W1` | Protocol and lexicons | Profile policy and release provenance records | -| `W2` | Shared verification | One verification implementation for all consumers | -| `W3` | OAuth, crypto, and passkeys | Secure identity, grant custody, and approval primitives | -| `W4` | Workload identity | GitHub OIDC verification and typed workflow policies | -| `W5` | Service persistence and orchestration | D1 state machine, queues, publication, reconciliation | -| `W6` | Publisher and approver console | Full management and approval UI | -| `W7` | Notifications | Email, signed webhooks, outbox delivery | -| `W8` | CLI and GitHub Action | Author and CI clients | -| `W9` | Installer enforcement | Independent install-time verification | -| `W10` | Aggregator enforcement | Historical policy and discovery filtering | -| `W11` | Operations and self-hosting | Production config, observability, abuse controls, runbooks | -| `W12` | Conformance and security | Cross-component, browser, adversarial, and production tests | +| ID | Workstream | Primary output | +| ----- | ------------------------------------- | ----------------------------------------------------------- | +| `W0` | Decisions and feasibility | Ratified contracts and proved platform assumptions | +| `W1` | Protocol and lexicons | Profile policy and release provenance records | +| `W2` | Shared verification | One verification implementation for all consumers | +| `W3` | OAuth, crypto, and passkeys | Secure identity, grant custody, and approval primitives | +| `W4` | Workload identity | GitHub OIDC verification and typed workflow policies | +| `W5` | Service persistence and orchestration | D1 state machine, queues, publication, reconciliation | +| `W6` | Publisher and approver console | Full management and approval UI | +| `W7` | Notifications | Email, signed webhooks, outbox delivery | +| `W8` | CLI and GitHub Action | Author and CI clients | +| `W9` | Installer enforcement | Independent install-time verification | +| `W10` | Aggregator enforcement | Historical policy and discovery filtering | +| `W11` | Operations and self-hosting | Production config, observability, abuse controls, runbooks | +| `W12` | Conformance and security | Cross-component, browser, adversarial, and production tests | ### High-Level Graph @@ -978,40 +978,40 @@ No unresolved critical/high security findings, all conformance suites pass, and The following sequence preserves parallelism while keeping each merge independently coherent. Items on the same row may proceed concurrently after their dependencies land. -| Sequence | Merge unit | Depends on | -| --- | --- | --- | -| 1 | Gate 0 RFC decisions and CLI naming | None | -| 2 | Create-only OAuth/PDS spike | Draft record NSID | -| 3 | Confidential OAuth/workerd spike | None | -| 4 | Sigstore/workerd spike | Draft provenance shape | -| 5 | Aggregator historical-event prototype | None | -| 6 | Profile and release lexicons plus generated types | Gate 0 record decisions | -| 7 | Profile-extension preservation regression fix | Profile lexicon | -| 8 | Create-only release publishing helper | Generated types, scope contract | -| 9 | Shared package scaffold, checksum, fetch, and bundle validation | Gate 0 | -| 10 | Declared-access canonical diff | Escalation decision, generated types | -| 11 | Shared provenance and record verification | Sigstore spike, lexicons, safe fetch | -| 12 | Passkey required-UV and challenge-context extension | Gate 0 | -| 13 | Release-service scaffold and initial D1 migrations | Gate 0 | -| 14 | Encryption, confidential metadata, and OAuth stores | OAuth spike, scaffold | -| 15 | GitHub verifier and workload-policy repository | Scaffold | -| 16 | Intent submission and validation worker | Shared verification, GitHub policy, D1 repositories | -| 17 | Enrolment and multiple-passkey service flow | OAuth identity, passkey extension, D1 repositories | -| 18 | Approval digest and approval/rejection lifecycle | Validation worker, passkey flow | -| 19 | Publication, refresh coordination, and reconciliation | Create-only helper, approval lifecycle, OAuth store | -| 20 | Outbox, Queues, cron, expiry, and recovery | Core lifecycle | -| 21 | Versioned API client and CI endpoints | Core lifecycle/API | -| 22 | Installer shared-verification migration and enforcement | Shared verification, lexicons | -| 23 | Minimum aggregator policy status and filtering | Shared verification, lexicons | -| 24 | Console foundation, delegation, policy, intent, passkey, approval, and audit pages | Stable service API and service flows | -| 25 | Email, webhook, and delivery workers | Outbox lifecycle | -| 26 | Notification console pages | Notification API and delivery workers | -| 27 | CLI delegation, policy, enrolment, approval, and release commands | API client and service flows | -| 28 | Official GitHub Action | CI API and GitHub verifier | -| 29 | Event-specific aggregator ingest and policy history | Historical-event prototype | -| 30 | Aggregator provenance pipeline, historical views, and cooldown | Policy history, notifications | -| 31 | Operations, self-hosting, and conformance hardening | Feature-complete service | -| 32 | Self-host conformance, production smoke, and launch gate | All prior launch dependencies | +| Sequence | Merge unit | Depends on | +| -------- | ---------------------------------------------------------------------------------- | --------------------------------------------------- | +| 1 | Gate 0 RFC decisions and CLI naming | None | +| 2 | Create-only OAuth/PDS spike | Draft record NSID | +| 3 | Confidential OAuth/workerd spike | None | +| 4 | Sigstore/workerd spike | Draft provenance shape | +| 5 | Aggregator historical-event prototype | None | +| 6 | Profile and release lexicons plus generated types | Gate 0 record decisions | +| 7 | Profile-extension preservation regression fix | Profile lexicon | +| 8 | Create-only release publishing helper | Generated types, scope contract | +| 9 | Shared package scaffold, checksum, fetch, and bundle validation | Gate 0 | +| 10 | Declared-access canonical diff | Escalation decision, generated types | +| 11 | Shared provenance and record verification | Sigstore spike, lexicons, safe fetch | +| 12 | Passkey required-UV and challenge-context extension | Gate 0 | +| 13 | Release-service scaffold and initial D1 migrations | Gate 0 | +| 14 | Encryption, confidential metadata, and OAuth stores | OAuth spike, scaffold | +| 15 | GitHub verifier and workload-policy repository | Scaffold | +| 16 | Intent submission and validation worker | Shared verification, GitHub policy, D1 repositories | +| 17 | Enrolment and multiple-passkey service flow | OAuth identity, passkey extension, D1 repositories | +| 18 | Approval digest and approval/rejection lifecycle | Validation worker, passkey flow | +| 19 | Publication, refresh coordination, and reconciliation | Create-only helper, approval lifecycle, OAuth store | +| 20 | Outbox, Queues, cron, expiry, and recovery | Core lifecycle | +| 21 | Versioned API client and CI endpoints | Core lifecycle/API | +| 22 | Installer shared-verification migration and enforcement | Shared verification, lexicons | +| 23 | Minimum aggregator policy status and filtering | Shared verification, lexicons | +| 24 | Console foundation, delegation, policy, intent, passkey, approval, and audit pages | Stable service API and service flows | +| 25 | Email, webhook, and delivery workers | Outbox lifecycle | +| 26 | Notification console pages | Notification API and delivery workers | +| 27 | CLI delegation, policy, enrolment, approval, and release commands | API client and service flows | +| 28 | Official GitHub Action | CI API and GitHub verifier | +| 29 | Event-specific aggregator ingest and policy history | Historical-event prototype | +| 30 | Aggregator provenance pipeline, historical views, and cooldown | Policy history, notifications | +| 31 | Operations, self-hosting, and conformance hardening | Feature-complete service | +| 32 | Self-host conformance, production smoke, and launch gate | All prior launch dependencies | ## Parallelization Map @@ -1037,16 +1037,16 @@ After Gate 2: ## Dependency Risks -| Risk | Impacted work | Required response | -| --- | --- | --- | -| Supported PDS rejects create-only scope | `W3`, `W5`, `W8` | Change RFC/support matrix; never add broad fallback. | -| Sigstore verifier cannot run in workerd | `W2`, `W5`, `W9`, `W10` | Resolve runtime compatibility or controlled cryptographic worker design before Gate 1. | -| Historical profile values cannot be recovered | `W10`, production launch | Redesign history source or revise RFC downgrade guarantees before Gate 5. | -| Profile extension shape changes after implementation | `W1`, all consumers | Block downstream schema work until ratification; regenerate fixtures together. | -| D1 refresh lease proves unsafe under real atcute behavior | `W3`, `W5` | Introduce per-publisher DO coordinator behind `PublisherCoordinator`, keeping D1 canonical. | -| Current CLI profile update strips extensions | `W1`, policy security | Land preservation regression fix with the lexicon, before policy can be set. | -| GitHub attestation fields differ from RFC assumptions | `W0`, `W2`, `W4` | Ratify mapping from real bundle and update RFC fields before verifier implementation. | -| Verifier Worker cannot enforce required egress policy | `W2`, `W7`, self-hosting | Require controlled egress proxy for deployments with private connectivity. | +| Risk | Impacted work | Required response | +| --------------------------------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------- | +| Supported PDS rejects create-only scope | `W3`, `W5`, `W8` | Change RFC/support matrix; never add broad fallback. | +| Sigstore verifier cannot run in workerd | `W2`, `W5`, `W9`, `W10` | Resolve runtime compatibility or controlled cryptographic worker design before Gate 1. | +| Historical profile values cannot be recovered | `W10`, production launch | Redesign history source or revise RFC downgrade guarantees before Gate 5. | +| Profile extension shape changes after implementation | `W1`, all consumers | Block downstream schema work until ratification; regenerate fixtures together. | +| D1 refresh lease proves unsafe under real atcute behavior | `W3`, `W5` | Introduce per-publisher DO coordinator behind `PublisherCoordinator`, keeping D1 canonical. | +| Current CLI profile update strips extensions | `W1`, policy security | Land preservation regression fix with the lexicon, before policy can be set. | +| GitHub attestation fields differ from RFC assumptions | `W0`, `W2`, `W4` | Ratify mapping from real bundle and update RFC fields before verifier implementation. | +| Verifier Worker cannot enforce required egress policy | `W2`, `W7`, self-hosting | Require controlled egress proxy for deployments with private connectivity. | ## Definition of Done for Every Work Item diff --git a/.opencode/plans/delegated-release-service/spec.md b/.opencode/plans/delegated-release-service/spec.md index 26f14c45fd..1da6350638 100644 --- a/.opencode/plans/delegated-release-service/spec.md +++ b/.opencode/plans/delegated-release-service/spec.md @@ -8,20 +8,20 @@ This spec covers the complete feature: protocol records, shared verification, th ## Decisions -| Area | Decision | -| --- | --- | -| Reference runtime | A standalone Cloudflare Workers app at `apps/release-service`, separate from the aggregator. | -| Canonical state | D1. Use CAS state transitions, unique constraints, leases, and a transactional outbox. | -| Durable Objects | Do not use one in v1. A per-publisher DO is a later optimization for OAuth refresh and PDS write serialization if D1 lease contention becomes material. | -| Workload identity | GitHub Actions OIDC in v1 behind an issuer-neutral verifier interface. | -| Management surface | A full web console for service-local configuration and audit. Signed package policy remains read-only in the service. | -| Approver credentials | Multiple named passkeys per approver DID, individually revocable. | -| Notifications | Email and signed webhooks, both behind adapters. | -| API style | Versioned JSON HTTP API under `/v1`, not XRPC. Protocol records remain atproto lexicons. | -| Provenance predicate | SLSA provenance v1 is the only understood predicate in v1. Other predicates are present-but-unverifiable and fail when provenance is supplied. | -| Approval threshold | One valid approval from any currently listed, enrolled approver. Quorum is out of scope. | -| Stage TTL | 24 hours by default, operator-configurable with a bounded range of 15 minutes to 7 days. | -| Service tenancy | Multi-tenant by default. A v1 self-host deploys the same Worker app in its own Cloudflare account and can restrict allowed publisher DIDs. | +| Area | Decision | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Reference runtime | A standalone Cloudflare Workers app at `apps/release-service`, separate from the aggregator. | +| Canonical state | D1. Use CAS state transitions, unique constraints, leases, and a transactional outbox. | +| Durable Objects | Do not use one in v1. A per-publisher DO is a later optimization for OAuth refresh and PDS write serialization if D1 lease contention becomes material. | +| Workload identity | GitHub Actions OIDC in v1 behind an issuer-neutral verifier interface. | +| Management surface | A full web console for service-local configuration and audit. Signed package policy remains read-only in the service. | +| Approver credentials | Multiple named passkeys per approver DID, individually revocable. | +| Notifications | Email and signed webhooks, both behind adapters. | +| API style | Versioned JSON HTTP API under `/v1`, not XRPC. Protocol records remain atproto lexicons. | +| Provenance predicate | SLSA provenance v1 is the only understood predicate in v1. Other predicates are present-but-unverifiable and fail when provenance is supplied. | +| Approval threshold | One valid approval from any currently listed, enrolled approver. Quorum is out of scope. | +| Stage TTL | 24 hours by default, operator-configurable with a bounded range of 15 minutes to 7 days. | +| Service tenancy | Multi-tenant by default. A v1 self-host deploys the same Worker app in its own Cloudflare account and can restrict allowed publisher DIDs. | ## Non-Negotiable Security Invariants @@ -294,10 +294,10 @@ Workers `fetch()` does not expose or pin the IP used for the actual connection, Exports: ```ts -computeMultihash(bytes, algorithm) -decodeMultihash(value) -verifyMultihash(bytes, expected) -compareDigestBytes(left, right) +computeMultihash(bytes, algorithm); +decodeMultihash(value); +verifyMultihash(bytes, expected); +compareDigestBytes(left, right); ``` The delegated path accepts multibase multihashes only. Legacy bare hexadecimal checksums may remain install-compatible for old records but are rejected for new delegated releases. @@ -378,29 +378,29 @@ All IDs are ULIDs. Timestamps are UTC ISO strings. JSON columns contain canonica `publisher_accounts` -| Column | Notes | -| --- | --- | -| `did` | Primary key. | -| `handle` | Display cache only. | -| `pds_url` | Resolved cache, never a permanent authority. | -| `pds_resolved_at` | Cache timestamp. | -| `created_at`, `updated_at` | Audit metadata. | +| Column | Notes | +| -------------------------- | -------------------------------------------- | +| `did` | Primary key. | +| `handle` | Display cache only. | +| `pds_url` | Resolved cache, never a permanent authority. | +| `pds_resolved_at` | Cache timestamp. | +| `created_at`, `updated_at` | Audit metadata. | `delegations` -| Column | Notes | -| --- | --- | -| `id` | Primary key. | -| `publisher_did` | Unique active delegation per DID and release NSID. | -| `release_nsid` | Scope-bound collection. | -| `encrypted_session` | atcute session state, refresh token, DPoP private key, and nonce data. | -| `encryption_key_version` | Enables envelope-key rotation. | -| `client_key_id` | Confidential client assertion key that created the session. | -| `scope` | Must equal the expected exact scope. | -| `status` | `active`, `refreshing`, `reauthorization_required`, `revoked`. | -| `lease_owner`, `lease_expires_at` | Distributed refresh lock. | -| `last_refreshed_at`, `refresh_before` | Proactive refresh schedule. | -| `created_at`, `updated_at`, `revoked_at` | Lifecycle. | +| Column | Notes | +| ---------------------------------------- | ---------------------------------------------------------------------- | +| `id` | Primary key. | +| `publisher_did` | Unique active delegation per DID and release NSID. | +| `release_nsid` | Scope-bound collection. | +| `encrypted_session` | atcute session state, refresh token, DPoP private key, and nonce data. | +| `encryption_key_version` | Enables envelope-key rotation. | +| `client_key_id` | Confidential client assertion key that created the session. | +| `scope` | Must equal the expected exact scope. | +| `status` | `active`, `refreshing`, `reauthorization_required`, `revoked`. | +| `lease_owner`, `lease_expires_at` | Distributed refresh lock. | +| `last_refreshed_at`, `refresh_before` | Proactive refresh schedule. | +| `created_at`, `updated_at`, `revoked_at` | Lifecycle. | `console_sessions` stores an opaque hashed browser session token, publisher DID, expiry, and CSRF secret. The atproto identity OAuth session is discarded after login; console authentication does not need durable PDS access. @@ -410,21 +410,21 @@ All IDs are ULIDs. Timestamps are UTC ISO strings. JSON columns contain canonica `workload_policies` -| Column | Notes | -| --- | --- | -| `id` | Primary key. | -| `publisher_did`, `package_slug` | Unique package mapping. | -| `provider` | `github-actions` in v1. | -| `issuer` | Exact issuer URL. | -| `audience` | Exact service audience. | -| `repository_id`, `repository_owner_id` | Immutable GitHub identifiers, required. | -| `repository` | Human-readable `owner/name`, checked in addition to IDs. | -| `workflow_ref` | Exact reusable workflow or workflow file identity. | -| `allowed_refs` | JSON list of exact refs or validated prefix patterns. | -| `allowed_environments` | Optional JSON allowlist. | -| `enabled` | Boolean. | -| `version` | Increments on every edit and is captured by an intent. | -| `created_by_did`, `created_at`, `updated_at` | Audit. | +| Column | Notes | +| -------------------------------------------- | -------------------------------------------------------- | +| `id` | Primary key. | +| `publisher_did`, `package_slug` | Unique package mapping. | +| `provider` | `github-actions` in v1. | +| `issuer` | Exact issuer URL. | +| `audience` | Exact service audience. | +| `repository_id`, `repository_owner_id` | Immutable GitHub identifiers, required. | +| `repository` | Human-readable `owner/name`, checked in addition to IDs. | +| `workflow_ref` | Exact reusable workflow or workflow file identity. | +| `allowed_refs` | JSON list of exact refs or validated prefix patterns. | +| `allowed_environments` | Optional JSON allowlist. | +| `enabled` | Boolean. | +| `version` | Increments on every edit and is captured by an intent. | +| `created_by_did`, `created_at`, `updated_at` | Audit. | Policies do not accept arbitrary JavaScript or expression languages. The console presents typed fields. A package may have multiple policies, for example release and prerelease workflows. @@ -434,24 +434,24 @@ Policies do not accept arbitrary JavaScript or expression languages. The console `approver_identities` -| Column | Notes | -| --- | --- | -| `did` | Primary key. | -| `handle` | Display cache. | -| `encrypted_email` | Optional private notification route. | -| `email_verified_at` | Null until verified. | -| `created_at`, `updated_at` | Lifecycle. | +| Column | Notes | +| -------------------------- | ------------------------------------ | +| `did` | Primary key. | +| `handle` | Display cache. | +| `encrypted_email` | Optional private notification route. | +| `email_verified_at` | Null until verified. | +| `created_at`, `updated_at` | Lifecycle. | `approver_credentials` -| Column | Notes | -| --- | --- | -| `credential_id` | Primary key, base64url credential ID. | -| `approver_did` | Owner. | -| `name` | User-provided authenticator label. | -| `public_key`, `algorithm`, `counter`, `transports` | WebAuthn verification data. | -| `device_type`, `backed_up` | Display and risk metadata. | -| `created_at`, `last_used_at`, `revoked_at` | Lifecycle. | +| Column | Notes | +| -------------------------------------------------- | ------------------------------------- | +| `credential_id` | Primary key, base64url credential ID. | +| `approver_did` | Owner. | +| `name` | User-provided authenticator label. | +| `public_key`, `algorithm`, `counter`, `transports` | WebAuthn verification data. | +| `device_type`, `backed_up` | Display and risk metadata. | +| `created_at`, `last_used_at`, `revoked_at` | Lifecycle. | `enrolment_invitations` stores a random-token hash, target DID, optional publisher/package context, expiry, consumed time, and inviter DID. Possession does not authorize enrolment; OAuth must return the same target DID. @@ -461,35 +461,35 @@ Policies do not accept arbitrary JavaScript or expression languages. The console `release_targets` reserves immutable versions. -| Column | Notes | -| --- | --- | -| `publisher_did`, `package_slug`, `version` | Composite primary key. | -| `record_digest` | Canonical proposed release record hash. | -| `intent_id` | Owning intent. | -| `at_uri`, `cid` | Populated on publication. | +| Column | Notes | +| ------------------------------------------ | --------------------------------------- | +| `publisher_did`, `package_slug`, `version` | Composite primary key. | +| `record_digest` | Canonical proposed release record hash. | +| `intent_id` | Owning intent. | +| `at_uri`, `cid` | Populated on publication. | `release_intents` -| Column | Notes | -| --- | --- | -| `id` | Primary key. | -| `publisher_did`, `package_slug`, `version`, `rkey` | Release identity. | -| `idempotency_key` | Unique per workload policy. | -| `record_json`, `record_digest` | Immutable canonical release draft. | -| `approval_digest` | Hash of every fact displayed and authorized by a passkey approval. | -| `artifact_digest` | Raw digest representation for provenance comparison. | -| `workload_policy_id`, `workload_policy_version` | Policy used at submission. | -| `workload_evidence` | Canonical verified claims, never the raw bearer token. | -| `profile_cid`, `profile_policy_json`, `profile_policy_digest` | Submission snapshot. | -| `baseline_release_uri`, `baseline_release_cid`, `baseline_access_json` | Escalation baseline. | -| `access_diff_json`, `is_escalation` | Approval presentation and decision. | -| `requires_approval` | Derived from signed profile policy plus escalation. | -| `status`, `state_version` | CAS state machine. | -| `expires_at` | Stage TTL. | -| `lease_owner`, `lease_expires_at` | Queue consumer lease. | -| `at_uri`, `release_cid` | Publication result. | -| `failure_code`, `failure_detail` | Stable machine code plus safe detail. | -| `created_at`, `updated_at`, `published_at` | Lifecycle. | +| Column | Notes | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `id` | Primary key. | +| `publisher_did`, `package_slug`, `version`, `rkey` | Release identity. | +| `idempotency_key` | Unique per workload policy. | +| `record_json`, `record_digest` | Immutable canonical release draft. | +| `approval_digest` | Hash of every fact displayed and authorized by a passkey approval. | +| `artifact_digest` | Raw digest representation for provenance comparison. | +| `workload_policy_id`, `workload_policy_version` | Policy used at submission. | +| `workload_evidence` | Canonical verified claims, never the raw bearer token. | +| `profile_cid`, `profile_policy_json`, `profile_policy_digest` | Submission snapshot. | +| `baseline_release_uri`, `baseline_release_cid`, `baseline_access_json` | Escalation baseline. | +| `access_diff_json`, `is_escalation` | Approval presentation and decision. | +| `requires_approval` | Derived from signed profile policy plus escalation. | +| `status`, `state_version` | CAS state machine. | +| `expires_at` | Stage TTL. | +| `lease_owner`, `lease_expires_at` | Queue consumer lease. | +| `at_uri`, `release_cid` | Publication result. | +| `failure_code`, `failure_detail` | Stable machine code plus safe detail. | +| `created_at`, `updated_at`, `published_at` | Lifecycle. | The approval digest is not the release record digest. It is a domain-separated hash over canonical release record JSON, artifact and provenance references, derived declared access, normalized verified workload evidence, workload policy ID and version, profile CID and policy digest, baseline release CID and access, approver DID, and action. Recompute it when issuing and consuming a challenge. Any mismatch invalidates the challenge. @@ -633,11 +633,11 @@ The exact `client_id`, redirects, scope declaration, and `jwks_uri` are deployme ### OAuth purposes -| Purpose | Requested scope | Persistence | -| --- | --- | --- | -| Console login | `atproto` | Delete OAuth session after creating the service cookie. | -| Approver identity proof | `atproto` | Delete immediately after matching the expected DID. | -| Release delegation | `atproto repo:?action=create` | Store encrypted until revoked. | +| Purpose | Requested scope | Persistence | +| ----------------------- | ------------------------------------------- | ------------------------------------------------------- | +| Console login | `atproto` | Delete OAuth session after creating the service cookie. | +| Approver identity proof | `atproto` | Delete immediately after matching the expected DID. | +| Release delegation | `atproto repo:?action=create` | Store encrypted until revoked. | Use separate logical atcute stores per purpose so sessions for the same DID cannot overwrite each other. From e396d031acc9fa6adfd0dd57a25966f7ba095395 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Fri, 10 Jul 2026 10:10:29 +0000 Subject: [PATCH 03/24] ci: update query-count snapshots --- scripts/query-counts.queries.d1.json | 6 ++++-- scripts/query-counts.queries.sqlite.json | 6 ++++-- scripts/query-counts.snapshot.d1.json | 4 ++-- scripts/query-counts.snapshot.sqlite.json | 4 ++-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/scripts/query-counts.queries.d1.json b/scripts/query-counts.queries.d1.json index c0ea868a41..8817345ae2 100644 --- a/scripts/query-counts.queries.d1.json +++ b/scripts/query-counts.queries.d1.json @@ -219,6 +219,7 @@ }, "GET /search (cold)": { "select \"a\".\"id\" as \"a_id\", \"a\".\"name\" as \"a_name\", \"a\".\"label\" as \"a_label\", \"a\".\"description\" as \"a_description\", \"w\".\"id\" as \"w_id\", \"w\".\"type\" as \"w_type\", \"w\".\"title\" as \"w_title\", \"w\".\"content\" as \"w_content\", \"w\".\"menu_name\" as \"w_menu_name\", \"w\".\"component_id\" as \"w_component_id\", \"w\".\"component_props\" as \"w_component_props\", \"w\".\"area_id\" as \"w_area_id\", \"w\".\"sort_order\" as \"w_sort_order\", \"w\".\"created_at\" as \"w_created_at\" from \"_emdash_widget_areas\" as \"a\" left join \"_emdash_widgets\" as \"w\" on \"w\".\"area_id\" = \"a\".\"id\" where \"a\".\"name\" = ? order by \"w\".\"sort_order\" asc": 1, + "select \"c\".\"slug\" as \"collection_slug\" from \"_emdash_fields\" as \"f\" inner join \"_emdash_collections\" as \"c\" on \"c\".\"id\" = \"f\".\"collection_id\" where \"f\".\"slug\" = ?": 1, "select \"id\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"name\", \"value\" from \"options\" where \"name\" in (...)": 2, "select \"name\", \"value\" from \"options\" where name LIKE ? ESCAPE '\\'": 1, @@ -232,7 +233,7 @@ "select * from \"_emdash_migrations\" limit ?": 1, "select * from \"_emdash_redirects\" where \"enabled\" = ?": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct CROSS JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, - "SELECT c.id, c.slug, c.locale, c.title, snippet(\"_emdash_fts_posts\", 2, '', '', '...', 32) as snippet, bm25(\"_emdash_fts_posts\") as score FROM \"_emdash_fts_posts\" f JOIN \"ec_posts\" c ON f.id = c.id WHERE \"_emdash_fts_posts\" MATCH ? AND c.status = ? AND c.deleted_at IS NULL ORDER BY score LIMIT ?": 1, + "SELECT c.id, c.slug, c.locale, c.title as title, snippet(\"_emdash_fts_posts\", 2, '', '', '...', 32) as snippet, bm25(\"_emdash_fts_posts\") as score FROM \"_emdash_fts_posts\" f JOIN \"ec_posts\" c ON f.id = c.id WHERE \"_emdash_fts_posts\" MATCH ? AND c.status = ? AND c.deleted_at IS NULL ORDER BY score LIMIT ?": 1, "select count(*) as \"count\" from \"_emdash_collections\"": 1, "SELECT COUNT(*) as count FROM \"_emdash_migrations\"": 1, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?": 1, @@ -240,6 +241,7 @@ }, "GET /search (warm)": { "select \"a\".\"id\" as \"a_id\", \"a\".\"name\" as \"a_name\", \"a\".\"label\" as \"a_label\", \"a\".\"description\" as \"a_description\", \"w\".\"id\" as \"w_id\", \"w\".\"type\" as \"w_type\", \"w\".\"title\" as \"w_title\", \"w\".\"content\" as \"w_content\", \"w\".\"menu_name\" as \"w_menu_name\", \"w\".\"component_id\" as \"w_component_id\", \"w\".\"component_props\" as \"w_component_props\", \"w\".\"area_id\" as \"w_area_id\", \"w\".\"sort_order\" as \"w_sort_order\", \"w\".\"created_at\" as \"w_created_at\" from \"_emdash_widget_areas\" as \"a\" left join \"_emdash_widgets\" as \"w\" on \"w\".\"area_id\" = \"a\".\"id\" where \"a\".\"name\" = ? order by \"w\".\"sort_order\" asc": 1, + "select \"c\".\"slug\" as \"collection_slug\" from \"_emdash_fields\" as \"f\" inner join \"_emdash_collections\" as \"c\" on \"c\".\"id\" = \"f\".\"collection_id\" where \"f\".\"slug\" = ?": 1, "select \"id\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"search_config\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"slug\" from \"_emdash_fields\" where \"collection_id\" = ? and \"searchable\" = ?": 1, @@ -247,7 +249,7 @@ "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct CROSS JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, - "SELECT c.id, c.slug, c.locale, c.title, snippet(\"_emdash_fts_posts\", 2, '', '', '...', 32) as snippet, bm25(\"_emdash_fts_posts\") as score FROM \"_emdash_fts_posts\" f JOIN \"ec_posts\" c ON f.id = c.id WHERE \"_emdash_fts_posts\" MATCH ? AND c.status = ? AND c.deleted_at IS NULL ORDER BY score LIMIT ?": 1, + "SELECT c.id, c.slug, c.locale, c.title as title, snippet(\"_emdash_fts_posts\", 2, '', '', '...', 32) as snippet, bm25(\"_emdash_fts_posts\") as score FROM \"_emdash_fts_posts\" f JOIN \"ec_posts\" c ON f.id = c.id WHERE \"_emdash_fts_posts\" MATCH ? AND c.status = ? AND c.deleted_at IS NULL ORDER BY score LIMIT ?": 1, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?": 1 }, "GET /tag/webdev (cold)": { diff --git a/scripts/query-counts.queries.sqlite.json b/scripts/query-counts.queries.sqlite.json index 31d78155d6..293d6a8839 100644 --- a/scripts/query-counts.queries.sqlite.json +++ b/scripts/query-counts.queries.sqlite.json @@ -148,6 +148,7 @@ }, "GET /search (cold)": { "select \"a\".\"id\" as \"a_id\", \"a\".\"name\" as \"a_name\", \"a\".\"label\" as \"a_label\", \"a\".\"description\" as \"a_description\", \"w\".\"id\" as \"w_id\", \"w\".\"type\" as \"w_type\", \"w\".\"title\" as \"w_title\", \"w\".\"content\" as \"w_content\", \"w\".\"menu_name\" as \"w_menu_name\", \"w\".\"component_id\" as \"w_component_id\", \"w\".\"component_props\" as \"w_component_props\", \"w\".\"area_id\" as \"w_area_id\", \"w\".\"sort_order\" as \"w_sort_order\", \"w\".\"created_at\" as \"w_created_at\" from \"_emdash_widget_areas\" as \"a\" left join \"_emdash_widgets\" as \"w\" on \"w\".\"area_id\" = \"a\".\"id\" where \"a\".\"name\" = ? order by \"w\".\"sort_order\" asc": 1, + "select \"c\".\"slug\" as \"collection_slug\" from \"_emdash_fields\" as \"f\" inner join \"_emdash_collections\" as \"c\" on \"c\".\"id\" = \"f\".\"collection_id\" where \"f\".\"slug\" = ?": 1, "select \"id\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"search_config\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"slug\" from \"_emdash_fields\" where \"collection_id\" = ? and \"searchable\" = ?": 1, @@ -155,11 +156,12 @@ "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct CROSS JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, - "SELECT c.id, c.slug, c.locale, c.title, snippet(\"_emdash_fts_posts\", 2, '', '', '...', 32) as snippet, bm25(\"_emdash_fts_posts\") as score FROM \"_emdash_fts_posts\" f JOIN \"ec_posts\" c ON f.id = c.id WHERE \"_emdash_fts_posts\" MATCH ? AND c.status = ? AND c.deleted_at IS NULL ORDER BY score LIMIT ?": 1, + "SELECT c.id, c.slug, c.locale, c.title as title, snippet(\"_emdash_fts_posts\", 2, '', '', '...', 32) as snippet, bm25(\"_emdash_fts_posts\") as score FROM \"_emdash_fts_posts\" f JOIN \"ec_posts\" c ON f.id = c.id WHERE \"_emdash_fts_posts\" MATCH ? AND c.status = ? AND c.deleted_at IS NULL ORDER BY score LIMIT ?": 1, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?": 1 }, "GET /search (warm)": { "select \"a\".\"id\" as \"a_id\", \"a\".\"name\" as \"a_name\", \"a\".\"label\" as \"a_label\", \"a\".\"description\" as \"a_description\", \"w\".\"id\" as \"w_id\", \"w\".\"type\" as \"w_type\", \"w\".\"title\" as \"w_title\", \"w\".\"content\" as \"w_content\", \"w\".\"menu_name\" as \"w_menu_name\", \"w\".\"component_id\" as \"w_component_id\", \"w\".\"component_props\" as \"w_component_props\", \"w\".\"area_id\" as \"w_area_id\", \"w\".\"sort_order\" as \"w_sort_order\", \"w\".\"created_at\" as \"w_created_at\" from \"_emdash_widget_areas\" as \"a\" left join \"_emdash_widgets\" as \"w\" on \"w\".\"area_id\" = \"a\".\"id\" where \"a\".\"name\" = ? order by \"w\".\"sort_order\" asc": 1, + "select \"c\".\"slug\" as \"collection_slug\" from \"_emdash_fields\" as \"f\" inner join \"_emdash_collections\" as \"c\" on \"c\".\"id\" = \"f\".\"collection_id\" where \"f\".\"slug\" = ?": 1, "select \"id\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"search_config\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"slug\" from \"_emdash_fields\" where \"collection_id\" = ? and \"searchable\" = ?": 1, @@ -167,7 +169,7 @@ "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct CROSS JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, - "SELECT c.id, c.slug, c.locale, c.title, snippet(\"_emdash_fts_posts\", 2, '', '', '...', 32) as snippet, bm25(\"_emdash_fts_posts\") as score FROM \"_emdash_fts_posts\" f JOIN \"ec_posts\" c ON f.id = c.id WHERE \"_emdash_fts_posts\" MATCH ? AND c.status = ? AND c.deleted_at IS NULL ORDER BY score LIMIT ?": 1, + "SELECT c.id, c.slug, c.locale, c.title as title, snippet(\"_emdash_fts_posts\", 2, '', '', '...', 32) as snippet, bm25(\"_emdash_fts_posts\") as score FROM \"_emdash_fts_posts\" f JOIN \"ec_posts\" c ON f.id = c.id WHERE \"_emdash_fts_posts\" MATCH ? AND c.status = ? AND c.deleted_at IS NULL ORDER BY score LIMIT ?": 1, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?": 1 }, "GET /tag/webdev (cold)": { diff --git a/scripts/query-counts.snapshot.d1.json b/scripts/query-counts.snapshot.d1.json index 9de686e47a..b93ebe8f6d 100644 --- a/scripts/query-counts.snapshot.d1.json +++ b/scripts/query-counts.snapshot.d1.json @@ -15,8 +15,8 @@ "GET /posts/building-for-the-long-term (warm)": 18, "GET /rss.xml (cold)": 12, "GET /rss.xml (warm)": 2, - "GET /search (cold)": 21, - "GET /search (warm)": 10, + "GET /search (cold)": 22, + "GET /search (warm)": 11, "GET /tag/webdev (cold)": 22, "GET /tag/webdev (warm)": 10 } diff --git a/scripts/query-counts.snapshot.sqlite.json b/scripts/query-counts.snapshot.sqlite.json index 11d8677101..222f7d5ddf 100644 --- a/scripts/query-counts.snapshot.sqlite.json +++ b/scripts/query-counts.snapshot.sqlite.json @@ -15,8 +15,8 @@ "GET /posts/building-for-the-long-term (warm)": 18, "GET /rss.xml (cold)": 2, "GET /rss.xml (warm)": 2, - "GET /search (cold)": 10, - "GET /search (warm)": 10, + "GET /search (cold)": 11, + "GET /search (warm)": 11, "GET /tag/webdev (cold)": 10, "GET /tag/webdev (warm)": 10 } From a5e8e0b421768624d8368e30074ff63df877050b Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 15:11:14 +0100 Subject: [PATCH 04/24] docs: make gate zero ratification-only (#1915) * docs: make gate zero ratification-only * docs: align gate zero with RFC decisions --- .../implementation-plan.md | 61 ++++++++++++------- .../plans/delegated-release-service/spec.md | 17 +++--- 2 files changed, 47 insertions(+), 31 deletions(-) diff --git a/.opencode/plans/delegated-release-service/implementation-plan.md b/.opencode/plans/delegated-release-service/implementation-plan.md index 176fd9bf8c..eedfb6769a 100644 --- a/.opencode/plans/delegated-release-service/implementation-plan.md +++ b/.opencode/plans/delegated-release-service/implementation-plan.md @@ -2,10 +2,23 @@ Companion: [Implementation spec](./spec.md) -Status: execution plan pending Gate 0 decisions and feasibility results +Status: execution plan pending Gate 0 external validation This plan turns the delegated release service spec into independently deliverable workstreams. It defines ownership boundaries, dependencies, integration gates, and completion criteria. It intentionally contains no time estimates. +## Stage Deliverables + +| Stage | Deliverable | Repository change allowed | +| ------ | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Gate 0 | RFC implementation clarifications, supported-platform matrix, and documented external research conclusions | Spec and plan updates only | +| Gate 1 | Experimental lexicons, generated types, and one shared verification contract | Production contract code and tests | +| Gate 2 | Secure delegated release service vertical slice | Service, client, and installer code and tests | +| Gate 3 | Independent install and minimum discovery enforcement | Installer and aggregator code and tests | +| Gate 4 | Hosted beta product and operational readiness | Console, notifications, tooling, operations, and conformance code | +| Gate 5 | Accurate historical policy enforcement and production launch evidence | Aggregator history implementation and production verification | + +Gate 0 is deliberately not an implementation stage. RFC #1870 is the product and protocol decision; Gate 0 records only implementation clarifications and external validation required to begin implementation. Do not reopen RFC decisions unless its text is ambiguous, contradicts an existing constraint, or an external result makes it impossible. Do not add test harnesses, prototype services, package dependencies, root scripts, CI wiring, or production code for Gate 0. If external research changes an assumption, commit only the resulting spec or plan update. + ## Outcomes The implementation is complete when: @@ -38,7 +51,7 @@ The implementation is complete when: | ID | Workstream | Primary output | | ----- | ------------------------------------- | ----------------------------------------------------------- | -| `W0` | Decisions and feasibility | Ratified contracts and proved platform assumptions | +| `W0` | Clarification and external validation | RFC-derived acceptance criteria and platform assumptions | | `W1` | Protocol and lexicons | Profile policy and release provenance records | | `W2` | Shared verification | One verification implementation for all consumers | | `W3` | OAuth, crypto, and passkeys | Secure identity, grant custody, and approval primitives | @@ -118,9 +131,9 @@ W0 record/auth/provenance feasibility Required before protocol or service implementation is treated as production work: -- Package profile extension and repository anchor are ratified. -- Release baseline and declared-access escalation semantics are ratified. -- Public CLI spelling is decided. +- Package profile extension and repository anchor have an implementation acceptance table derived from the RFC. +- Release baseline and declared-access escalation semantics have an implementation acceptance table derived from the RFC. +- Public CLI spelling is `emdash-plugin`. - Create-only repo scope works on each supported PDS without broad fallback. - Confidential OAuth sessions can be restored and refreshed safely in workerd. - The selected Sigstore implementation verifies a real GitHub provenance bundle in workerd. @@ -128,6 +141,8 @@ Required before protocol or service implementation is treated as production work Gate owner: `W0`. +Gate 0 evidence is an implementation-clarification note in the tracked spec/plan and, where useful, a link to an external reproduction or upstream issue. It is not a repository test suite. The umbrella PR records the accepted external-validation summary before implementation begins. + ### Gate 1: Protocol and Verification Foundation - New profile and release extension fixtures round-trip through generated lexicon types. @@ -175,11 +190,11 @@ Gate owners: `W10`, `W12`. ## Workstream W0: Decisions and Feasibility -This workstream closes the implementation blockers in the spec. Its outputs are executable fixtures and recorded decisions, not exploratory prose alone. +This workstream closes the implementation blockers in the spec. Its outputs are RFC-derived acceptance criteria and concise research conclusions. It does not land product code, test fixtures, package dependencies, or internal prototypes. -### `W0.1` Ratify the record shape +### `W0.1` Record the profile contract acceptance criteria -Decide and update RFC #1870 with: +Extract from RFC #1870: - Exact profile extension NSID. - Exact location and canonical form of the signed repository URL. @@ -188,13 +203,13 @@ Decide and update RFC #1870 with: - Stable treatment of unknown provenance predicates. - Experimental-to-stable NSID migration consequences for OAuth grants. -Output: accepted RFC text and matching JSON examples. +Output: an implementation acceptance table and matching JSON examples. Do not reopen the RFC decision unless its text is contradictory or ambiguous. Dependencies: none. -### `W0.2` Ratify escalation semantics +### `W0.2` Record escalation acceptance criteria -Decide: +Extract from RFC #1870: - Highest-semver current release as the baseline. - First release uses empty access. @@ -203,13 +218,13 @@ Decide: - Unknown constraint changes are conservatively escalating. - Which baseline changes invalidate an approval. -Output: decision table consumed directly by `W2.4` tests. +Output: an implementation acceptance table consumed directly by `W2.4` tests. Do not reopen the RFC decision unless its text is contradictory or ambiguous. Dependencies: none. ### `W0.3` Prove create-only PDS support -Build a minimal confidential client and test: +Validate externally, against the candidate PDS implementations: - `atproto repo:?action=create` authorization. - Successful release create. @@ -219,13 +234,13 @@ Build a minimal confidential client and test: Targets: Bluesky-hosted PDS and at least one alternative implementation intended for support. -Output: committed integration fixture or reproducible test harness plus compatibility matrix. +Output: a supported-PDS compatibility matrix and any required RFC/spec correction. Commit only the resulting spec/plan update; keep disposable clients and accounts outside this repository. Dependencies: `W0.1` draft NSID. ### `W0.4` Prove confidential OAuth custody in workerd -Exercise real `@atcute/oauth-node-client` behavior with: +Research the real `@atcute/oauth-node-client` behavior needed by the future service: - Private-key JWT and published JWKS. - Separate client assertion and DPoP keys. @@ -234,13 +249,13 @@ Exercise real `@atcute/oauth-node-client` behavior with: - Concurrent refresh attempts under a D1 lease. - Rotating refresh tokens and client assertion keys. -Output: service-auth prototype and exact persisted session requirements. +Output: exact persisted session requirements, lock expectations, key-rotation constraints, and any incompatible upstream behavior. Commit only a concise spec/plan update when the result changes the design. Dependencies: none. ### `W0.5` Prove Sigstore verification in workerd -Use a real `actions/attest-build-provenance` bundle to map and verify: +Inspect a real `actions/attest-build-provenance` bundle and validate, outside this repository: - Sigstore signature and transparency evidence. - Artifact subject digest. @@ -249,29 +264,29 @@ Use a real `actions/attest-build-provenance` bundle to map and verify: - Workflow identity and SLSA builder fields. - RFC `sourceRepository` and `builderId` mapping. -Output: Workers-compatible verifier choice, fixture, and field-mapping contract. +Output: a Workers-compatible verifier choice and exact field-mapping contract. Commit only the resulting spec/plan decision; fixture acquisition and experimentation remain external until `W2.5` implements the verifier. Dependencies: `W0.1` provenance draft. ### `W0.6` Prove historical aggregator input -Publish profile states `strict -> relaxed -> strict` before the aggregator queue drains, with a release between transitions. Prove the chosen source can recover all values, ordering keys, CIDs, revisions, and commit proof material. +Determine whether an event source can recover profile states `strict -> relaxed -> strict`, with a release between transitions, after queue delay. The selected source must provide event-specific record values, ordering keys, CIDs, revisions, and verifiable commit proof material. -Output: selected event source and ingest prototype. If no source can provide this, return to the RFC before implementing cooldown semantics. +Output: a source-selection decision and explicit W10.1 constraints. If no source can provide this, return to the RFC before implementing cooldown semantics. Do not add an aggregator prototype to this repository. Dependencies: none. ### `W0.7` Decide public CLI shape -Resolve `emdash-plugin` versus `emdash plugin`, including compatibility policy and documentation naming. +Use `emdash-plugin` as the v1 public command. A future `emdash plugin` alias is additive work, not a Gate 0 blocker. -Output: one command spelling used by `W8` and RFC examples. +Output: update the RFC examples and implementation documentation to use `emdash-plugin`. Dependencies: none. ### W0 Completion -Gate 0 passes. Any failed feasibility spike changes the RFC or architecture before downstream work proceeds. +Gate 0 passes after `W0.1`, `W0.2`, and `W0.7` accurately reflect the RFC and established command decision, and the recorded conclusions for `W0.3` through `W0.6` reveal no incompatible external constraint. Any incompatible external result changes the RFC or architecture before downstream work proceeds. ## Workstream W1: Protocol and Lexicons diff --git a/.opencode/plans/delegated-release-service/spec.md b/.opencode/plans/delegated-release-service/spec.md index 1da6350638..934e1734c3 100644 --- a/.opencode/plans/delegated-release-service/spec.md +++ b/.opencode/plans/delegated-release-service/spec.md @@ -1,6 +1,6 @@ # Delegated Release Service Implementation Spec -Status: design draft pending RFC record-shape ratification and Phase 0 feasibility results +Status: design draft pending Phase 0 external validation Source: [RFC PR #1870](https://github.com/emdash-cms/emdash/pull/1870), Attested Automated Publishing @@ -1040,15 +1040,16 @@ Run a second suite against real GitHub OIDC and a real supported PDS in a contro ## Delivery Plan -### Phase 0: Feasibility spikes +### Phase 0: RFC clarification and external validation -- Prove create-only permission support on target PDSes. -- Prove confidential `private_key_jwt` OAuth with persistent DPoP session restoration in workerd. -- Prove distributed refresh locking under concurrent Worker requests. -- Prove Sigstore/SLSA verification in workerd. -- Resolve and prototype the aggregator historical-event source needed for policy-at-publication ordering. The prototype must recover two intermediate profile values changed before queue drain, not only their event metadata. +- Record implementation acceptance criteria for the profile extension, repository anchor, release provenance, and escalation contracts already decided by RFC #1870. +- Validate create-only permission support on target PDSes outside this repository. +- Confirm atcute confidential-client persistence, refresh-lock, and key-rotation constraints through external research or a disposable reproduction. +- Inspect a real GitHub provenance bundle and select the Workers-compatible Sigstore verifier plus exact field mapping. +- Select an aggregator history source that can retain event-specific signed profile values; document the constraints for the later W10.1 implementation. +- Use `emdash-plugin` as the v1 public command and update RFC examples accordingly. -Exit criterion: no unresolved platform blocker. These are spikes, not production shortcuts. +Exit criterion: implementation acceptance criteria accurately reflect RFC #1870 and external validation reveals no incompatible constraint. Gate 0 adds no repository harnesses, production code, test scripts, package dependencies, or CI wiring. If research changes an assumption, commit only the corresponding spec or plan update. ### Phase 1: Protocol and verification foundation From 2e778e46e46fcf16960e70e9ccc01a9b5f322d08 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 15:53:56 +0100 Subject: [PATCH 05/24] feat(registry): add delegated release contracts (#1918) --- .changeset/bright-otters-sleep.md | 5 + .../experimental/package/profile.json | 4 + .../package/profileExtension.json | 48 ++++++ .../package/releaseExtension.json | 36 +++++ .../registry-lexicons/src/generated/index.ts | 1 + .../emdashcms/experimental/package/profile.ts | 4 + .../experimental/package/profileExtension.ts | 61 ++++++++ .../experimental/package/releaseExtension.ts | 49 ++++++ packages/registry-lexicons/src/index.ts | 6 +- .../registry-lexicons/tests/types.test.ts | 145 +++++++++++++++++- 10 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 .changeset/bright-otters-sleep.md create mode 100644 packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/profileExtension.json create mode 100644 packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/profileExtension.ts diff --git a/.changeset/bright-otters-sleep.md b/.changeset/bright-otters-sleep.md new file mode 100644 index 0000000000..e0854fc583 --- /dev/null +++ b/.changeset/bright-otters-sleep.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-lexicons": minor +--- + +Adds experimental package profile policy and release provenance lexicon contracts. diff --git a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/profile.json b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/profile.json index b30765ea44..775a9de9d8 100644 --- a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/profile.json +++ b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/profile.json @@ -84,6 +84,10 @@ "type": "string", "format": "datetime", "description": "ISO 8601 datetime for the package's last update." + }, + "extensions": { + "type": "unknown", + "description": "Opaque extension container keyed by NSID. The base profile Lexicon does not validate entries; consumers dispatch known entries to their embedded extension schemas. EmDash delegated-release policy uses com.emdashcms.experimental.package.profileExtension here." } } } diff --git a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/profileExtension.json b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/profileExtension.json new file mode 100644 index 0000000000..5e43e3df40 --- /dev/null +++ b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/profileExtension.json @@ -0,0 +1,48 @@ +{ + "lexicon": 1, + "id": "com.emdashcms.experimental.package.profileExtension", + "description": "EmDash delegated-release policy embedded inside a package profile record under its extensions field. EXPERIMENTAL: shape may change before stabilisation.", + "defs": { + "main": { + "type": "object", + "description": "Typed embedded object carrying the package repository anchor and optional delegated-release policy. Lives under the profile record's extensions map keyed by this NSID. The $type field is required when embedded; clients use it to dispatch to this schema.", + "required": ["repository"], + "properties": { + "repository": { + "type": "string", + "format": "uri", + "description": "Canonical HTTPS source repository URL. Lexicon validates URI syntax and length; consumers must require HTTPS and canonicalization.", + "maxLength": 1024 + }, + "releasePolicy": { + "type": "ref", + "ref": "#releasePolicy" + } + } + }, + "releasePolicy": { + "type": "object", + "description": "Optional delegated-release policy. Absent fields normalize for consumers to requireProvenance false, confirmation escalation-only, and no approvers.", + "properties": { + "requireProvenance": { + "type": "boolean", + "description": "Whether releases require a verifiable provenance reference." + }, + "confirmation": { + "type": "string", + "description": "When a release requires human confirmation. The generated TypeScript type exposes escalation-only and always; Lexicon runtime validators do not enforce knownValues, so consumers must reject other values.", + "knownValues": ["escalation-only", "always"] + }, + "approvers": { + "type": "array", + "description": "Atproto DIDs authorized to approve releases. Lexicon validates DID syntax and the 32-item cap; consumers must reject duplicate DIDs.", + "maxLength": 32, + "items": { + "type": "string", + "format": "did" + } + } + } + } + } +} diff --git a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/releaseExtension.json b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/releaseExtension.json index ff1c5c87c3..5b7f7377f3 100644 --- a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/releaseExtension.json +++ b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/releaseExtension.json @@ -12,6 +12,42 @@ "type": "ref", "ref": "#declaredAccess", "description": "Structured per-category access manifest. The sandbox enforces every operation declared here at runtime." + }, + "provenance": { + "type": "ref", + "ref": "#provenance", + "description": "Optional reference to a provenance document for this release." + } + } + }, + "provenance": { + "type": "object", + "description": "Provenance document reference. Unknown predicate types are retained as present-but-unverifiable; consumers understand https://slsa.dev/provenance/v1 in v1. Lexicon validates URI syntax and field lengths; consumers validate multibase-multihash syntax and source and builder canonicalization.", + "required": ["predicateType", "url", "checksum", "sourceRepository", "builderId"], + "properties": { + "predicateType": { + "type": "string", + "maxLength": 1024 + }, + "url": { + "type": "string", + "format": "uri", + "maxLength": 2048 + }, + "checksum": { + "type": "string", + "description": "Multibase-encoded multihash of the provenance document bytes. Consumers validate the multibase-multihash syntax and supported hash function.", + "maxLength": 256 + }, + "sourceRepository": { + "type": "string", + "format": "uri", + "maxLength": 1024 + }, + "builderId": { + "type": "string", + "format": "uri", + "maxLength": 1024 } } }, diff --git a/packages/registry-lexicons/src/generated/index.ts b/packages/registry-lexicons/src/generated/index.ts index 7898b48245..e9340a668e 100644 --- a/packages/registry-lexicons/src/generated/index.ts +++ b/packages/registry-lexicons/src/generated/index.ts @@ -5,6 +5,7 @@ export * as ComEmdashcmsExperimentalAggregatorListReleases from "./types/com/emd export * as ComEmdashcmsExperimentalAggregatorResolvePackage from "./types/com/emdashcms/experimental/aggregator/resolvePackage.js"; export * as ComEmdashcmsExperimentalAggregatorSearchPackages from "./types/com/emdashcms/experimental/aggregator/searchPackages.js"; export * as ComEmdashcmsExperimentalPackageProfile from "./types/com/emdashcms/experimental/package/profile.js"; +export * as ComEmdashcmsExperimentalPackageProfileExtension from "./types/com/emdashcms/experimental/package/profileExtension.js"; export * as ComEmdashcmsExperimentalPackageRelease from "./types/com/emdashcms/experimental/package/release.js"; export * as ComEmdashcmsExperimentalPackageReleaseExtension from "./types/com/emdashcms/experimental/package/releaseExtension.js"; export * as ComEmdashcmsExperimentalPublisherProfile from "./types/com/emdashcms/experimental/publisher/profile.js"; diff --git a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/profile.ts b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/profile.ts index d7d17b1954..a6d85c5183 100644 --- a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/profile.ts +++ b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/profile.ts @@ -83,6 +83,10 @@ const _mainSchema = /*#__PURE__*/ v.record( /*#__PURE__*/ v.stringGraphemes(0, 140), ]), ), + /** + * Opaque extension container keyed by NSID. The base profile Lexicon does not validate entries; consumers dispatch known entries to their embedded extension schemas. EmDash delegated-release policy uses com.emdashcms.experimental.package.profileExtension here. + */ + extensions: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.unknown()), /** * Canonical AT URI of this profile record. Derived from the record's location at publish time; not authored by the publisher. Aggregators MUST reject records whose id does not match the AT URI they were fetched from. Mirrors FAIR's id-must-match-the-resolved-identifier rule. */ diff --git a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/profileExtension.ts b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/profileExtension.ts new file mode 100644 index 0000000000..f8533b3073 --- /dev/null +++ b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/profileExtension.ts @@ -0,0 +1,61 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; + +const _mainSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.package.profileExtension", + ), + ), + get releasePolicy() { + return /*#__PURE__*/ v.optional(releasePolicySchema); + }, + /** + * Canonical HTTPS source repository URL. Lexicon validates URI syntax and length; consumers must require HTTPS and canonicalization. + * @maxLength 1024 + */ + repository: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.genericUriString(), [ + /*#__PURE__*/ v.stringLength(0, 1024), + ]), +}); +const _releasePolicySchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.package.profileExtension#releasePolicy", + ), + ), + /** + * Atproto DIDs authorized to approve releases. Lexicon validates DID syntax and the 32-item cap; consumers must reject duplicate DIDs. + * @maxLength 32 + */ + approvers: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(/*#__PURE__*/ v.didString()), + [/*#__PURE__*/ v.arrayLength(0, 32)], + ), + ), + /** + * When a release requires human confirmation. The generated TypeScript type exposes escalation-only and always; Lexicon runtime validators do not enforce knownValues, so consumers must reject other values. + */ + confirmation: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.string<"always" | "escalation-only" | (string & {})>(), + ), + /** + * Whether releases require a verifiable provenance reference. + */ + requireProvenance: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), +}); + +type main$schematype = typeof _mainSchema; +type releasePolicy$schematype = typeof _releasePolicySchema; + +export interface mainSchema extends main$schematype {} +export interface releasePolicySchema extends releasePolicy$schematype {} + +export const mainSchema = _mainSchema as mainSchema; +export const releasePolicySchema = _releasePolicySchema as releasePolicySchema; + +export interface Main extends v.InferInput {} +export interface ReleasePolicy extends v.InferInput< + typeof releasePolicySchema +> {} diff --git a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/releaseExtension.ts b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/releaseExtension.ts index 5b3c1fd86b..90a5f3e924 100644 --- a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/releaseExtension.ts +++ b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/releaseExtension.ts @@ -135,6 +135,12 @@ const _mainSchema = /*#__PURE__*/ v.object({ get declaredAccess() { return declaredAccessSchema; }, + /** + * Optional reference to a provenance document for this release. + */ + get provenance() { + return /*#__PURE__*/ v.optional(provenanceSchema); + }, }); const _mediaAccessSchema = /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.optional( @@ -224,6 +230,45 @@ const _pageFragmentsConstraintsSchema = /*#__PURE__*/ v.object({ ), ), }); +const _provenanceSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.package.releaseExtension#provenance", + ), + ), + /** + * @maxLength 1024 + */ + builderId: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.genericUriString(), [ + /*#__PURE__*/ v.stringLength(0, 1024), + ]), + /** + * Multibase-encoded multihash of the provenance document bytes. Consumers validate the multibase-multihash syntax and supported hash function. + * @maxLength 256 + */ + checksum: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 256), + ]), + /** + * @maxLength 1024 + */ + predicateType: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 1024), + ]), + /** + * @maxLength 1024 + */ + sourceRepository: /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.genericUriString(), + [/*#__PURE__*/ v.stringLength(0, 1024)], + ), + /** + * @maxLength 2048 + */ + url: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.genericUriString(), [ + /*#__PURE__*/ v.stringLength(0, 2048), + ]), +}); const _usersAccessSchema = /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.optional( /*#__PURE__*/ v.literal( @@ -264,6 +309,7 @@ type networkRequestConstraints$schematype = type pageAccess$schematype = typeof _pageAccessSchema; type pageFragmentsConstraints$schematype = typeof _pageFragmentsConstraintsSchema; +type provenance$schematype = typeof _provenanceSchema; type usersAccess$schematype = typeof _usersAccessSchema; type usersReadConstraints$schematype = typeof _usersReadConstraintsSchema; @@ -283,6 +329,7 @@ export interface networkAccessSchema extends networkAccess$schematype {} export interface networkRequestConstraintsSchema extends networkRequestConstraints$schematype {} export interface pageAccessSchema extends pageAccess$schematype {} export interface pageFragmentsConstraintsSchema extends pageFragmentsConstraints$schematype {} +export interface provenanceSchema extends provenance$schematype {} export interface usersAccessSchema extends usersAccess$schematype {} export interface usersReadConstraintsSchema extends usersReadConstraints$schematype {} @@ -312,6 +359,7 @@ export const networkRequestConstraintsSchema = export const pageAccessSchema = _pageAccessSchema as pageAccessSchema; export const pageFragmentsConstraintsSchema = _pageFragmentsConstraintsSchema as pageFragmentsConstraintsSchema; +export const provenanceSchema = _provenanceSchema as provenanceSchema; export const usersAccessSchema = _usersAccessSchema as usersAccessSchema; export const usersReadConstraintsSchema = _usersReadConstraintsSchema as usersReadConstraintsSchema; @@ -356,6 +404,7 @@ export interface PageAccess extends v.InferInput {} export interface PageFragmentsConstraints extends v.InferInput< typeof pageFragmentsConstraintsSchema > {} +export interface Provenance extends v.InferInput {} export interface UsersAccess extends v.InferInput {} export interface UsersReadConstraints extends v.InferInput< typeof usersReadConstraintsSchema diff --git a/packages/registry-lexicons/src/index.ts b/packages/registry-lexicons/src/index.ts index c38ba88d05..03a11b241c 100644 --- a/packages/registry-lexicons/src/index.ts +++ b/packages/registry-lexicons/src/index.ts @@ -29,6 +29,7 @@ export * as AggregatorResolvePackage from "./generated/types/com/emdashcms/exper export * as AggregatorSearchPackages from "./generated/types/com/emdashcms/experimental/aggregator/searchPackages.js"; export * as PackageProfile from "./generated/types/com/emdashcms/experimental/package/profile.js"; +export * as PackageProfileExtension from "./generated/types/com/emdashcms/experimental/package/profileExtension.js"; export * as PackageRelease from "./generated/types/com/emdashcms/experimental/package/release.js"; export * as PackageReleaseExtension from "./generated/types/com/emdashcms/experimental/package/releaseExtension.js"; @@ -42,6 +43,7 @@ export * as PublisherVerification from "./generated/types/com/emdashcms/experime */ export const NSID = { packageProfile: "com.emdashcms.experimental.package.profile", + packageProfileExtension: "com.emdashcms.experimental.package.profileExtension", packageRelease: "com.emdashcms.experimental.package.release", packageReleaseExtension: "com.emdashcms.experimental.package.releaseExtension", publisherProfile: "com.emdashcms.experimental.publisher.profile", @@ -58,7 +60,7 @@ export type NSIDValue = (typeof NSID)[keyof typeof NSID]; /** * NSIDs of record-shaped lexicons in this package (one row per NSID in the - * publisher's repo). Embedded objects (`releaseExtension`) and shared defs + * publisher's repo). Embedded objects (`profileExtension`, `releaseExtension`) and shared defs * (`aggregator.defs`) are excluded — they don't address their own collection. * * Useful for consumers building OAuth `repo:` scopes or enumerating writable @@ -96,7 +98,7 @@ import type * as PublisherVerificationNs from "./generated/types/com/emdashcms/e * to its PDS. Used by `PublishingClient.putRecord` (and any other typed-write * helper) to ensure callers pass a record matching the collection's lexicon. * - * Embedded objects (`releaseExtension`, which lives inside a release record's + * Embedded objects (`profileExtension`, `releaseExtension`, which live inside profile and release records' * `extensions` map) and query/procedure NSIDs (the `aggregator.*` ones) are * deliberately absent -- they aren't standalone repo collections. */ diff --git a/packages/registry-lexicons/tests/types.test.ts b/packages/registry-lexicons/tests/types.test.ts index d8f8e08ba5..d93b6ff167 100644 --- a/packages/registry-lexicons/tests/types.test.ts +++ b/packages/registry-lexicons/tests/types.test.ts @@ -1,7 +1,14 @@ import { is, safeParse } from "@atcute/lexicons/validations"; import { describe, expect, it } from "vitest"; -import { NSID, PackageProfile, PackageRelease, PublisherProfile } from "../src/index.js"; +import { + NSID, + PackageProfile, + PackageProfileExtension, + PackageRelease, + PackageReleaseExtension, + PublisherProfile, +} from "../src/index.js"; /** * Smoke tests over the generated types and validation schemas. The goal isn't @@ -90,6 +97,92 @@ describe("PackageProfile", () => { expect(is(PackageProfile.mainSchema, known)).toBe(true); expect(is(PackageProfile.mainSchema, custom)).toBe(true); }); + + it("intentionally treats extension entries as opaque until consumers dispatch them", () => { + const profile: PackageProfile.Main = { + $type: NSID.packageProfile, + id: "at://did:plc:abc123/com.emdashcms.experimental.package.profile/gallery", + type: "emdash-plugin", + license: "MIT", + authors: [{ name: "Alice Example" }], + security: [{ email: "security@example.com" }], + extensions: { + [NSID.packageProfileExtension]: "not validated by the base profile schema", + }, + }; + + expect(safeParse(PackageProfile.mainSchema, profile)).toMatchObject({ ok: true }); + // Consumers validate an entry only after dispatching its NSID to this schema. + expect( + is(PackageProfileExtension.mainSchema, { + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/gallery", + }), + ).toBe(true); + }); +}); + +describe("PackageProfileExtension", () => { + it("round-trips a release policy", () => { + const extension: PackageProfileExtension.Main = { + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/gallery", + releasePolicy: { + requireProvenance: true, + confirmation: "always", + approvers: ["did:plc:abc123"], + }, + }; + + expect(safeParse(PackageProfileExtension.mainSchema, extension)).toMatchObject({ ok: true }); + }); + + it("accepts an absent policy", () => { + expect( + is(PackageProfileExtension.mainSchema, { + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/gallery", + }), + ).toBe(true); + }); + + it("rejects invalid repository URIs, approver DIDs, and oversized approver lists", () => { + expect( + is(PackageProfileExtension.mainSchema, { + $type: NSID.packageProfileExtension, + repository: "not-a-uri", + }), + ).toBe(false); + expect( + is(PackageProfileExtension.mainSchema, { + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/gallery", + releasePolicy: { approvers: ["not-a-did"] }, + }), + ).toBe(false); + expect( + is(PackageProfileExtension.mainSchema, { + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/gallery", + releasePolicy: { + approvers: Array.from({ length: 33 }, (_, index) => `did:plc:${index}`), + }, + }), + ).toBe(false); + }); + + it("intentionally defers unknown confirmation values and duplicate approvers to consumers", () => { + expect( + is(PackageProfileExtension.mainSchema, { + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/gallery", + releasePolicy: { + confirmation: "manual-review", + approvers: ["did:plc:abc123", "did:plc:abc123"], + }, + }), + ).toBe(true); + }); }); describe("PackageRelease", () => { @@ -128,6 +221,55 @@ describe("PackageRelease", () => { }); }); +describe("PackageReleaseExtension", () => { + it("intentionally leaves unknown provenance predicates for consumer verification", () => { + const extension: PackageReleaseExtension.Main = { + $type: NSID.packageReleaseExtension, + declaredAccess: {}, + provenance: { + predicateType: "https://example.com/provenance/v2", + url: "https://github.com/example/gallery/attestation.json", + checksum: "bciqkkpvkbtfcwq6kjkbq3kgjxe5j6ihzkxlfxkzqhwzaaaa3wkbq3a", + sourceRepository: "https://github.com/example/gallery", + builderId: + "https://github.com/example/gallery/.github/workflows/release.yml@refs/heads/main", + }, + }; + + expect(safeParse(PackageReleaseExtension.mainSchema, extension)).toMatchObject({ ok: true }); + }); + + it("rejects incomplete provenance", () => { + expect( + is(PackageReleaseExtension.mainSchema, { + $type: NSID.packageReleaseExtension, + declaredAccess: {}, + provenance: { + predicateType: "https://slsa.dev/provenance/v1", + url: "https://github.com/example/gallery/attestation.json", + }, + }), + ).toBe(false); + }); + + it("does not enforce profile policy across records", () => { + // requireProvenance is enforced by later consumers, not a cross-record Lexicon rule. + expect( + is(PackageProfileExtension.mainSchema, { + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/gallery", + releasePolicy: { requireProvenance: true }, + }), + ).toBe(true); + expect( + is(PackageReleaseExtension.mainSchema, { + $type: NSID.packageReleaseExtension, + declaredAccess: {}, + }), + ).toBe(true); + }); +}); + describe("PublisherProfile", () => { it("validates a publisher profile with only required fields", () => { // Only `displayName` is required by the lexicon. Verification records bind @@ -157,6 +299,7 @@ describe("NSID map", () => { // also sanity-checked in the schema modules' `$type` literals above. const expected = [ "com.emdashcms.experimental.package.profile", + "com.emdashcms.experimental.package.profileExtension", "com.emdashcms.experimental.package.release", "com.emdashcms.experimental.package.releaseExtension", "com.emdashcms.experimental.publisher.profile", From 3de643ad16cd3d09c30afa6266b16048eed27b7c Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 16:17:24 +0100 Subject: [PATCH 06/24] fix(plugin-cli): preserve profile extensions on publish (#1920) --- .changeset/preserve-profile-extensions.md | 5 +++ packages/plugin-cli/src/publish/api.ts | 12 ++++--- packages/plugin-cli/tests/publish.test.ts | 33 +++++++++++++++++++ .../plugin-cli/tests/update-package.test.ts | 15 ++++++++- 4 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 .changeset/preserve-profile-extensions.md diff --git a/.changeset/preserve-profile-extensions.md b/.changeset/preserve-profile-extensions.md new file mode 100644 index 0000000000..e978e5184b --- /dev/null +++ b/.changeset/preserve-profile-extensions.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/plugin-cli": patch +--- + +Fixes subsequent `emdash-plugin publish` commands removing existing package-profile extensions. diff --git a/packages/plugin-cli/src/publish/api.ts b/packages/plugin-cli/src/publish/api.ts index 1a4f73b52e..1d478a2c1e 100644 --- a/packages/plugin-cli/src/publish/api.ts +++ b/packages/plugin-cli/src/publish/api.ts @@ -277,6 +277,7 @@ interface PackageProfileRecordShape { description?: string; keywords?: string[]; sections?: Record; + extensions?: Record; } /** An image artifact embedded in a release (`release.json#artifact`). */ @@ -709,11 +710,9 @@ async function getRecordOrNull( * update rather than overwriting an invalid record with a slightly-different * invalid record). * - * Unknown / extra fields on the existing record are intentionally preserved - * verbatim via the spread. If they violate the lexicon, the local - * `validateLocally` pass before `applyWrites` will reject the candidate - * with a `LEXICON_VALIDATION_FAILED` error rather than letting an invalid - * record propagate to the registry. + * The canonical profile fields are re-emitted explicitly. A valid keyed + * `extensions` map is preserved verbatim; other unknown top-level fields + * remain excluded from the canonical update path. */ function stampLastUpdated(existingValue: unknown): PackageProfileRecordShape | null { if (!existingValue || typeof existingValue !== "object") return null; @@ -745,6 +744,9 @@ function stampLastUpdated(existingValue: unknown): PackageProfileRecordShape | n if (typeof v.description === "string") candidate.description = v.description; if (Array.isArray(v.keywords)) candidate.keywords = v.keywords; if (v.sections && typeof v.sections === "object") candidate.sections = v.sections; + if (v.extensions && typeof v.extensions === "object" && !Array.isArray(v.extensions)) { + candidate.extensions = v.extensions; + } return candidate as unknown as PackageProfileRecordShape; } diff --git a/packages/plugin-cli/tests/publish.test.ts b/packages/plugin-cli/tests/publish.test.ts index 984d2d89ba..58e3ff36a6 100644 --- a/packages/plugin-cli/tests/publish.test.ts +++ b/packages/plugin-cli/tests/publish.test.ts @@ -254,6 +254,39 @@ describe("publishRelease", () => { expect(profileOp?.$type).toBe("com.atproto.repo.applyWrites#update"); }); + it("preserves profile extensions when publishing a later release", async () => { + const pds = new MockPds({ did: TEST_DID }); + const extensions = { + [NSID.packageProfileExtension]: { + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/test-plugin", + releasePolicy: { + requireProvenance: true, + confirmation: "always", + approvers: ["did:plc:approver"], + }, + }, + "com.example.unknown.extension": { + $type: "com.example.unknown.extension", + value: { preserve: ["this", "exactly"] }, + }, + }; + pds.seedRecord(NSID.packageProfile, "test-plugin", { + ...wellShapedProfile, + extensions, + }); + + await publishRelease( + buildOptions(pds, { + manifest: buildManifest({ version: "1.1.0" }), + url: "https://example.com/test-plugin-1.1.0.tar.gz", + }), + ); + + const profile = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/test-plugin`); + expect((profile!.value as { extensions?: unknown }).extensions).toEqual(extensions); + }); + it("does not touch a malformed existing profile (just writes the release)", async () => { const pds = new MockPds({ did: TEST_DID }); // Existing profile is missing required fields. We refuse to update diff --git a/packages/plugin-cli/tests/update-package.test.ts b/packages/plugin-cli/tests/update-package.test.ts index d40efa9d64..31932c0570 100644 --- a/packages/plugin-cli/tests/update-package.test.ts +++ b/packages/plugin-cli/tests/update-package.test.ts @@ -224,10 +224,22 @@ describe("updatePackage", () => { expect(pds.callsTo("com.atproto.repo.putRecord")).toHaveLength(0); }); - it("preserves unknown forward-compatible fields on the existing record", async () => { + it("preserves extensions and unknown forward-compatible fields on the existing record", async () => { const pds = new MockPds({ did: TEST_DID }); + const extensions = { + [NSID.packageProfileExtension]: { + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/test-plugin", + releasePolicy: { requireProvenance: true }, + }, + "com.example.unknown.extension": { + $type: "com.example.unknown.extension", + value: { preserve: ["this", "exactly"] }, + }, + }; seedProfile(pds, { sections: { description: "long-form text" }, + extensions, someFutureField: { nested: true }, }); @@ -242,6 +254,7 @@ describe("updatePackage", () => { const stored = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/${SLUG}`); const value = stored!.value as Record; expect(value.sections).toEqual({ description: "long-form text" }); + expect(value.extensions).toEqual(extensions); expect(value.someFutureField).toEqual({ nested: true }); }); }); From e819a5f3a0471d4aaa6c8091559e064e08f8fe2e Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 19:38:57 +0100 Subject: [PATCH 07/24] feat(plugin-cli): add profile policy command (#1925) * feat(plugin-cli): add profile policy command * fix(plugin-cli): stabilize policy JSON output --- .changeset/profile-policy-editor.md | 5 + packages/plugin-cli/src/commands/policy.ts | 213 +++++++++++ packages/plugin-cli/src/index.ts | 2 + packages/plugin-cli/src/policy/api.ts | 337 ++++++++++++++++++ .../plugin-cli/tests/policy-command.test.ts | 109 ++++++ packages/plugin-cli/tests/policy.test.ts | 298 ++++++++++++++++ 6 files changed, 964 insertions(+) create mode 100644 .changeset/profile-policy-editor.md create mode 100644 packages/plugin-cli/src/commands/policy.ts create mode 100644 packages/plugin-cli/src/policy/api.ts create mode 100644 packages/plugin-cli/tests/policy-command.test.ts create mode 100644 packages/plugin-cli/tests/policy.test.ts diff --git a/.changeset/profile-policy-editor.md b/.changeset/profile-policy-editor.md new file mode 100644 index 0000000000..40ec086ce9 --- /dev/null +++ b/.changeset/profile-policy-editor.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/plugin-cli": minor +--- + +Adds `emdash-plugin policy set` for editing an existing package's signed release policy. diff --git a/packages/plugin-cli/src/commands/policy.ts b/packages/plugin-cli/src/commands/policy.ts new file mode 100644 index 0000000000..4b1e8543c9 --- /dev/null +++ b/packages/plugin-cli/src/commands/policy.ts @@ -0,0 +1,213 @@ +import { FileCredentialStore, PublishingClient } from "@emdash-cms/registry-client"; +import { defineCommand } from "citty"; +import consola from "consola"; + +import { redirectConsolaToStderr } from "../cli-output.js"; +import { loadManifest, MANIFEST_FILENAME, ManifestError } from "../manifest/load.js"; +import { checkPublisher, PublisherCheckError } from "../manifest/publisher.js"; +import { resumeSession } from "../oauth.js"; +import { ProfilePolicyError, setProfilePolicy } from "../policy/api.js"; + +export const policySetArgs = { + manifest: { + type: "string", + description: `Manifest path or directory. Defaults to ./${MANIFEST_FILENAME}.`, + }, + repository: { + type: "string", + description: "HTTPS repository anchor, required only when creating the profile extension.", + }, + "require-provenance": { + type: "boolean", + description: "Require verifiable release provenance.", + }, + confirmation: { + type: "string", + description: "Release confirmation: escalation-only or always.", + }, + approver: { + type: "string", + description: "Approver DID. Repeat to replace the complete approver list.", + }, + "clear-approvers": { + type: "boolean", + description: "Replace the complete approver list with an empty list.", + }, + yes: { + type: "boolean", + default: false, + description: "Apply the policy change. The default is a dry-run.", + }, + json: { type: "boolean", description: "Emit stable single-line JSON to stdout." }, +} as const; + +export const policySetCommand = defineCommand({ + meta: { name: "set", description: "Edit the release policy on an existing package profile" }, + args: policySetArgs, + async run({ args, rawArgs }) { + const json = args.json === true; + const restoreReporters = json ? redirectConsolaToStderr() : null; + let exitCode = 0; + try { + const commandInput = resolvePolicyCommandInput(args, rawArgs); + const { manifest, path } = await loadPolicyManifest(commandInput.manifestPath); + const credentials = new FileCredentialStore(); + const session = await credentials.current(); + if (!session) + throw new CliError( + "Not logged in. Run: emdash-plugin login ", + "NOT_LOGGED_IN", + ); + try { + const publisherCheck = await checkPublisher({ + manifestPublisher: manifest.publisher, + sessionDid: session.did, + }); + if (publisherCheck.kind === "mismatch") { + throw new CliError( + `Manifest publisher ${publisherCheck.pinnedDid} does not match the active session ${session.did}.`, + "MANIFEST_PUBLISHER_MISMATCH", + ); + } + } catch (error) { + if (error instanceof PublisherCheckError) { + throw new CliError(error.message, error.code); + } + throw error; + } + consola.info(`Loaded manifest: ${path}`); + const oauthSession = await resumeSession(session.did); + const publisher = PublishingClient.fromHandler({ + handler: oauthSession, + did: session.did, + pds: session.pds, + }); + const result = await setProfilePolicy({ + publisher, + slug: manifest.slug, + apply: commandInput.apply, + input: commandInput.input, + }); + if (json) { + process.stdout.write( + `${JSON.stringify(formatPolicyJsonResult(result, commandInput.apply))}\n`, + ); + return; + } + if (result.diffs.length === 0) + consola.success("Package release policy is already up to date."); + else if (result.written) consola.success(`Updated release policy: ${result.profileUri}`); + else consola.info("Dry-run complete. Re-run with --yes to write this policy change."); + } catch (error) { + const code = + error instanceof ProfilePolicyError || error instanceof CliError + ? error.code + : "INTERNAL_ERROR"; + const message = error instanceof Error ? error.message : String(error); + const detail = + error instanceof ProfilePolicyError || error instanceof CliError ? error.detail : undefined; + consola.error(message); + if (json) { + process.stdout.write(`${JSON.stringify(formatPolicyJsonError(code, message, detail))}\n`); + } + exitCode = error instanceof CliError ? error.exitCode : 1; + } finally { + restoreReporters?.(); + } + if (exitCode !== 0) process.exit(exitCode); + }, +}); + +export const policyCommand = defineCommand({ + meta: { name: "policy", description: "Manage a package profile's release policy" }, + subCommands: { set: policySetCommand }, +}); + +export function resolvePolicyCommandInput(args: Record, rawArgs: string[]) { + const provenanceFlags = rawArgs.filter( + (arg) => arg === "--require-provenance" || arg === "--no-require-provenance", + ); + if (provenanceFlags.length > 1 || Array.isArray(args["require-provenance"])) { + throw new CliError( + "Specify --require-provenance or --no-require-provenance at most once.", + "INVALID_POLICY_FLAGS", + ); + } + const clearApproverFlags = rawArgs.filter((arg) => arg === "--clear-approvers"); + if (clearApproverFlags.length > 1 || Array.isArray(args["clear-approvers"])) { + throw new CliError("Specify --clear-approvers at most once.", "INVALID_POLICY_FLAGS"); + } + const approvers = toStringArray(args.approver); + if (args["clear-approvers"] === true && approvers !== undefined) { + throw new CliError( + "--clear-approvers cannot be combined with --approver.", + "INVALID_POLICY_FLAGS", + ); + } + const provenance = args["require-provenance"]; + if (provenance !== undefined && typeof provenance !== "boolean") { + throw new CliError("Invalid --require-provenance value.", "INVALID_POLICY_FLAGS"); + } + return { + manifestPath: typeof args.manifest === "string" ? args.manifest : `./${MANIFEST_FILENAME}`, + apply: args.yes === true, + input: { + repository: typeof args.repository === "string" ? args.repository : undefined, + requireProvenance: provenance, + confirmation: typeof args.confirmation === "string" ? args.confirmation : undefined, + approvers: args["clear-approvers"] === true ? [] : approvers, + }, + }; +} + +export function formatPolicyJsonResult( + result: Awaited>, + applied: boolean, +) { + return { + profile: result.profileUri, + written: result.written, + applied, + diffs: result.diffs.map((diff) => ({ + field: diff.field, + before: diff.before ?? null, + after: diff.after ?? null, + })), + ...(result.cid ? { cid: result.cid } : {}), + }; +} + +export function formatPolicyJsonError( + code: string, + message: string, + detail?: Record, +) { + return { error: { code, message, ...(detail === undefined ? {} : { detail }) } }; +} + +async function loadPolicyManifest(path: string) { + try { + return await loadManifest(path); + } catch (error) { + if (error instanceof ManifestError) throw new CliError(error.message, error.code); + throw error; + } +} + +function toStringArray(value: unknown): string[] | undefined { + if (value === undefined) return undefined; + if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string"); + return typeof value === "string" ? [value] : undefined; +} + +class CliError extends Error { + override readonly name = "CliError"; + constructor( + message: string, + readonly code: string, + readonly exitCode = 1, + readonly detail?: Record, + ) { + super(message); + } +} diff --git a/packages/plugin-cli/src/index.ts b/packages/plugin-cli/src/index.ts index 4e337067d2..9501cfab6a 100644 --- a/packages/plugin-cli/src/index.ts +++ b/packages/plugin-cli/src/index.ts @@ -31,6 +31,7 @@ import { infoCommand } from "./commands/info.js"; import { initCommand } from "./commands/init.js"; import { loginCommand } from "./commands/login.js"; import { logoutCommand } from "./commands/logout.js"; +import { policyCommand } from "./commands/policy.js"; import { publishCommand } from "./commands/publish.js"; import { searchCommand } from "./commands/search.js"; import { switchCommand } from "./commands/switch.js"; @@ -56,6 +57,7 @@ const main = defineCommand({ dev: devCommand, bundle: bundleCommand, publish: publishCommand, + policy: policyCommand, "update-package": updatePackageCommand, validate: validateCommand, }, diff --git a/packages/plugin-cli/src/policy/api.ts b/packages/plugin-cli/src/policy/api.ts new file mode 100644 index 0000000000..b9bf63a70e --- /dev/null +++ b/packages/plugin-cli/src/policy/api.ts @@ -0,0 +1,337 @@ +import { ClientResponseError } from "@atcute/client"; +import { isDid } from "@atcute/lexicons/syntax"; +import { safeParse } from "@atcute/lexicons/validations"; +import type { PublishingClient } from "@emdash-cms/registry-client"; +import { NSID, PackageProfile, PackageProfileExtension } from "@emdash-cms/registry-lexicons"; + +export type ProfilePolicyErrorCode = + | "PROFILE_NOT_FOUND" + | "PROFILE_INVALID" + | "PROFILE_EXTENSION_INVALID" + | "REPOSITORY_REQUIRED" + | "REPOSITORY_ALREADY_SET" + | "INVALID_REPOSITORY" + | "INVALID_CONFIRMATION" + | "INVALID_APPROVERS" + | "STALE_RECORD" + | "LEXICON_VALIDATION_FAILED"; + +export class ProfilePolicyError extends Error { + override readonly name = "ProfilePolicyError"; + + constructor( + readonly code: ProfilePolicyErrorCode, + message: string, + readonly detail?: Record, + ) { + super(message); + } +} + +export interface ProfilePolicyInput { + repository?: string; + requireProvenance?: boolean; + confirmation?: string; + approvers?: string[]; +} + +export interface SetProfilePolicyOptions { + publisher: PublishingClient; + slug: string; + input: ProfilePolicyInput; + apply?: boolean; + now?: () => Date; +} + +export interface PolicyFieldDiff { + field: "repository" | "requireProvenance" | "confirmation" | "approvers"; + before: unknown; + after: unknown; +} + +export interface SetProfilePolicyResult { + profileUri: string; + diffs: PolicyFieldDiff[]; + candidate: Record; + written: boolean; + cid?: string; +} + +const CONFIRMATIONS = new Set(["escalation-only", "always"]); +const TRAILING_SLASHES_RE = /\/+$/; + +export async function setProfilePolicy( + options: SetProfilePolicyOptions, +): Promise { + const profileUri = `at://${options.publisher.did}/${NSID.packageProfile}/${options.slug}`; + const policyInput = { + ...options.input, + repository: + options.input.repository === undefined + ? undefined + : canonicaliseRepository(options.input.repository), + approvers: normaliseApprovers(options.input.approvers), + }; + validateInput(policyInput); + + let existing: { cid: string; value: unknown }; + try { + existing = await options.publisher.getRecord({ + collection: NSID.packageProfile, + rkey: options.slug, + }); + } catch (error) { + if (error instanceof ClientResponseError && error.error === "RecordNotFound") { + throw new ProfilePolicyError( + "PROFILE_NOT_FOUND", + `No package profile exists at ${profileUri}. Publish the package before setting its release policy.`, + { slug: options.slug }, + ); + } + throw error; + } + + if (!isPlainObject(existing.value) || !safeParse(PackageProfile.mainSchema, existing.value).ok) { + throw new ProfilePolicyError( + "PROFILE_INVALID", + `Existing profile at ${profileUri} does not match the package profile lexicon. Refusing to overwrite it.`, + { slug: options.slug }, + ); + } + + const { candidate, diffs } = buildProfilePolicyCandidate({ + existing: existing.value, + input: policyInput, + now: (options.now ?? (() => new Date()))(), + }); + + if (options.apply !== true || diffs.length === 0) { + return { profileUri, diffs, candidate, written: false }; + } + + const profileValidation = safeParse(PackageProfile.mainSchema, candidate); + const extension = getKnownExtension(candidate); + const extensionValidation = extension && safeParse(PackageProfileExtension.mainSchema, extension); + if (!profileValidation.ok || !extensionValidation?.ok || !isValidExtension(extension)) { + throw new ProfilePolicyError( + "LEXICON_VALIDATION_FAILED", + "The edited package profile or profile extension did not pass local validation. Nothing was written.", + { profile: profileValidation, extension: extensionValidation }, + ); + } + + try { + const put = await options.publisher.unsafePutRecord({ + collection: NSID.packageProfile, + rkey: options.slug, + record: candidate, + skipValidation: true, + swapRecord: existing.cid, + }); + return { profileUri, diffs, candidate, written: true, cid: put.cid }; + } catch (error) { + if (error instanceof ClientResponseError && error.error === "InvalidSwap") { + throw new ProfilePolicyError( + "STALE_RECORD", + `The package profile at ${profileUri} changed before the policy could be written. Re-run the command to review and apply the latest policy.`, + { slug: options.slug, expectedCid: existing.cid }, + ); + } + throw error; + } +} + +export function buildProfilePolicyCandidate(input: { + existing: Record; + input: ProfilePolicyInput; + now: Date; +}): { candidate: Record; diffs: PolicyFieldDiff[] } { + const existingExtensions = input.existing.extensions; + if (existingExtensions !== undefined && !isPlainObject(existingExtensions)) { + throw new ProfilePolicyError( + "PROFILE_INVALID", + "Existing profile extensions must be an object. Refusing to overwrite an unknown shape.", + ); + } + + const extensions = { ...existingExtensions }; + const current = extensions[NSID.packageProfileExtension]; + let extension: Record; + if (current === undefined) { + if (input.input.repository === undefined) { + throw new ProfilePolicyError( + "REPOSITORY_REQUIRED", + `This package profile has no ${NSID.packageProfileExtension} extension. Pass --repository to create its required repository anchor.`, + ); + } + extension = { $type: NSID.packageProfileExtension, repository: input.input.repository }; + } else { + if (!isPlainObject(current) || !isValidExistingExtension(current)) { + throw new ProfilePolicyError( + "PROFILE_EXTENSION_INVALID", + "The existing package profile extension must have a valid HTTPS repository anchor before its policy can be edited.", + ); + } + if (input.input.repository !== undefined) { + throw new ProfilePolicyError( + "REPOSITORY_ALREADY_SET", + "The package profile extension already has a repository anchor. Policy edits preserve it and cannot replace it.", + ); + } + extension = { ...current, $type: NSID.packageProfileExtension }; + } + + const currentPolicy = extension.releasePolicy; + if (currentPolicy !== undefined && !isPlainObject(currentPolicy)) { + throw new ProfilePolicyError( + "PROFILE_EXTENSION_INVALID", + "The existing package profile release policy must be an object. Refusing to overwrite an unknown shape.", + ); + } + const policy = { ...currentPolicy }; + const diffs: PolicyFieldDiff[] = []; + + if (current === undefined) { + diffs.push({ field: "repository", before: undefined, after: input.input.repository }); + } + if ( + input.input.requireProvenance !== undefined && + policy.requireProvenance !== input.input.requireProvenance + ) { + diffs.push({ + field: "requireProvenance", + before: policy.requireProvenance, + after: input.input.requireProvenance, + }); + policy.requireProvenance = input.input.requireProvenance; + } + if (input.input.confirmation !== undefined && policy.confirmation !== input.input.confirmation) { + diffs.push({ + field: "confirmation", + before: policy.confirmation, + after: input.input.confirmation, + }); + policy.confirmation = input.input.confirmation; + } + if ( + input.input.approvers !== undefined && + !sameStringSet(policy.approvers, input.input.approvers) + ) { + diffs.push({ field: "approvers", before: policy.approvers, after: input.input.approvers }); + policy.approvers = input.input.approvers; + } + + if (Object.keys(policy).length > 0) extension.releasePolicy = policy; + extensions[NSID.packageProfileExtension] = extension; + const candidate: Record = { ...input.existing, extensions }; + if (diffs.length > 0) candidate.lastUpdated = input.now.toISOString(); + return { candidate, diffs }; +} + +function validateInput(input: ProfilePolicyInput): void { + if (input.confirmation !== undefined && !CONFIRMATIONS.has(input.confirmation)) { + throw new ProfilePolicyError( + "INVALID_CONFIRMATION", + "--confirmation must be either escalation-only or always.", + ); + } + if (input.approvers !== undefined) { + if (input.approvers.length > 32) { + throw new ProfilePolicyError("INVALID_APPROVERS", "--approver accepts at most 32 DIDs."); + } + const seen = new Set(); + for (const did of input.approvers) { + if (!isDid(did)) { + throw new ProfilePolicyError("INVALID_APPROVERS", `Invalid approver DID: ${did}`); + } + if (seen.has(did)) { + throw new ProfilePolicyError("INVALID_APPROVERS", `Duplicate approver DID: ${did}`); + } + seen.add(did); + } + } +} + +function normaliseApprovers(approvers: string[] | undefined): string[] | undefined { + return approvers?.map((did) => did.trim()); +} + +function getKnownExtension(profile: Record): Record | null { + const extensions = profile.extensions; + if (!isPlainObject(extensions)) return null; + const extension = extensions[NSID.packageProfileExtension]; + return isPlainObject(extension) ? extension : null; +} + +function isValidExistingExtension(extension: Record): boolean { + return ( + safeParse(PackageProfileExtension.mainSchema, extension).ok && + typeof extension.repository === "string" && + canonicaliseExistingRepository(extension.repository) + ); +} + +function isValidExtension(extension: Record | null): boolean { + if (!extension || !isValidExistingExtension(extension)) return false; + const policy = extension.releasePolicy; + if (policy === undefined) return true; + if (!isPlainObject(policy)) return false; + if ( + policy.confirmation !== undefined && + (typeof policy.confirmation !== "string" || !CONFIRMATIONS.has(policy.confirmation)) + ) { + return false; + } + if (policy.approvers !== undefined) { + if ( + !Array.isArray(policy.approvers) || + new Set(policy.approvers).size !== policy.approvers.length + ) { + return false; + } + } + return true; +} + +/** + * Canonical form for the signed profile repository anchor. This deliberately + * leaves path case and a `.git` suffix alone: both can be meaningful to the + * source host, unlike the host name and trailing path separators. + */ +export function canonicaliseRepository(value: string): string { + try { + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) { + throw new Error("not a canonical repository URL"); + } + if (url.port) throw new Error("not a canonical repository URL"); + let path = url.pathname; + if (path !== "/") { + path = path.replace(TRAILING_SLASHES_RE, ""); + if (path === "") path = "/"; + } + return `https://${url.hostname.toLowerCase()}${path}`; + } catch { + throw new ProfilePolicyError( + "INVALID_REPOSITORY", + "--repository must be an HTTPS URL without userinfo, query, fragment, or a non-default port.", + ); + } +} + +function canonicaliseExistingRepository(value: string): boolean { + try { + return canonicaliseRepository(value) === value; + } catch { + return false; + } +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function sameStringSet(existing: unknown, next: string[]): boolean { + if (!Array.isArray(existing) || existing.length !== next.length) return false; + return new Set(existing).size === existing.length && existing.every((did) => next.includes(did)); +} diff --git a/packages/plugin-cli/tests/policy-command.test.ts b/packages/plugin-cli/tests/policy-command.test.ts new file mode 100644 index 0000000000..f0157ed3f9 --- /dev/null +++ b/packages/plugin-cli/tests/policy-command.test.ts @@ -0,0 +1,109 @@ +import { parseArgs } from "citty"; +import { describe, expect, it } from "vitest"; + +import { + formatPolicyJsonError, + formatPolicyJsonResult, + policySetArgs, + resolvePolicyCommandInput, +} from "../src/commands/policy.js"; +import { ProfilePolicyError } from "../src/policy/api.js"; + +function resolve(rawArgs: string[]) { + return resolvePolicyCommandInput(parseArgs(rawArgs, policySetArgs), rawArgs); +} + +function expectErrorCode(action: () => unknown, code: string): void { + try { + action(); + } catch (error) { + expect(error).toMatchObject({ code }); + return; + } + throw new Error(`Expected ${code}`); +} + +describe("policy set command parsing", () => { + it("maps absent, enabled, and native-negated provenance flags", () => { + expect(resolve([]).input.requireProvenance).toBeUndefined(); + expect(resolve(["--require-provenance"]).input.requireProvenance).toBe(true); + expect(resolve(["--no-require-provenance"]).input.requireProvenance).toBe(false); + }); + + it.each([ + ["--require-provenance", "--require-provenance"], + ["--require-provenance", "--no-require-provenance"], + ["--no-require-provenance", "--no-require-provenance"], + ])("rejects repeated or contradictory provenance flags", (...rawArgs) => { + expectErrorCode(() => resolve(rawArgs), "INVALID_POLICY_FLAGS"); + }); + + it("rejects an array-valued provenance flag instead of treating it as absent", () => { + expectErrorCode( + () => resolvePolicyCommandInput({ "require-provenance": [true, false] }, []), + "INVALID_POLICY_FLAGS", + ); + }); + + it("keeps Citty's repeated approver values as the replacement list", () => { + expect( + resolve(["--approver", "did:plc:alice", "--approver", "did:plc:bob"]).input.approvers, + ).toEqual(["did:plc:alice", "did:plc:bob"]); + }); + + it("defaults to a dry-run, applies with --yes, and supports clearing approvers", () => { + expect(resolve([]).apply).toBe(false); + expect(resolve(["--yes"]).apply).toBe(true); + expect(resolve(["--clear-approvers"]).input.approvers).toEqual([]); + expectErrorCode( + () => resolve(["--clear-approvers", "--approver", "did:plc:alice"]), + "INVALID_POLICY_FLAGS", + ); + }); + + it("rejects repeated clear-approvers flags", () => { + expectErrorCode( + () => resolve(["--clear-approvers", "--clear-approvers"]), + "INVALID_POLICY_FLAGS", + ); + }); + + it("formats stable JSON success and error envelopes", () => { + expect( + formatPolicyJsonResult( + { profileUri: "at://did:plc:test/profile/test", written: false, diffs: [], candidate: {} }, + false, + ), + ).toEqual({ + profile: "at://did:plc:test/profile/test", + written: false, + applied: false, + diffs: [], + }); + expect( + formatPolicyJsonResult( + { + profileUri: "at://did:plc:test/profile/test", + written: false, + diffs: [{ field: "repository", before: undefined, after: "https://example.com/repo" }], + candidate: {}, + }, + false, + ), + ).toMatchObject({ + diffs: [{ field: "repository", before: null, after: "https://example.com/repo" }], + }); + const stale = new ProfilePolicyError("STALE_RECORD", "stale", { expectedCid: "bafyold" }); + const errorEnvelope = formatPolicyJsonError(stale.code, stale.message, stale.detail); + expect(errorEnvelope).toEqual({ + error: { code: "STALE_RECORD", message: "stale", detail: { expectedCid: "bafyold" } }, + }); + const stdout = `${JSON.stringify(errorEnvelope)}\n`; + expect(stdout).toBe( + '{"error":{"code":"STALE_RECORD","message":"stale","detail":{"expectedCid":"bafyold"}}}\n', + ); + expect(formatPolicyJsonError("INVALID_POLICY_FLAGS", "bad flags")).toEqual({ + error: { code: "INVALID_POLICY_FLAGS", message: "bad flags" }, + }); + }); +}); diff --git a/packages/plugin-cli/tests/policy.test.ts b/packages/plugin-cli/tests/policy.test.ts new file mode 100644 index 0000000000..51fdc9091e --- /dev/null +++ b/packages/plugin-cli/tests/policy.test.ts @@ -0,0 +1,298 @@ +import { PublishingClient } from "@emdash-cms/registry-client"; +import type { Did } from "@emdash-cms/registry-client"; +import { NSID } from "@emdash-cms/registry-lexicons"; +import { describe, expect, it } from "vitest"; + +import { canonicaliseRepository, ProfilePolicyError, setProfilePolicy } from "../src/policy/api.js"; +import { MockPds } from "./mock-pds.js"; + +const DID: Did = "did:plc:test123"; +const SLUG = "test-plugin"; +const NOW = new Date("2026-07-10T12:00:00.000Z"); + +function publisher(pds: MockPds): PublishingClient { + return PublishingClient.fromHandler({ handler: pds, did: pds.did, pds: "http://mock.test" }); +} + +function seedProfile(pds: MockPds, overrides: Record = {}) { + pds.seedRecord(NSID.packageProfile, SLUG, { + $type: NSID.packageProfile, + id: `at://${DID}/${NSID.packageProfile}/${SLUG}`, + type: "emdash-plugin", + license: "MIT", + authors: [{ name: "Alice" }], + security: [{ email: "security@example.com" }], + slug: SLUG, + lastUpdated: "2024-01-01T00:00:00.000Z", + ...overrides, + }); +} + +function set(pds: MockPds, input: Parameters[0]["input"], apply = false) { + return setProfilePolicy({ publisher: publisher(pds), slug: SLUG, input, apply, now: () => NOW }); +} + +function expectErrorCode(action: () => unknown, code: string): void { + try { + action(); + } catch (error) { + expect(error).toMatchObject({ code }); + return; + } + throw new Error(`Expected ${code}`); +} + +describe("setProfilePolicy", () => { + it("dry-runs policy changes without writing", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + extensions: { + [NSID.packageProfileExtension]: { repository: "https://github.com/example/plugin" }, + }, + }); + const result = await set(pds, { requireProvenance: true }); + expect(result.written).toBe(false); + expect(result.candidate.lastUpdated).toBe(NOW.toISOString()); + expect(pds.callsTo("com.atproto.repo.putRecord")).toHaveLength(0); + }); + + it("applies with CAS and preserves unknown profile and extension data", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + futureField: { preserved: true }, + extensions: { + [NSID.packageProfileExtension]: { + repository: "https://github.com/example/plugin", + future: { keep: true }, + }, + "com.example.other": { $type: "com.example.other", exact: ["keep"] }, + }, + }); + const result = await set(pds, { requireProvenance: true }, true); + expect(result.written).toBe(true); + const put = pds.callsTo("com.atproto.repo.putRecord")[0]!.body as { + swapRecord?: string; + validate?: boolean; + record: Record; + }; + expect(put.swapRecord).toBeTruthy(); + expect(put.validate).toBe(false); + expect(put.record.futureField).toEqual({ preserved: true }); + const extensions = put.record.extensions as Record>; + expect(extensions["com.example.other"]).toEqual({ + $type: "com.example.other", + exact: ["keep"], + }); + expect(extensions[NSID.packageProfileExtension]).toMatchObject({ + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/plugin", + future: { keep: true }, + releasePolicy: { requireProvenance: true }, + }); + }); + + it("requires a repository to create an extension and preserves it later", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds); + await expect(set(pds, { requireProvenance: true })).rejects.toMatchObject({ + code: "REPOSITORY_REQUIRED", + }); + const created = await set( + pds, + { repository: "https://github.com/example/plugin", requireProvenance: true }, + true, + ); + expect(created.diffs).toContainEqual({ + field: "repository", + before: undefined, + after: "https://github.com/example/plugin", + }); + await set(pds, { confirmation: "always" }, true); + const stored = pds.records.get(`at://${DID}/${NSID.packageProfile}/${SLUG}`)!.value as Record< + string, + unknown + >; + expect( + (stored.extensions as Record>)[NSID.packageProfileExtension] + .repository, + ).toBe("https://github.com/example/plugin"); + }); + + it("writes a strict policy exactly", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + extensions: { + [NSID.packageProfileExtension]: { repository: "https://github.com/example/plugin" }, + }, + }); + await set( + pds, + { requireProvenance: true, confirmation: "always", approvers: ["did:plc:alice"] }, + true, + ); + const stored = pds.records.get(`at://${DID}/${NSID.packageProfile}/${SLUG}`)!.value as Record< + string, + unknown + >; + expect( + (stored.extensions as Record>)[NSID.packageProfileExtension] + .releasePolicy, + ).toEqual({ requireProvenance: true, confirmation: "always", approvers: ["did:plc:alice"] }); + }); + + it("normalizes approver DIDs before storing and detecting duplicates", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + extensions: { + [NSID.packageProfileExtension]: { repository: "https://github.com/example/plugin" }, + }, + }); + await set(pds, { approvers: [" did:plc:alice "] }, true); + const stored = pds.records.get(`at://${DID}/${NSID.packageProfile}/${SLUG}`)!.value as Record< + string, + unknown + >; + expect( + (stored.extensions as Record>)[NSID.packageProfileExtension] + .releasePolicy, + ).toEqual({ approvers: ["did:plc:alice"] }); + await expect( + set(pds, { approvers: ["did:plc:alice", " did:plc:alice "] }), + ).rejects.toMatchObject({ code: "INVALID_APPROVERS" }); + }); + + it("treats the same approvers in a different order as a no-op", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + extensions: { + [NSID.packageProfileExtension]: { + repository: "https://github.com/example/plugin", + releasePolicy: { approvers: ["did:plc:alice", "did:plc:bob"] }, + }, + }, + }); + const result = await set(pds, { approvers: ["did:plc:bob", "did:plc:alice"] }, true); + expect(result.diffs).toEqual([]); + expect(result.written).toBe(false); + }); + + it("writes an explicit empty approver list", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + extensions: { + [NSID.packageProfileExtension]: { + repository: "https://github.com/example/plugin", + releasePolicy: { approvers: ["did:plc:alice"] }, + }, + }, + }); + await set(pds, { approvers: [] }, true); + const stored = pds.records.get(`at://${DID}/${NSID.packageProfile}/${SLUG}`)!.value as Record< + string, + unknown + >; + expect( + (stored.extensions as Record>)[NSID.packageProfileExtension] + .releasePolicy, + ).toEqual({ approvers: [] }); + }); + + it.each([ + [{ confirmation: "manual-review" }, "INVALID_CONFIRMATION"], + [{ repository: "http://example.com/repo" }, "INVALID_REPOSITORY"], + [{ approvers: ["not-a-did"] }, "INVALID_APPROVERS"], + [{ approvers: ["did:plc:alice", "did:plc:alice"] }, "INVALID_APPROVERS"], + ] as const)("rejects invalid policy input before writing", async (input, code) => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + extensions: { + [NSID.packageProfileExtension]: { repository: "https://github.com/example/plugin" }, + }, + }); + await expect(set(pds, input)).rejects.toMatchObject({ code }); + expect(pds.callsTo("com.atproto.repo.putRecord")).toHaveLength(0); + }); + + it("canonicalizes a new repository anchor before writing", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds); + await set(pds, { repository: "https://GitHub.com/Example/Plugin///" }, true); + const stored = pds.records.get(`at://${DID}/${NSID.packageProfile}/${SLUG}`)!.value as Record< + string, + unknown + >; + expect( + (stored.extensions as Record>)[NSID.packageProfileExtension] + .repository, + ).toBe("https://github.com/Example/Plugin"); + }); + + it.each([ + ["http://example.com/repo"], + ["https://user@example.com/repo"], + ["https://example.com/repo?ref=main"], + ["https://example.com/repo#readme"], + ["https://example.com:8443/repo"], + ] as const)("rejects a non-canonicalizable repository input", (repository) => { + expectErrorCode(() => canonicaliseRepository(repository), "INVALID_REPOSITORY"); + }); + + it("preserves root slashes and path case when canonicalizing repositories", () => { + expect(canonicaliseRepository("https://EXAMPLE.com/")).toBe("https://example.com/"); + expect(canonicaliseRepository("https://EXAMPLE.com///")).toBe("https://example.com/"); + expect(canonicaliseRepository("https://EXAMPLE.com/Owner/Repo.git/")).toBe( + "https://example.com/Owner/Repo.git", + ); + }); + + it("refuses a non-canonical existing repository anchor", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + extensions: { + [NSID.packageProfileExtension]: { repository: "https://GitHub.com/example/plugin/" }, + }, + }); + await expect(set(pds, { requireProvenance: true })).rejects.toMatchObject({ + code: "PROFILE_EXTENSION_INVALID", + }); + }); + + it("maps a stale CAS write to a stable error", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + extensions: { + [NSID.packageProfileExtension]: { repository: "https://github.com/example/plugin" }, + }, + }); + const handle = pds.handle.bind(pds); + pds.handle = async (pathname, init) => + pathname.includes("putRecord") + ? new Response(JSON.stringify({ error: "InvalidSwap" }), { + status: 400, + headers: { "content-type": "application/json" }, + }) + : handle(pathname, init); + await expect(set(pds, { requireProvenance: true }, true)).rejects.toBeInstanceOf( + ProfilePolicyError, + ); + await expect(set(pds, { requireProvenance: true }, true)).rejects.toMatchObject({ + code: "STALE_RECORD", + }); + }); + + it("does not write or change lastUpdated for a no-op apply", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + extensions: { + [NSID.packageProfileExtension]: { + repository: "https://github.com/example/plugin", + releasePolicy: { requireProvenance: true }, + }, + }, + }); + const result = await set(pds, { requireProvenance: true }, true); + expect(result.written).toBe(false); + expect(result.candidate.lastUpdated).toBe("2024-01-01T00:00:00.000Z"); + expect(pds.callsTo("com.atproto.repo.putRecord")).toHaveLength(0); + }); +}); From 9c4ce3cc86272e7ddf300962ba7ea2f61040a4b3 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 20:59:51 +0100 Subject: [PATCH 08/24] feat(registry): add verification primitives (#1929) * feat(registry): add verification primitives * chore: remove generated package archive --- .changeset/lucky-verifiers-float.md | 5 + package.json | 2 +- packages/registry-verification/package.json | 48 +++ .../registry-verification/src/checksum.ts | 168 ++++++++ packages/registry-verification/src/errors.ts | 29 ++ packages/registry-verification/src/fetch.ts | 393 ++++++++++++++++++ packages/registry-verification/src/index.ts | 15 + .../tests/checksum.test.ts | 47 +++ .../registry-verification/tests/fetch.test.ts | 213 ++++++++++ .../tests/workerd/smoke.test.ts | 22 + .../tests/workerd/worker.ts | 5 + packages/registry-verification/tsconfig.json | 11 + .../registry-verification/tsdown.config.ts | 10 + .../registry-verification/vitest.config.ts | 8 + .../vitest.workerd.config.ts | 9 + packages/registry-verification/wrangler.jsonc | 6 + pnpm-lock.yaml | 53 ++- 17 files changed, 1037 insertions(+), 7 deletions(-) create mode 100644 .changeset/lucky-verifiers-float.md create mode 100644 packages/registry-verification/package.json create mode 100644 packages/registry-verification/src/checksum.ts create mode 100644 packages/registry-verification/src/errors.ts create mode 100644 packages/registry-verification/src/fetch.ts create mode 100644 packages/registry-verification/src/index.ts create mode 100644 packages/registry-verification/tests/checksum.test.ts create mode 100644 packages/registry-verification/tests/fetch.test.ts create mode 100644 packages/registry-verification/tests/workerd/smoke.test.ts create mode 100644 packages/registry-verification/tests/workerd/worker.ts create mode 100644 packages/registry-verification/tsconfig.json create mode 100644 packages/registry-verification/tsdown.config.ts create mode 100644 packages/registry-verification/vitest.config.ts create mode 100644 packages/registry-verification/vitest.workerd.config.ts create mode 100644 packages/registry-verification/wrangler.jsonc diff --git a/.changeset/lucky-verifiers-float.md b/.changeset/lucky-verifiers-float.md new file mode 100644 index 0000000000..efff56821b --- /dev/null +++ b/.changeset/lucky-verifiers-float.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-verification": minor +--- + +Adds shared registry checksum verification and bounded HTTPS resource fetching primitives. diff --git a/package.json b/package.json index 0d62e3cbf6..a7554c58b8 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "typecheck:templates": "pnpm run --workspace-concurrency=1 --filter {./templates/*} typecheck", "check": "pnpm run typecheck && pnpm run --filter {./packages/*} check", "test": "pnpm run --filter {./packages/*} test", - "test:unit": "pnpm run --filter emdash --filter @emdash-cms/auth --filter @emdash-cms/blocks --filter @emdash-cms/gutenberg-to-portable-text --filter @emdash-cms/marketplace --filter @emdash-cms/plugin-cli --filter @emdash-cms/plugin-forms --filter @emdash-cms/plugin-types --filter @emdash-cms/registry-client --filter @emdash-cms/registry-lexicons test", + "test:unit": "pnpm run --filter emdash --filter @emdash-cms/auth --filter @emdash-cms/blocks --filter @emdash-cms/gutenberg-to-portable-text --filter @emdash-cms/marketplace --filter @emdash-cms/plugin-cli --filter @emdash-cms/plugin-forms --filter @emdash-cms/plugin-types --filter @emdash-cms/registry-client --filter @emdash-cms/registry-lexicons --filter @emdash-cms/registry-verification test", "test:browser": "pnpm run --filter @emdash-cms/admin test", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", diff --git a/packages/registry-verification/package.json b/packages/registry-verification/package.json new file mode 100644 index 0000000000..fdadcf4498 --- /dev/null +++ b/packages/registry-verification/package.json @@ -0,0 +1,48 @@ +{ + "name": "@emdash-cms/registry-verification", + "version": "0.0.0", + "description": "Runtime-neutral verification primitives for the EmDash plugin registry.", + "type": "module", + "main": "dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "prepublishOnly": "node --run build", + "typecheck": "tsgo --noEmit", + "test": "vitest run && pnpm run test:workerd", + "test:workerd": "vitest run --config vitest.workerd.config.ts", + "check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm --ignore-rules=no-resolution" + }, + "devDependencies": { + "@arethetypeswrong/cli": "catalog:", + "@cloudflare/vitest-pool-workers": "catalog:", + "publint": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "keywords": [ + "emdash", + "cms", + "plugin-registry", + "verification", + "multihash" + ], + "author": "Matt Kane", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/emdash-cms/emdash.git", + "directory": "packages/registry-verification" + }, + "homepage": "https://github.com/emdash-cms/emdash" +} diff --git a/packages/registry-verification/src/checksum.ts b/packages/registry-verification/src/checksum.ts new file mode 100644 index 0000000000..7271013494 --- /dev/null +++ b/packages/registry-verification/src/checksum.ts @@ -0,0 +1,168 @@ +import { verificationError } from "./errors.js"; +import type { VerificationResult } from "./errors.js"; + +const SHA2_256_CODE = 0x12; +const SHA2_256_LENGTH = 32; +const BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"; + +export type MultihashAlgorithm = "sha2-256"; + +export interface DecodedMultihash { + algorithm: MultihashAlgorithm; + digest: Uint8Array; +} + +/** Decodes a lowercase base32 multibase-encoded multihash. */ +export function decodeMultihash(value: string): VerificationResult { + if (!value.startsWith("b")) { + return verificationError( + "INVALID_MULTIHASH", + "Checksums must be lowercase base32 multibase-encoded multihashes.", + ); + } + + const bytes = decodeBase32(value.slice(1)); + if (bytes === null) { + return verificationError("INVALID_MULTIHASH", "The multibase checksum is malformed."); + } + + const code = readVarint(bytes, 0); + if (code === null) { + return verificationError("INVALID_MULTIHASH", "The multihash algorithm code is malformed."); + } + const length = readVarint(bytes, code.nextOffset); + if (length === null || length.value !== bytes.length - length.nextOffset) { + return verificationError("INVALID_MULTIHASH", "The multihash digest length is malformed."); + } + if (code.value !== SHA2_256_CODE) { + return verificationError("UNSUPPORTED_MULTIHASH", "The multihash algorithm is not supported."); + } + if (length.value !== SHA2_256_LENGTH) { + return verificationError("INVALID_MULTIHASH", "The sha2-256 digest must be 32 bytes."); + } + + return { + success: true, + value: { algorithm: "sha2-256", digest: bytes.slice(length.nextOffset) }, + }; +} + +export async function computeMultihash( + bytes: Uint8Array, + algorithm: MultihashAlgorithm = "sha2-256", +): Promise> { + if (algorithm !== "sha2-256") { + return verificationError("UNSUPPORTED_MULTIHASH", "The multihash algorithm is not supported."); + } + + try { + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new Uint8Array(bytes))); + const multihash = new Uint8Array(2 + digest.length); + multihash[0] = SHA2_256_CODE; + multihash[1] = digest.length; + multihash.set(digest, 2); + return { success: true, value: `b${encodeBase32(multihash)}` }; + } catch { + return verificationError("UNSUPPORTED_MULTIHASH", "The sha2-256 algorithm is unavailable."); + } +} + +export async function verifyMultihash( + bytes: Uint8Array, + expected: string, +): Promise> { + const decoded = decodeMultihash(expected); + if (!decoded.success) return decoded; + + const actual = await computeDigest(bytes, decoded.value.algorithm); + if (!actual.success) return actual; + if (!compareDigestBytes(actual.value, decoded.value.digest)) { + return verificationError( + "CHECKSUM_MISMATCH", + "The resource bytes do not match the expected checksum.", + ); + } + return { success: true, value: true }; +} + +/** + * Compare public checksum digest bytes without early exit. Digests are public + * integrity metadata, so a portable byte loop is preferable to a Node-only + * timing primitive while still avoiding an ordinary equality short circuit. + */ +export function compareDigestBytes(left: Uint8Array, right: Uint8Array): boolean { + let difference = left.length ^ right.length; + const maximumLength = Math.max(left.length, right.length); + for (let index = 0; index < maximumLength; index += 1) { + difference |= (left[index] ?? 0) ^ (right[index] ?? 0); + } + return difference === 0; +} + +async function computeDigest( + bytes: Uint8Array, + algorithm: MultihashAlgorithm, +): Promise> { + if (algorithm !== "sha2-256") { + return verificationError("UNSUPPORTED_MULTIHASH", "The multihash algorithm is not supported."); + } + try { + return { + success: true, + value: new Uint8Array(await crypto.subtle.digest("SHA-256", new Uint8Array(bytes))), + }; + } catch { + return verificationError("UNSUPPORTED_MULTIHASH", "The sha2-256 algorithm is unavailable."); + } +} + +function readVarint( + bytes: Uint8Array, + offset: number, +): { value: number; nextOffset: number } | null { + let value = 0; + let shift = 0; + for (let index = offset; index < bytes.length && index < offset + 5; index += 1) { + const byte = bytes[index]; + if (byte === undefined) return null; + value |= (byte & 0x7f) << shift; + if ((byte & 0x80) === 0) return { value, nextOffset: index + 1 }; + shift += 7; + } + return null; +} + +function encodeBase32(bytes: Uint8Array): string { + let result = ""; + let buffer = 0; + let bits = 0; + for (const byte of bytes) { + buffer = (buffer << 8) | byte; + bits += 8; + while (bits >= 5) { + result += BASE32_ALPHABET[(buffer >>> (bits - 5)) & 31] ?? ""; + bits -= 5; + } + } + if (bits > 0) result += BASE32_ALPHABET[(buffer << (5 - bits)) & 31] ?? ""; + return result; +} + +function decodeBase32(value: string): Uint8Array | null { + if (value.length === 0) return null; + let buffer = 0; + let bits = 0; + const output: number[] = []; + for (const character of value) { + const digit = BASE32_ALPHABET.indexOf(character); + if (digit === -1) return null; + buffer = (buffer << 5) | digit; + bits += 5; + if (bits >= 8) { + output.push((buffer >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + if (bits > 0 && (buffer & ((1 << bits) - 1)) !== 0) return null; + return new Uint8Array(output); +} diff --git a/packages/registry-verification/src/errors.ts b/packages/registry-verification/src/errors.ts new file mode 100644 index 0000000000..70b37cf7a6 --- /dev/null +++ b/packages/registry-verification/src/errors.ts @@ -0,0 +1,29 @@ +/** Stable, machine-readable failures returned by this package. */ +export type VerificationErrorCode = + | "CHECKSUM_MISMATCH" + | "FETCH_FAILED" + | "HOST_REJECTED" + | "INVALID_MULTIHASH" + | "INVALID_URL" + | "REDIRECT_LIMIT_EXCEEDED" + | "REDIRECT_LOCATION_MISSING" + | "RESOURCE_SIZE_EXCEEDED" + | "RESOURCE_STATUS_ERROR" + | "RESOURCE_TIMEOUT" + | "UNSUPPORTED_MULTIHASH"; + +export interface VerificationError { + code: VerificationErrorCode; + message: string; +} + +export type VerificationResult = + | { success: true; value: T } + | { success: false; error: VerificationError }; + +export function verificationError( + code: VerificationErrorCode, + message: string, +): VerificationResult { + return { success: false, error: { code, message } }; +} diff --git a/packages/registry-verification/src/fetch.ts b/packages/registry-verification/src/fetch.ts new file mode 100644 index 0000000000..6c57af0517 --- /dev/null +++ b/packages/registry-verification/src/fetch.ts @@ -0,0 +1,393 @@ +import { verificationError } from "./errors.js"; +import type { VerificationResult } from "./errors.js"; + +const DIGITS = /^\d+$/; +const IPV4_LITERAL = /^\d{1,3}(?:\.\d{1,3}){3}$/; +const IPV4_PART = /^\d{1,3}$/; +const IPV6_PART = /^[0-9a-f]{1,4}$/i; + +export const DEFAULT_FETCH_LIMITS = { + headerTimeoutMs: 10_000, + totalTimeoutMs: 30_000, + maxBytes: 25 * 1024 * 1024, + maxRedirects: 3, +} as const; + +export type FetchImplementation = (input: URL, init: RequestInit) => Promise; +/** + * Resolves every hostname before it is fetched. Returned addresses are checked + * for local and private ranges. Workers cannot pin fetch() to these addresses, + * so callers with private-network access must use an egress proxy that does. + */ +export type HostnameResolver = (hostname: string) => Promise; + +export interface FetchVerifiedResourceOptions { + fetch: FetchImplementation; + resolveHostname: HostnameResolver; + headerTimeoutMs?: number; + totalTimeoutMs?: number; + maxBytes?: number; + maxRedirects?: number; +} + +export interface VerifiedResource { + bytes: Uint8Array; + url: URL; + status: number; + headers: Headers; +} + +export async function fetchVerifiedResource( + value: string | URL, + options: FetchVerifiedResourceOptions, +): Promise> { + const limits = { + headerTimeoutMs: options.headerTimeoutMs ?? DEFAULT_FETCH_LIMITS.headerTimeoutMs, + totalTimeoutMs: options.totalTimeoutMs ?? DEFAULT_FETCH_LIMITS.totalTimeoutMs, + maxBytes: options.maxBytes ?? DEFAULT_FETCH_LIMITS.maxBytes, + maxRedirects: options.maxRedirects ?? DEFAULT_FETCH_LIMITS.maxRedirects, + }; + if (!areValidLimits(limits)) + return verificationError("INVALID_URL", "Fetch limits must be positive integers."); + + const startedAt = Date.now(); + let currentUrl = parseAndValidateUrl(value); + if (!currentUrl.success) return currentUrl; + + for (let redirects = 0; ; redirects += 1) { + const remaining = limits.totalTimeoutMs - (Date.now() - startedAt); + if (remaining <= 0) + return verificationError( + "RESOURCE_TIMEOUT", + "The resource fetch exceeded its total timeout.", + ); + const host = await validateHost(currentUrl.value, options.resolveHostname, remaining); + if (!host.success) return host; + + const remainingAfterResolution = limits.totalTimeoutMs - (Date.now() - startedAt); + if (remainingAfterResolution <= 0) + return verificationError( + "RESOURCE_TIMEOUT", + "The resource fetch exceeded its total timeout.", + ); + const response = await fetchResponse( + options.fetch, + currentUrl.value, + Math.min(remainingAfterResolution, limits.headerTimeoutMs), + ); + if (!response.success) return response; + + if (isRedirect(response.value.status)) { + if (redirects >= limits.maxRedirects) { + return verificationError( + "REDIRECT_LIMIT_EXCEEDED", + "The resource exceeded the redirect limit.", + ); + } + const location = response.value.headers.get("location"); + if (location === null) { + return verificationError( + "REDIRECT_LOCATION_MISSING", + "The redirect response has no location header.", + ); + } + currentUrl = parseAndValidateUrl(location, currentUrl.value); + if (!currentUrl.success) return currentUrl; + continue; + } + + if (!response.value.ok) { + return verificationError( + "RESOURCE_STATUS_ERROR", + `The resource returned HTTP ${response.value.status}.`, + ); + } + const contentLength = response.value.headers.get("content-length"); + if ( + contentLength !== null && + (!DIGITS.test(contentLength) || Number(contentLength) > limits.maxBytes) + ) { + return verificationError("RESOURCE_SIZE_EXCEEDED", "The resource exceeds the byte limit."); + } + + const bytes = await readResponse( + response.value, + limits.maxBytes, + startedAt, + limits.totalTimeoutMs, + ); + if (!bytes.success) return bytes; + return { + success: true, + value: { + bytes: bytes.value, + url: currentUrl.value, + status: response.value.status, + headers: response.value.headers, + }, + }; + } +} + +function parseAndValidateUrl(value: string | URL, base?: URL): VerificationResult { + let url: URL; + try { + url = new URL(value, base); + } catch { + return verificationError("INVALID_URL", "The resource URL is invalid."); + } + if (url.protocol !== "https:" || url.username !== "" || url.password !== "") { + return verificationError( + "INVALID_URL", + "Resource URLs must use HTTPS and cannot include credentials.", + ); + } + if (isIpLiteral(url.hostname) || isLocalHostname(url.hostname)) { + return verificationError("HOST_REJECTED", "The resource host is not permitted."); + } + return { success: true, value: url }; +} + +async function validateHost( + url: URL, + resolver: HostnameResolver, + timeoutMs: number, +): Promise> { + try { + const addresses = await withTimeout( + Promise.resolve().then(() => resolver(url.hostname)), + timeoutMs, + ); + if (addresses.length === 0 || addresses.some(isForbiddenAddress)) { + return verificationError( + "HOST_REJECTED", + "The resource host resolved to a forbidden address.", + ); + } + return { success: true, value: true }; + } catch (error) { + if (isAbortError(error)) { + return verificationError( + "RESOURCE_TIMEOUT", + "The resource fetch exceeded its total timeout.", + ); + } + return verificationError("HOST_REJECTED", "The resource host could not be validated."); + } +} + +async function fetchResponse( + fetchImplementation: FetchImplementation, + url: URL, + timeoutMs: number, +): Promise> { + const controller = new AbortController(); + try { + return { + success: true, + value: await withTimeout( + Promise.resolve().then(() => + fetchImplementation(url, { + method: "GET", + redirect: "manual", + signal: controller.signal, + }), + ), + timeoutMs, + () => controller.abort(), + ), + }; + } catch (error) { + if (controller.signal.aborted || isAbortError(error)) { + return verificationError("RESOURCE_TIMEOUT", "The resource response headers timed out."); + } + return verificationError("FETCH_FAILED", "The resource request failed."); + } +} + +async function readResponse( + response: Response, + maximumBytes: number, + startedAt: number, + totalTimeoutMs: number, +): Promise> { + if (response.body === null) return { success: true, value: new Uint8Array() }; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + for (;;) { + const remaining = totalTimeoutMs - (Date.now() - startedAt); + if (remaining <= 0) { + await reader.cancel(); + return verificationError( + "RESOURCE_TIMEOUT", + "The resource fetch exceeded its total timeout.", + ); + } + const chunk = await readWithTimeout(reader, remaining); + if (!chunk.success) { + await reader.cancel(); + return chunk; + } + if (chunk.value.done) break; + const value = chunk.value.value; + length += value.length; + if (length > maximumBytes) { + await reader.cancel(); + return verificationError("RESOURCE_SIZE_EXCEEDED", "The resource exceeds the byte limit."); + } + chunks.push(value); + } + } catch { + return verificationError("FETCH_FAILED", "The resource response could not be read."); + } finally { + reader.releaseLock(); + } + const output = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.length; + } + return { success: true, value: output }; +} + +async function readWithTimeout( + reader: ReadableStreamDefaultReader, + timeoutMs: number, +): Promise>> { + try { + return { + success: true, + value: await withTimeout( + Promise.resolve().then(() => reader.read()), + timeoutMs, + ), + }; + } catch (error) { + if (isAbortError(error)) + return verificationError( + "RESOURCE_TIMEOUT", + "The resource fetch exceeded its total timeout.", + ); + return verificationError("FETCH_FAILED", "The resource response could not be read."); + } +} + +/** + * Every operation has both fulfillment and rejection handlers, so an operation + * that loses the timeout race cannot later become an unhandled rejection. + */ +function withTimeout( + operation: Promise, + timeoutMs: number, + onTimeout?: () => void, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + onTimeout?.(); + reject(new DOMException("Timed out", "AbortError")); + }, timeoutMs); + void operation.then( + (value) => { + if (settled) return undefined; + settled = true; + clearTimeout(timeout); + resolve(value); + return undefined; + }, + (error: unknown) => { + if (settled) return undefined; + settled = true; + clearTimeout(timeout); + reject(error); + return undefined; + }, + ); + }); +} + +function areValidLimits(limits: Record): boolean { + return Object.values(limits).every((value) => Number.isSafeInteger(value) && value > 0); +} + +function isRedirect(status: number): boolean { + return status === 301 || status === 302 || status === 303 || status === 307 || status === 308; +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} + +function isIpLiteral(hostname: string): boolean { + return IPV4_LITERAL.test(hostname) || hostname.includes(":"); +} + +function isLocalHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + return ( + normalized === "localhost" || + normalized.endsWith(".localhost") || + normalized.endsWith(".local") || + normalized.endsWith(".internal") + ); +} + +function isForbiddenAddress(address: string): boolean { + const ipv4 = parseIpv4(address); + if (ipv4 !== null) { + const [a, b] = ipv4; + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && (b === 0 || b === 168)) || + (a === 198 && (b === 18 || b === 19)) || + a >= 224 + ); + } + const ipv6 = parseIpv6(address); + if (ipv6 === null) return true; + const first = ipv6[0]!; + const isUnspecified = ipv6.every((part) => part === 0); + const isLoopback = ipv6.slice(0, 7).every((part) => part === 0) && ipv6[7] === 1; + const isPrivate = (first & 0xfe00) === 0xfc00; + const isLinkLocal = (first & 0xffc0) === 0xfe80; + const isMulticast = (first & 0xff00) === 0xff00; + const mappedIpv4 = ipv6.slice(0, 5).every((part) => part === 0) && ipv6[5] === 0xffff; + if (mappedIpv4) { + return isForbiddenAddress( + `${ipv6[6]! >>> 8}.${ipv6[6]! & 0xff}.${ipv6[7]! >>> 8}.${ipv6[7]! & 0xff}`, + ); + } + return isUnspecified || isLoopback || isPrivate || isLinkLocal || isMulticast; +} + +function parseIpv4(value: string): [number, number, number, number] | null { + const parts = value.split("."); + if (parts.length !== 4) return null; + const numbers = parts.map((part) => (IPV4_PART.test(part) ? Number(part) : Number.NaN)); + if (numbers.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null; + return [numbers[0]!, numbers[1]!, numbers[2]!, numbers[3]!]; +} + +function parseIpv6(value: string): number[] | null { + if (value.includes(":::") || value.split("::").length > 2) return null; + const [before = "", after = ""] = value.split("::"); + const head = before === "" ? [] : before.split(":"); + const tail = after === "" ? [] : after.split(":"); + const parts = [...head, ...tail]; + if (parts.some((part) => !IPV6_PART.test(part)) || parts.length > 8) return null; + if (!value.includes("::") && parts.length !== 8) return null; + const zeroes = value.includes("::") ? 8 - parts.length : 0; + return [...head, ...Array.from({ length: zeroes }).fill("0"), ...tail].map((part) => + Number.parseInt(part, 16), + ); +} diff --git a/packages/registry-verification/src/index.ts b/packages/registry-verification/src/index.ts new file mode 100644 index 0000000000..0cf243a108 --- /dev/null +++ b/packages/registry-verification/src/index.ts @@ -0,0 +1,15 @@ +export { + compareDigestBytes, + computeMultihash, + decodeMultihash, + verifyMultihash, +} from "./checksum.js"; +export { DEFAULT_FETCH_LIMITS, fetchVerifiedResource } from "./fetch.js"; +export type { DecodedMultihash, MultihashAlgorithm } from "./checksum.js"; +export type { + FetchImplementation, + FetchVerifiedResourceOptions, + HostnameResolver, + VerifiedResource, +} from "./fetch.js"; +export type { VerificationError, VerificationErrorCode, VerificationResult } from "./errors.js"; diff --git a/packages/registry-verification/tests/checksum.test.ts b/packages/registry-verification/tests/checksum.test.ts new file mode 100644 index 0000000000..ddfa02ea58 --- /dev/null +++ b/packages/registry-verification/tests/checksum.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { + compareDigestBytes, + computeMultihash, + decodeMultihash, + verifyMultihash, +} from "../src/index.js"; + +const encoder = new TextEncoder(); + +describe("multihash checksums", () => { + it("computes the sha2-256 multibase multihash known vector", async () => { + // Independently fixed SHA-256 multihash vector for the UTF-8 bytes "hello". + const result = await computeMultihash(encoder.encode("hello")); + expect(result).toEqual({ + success: true, + value: "bciqcz4snxjp3biyoe3udwkwfxhrj4gywdzob7j2clzzqim3csofzqja", + }); + }); + + it("rejects malformed and unsupported multihashes", () => { + expect(decodeMultihash("2cf24dba")).toMatchObject({ + success: false, + error: { code: "INVALID_MULTIHASH" }, + }); + expect( + decodeMultihash("bcmqcz4snxjp3biyoe3udwkwfxhrj4gywdzob7j2clzzqim3csofzqja"), + ).toMatchObject({ + success: false, + error: { code: "UNSUPPORTED_MULTIHASH" }, + }); + }); + + it("reports checksum mismatches", async () => { + const expected = await computeMultihash(encoder.encode("expected")); + if (!expected.success) throw new Error(expected.error.message); + const result = await verifyMultihash(encoder.encode("actual"), expected.value); + expect(result).toMatchObject({ success: false, error: { code: "CHECKSUM_MISMATCH" } }); + }); + + it("compares public digest bytes without accepting length or byte mismatches", () => { + expect(compareDigestBytes(new Uint8Array([1, 2]), new Uint8Array([1, 2]))).toBe(true); + expect(compareDigestBytes(new Uint8Array([1, 2]), new Uint8Array([1, 3]))).toBe(false); + expect(compareDigestBytes(new Uint8Array([1, 2]), new Uint8Array([1, 2, 0]))).toBe(false); + }); +}); diff --git a/packages/registry-verification/tests/fetch.test.ts b/packages/registry-verification/tests/fetch.test.ts new file mode 100644 index 0000000000..5aa30983ad --- /dev/null +++ b/packages/registry-verification/tests/fetch.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it, vi } from "vitest"; + +import { fetchVerifiedResource } from "../src/index.js"; + +const encoder = new TextEncoder(); +const resolvePublicHostname = async (): Promise => ["203.0.113.5"]; + +function bytesResponse(value: string, init?: ResponseInit): Response { + return new Response(encoder.encode(value), init); +} + +function streamingResponse(chunks: string[]): Response { + let index = 0; + return new Response( + new ReadableStream({ + pull(controller) { + const chunk = chunks[index++]; + if (chunk === undefined) controller.close(); + else controller.enqueue(encoder.encode(chunk)); + }, + }), + ); +} + +describe("fetchVerifiedResource", () => { + it("rejects non-HTTPS, malformed, and credential-bearing URLs before fetching", async () => { + const fetch = vi.fn(); + for (const value of ["http://example.com", "https://user:pass@example.com", "not a URL"]) { + const result = await fetchVerifiedResource(value, { + fetch, + resolveHostname: resolvePublicHostname, + }); + expect(result).toMatchObject({ success: false, error: { code: "INVALID_URL" } }); + } + expect(fetch).not.toHaveBeenCalled(); + }); + + it("follows validated redirects and validates every hostname hop", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(null, { + status: 302, + headers: { location: "https://cdn.example.test/artifact" }, + }), + ) + .mockResolvedValueOnce(bytesResponse("artifact")); + const resolveHostname = vi.fn().mockResolvedValue(["203.0.113.5"]); + const result = await fetchVerifiedResource("https://origin.example.test/start", { + fetch, + resolveHostname, + }); + expect(result).toMatchObject({ + success: true, + value: { url: expect.objectContaining({ hostname: "cdn.example.test" }) }, + }); + expect(resolveHostname).toHaveBeenNthCalledWith(1, "origin.example.test"); + expect(resolveHostname).toHaveBeenNthCalledWith(2, "cdn.example.test"); + expect(fetch).toHaveBeenNthCalledWith( + 1, + expect.any(URL), + expect.objectContaining({ redirect: "manual" }), + ); + }); + + it("enforces the redirect limit", async () => { + const fetch = vi + .fn() + .mockResolvedValue( + new Response(null, { status: 302, headers: { location: "https://example.test/again" } }), + ); + const result = await fetchVerifiedResource("https://example.test/start", { + fetch, + resolveHostname: resolvePublicHostname, + maxRedirects: 1, + }); + expect(result).toMatchObject({ success: false, error: { code: "REDIRECT_LIMIT_EXCEEDED" } }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("rejects a redirect target that resolves to a forbidden address", async () => { + const fetch = vi.fn().mockResolvedValue( + new Response(null, { + status: 302, + headers: { location: "https://private.example.test/artifact" }, + }), + ); + const resolveHostname = vi.fn(async (hostname: string) => + hostname === "private.example.test" ? ["127.0.0.1"] : ["203.0.113.5"], + ); + const result = await fetchVerifiedResource("https://origin.example.test/start", { + fetch, + resolveHostname, + }); + expect(result).toMatchObject({ success: false, error: { code: "HOST_REJECTED" } }); + expect(resolveHostname).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("re-resolves the same hostname after a redirect", async () => { + const fetch = vi.fn().mockResolvedValue( + new Response(null, { + status: 302, + headers: { location: "/again" }, + }), + ); + const resolveHostname = vi + .fn() + .mockResolvedValueOnce(["203.0.113.5"]) + .mockResolvedValueOnce(["10.0.0.1"]); + const result = await fetchVerifiedResource("https://artifact.example.test/start", { + fetch, + resolveHostname, + }); + expect(result).toMatchObject({ success: false, error: { code: "HOST_REJECTED" } }); + expect(resolveHostname).toHaveBeenNthCalledWith(1, "artifact.example.test"); + expect(resolveHostname).toHaveBeenNthCalledWith(2, "artifact.example.test"); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("rejects oversize content-length and streaming bodies", async () => { + const contentLength = await fetchVerifiedResource("https://example.test/file", { + fetch: vi + .fn() + .mockResolvedValue(bytesResponse("small", { headers: { "content-length": "6" } })), + maxBytes: 5, + resolveHostname: resolvePublicHostname, + }); + expect(contentLength).toMatchObject({ + success: false, + error: { code: "RESOURCE_SIZE_EXCEEDED" }, + }); + + const streamed = await fetchVerifiedResource("https://example.test/file", { + fetch: vi.fn().mockResolvedValue(streamingResponse(["abc", "def"])), + maxBytes: 5, + resolveHostname: resolvePublicHostname, + }); + expect(streamed).toMatchObject({ success: false, error: { code: "RESOURCE_SIZE_EXCEEDED" } }); + }); + + it("maps fetch failures, statuses, and header timeouts to stable errors", async () => { + const failed = await fetchVerifiedResource("https://example.test/file", { + fetch: vi.fn().mockRejectedValue(new Error("network unavailable")), + resolveHostname: resolvePublicHostname, + }); + expect(failed).toMatchObject({ success: false, error: { code: "FETCH_FAILED" } }); + + const status = await fetchVerifiedResource("https://example.test/file", { + fetch: vi.fn().mockResolvedValue(new Response(null, { status: 404 })), + resolveHostname: resolvePublicHostname, + }); + expect(status).toMatchObject({ success: false, error: { code: "RESOURCE_STATUS_ERROR" } }); + + let signal: AbortSignal | undefined; + const timeout = await fetchVerifiedResource("https://example.test/file", { + fetch: vi.fn((_, init) => { + signal = init.signal ?? undefined; + return new Promise(() => {}); + }), + resolveHostname: resolvePublicHostname, + headerTimeoutMs: 1, + }); + expect(timeout).toMatchObject({ success: false, error: { code: "RESOURCE_TIMEOUT" } }); + expect(signal?.aborted).toBe(true); + }); + + it("cancels a stalled stream when the total timeout expires", async () => { + let cancelled = false; + const response = new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + ); + const result = await fetchVerifiedResource("https://example.test/file", { + fetch: vi.fn().mockResolvedValue(response), + resolveHostname: resolvePublicHostname, + totalTimeoutMs: 1, + }); + expect(result).toMatchObject({ success: false, error: { code: "RESOURCE_TIMEOUT" } }); + expect(cancelled).toBe(true); + }); + + it("maps resolver rejection to a stable error before fetching", async () => { + const fetch = vi.fn(); + for (const address of ["127.0.0.1", "169.254.169.254", "::ffff:127.0.0.1", "not-an-address"]) { + const result = await fetchVerifiedResource("https://example.test/file", { + fetch, + resolveHostname: vi.fn().mockResolvedValue([address]), + }); + expect(result).toMatchObject({ success: false, error: { code: "HOST_REJECTED" } }); + } + const rejected = await fetchVerifiedResource("https://example.test/file", { + fetch, + resolveHostname: vi.fn().mockRejectedValue(new Error("DNS failure")), + }); + expect(rejected).toMatchObject({ success: false, error: { code: "HOST_REJECTED" } }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("bounds a stalled resolver with the total timeout before fetching", async () => { + const fetch = vi.fn(); + const result = await fetchVerifiedResource("https://example.test/file", { + fetch, + resolveHostname: () => new Promise(() => {}), + totalTimeoutMs: 1, + }); + expect(result).toMatchObject({ success: false, error: { code: "RESOURCE_TIMEOUT" } }); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/registry-verification/tests/workerd/smoke.test.ts b/packages/registry-verification/tests/workerd/smoke.test.ts new file mode 100644 index 0000000000..29dd6fe161 --- /dev/null +++ b/packages/registry-verification/tests/workerd/smoke.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { computeMultihash, fetchVerifiedResource } from "../../src/index.js"; + +const encoder = new TextEncoder(); + +describe("registry verification in workerd", () => { + it("computes checksums and fetches through injected dependencies", async () => { + const checksum = await computeMultihash(encoder.encode("hello")); + expect(checksum).toEqual({ + success: true, + value: "bciqcz4snxjp3biyoe3udwkwfxhrj4gywdzob7j2clzzqim3csofzqja", + }); + + const resource = await fetchVerifiedResource("https://artifact.example.test/package.tgz", { + fetch: async () => new Response(encoder.encode("artifact")), + resolveHostname: async () => ["203.0.113.5"], + }); + expect(resource).toMatchObject({ success: true, value: { status: 200 } }); + if (resource.success) expect(new TextDecoder().decode(resource.value.bytes)).toBe("artifact"); + }); +}); diff --git a/packages/registry-verification/tests/workerd/worker.ts b/packages/registry-verification/tests/workerd/worker.ts new file mode 100644 index 0000000000..d19f25a6a9 --- /dev/null +++ b/packages/registry-verification/tests/workerd/worker.ts @@ -0,0 +1,5 @@ +export default { + fetch() { + return new Response("registry-verification test worker"); + }, +}; diff --git a/packages/registry-verification/tsconfig.json b/packages/registry-verification/tsconfig.json new file mode 100644 index 0000000000..8c7c2bda9a --- /dev/null +++ b/packages/registry-verification/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["es2024", "dom", "esnext.typedarrays"], + "types": ["@cloudflare/vitest-pool-workers/types"] + }, + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/registry-verification/tsdown.config.ts b/packages/registry-verification/tsdown.config.ts new file mode 100644 index 0000000000..6df95bede2 --- /dev/null +++ b/packages/registry-verification/tsdown.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, + platform: "neutral", + target: "es2024", +}); diff --git a/packages/registry-verification/vitest.config.ts b/packages/registry-verification/vitest.config.ts new file mode 100644 index 0000000000..b6da2509b8 --- /dev/null +++ b/packages/registry-verification/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/*.test.ts"], + }, +}); diff --git a/packages/registry-verification/vitest.workerd.config.ts b/packages/registry-verification/vitest.workerd.config.ts new file mode 100644 index 0000000000..859f135ba2 --- /dev/null +++ b/packages/registry-verification/vitest.workerd.config.ts @@ -0,0 +1,9 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [cloudflareTest({ wrangler: { configPath: "./wrangler.jsonc" } })], + test: { + include: ["tests/workerd/**/*.test.ts"], + }, +}); diff --git a/packages/registry-verification/wrangler.jsonc b/packages/registry-verification/wrangler.jsonc new file mode 100644 index 0000000000..87766b2710 --- /dev/null +++ b/packages/registry-verification/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "emdash-registry-verification-test", + "main": "./tests/workerd/worker.ts", + "compatibility_date": "2026-05-14", +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af45cfb8a9..52f2d0483e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2232,6 +2232,27 @@ importers: specifier: 'catalog:' version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + packages/registry-verification: + devDependencies: + '@arethetypeswrong/cli': + specifier: 'catalog:' + version: 0.18.2 + '@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@25.9.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))) + publint: + specifier: 'catalog:' + version: 0.3.17 + 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.10.0))(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@25.9.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + packages/workerd: dependencies: emdash: @@ -2651,6 +2672,7 @@ packages: '@arethetypeswrong/cli@0.18.2': resolution: {integrity: sha512-PcFM20JNlevEDKBg4Re29Rtv2xvjvQZzg7ENnrWFSS0PHgdP2njibVFw+dRUhNkPgNfac9iUqO0ohAXqQL4hbw==} engines: {node: '>=20'} + hasBin: true '@arethetypeswrong/core@0.18.2': resolution: {integrity: sha512-GiwTmBFOU1/+UVNqqCGzFJYfBXEytUkiI+iRZ6Qx7KmUVtLm00sYySkfe203C9QtPG11yOz1ZaMek8dT/xnlgg==} @@ -7922,9 +7944,6 @@ packages: citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} - cjs-module-lexer@1.4.0: - resolution: {integrity: sha512-N1NGmowPlGBLsOZLPvm48StN04V4YvQRL0i6b7ctrVY3epjP/ct7hFLOItz6pDIvRjwpfPxi52a2UWV2ziir8g==} - cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} @@ -8370,6 +8389,7 @@ packages: esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} + hasBin: true esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} @@ -9584,6 +9604,7 @@ packages: miniflare@4.20260415.0: resolution: {integrity: sha512-JoExRWN4YBI2luA5BoSMFEgi8rQWXUGzo3mtE+58VXCLV3jj/Xnk5Yeqs/IXWz8Es5GJIaq6BtsixDvAxXSIng==} engines: {node: '>=18.0.0'} + hasBin: true miniflare@4.20260507.1: resolution: {integrity: sha512-PSXBiLExTdZ4UGO/raKCHQauUpYL7F880ZRB7j0+78Rv8h7TsdN2E/iEDK9sK2Y+SPQ5wJSeAa+rDeVKoZZoEw==} @@ -9592,10 +9613,12 @@ packages: miniflare@4.20260609.0: resolution: {integrity: sha512-4ZfNh9ACDa/mKKQvTSO2vigyQS2MB7dEU02KRPle4FqL7S6nek+2Fq6WGzazZbt1OORYgb4OGVLnOCx+My2NNA==} engines: {node: '>=22.0.0'} + hasBin: true miniflare@4.20260611.0: resolution: {integrity: sha512-i+JwEo8vN96naz1WL3ntFgFyRluBDYL408zwhHKvR2jefJ464KsZ/gCmJAQ5k+oaWeb5Ug+s7yne5AyiAEswjg==} engines: {node: '>=22.0.0'} + hasBin: true minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} @@ -10324,6 +10347,7 @@ packages: publint@0.3.17: resolution: {integrity: sha512-Q3NLegA9XM6usW+dYQRG1g9uEHiYUzcCVBJDJ7yMcWRqVU9LYZUWdqbwMZfmTCFC5PZLQpLAmhvRcQRl3exqkw==} engines: {node: '>=18'} + hasBin: true pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -11125,6 +11149,7 @@ packages: typescript@5.6.1-rc: resolution: {integrity: sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==} engines: {node: '>=14.17'} + hasBin: true typescript@6.0.0-beta: resolution: {integrity: sha512-CldZdztDpQRLM1HC6WDQjQkQN5Ub5zRau737a1diGh3lPmb9oRsaWHk1y5iqK0o7+1bNJ0oXfEGRkAogFZBL+Q==} @@ -11819,6 +11844,7 @@ packages: workerd@1.20260415.1: resolution: {integrity: sha512-phyPjRnx+mQDfkhN9ENPioL1L0SdhYs4S0YmJK/xF9Oga+ykNfdSy1MHnsOj8yqnOV96zcVQMx32dJ0r3pq0jQ==} engines: {node: '>=16'} + hasBin: true workerd@1.20260507.1: resolution: {integrity: sha512-z7JhsFSe6+X1b5fUHaVpo15VM1IRMJiLofEkq8iKdCo+Veqc+FUg5lIsuz8NwePxuSKrXtO4ZQpGkQLbPVXFhg==} @@ -11827,10 +11853,12 @@ packages: workerd@1.20260609.1: resolution: {integrity: sha512-KF/Y/8f4VoXCk87NuU6RqmO0X5fdzcrxU3XzAgoPUpnH9t1ZyzRgX1O/9sJvjItxroCBTEBzKssda02Dz9i6BA==} engines: {node: '>=16'} + hasBin: true workerd@1.20260611.1: resolution: {integrity: sha512-CS/640T7pIJ2HYX6x2DwKFGbcSckAWN3tgcdq+ptB6SaqjWUhlzIgA/YhPuwIU+/NnMnGpqOFX/hC18Oyge63w==} engines: {node: '>=16'} + hasBin: true wrangler@4.100.0: resolution: {integrity: sha512-dSQO7DO+mD6XDzkVWIWBoGLO3yw+lacWSc/KhFvd7pgfpth+kX98qb5SGRHZN8ACCDhhfwzDLXwB6qHsIHhfBg==} @@ -13969,7 +13997,7 @@ snapshots: dependencies: '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 - cjs-module-lexer: 1.4.0 + cjs-module-lexer: 1.4.3 esbuild: 0.27.3 miniflare: 4.20260507.1 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)(yaml@2.9.0)) @@ -13980,6 +14008,21 @@ snapshots: - bufferutil - utf-8-validate + '@cloudflare/vitest-pool-workers@0.16.3(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))': + dependencies: + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + cjs-module-lexer: 1.4.3 + esbuild: 0.27.3 + miniflare: 4.20260507.1 + vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + wrangler: 4.90.0 + zod: 3.25.76 + transitivePeerDependencies: + - '@cloudflare/workers-types' + - bufferutil + - utf-8-validate + '@cloudflare/workerd-darwin-64@1.20260301.1': optional: true @@ -18350,8 +18393,6 @@ snapshots: dependencies: consola: 3.4.2 - cjs-module-lexer@1.4.0: {} - cjs-module-lexer@1.4.3: {} class-variance-authority@0.7.1: From c76e7fb4dfaffc2f612d24cd35118fa31c7d2e44 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 22:01:24 +0100 Subject: [PATCH 09/24] feat(registry): validate canonical plugin bundles (#1932) --- .changeset/canonical-bundle-verification.md | 5 + .changeset/cli-canonical-bundle-validation.md | 5 + .../core-canonical-bundle-validation.md | 5 + .changeset/shared-plugin-manifest-schema.md | 5 + packages/core/package.json | 1 + packages/core/src/plugins/manifest-schema.ts | 366 +----------------- packages/core/src/plugins/marketplace.ts | 153 ++------ .../unit/plugins/marketplace-client.test.ts | 35 +- packages/plugin-cli/package.json | 1 + packages/plugin-cli/src/bundle/utils.ts | 9 +- packages/plugin-cli/src/commands/publish.ts | 153 ++------ .../plugin-cli/tests/publish-tarball.test.ts | 39 +- packages/plugin-types/package.json | 5 +- packages/plugin-types/src/index.ts | 12 + packages/plugin-types/src/manifest-schema.ts | 351 +++++++++++++++++ packages/registry-verification/package.json | 6 +- .../src/bundle-limits.ts | 21 + packages/registry-verification/src/bundle.ts | 324 ++++++++++++++++ packages/registry-verification/src/errors.ts | 13 + packages/registry-verification/src/index.ts | 10 + .../tests/bundle.test.ts | 305 +++++++++++++++ .../tests/workerd/smoke.test.ts | 35 +- pnpm-lock.yaml | 17 + 23 files changed, 1250 insertions(+), 626 deletions(-) create mode 100644 .changeset/canonical-bundle-verification.md create mode 100644 .changeset/cli-canonical-bundle-validation.md create mode 100644 .changeset/core-canonical-bundle-validation.md create mode 100644 .changeset/shared-plugin-manifest-schema.md create mode 100644 packages/plugin-types/src/manifest-schema.ts create mode 100644 packages/registry-verification/src/bundle-limits.ts create mode 100644 packages/registry-verification/src/bundle.ts create mode 100644 packages/registry-verification/tests/bundle.test.ts diff --git a/.changeset/canonical-bundle-verification.md b/.changeset/canonical-bundle-verification.md new file mode 100644 index 0000000000..9127fc44ea --- /dev/null +++ b/.changeset/canonical-bundle-verification.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-verification": minor +--- + +Adds canonical, runtime-neutral validation for plugin bundle archives and exports their security limits. diff --git a/.changeset/cli-canonical-bundle-validation.md b/.changeset/cli-canonical-bundle-validation.md new file mode 100644 index 0000000000..3e6f3eaacc --- /dev/null +++ b/.changeset/cli-canonical-bundle-validation.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/plugin-cli": patch +--- + +Rejects unsafe or malformed plugin bundles before publishing with consistent archive and manifest validation. diff --git a/.changeset/core-canonical-bundle-validation.md b/.changeset/core-canonical-bundle-validation.md new file mode 100644 index 0000000000..66a5ba149e --- /dev/null +++ b/.changeset/core-canonical-bundle-validation.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Rejects unsafe, mismatched, or non-canonical marketplace plugin bundles during installation, including archives with links or extended headers that older parsing may have ignored. diff --git a/.changeset/shared-plugin-manifest-schema.md b/.changeset/shared-plugin-manifest-schema.md new file mode 100644 index 0000000000..7c443d40ab --- /dev/null +++ b/.changeset/shared-plugin-manifest-schema.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/plugin-types": minor +--- + +Adds the authoritative plugin manifest schema and access reconciliation helpers to the shared manifest contract. diff --git a/packages/core/package.json b/packages/core/package.json index 0ab1bf8cd3..237781ceab 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -212,6 +212,7 @@ "@emdash-cms/gutenberg-to-portable-text": "workspace:*", "@emdash-cms/plugin-types": "workspace:*", "@emdash-cms/registry-client": "workspace:*", + "@emdash-cms/registry-verification": "workspace:*", "@floating-ui/react": "^0.27.16", "@modelcontextprotocol/sdk": "^1.26.0", "@oslojs/crypto": "catalog:", diff --git a/packages/core/src/plugins/manifest-schema.ts b/packages/core/src/plugins/manifest-schema.ts index 20257b43ea..e7b56dac4b 100644 --- a/packages/core/src/plugins/manifest-schema.ts +++ b/packages/core/src/plugins/manifest-schema.ts @@ -1,358 +1,22 @@ -/** - * Zod schema for PluginManifest validation - * - * Used to validate manifest.json from plugin bundles at every parse site: - * - Client-side download (marketplace.ts extractBundle) - * - R2 load (api/handlers/marketplace.ts loadBundleFromR2) - * - CLI publish preview (cli/commands/publish.ts readManifestFromTarball) - * - Marketplace ingest extends this with publishing-specific fields - */ - -import { - capabilitiesToDeclaredAccess, - declaredAccessToCapabilities, +export { + CURRENT_PLUGIN_CAPABILITIES, + DEPRECATED_PLUGIN_CAPABILITIES, + HOOK_NAMES, + normalizeManifestHook, + normalizeManifestRoute, + PLUGIN_CAPABILITIES, + pluginManifestSchema, } from "@emdash-cms/plugin-types"; -import { z } from "zod"; +import { reconcileManifestAccess as reconcileSharedManifestAccess } from "@emdash-cms/plugin-types"; +import type { ValidatedPluginManifest } from "@emdash-cms/plugin-types"; import type { PluginManifest } from "./types.js"; -// ── Enum values (must stay in sync with types.ts) ─────────────── - -/** - * Current capability names — the ones authors should use going forward. - * See `PluginCapability` in `types.ts` for documentation of each. - */ -export const CURRENT_PLUGIN_CAPABILITIES = [ - "network:request", - "network:request:unrestricted", - "content:read", - "content:write", - "media:read", - "media:write", - "users:read", - "email:send", - "hooks.email-transport:register", - "hooks.email-events:register", - "hooks.page-fragments:register", -] as const; - -/** - * Legacy capability names accepted during the deprecation window. - * Normalized to current names via `normalizeCapability()` in types.ts - * before reaching the runtime. Plugin authors are warned at bundle/validate - * and hard-failed at publish. - */ -export const DEPRECATED_PLUGIN_CAPABILITIES = [ - "network:fetch", - "network:fetch:any", - "read:content", - "write:content", - "read:media", - "write:media", - "read:users", - "email:provide", - "email:intercept", - "page:inject", -] as const; - -/** - * Full set of accepted capability strings — current + deprecated. - * - * The manifest schema accepts both during the transition. The runtime only - * ever sees current names because `normalizeCapability()` rewrites legacy - * names at every external boundary (definePlugin, adaptSandboxEntry). - */ -export const PLUGIN_CAPABILITIES = [ - ...CURRENT_PLUGIN_CAPABILITIES, - ...DEPRECATED_PLUGIN_CAPABILITIES, -] as const; - -/** Must stay in sync with FieldType in schema/types.ts */ -const FIELD_TYPES = [ - "string", - "text", - "number", - "integer", - "boolean", - "datetime", - "select", - "multiSelect", - "portableText", - "image", - "file", - "reference", - "json", - "slug", - "repeater", -] as const; - -export const HOOK_NAMES = [ - "plugin:install", - "plugin:activate", - "plugin:deactivate", - "plugin:uninstall", - "content:beforeSave", - "content:afterSave", - "content:beforeDelete", - "content:afterDelete", - "content:afterPublish", - "content:afterUnpublish", - "content:afterRestore", - "content:afterSchedule", - "content:afterUnschedule", - "media:beforeUpload", - "media:afterUpload", - "cron", - "email:beforeSend", - "email:deliver", - "email:afterSend", - "comment:beforeCreate", - "comment:moderate", - "comment:afterCreate", - "comment:afterModerate", - "page:metadata", - "page:fragments", -] as const; - -/** - * Structured hook entry for manifest — name plus optional metadata. - * During a transition period, both plain strings and objects are accepted. - */ -const manifestHookEntrySchema = z.object({ - name: z.enum(HOOK_NAMES), - exclusive: z.boolean().optional(), - priority: z.number().int().optional(), - timeout: z.number().int().positive().optional(), -}); - -/** - * Structured route entry for manifest — name plus optional metadata. - * Both plain strings and objects are accepted; strings are normalized - * to `{ name }` objects via `normalizeManifestRoute()`. - */ -/** Route names must be safe path segments — alphanumeric, hyphens, underscores, forward slashes */ -const routeNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9_\-/]*$/; - -const manifestRouteEntrySchema = z.object({ - name: z.string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"), - public: z.boolean().optional(), -}); - -// ── Sub-schemas ───────────────────────────────────────────────── - -/** Index field names must be valid identifiers to prevent SQL injection via JSON path expressions */ -const indexFieldName = z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]*$/); - -const storageCollectionSchema = z.object({ - indexes: z.array(z.union([indexFieldName, z.array(indexFieldName)])), - uniqueIndexes: z.array(z.union([indexFieldName, z.array(indexFieldName)])).optional(), -}); - -const baseSettingFields = { - label: z.string(), - description: z.string().optional(), -}; - -const settingFieldSchema = z.discriminatedUnion("type", [ - z.object({ - ...baseSettingFields, - type: z.literal("string"), - default: z.string().optional(), - multiline: z.boolean().optional(), - }), - z.object({ - ...baseSettingFields, - type: z.literal("number"), - default: z.number().optional(), - min: z.number().optional(), - max: z.number().optional(), - }), - z.object({ ...baseSettingFields, type: z.literal("boolean"), default: z.boolean().optional() }), - z.object({ - ...baseSettingFields, - type: z.literal("select"), - options: z.array(z.object({ value: z.string(), label: z.string() })), - default: z.string().optional(), - }), - z.object({ ...baseSettingFields, type: z.literal("secret") }), - z.object({ - ...baseSettingFields, - type: z.literal("url"), - default: z.string().optional(), - placeholder: z.string().optional(), - }), - z.object({ - ...baseSettingFields, - type: z.literal("email"), - default: z.string().optional(), - placeholder: z.string().optional(), - }), -]); +export type { ValidatedPluginManifest } from "@emdash-cms/plugin-types"; -const adminPageSchema = z.object({ - path: z.string(), - label: z.string(), - icon: z.string().optional(), -}); - -const dashboardWidgetSchema = z.object({ - id: z.string(), - size: z.enum(["full", "half", "third"]).optional(), - title: z.string().optional(), -}); - -const pluginAdminConfigSchema = z.object({ - entry: z.string().optional(), - settingsSchema: z.record(z.string(), settingFieldSchema).optional(), - pages: z.array(adminPageSchema).optional(), - widgets: z.array(dashboardWidgetSchema).optional(), - fieldWidgets: z - .array( - z.object({ - name: z.string().min(1), - label: z.string().min(1), - fieldTypes: z.array(z.enum(FIELD_TYPES)), - elements: z - .array( - z - .object({ - type: z.string(), - action_id: z.string(), - label: z.string().optional(), - }) - .passthrough(), - ) - .optional(), - }), - ) - .optional(), -}); - -// ── declaredAccess ────────────────────────────────────────────── - -/** - * An operation's constraint object. Open vocabulary: keys the runtime - * recognises are enforced, others are advisory. The bundler emits `{}` for a - * granted operation; presence (not value) signals the grant. - */ -const accessConstraints = z.record(z.string(), z.unknown()); - -/** - * Structured trust contract embedded in the bundle manifest. Mirrors - * `DeclaredAccess` in `@emdash-cms/plugin-types`. Categories are host - * subsystems; operations are modes of participation. - */ -const declaredAccessSchema = z.object({ - content: z - .object({ read: accessConstraints.optional(), write: accessConstraints.optional() }) - .optional(), - media: z - .object({ read: accessConstraints.optional(), write: accessConstraints.optional() }) - .optional(), - network: z - .object({ - // allowedHosts: absent = unrestricted; present = host-restricted. Reject - // an empty array (which the decoder would otherwise have to treat as - // deny-all) to match the record lexicon's `minLength: 1` and keep the - // "absent vs empty" distinction from ever reaching enforcement ambiguous. - request: z.object({ allowedHosts: z.array(z.string()).min(1).optional() }).optional(), - }) - .optional(), - email: z - .object({ - send: accessConstraints.optional(), - events: accessConstraints.optional(), - transport: accessConstraints.optional(), - }) - .optional(), - page: z.object({ fragments: accessConstraints.optional() }).optional(), - users: z.object({ read: accessConstraints.optional() }).optional(), -}); - -// ── Main schema ───────────────────────────────────────────────── - -/** - * Zod schema matching the PluginManifest interface from types.ts. - * - * Every JSON.parse of a manifest.json should validate through this. - * - * `declaredAccess` is the trust contract; `capabilities`/`allowedHosts` are the - * runtime's enforcement currency. Apply `reconcileManifestAccess` after parsing - * to make them consistent (declaredAccess authoritative when present). Kept a - * plain object (no `.transform`) because callers `.pick()`/`.extend()` it. - */ -export const pluginManifestSchema = z.object({ - id: z.string().min(1), - version: z.string().min(1), - declaredAccess: declaredAccessSchema.optional(), - capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)), - allowedHosts: z.array(z.string()), - storage: z.record(z.string(), storageCollectionSchema), - /** - * Hook declarations — accepts both plain name strings (legacy) and - * structured objects with exclusive/priority/timeout metadata. - * Plain strings are normalized to `{ name }` objects after parsing. - */ - hooks: z.array(z.union([z.enum(HOOK_NAMES), manifestHookEntrySchema])), - /** - * Route declarations — accepts both plain name strings and - * structured objects with public metadata. - * Plain strings are normalized to `{ name }` objects after parsing. - */ - routes: z.array( - z.union([ - z.string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"), - manifestRouteEntrySchema, - ]), - ), - admin: pluginAdminConfigSchema, -}); - -export type ValidatedPluginManifest = z.infer; - -/** - * Reconcile a parsed manifest's trust contract with its enforcement currency. - * `declaredAccess` is authoritative: when present, `capabilities`/`allowedHosts` - * are re-derived from it so what the runtime enforces always matches what was - * recorded and consented to. A pre-migration bundle without `declaredAccess` - * has it derived from the legacy capability list instead. The result always - * carries both, mutually consistent. Apply this at every bundle-parse site. - */ export function reconcileManifestAccess(manifest: ValidatedPluginManifest): PluginManifest { - const reconciled: ValidatedPluginManifest = manifest.declaredAccess - ? { ...manifest, ...declaredAccessToCapabilities(manifest.declaredAccess) } - : { - ...manifest, - declaredAccess: capabilitiesToDeclaredAccess(manifest.capabilities, manifest.allowedHosts), - }; - // Block Kit admin elements are typed as `unknown` by the Zod schema (their - // Element shape is validated at render time), so the validated manifest - // needs a structural cast up to the runtime PluginManifest. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- admin elements are unknown[] in Zod; Element type checked at render time - return reconciled as unknown as PluginManifest; -} - -/** - * Normalize a manifest hook entry — plain strings become `{ name }` objects. - */ -export function normalizeManifestHook( - entry: string | { name: string; exclusive?: boolean; priority?: number; timeout?: number }, -): { name: string; exclusive?: boolean; priority?: number; timeout?: number } { - if (typeof entry === "string") { - return { name: entry }; - } - return entry; -} - -/** - * Normalize a manifest route entry — plain strings become `{ name }` objects. - */ -export function normalizeManifestRoute(entry: string | { name: string; public?: boolean }): { - name: string; - public?: boolean; -} { - if (typeof entry === "string") { - return { name: entry }; - } - return entry; + // The shared schema restricts hook names to core's known hook vocabulary; + // plugin-types intentionally keeps its standalone wire type open-ended. + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- schema validation is the runtime narrowing boundary + return reconcileSharedManifestAccess(manifest) as unknown as PluginManifest; } diff --git a/packages/core/src/plugins/marketplace.ts b/packages/core/src/plugins/marketplace.ts index a2950d3d1d..5bbad4631e 100644 --- a/packages/core/src/plugins/marketplace.ts +++ b/packages/core/src/plugins/marketplace.ts @@ -6,15 +6,16 @@ * not a runtime dependency — bundles are copied to site-local R2 at install time. */ -import { createGzipDecoder, unpackTar } from "modern-tar"; +import { + validatePluginBundle, + type ValidatePluginBundleOptions, +} from "@emdash-cms/registry-verification"; -import { pluginManifestSchema, reconcileManifestAccess } from "./manifest-schema.js"; import type { PluginManifest } from "./types.js"; // ── Module-level regex patterns ─────────────────────────────────── const TRAILING_SLASHES = /\/+$/; -const LEADING_DOT_SLASH = /^\.\//; // ── Types ────────────────────────────────────────────────────────── @@ -290,7 +291,7 @@ class MarketplaceClientImpl implements MarketplaceClient { const tarballBytes = new Uint8Array(await response.arrayBuffer()); try { - return await extractBundle(tarballBytes); + return await extractBundle(tarballBytes, { expectedSlug: id, expectedVersion: version }); } catch (err) { if (err instanceof MarketplaceError) throw err; throw new MarketplaceError( @@ -374,7 +375,6 @@ class MarketplaceClientImpl implements MarketplaceClient { * - backend.js * - admin.js (optional) * - * We use a minimal tar parser since we only need to read a few small files. */ /** * Exported so the experimental registry install handler can reuse the @@ -382,127 +382,19 @@ class MarketplaceClientImpl implements MarketplaceClient { * function predates the marketplace-vs-registry split and is generic * over plugin bundle tarballs regardless of distribution channel. */ -// Aligns with RFC 0001 §"Bundle size limits" (256 KiB decompressed, -// 20 files). Matches `MAX_BUNDLE_SIZE` in cli/commands/bundle-utils.ts -// (the publish-side cap). We don't import that constant to keep this -// runtime module independent of the CLI; the two values are -// load-bearing identical and must stay in sync. -// -// Tar adds per-file headers (~512 bytes each) plus directory entries, -// so the entry count cap is set comfortably above RFC's 20-file limit. -// Going over either is a strong signal the bundle isn't a legitimate -// sandboxed plugin. -const MAX_DECOMPRESSED_BUNDLE_BYTES = 256 * 1024; -const MAX_BUNDLE_TAR_ENTRIES = 32; - -export async function extractBundle(tarballBytes: Uint8Array): Promise { - // Decompress fully into memory first, then parse the tar. - // Passing a pipeThrough() stream directly to unpackTar causes a backpressure - // deadlock in workerd: the tar decoder's body-stream pull() needs more - // decompressed data, but the upstream pipe is stalled waiting for the - // decoder's writable side to drain — a circular dependency. - const decompressedStream = new ReadableStream({ - start(controller) { - controller.enqueue(tarballBytes); - controller.close(); - }, - }).pipeThrough(createGzipDecoder()); - - // Collect decompressed bytes with a hard cap. A gzip-bomb -- a small - // tarball that decompresses to gigabytes -- otherwise exhausts - // worker / Node memory before we know to reject it. The cap matches - // RFC 0001's publish-time bundle size limit (MAX_DECOMPRESSED_BUNDLE_BYTES); - // anything past that isn't a legitimate sandboxed plugin. - const reader = decompressedStream.getReader(); - const chunks: Uint8Array[] = []; - let total = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (!value) continue; - total += value.byteLength; - if (total > MAX_DECOMPRESSED_BUNDLE_BYTES) { - try { - await reader.cancel(); - } catch { - // nothing to do - } - throw new MarketplaceError( - `Bundle decompressed size exceeds limit (${MAX_DECOMPRESSED_BUNDLE_BYTES} bytes)`, - undefined, - "INVALID_BUNDLE", - ); - } - chunks.push(value); - } - const decompressedBytes = new Uint8Array(total); - { - let offset = 0; - for (const chunk of chunks) { - decompressedBytes.set(chunk, offset); - offset += chunk.byteLength; - } - } - - const decompressed = new ReadableStream({ - start(controller) { - controller.enqueue(decompressedBytes); - controller.close(); - }, - }); - - const entries = await unpackTar(decompressed); - if (entries.length > MAX_BUNDLE_TAR_ENTRIES) { - throw new MarketplaceError( - `Bundle has too many tar entries (${entries.length} > ${MAX_BUNDLE_TAR_ENTRIES})`, - undefined, - "INVALID_BUNDLE", - ); - } - - const decoder = new TextDecoder(); - const files = new Map(); - for (const entry of entries) { - if (entry.data && entry.header.type === "file") { - // Strip leading ./ prefix that tar tools commonly add - const name = entry.header.name.replace(LEADING_DOT_SLASH, ""); - files.set(name, decoder.decode(entry.data)); - } - } - - const manifestJson = files.get("manifest.json"); - const backendCode = files.get("backend.js"); - - if (!manifestJson) { - throw new MarketplaceError( - "Invalid bundle: missing manifest.json", - undefined, - "INVALID_BUNDLE", - ); - } - if (!backendCode) { - throw new MarketplaceError("Invalid bundle: missing backend.js", undefined, "INVALID_BUNDLE"); - } - - let manifest: PluginManifest; - try { - const parsed: unknown = JSON.parse(manifestJson); - const result = pluginManifestSchema.safeParse(parsed); - if (!result.success) { - throw new MarketplaceError( - "Invalid bundle: manifest.json failed validation", - undefined, - "INVALID_BUNDLE", - ); - } - manifest = reconcileManifestAccess(result.data); - } catch (err) { - if (err instanceof MarketplaceError) throw err; - throw new MarketplaceError( - "Invalid bundle: malformed manifest.json", - undefined, - "INVALID_BUNDLE", - ); +export async function extractBundle( + tarballBytes: Uint8Array, + options: ValidatePluginBundleOptions = {}, +): Promise { + const result = await validatePluginBundle(tarballBytes, options); + if (!result.success) { + const code = + result.error.code === "BUNDLE_ID_MISMATCH" + ? "MANIFEST_MISMATCH" + : result.error.code === "BUNDLE_VERSION_MISMATCH" + ? "MANIFEST_VERSION_MISMATCH" + : "INVALID_BUNDLE"; + throw new MarketplaceError(result.error.message, undefined, code); } // Compute SHA-256 checksum of the tarball for verification @@ -512,9 +404,12 @@ export async function extractBundle(tarballBytes: Uint8Array): Promise b.toString(16).padStart(2, "0")).join(""); return { - manifest, - backendCode, - adminCode: files.get("admin.js"), + // Canonical validation uses the shared wire type. Its schema restricts + // hooks to the narrower set represented by core's runtime type. + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- canonical schema validation narrows the wire manifest + manifest: result.value.manifest as unknown as PluginManifest, + backendCode: new TextDecoder().decode(result.value.backend), + adminCode: result.value.admin ? new TextDecoder().decode(result.value.admin) : undefined, checksum, }; } diff --git a/packages/core/tests/unit/plugins/marketplace-client.test.ts b/packages/core/tests/unit/plugins/marketplace-client.test.ts index 186efd4b68..674716d6df 100644 --- a/packages/core/tests/unit/plugins/marketplace-client.test.ts +++ b/packages/core/tests/unit/plugins/marketplace-client.test.ts @@ -13,6 +13,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createMarketplaceClient, + extractBundle, MarketplaceError, MarketplaceUnavailableError, type MarketplaceClient, @@ -392,7 +393,28 @@ describe("MarketplaceClient", () => { fetchSpy.mockResolvedValueOnce(new Response(gzipped, { status: 200 })); await expect(client.downloadBundle("test-seo", "1.0.0")).rejects.toThrow( - "malformed manifest.json", + "manifest is not valid JSON", + ); + }); + + it("requires the manifest to match the requested plugin and version", async () => { + const tarData = createTar({ + "manifest.json": JSON.stringify({ + id: "other-plugin", + version: "2.0.0", + capabilities: [], + allowedHosts: [], + storage: {}, + hooks: [], + routes: [], + admin: {}, + }), + "backend.js": "export default {};", + }); + fetchSpy.mockResolvedValueOnce(new Response(await gzip(tarData), { status: 200 })); + + await expect(client.downloadBundle("test-seo", "1.0.0")).rejects.toThrow( + "manifest id does not match", ); }); @@ -484,6 +506,17 @@ describe("MarketplaceClient", () => { }); describe("tar parser", () => { + it("uses canonical path validation", async () => { + const tarData = createTar({ + "../manifest.json": "{}", + "backend.js": "export default {};", + }); + await expect(extractBundle(await gzip(tarData))).rejects.toMatchObject({ + code: "INVALID_BUNDLE", + message: "The plugin bundle contains an unsafe path.", + }); + }); + it("handles files with ./ prefix in paths", async () => { // Create tar with ./ prefixed paths (common from tar tools) const manifest = { diff --git a/packages/plugin-cli/package.json b/packages/plugin-cli/package.json index 34b0fa368c..66b14a09c5 100644 --- a/packages/plugin-cli/package.json +++ b/packages/plugin-cli/package.json @@ -36,6 +36,7 @@ "@emdash-cms/plugin-types": "workspace:*", "@emdash-cms/registry-client": "workspace:*", "@emdash-cms/registry-lexicons": "workspace:*", + "@emdash-cms/registry-verification": "workspace:*", "@oslojs/crypto": "catalog:", "chokidar": "catalog:", "citty": "^0.1.6", diff --git a/packages/plugin-cli/src/bundle/utils.ts b/packages/plugin-cli/src/bundle/utils.ts index 22600a0b3d..3230263e31 100644 --- a/packages/plugin-cli/src/bundle/utils.ts +++ b/packages/plugin-cli/src/bundle/utils.ts @@ -12,6 +12,11 @@ import { access, readdir, stat } from "node:fs/promises"; import { join, resolve } from "node:path"; import { pipeline } from "node:stream/promises"; +import { + MAX_BUNDLE_FILE_BYTES as MAX_FILE_SIZE, + MAX_BUNDLE_FILE_COUNT as MAX_FILE_COUNT, + MAX_BUNDLE_SIZE, +} from "@emdash-cms/registry-verification"; import { imageSize } from "image-size"; import { packTar } from "modern-tar/fs"; @@ -22,9 +27,7 @@ import type { ManifestHookEntry, PluginManifest, ResolvedPlugin } from "./types. // Bundle size caps per RFC 0001 §"Bundle size limits". These are decompressed // sizes; the gzipped tarball is typically a fraction of MAX_BUNDLE_SIZE. -export const MAX_BUNDLE_SIZE = 256 * 1024; -export const MAX_FILE_SIZE = 128 * 1024; -export const MAX_FILE_COUNT = 20; +export { MAX_BUNDLE_SIZE, MAX_FILE_COUNT, MAX_FILE_SIZE }; export const MAX_SCREENSHOTS = 8; export const MAX_SCREENSHOT_WIDTH = 1920; diff --git a/packages/plugin-cli/src/commands/publish.ts b/packages/plugin-cli/src/commands/publish.ts index 0393cce636..70ef141caa 100644 --- a/packages/plugin-cli/src/commands/publish.ts +++ b/packages/plugin-cli/src/commands/publish.ts @@ -24,11 +24,16 @@ import { dirname, join, resolve } from "node:path"; import type { PluginManifest } from "@emdash-cms/plugin-types"; import { FileCredentialStore, PublishingClient } from "@emdash-cms/registry-client"; +import { + MAX_BUNDLE_COMPRESSED_BYTES, + validatePluginBundle, +} from "@emdash-cms/registry-verification"; +import type { ValidatePluginBundleOptions } from "@emdash-cms/registry-verification"; import { defineCommand } from "citty"; import consola from "consola"; import pc from "picocolors"; -import { formatBytes, MAX_BUNDLE_SIZE, validateBundleSize } from "../bundle/utils.js"; +import { formatBytes, MAX_BUNDLE_SIZE } from "../bundle/utils.js"; import { redirectConsolaToStderr } from "../cli-output.js"; import { loadManifest, MANIFEST_FILENAME, ManifestError } from "../manifest/load.js"; import { checkPublisher, PublisherCheckError, writePublisherBack } from "../manifest/publisher.js"; @@ -57,7 +62,7 @@ import { ArtifactUploadError, resolveReleaseArtifacts } from "../publish/upload- * fetch time before decompression. The decompressed bundle is then re-checked * against the per-file and total caps via `validateBundleSize`. */ -const MAX_TARBALL_BYTES = 384 * 1024; +const MAX_TARBALL_BYTES = MAX_BUNDLE_COMPRESSED_BYTES; export const publishCommand = defineCommand({ meta: { @@ -239,7 +244,15 @@ async function runPublish(args: PublishArgs): Promise { consola.start(`Fetching ${args.url}...`); const tarballBytes = await fetchTarball(args.url); const checksum = sha256Multihash(tarballBytes); - const manifest = await extractManifestFromTarball(tarballBytes); + const manifest = await extractManifestFromTarball( + tarballBytes, + manifestLoad + ? { + expectedSlug: manifestLoad.manifest.slug, + expectedVersion: manifestLoad.manifest.version, + } + : undefined, + ); consola.info(`Tarball: ${formatBytes(tarballBytes.length)}`); consola.info(`Multihash: ${pc.dim(checksum)}`); @@ -787,7 +800,6 @@ async function validateResolvedHost(host: string): Promise { // guard is the FIRST line of defence -- the redirect loop additionally runs // a DNS-resolved check (`validateResolvedHost`) so a public hostname // pointing at a private IP is also caught. -const TAR_LEADING_DOT_SLASH_RE = /^\.\//; const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; // IPv6 ULA (fc00::/7), link-local (fe80::/10). Bracket-stripped form. const IPV6_ULA_FC_RE = /^fc[0-9a-f]{2}(?::|$)/i; @@ -1087,130 +1099,13 @@ async function fetchTarball(url: string): Promise { // Exported for unit tests; not part of the public CLI surface. export const extractManifestFromTarballForTest = extractManifestFromTarball; -async function extractManifestFromTarball(bytes: Uint8Array): Promise { - const { unpackTar, createGzipDecoder } = await import("modern-tar"); - const source = new ReadableStream({ - start(controller) { - controller.enqueue(bytes); - controller.close(); - }, - }); - let entries; - try { - const decoded = source.pipeThrough(createGzipDecoder()) as ReadableStream; - entries = await unpackTar(decoded); - } catch (error) { - throw new Error( - `tarball at the URL is not a valid gzipped tar archive: ${error instanceof Error ? error.message : String(error)}`, - { cause: error }, - ); - } - // The bundle size caps apply to regular files only: directories, - // symlinks, hardlinks, devices, and FIFOs all carry no content and - // shouldn't appear in a sandboxed plugin bundle anyway. Filtering by - // header.type matches what `collectBundleEntries` does for the staging - // dir (where `item.isFile()` already excludes non-files), so the bundle - // command and the publish command agree on what counts. - const fileEntries = entries - .filter((e) => (e.header.type ?? "file") === "file") - .map((e) => ({ - name: e.header.name.replace(TAR_LEADING_DOT_SLASH_RE, ""), - bytes: e.data?.byteLength ?? 0, - })); - const sizeViolations = validateBundleSize(fileEntries); - if (sizeViolations.length > 0) { - throw new Error( - `tarball at the URL violates bundle size caps:\n - ${sizeViolations.join("\n - ")}`, - ); +async function extractManifestFromTarball( + bytes: Uint8Array, + options: ValidatePluginBundleOptions = {}, +): Promise { + const result = await validatePluginBundle(bytes, options); + if (!result.success) { + throw new Error(`tarball at the URL is invalid: ${result.error.message}`); } - const manifestEntry = entries.find((e) => { - const name = e.header.name; - return name === "manifest.json" || name === "./manifest.json"; - }); - if (!manifestEntry?.data) { - throw new Error("manifest.json not found in tarball"); - } - let parsed: unknown; - try { - parsed = JSON.parse(new TextDecoder().decode(manifestEntry.data)); - } catch (error) { - throw new Error( - `manifest.json in the tarball is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, - { cause: error }, - ); - } - return assertManifestShape(parsed); -} - -/** - * Best-effort structural sanity check on a parsed `manifest.json`. NOT a - * full lexicon validation: we check the shapes the publish flow actually - * touches (`id`, `version`, `capabilities`, `allowedHosts`, plus that - * `storage`/`hooks`/`routes`/`admin` are at least the right top-level - * shape). Hook and route entries are only checked to be string-or-object; - * we deliberately don't validate their inner structure here because (a) - * the publish flow doesn't iterate them and (b) the inner shape varies - * across manifest schema versions and is core's read-side problem. - * - * Callers who need a full validation should round-trip through the runtime - * narrowing helper in `packages/core/src/plugins/manifest-schema.ts`. - */ -function assertManifestShape(value: unknown): PluginManifest { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("manifest.json must be a JSON object"); - } - const v = value as Record; - if (typeof v.id !== "string" || v.id.length === 0) { - throw new Error("manifest.json: `id` must be a non-empty string"); - } - if (typeof v.version !== "string" || v.version.length === 0) { - throw new Error("manifest.json: `version` must be a non-empty string"); - } - if (!Array.isArray(v.capabilities) || v.capabilities.some((c) => typeof c !== "string")) { - throw new Error("manifest.json: `capabilities` must be an array of strings"); - } - if (!Array.isArray(v.allowedHosts) || v.allowedHosts.some((h) => typeof h !== "string")) { - throw new Error("manifest.json: `allowedHosts` must be an array of strings"); - } - // `typeof null === "object"` and `typeof [] === "object"`, so both checks - // matter. Same for `admin` below. - if (!v.storage || typeof v.storage !== "object" || Array.isArray(v.storage)) { - throw new Error("manifest.json: `storage` must be an object"); - } - if (!Array.isArray(v.hooks)) { - throw new Error("manifest.json: `hooks` must be an array"); - } - for (const [i, entry] of v.hooks.entries()) { - // Hook entries are either bare hook-name strings or - // `{ hook: string, ... }` objects per the manifest contract. Anything - // else means the publisher hand-edited (or corrupted) the manifest. - if (typeof entry === "string") continue; - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - throw new Error( - `manifest.json: \`hooks[${i}]\` must be a string or object, got ${describeJsonValue(entry)}`, - ); - } - } - if (!Array.isArray(v.routes)) { - throw new Error("manifest.json: `routes` must be an array"); - } - for (const [i, entry] of v.routes.entries()) { - if (typeof entry === "string") continue; - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - throw new Error( - `manifest.json: \`routes[${i}]\` must be a string or object, got ${describeJsonValue(entry)}`, - ); - } - } - if (!v.admin || typeof v.admin !== "object" || Array.isArray(v.admin)) { - throw new Error("manifest.json: `admin` must be an object"); - } - return v as unknown as PluginManifest; -} - -/** Shape-describing string for error messages. Distinguishes null/array/object. */ -function describeJsonValue(value: unknown): string { - if (value === null) return "null"; - if (Array.isArray(value)) return "array"; - return typeof value; + return result.value.manifest; } diff --git a/packages/plugin-cli/tests/publish-tarball.test.ts b/packages/plugin-cli/tests/publish-tarball.test.ts index 0613d82435..4295eb84d5 100644 --- a/packages/plugin-cli/tests/publish-tarball.test.ts +++ b/packages/plugin-cli/tests/publish-tarball.test.ts @@ -60,14 +60,34 @@ describe("extractManifestFromTarball (publish CLI)", () => { expect(manifest.version).toBe("1.0.0"); }); + it("rejects a bundle whose slug differs from the loaded manifest", async () => { + const bytes = await buildTarball({ + "manifest.json": minimalManifest, + "backend.js": "export default {};\n", + }); + + await expect( + extractManifestFromTarballForTest(bytes, { expectedSlug: "other-plugin" }), + ).rejects.toThrow(/manifest id does not match the expected plugin/); + }); + + it("rejects a bundle whose version differs from the loaded manifest", async () => { + const bytes = await buildTarball({ + "manifest.json": minimalManifest, + "backend.js": "export default {};\n", + }); + + await expect( + extractManifestFromTarballForTest(bytes, { expectedVersion: "2.0.0" }), + ).rejects.toThrow(/manifest version does not match the expected version/); + }); + it("rejects a tarball with a single file over the per-file cap", async () => { const bytes = await buildTarball({ "manifest.json": minimalManifest, "backend.js": "x".repeat(MAX_FILE_SIZE + 1), }); - await expect(extractManifestFromTarballForTest(bytes)).rejects.toThrow( - /violates bundle size caps[\s\S]*backend\.js/, - ); + await expect(extractManifestFromTarballForTest(bytes)).rejects.toThrow(/per-file size limit/); }); it("rejects a tarball whose total decompressed size exceeds the bundle cap", async () => { @@ -80,7 +100,7 @@ describe("extractManifestFromTarball (publish CLI)", () => { "c.js": "c".repeat(MAX_FILE_SIZE), }); await expect(extractManifestFromTarballForTest(bytes)).rejects.toThrow( - /violates bundle size caps[\s\S]*Bundle size/, + /decompressed plugin bundle exceeds the size limit/, ); }); @@ -88,12 +108,10 @@ describe("extractManifestFromTarball (publish CLI)", () => { const files: Record = { "manifest.json": minimalManifest }; for (let i = 0; i < 25; i++) files[`f-${String(i).padStart(2, "0")}.js`] = "x"; const bytes = await buildTarball(files); - await expect(extractManifestFromTarballForTest(bytes)).rejects.toThrow( - /violates bundle size caps[\s\S]*contains \d+ files/, - ); + await expect(extractManifestFromTarballForTest(bytes)).rejects.toThrow(/too many files/); }); - it("does not count non-file tar entries (symlinks etc.) toward the file cap", async () => { + it("rejects non-file tar entries", async () => { // Hand-build a tarball with 25 symlink entries plus one real file. // Symlinks have type "2" and size 0 in USTAR. The file cap is 20, so // counting symlinks would reject this; the filter should only see one @@ -117,7 +135,8 @@ describe("extractManifestFromTarball (publish CLI)", () => { ...symlinkEntries, ]); const gzipped = gzipSync(tarBytes); - const manifest = await extractManifestFromTarballForTest(new Uint8Array(gzipped)); - expect(manifest.id).toBe("test-plugin"); + await expect(extractManifestFromTarballForTest(new Uint8Array(gzipped))).rejects.toThrow( + /unsupported archive entry type/, + ); }); }); diff --git a/packages/plugin-types/package.json b/packages/plugin-types/package.json index 3bca2dd7c9..9d9eee44d4 100644 --- a/packages/plugin-types/package.json +++ b/packages/plugin-types/package.json @@ -42,5 +42,8 @@ "url": "git+https://github.com/emdash-cms/emdash.git", "directory": "packages/plugin-types" }, - "homepage": "https://github.com/emdash-cms/emdash" + "homepage": "https://github.com/emdash-cms/emdash", + "dependencies": { + "zod": "catalog:" + } } diff --git a/packages/plugin-types/src/index.ts b/packages/plugin-types/src/index.ts index 9a4777aba2..03b1c4fae7 100644 --- a/packages/plugin-types/src/index.ts +++ b/packages/plugin-types/src/index.ts @@ -459,3 +459,15 @@ export function isPluginVersion(value: string): boolean { value.length > 0 && value.length <= PLUGIN_VERSION_MAX_LENGTH && PLUGIN_VERSION_RE.test(value) ); } + +export { + CURRENT_PLUGIN_CAPABILITIES, + DEPRECATED_PLUGIN_CAPABILITIES, + HOOK_NAMES, + normalizeManifestHook, + normalizeManifestRoute, + PLUGIN_CAPABILITIES, + pluginManifestSchema, + reconcileManifestAccess, +} from "./manifest-schema.js"; +export type { ValidatedPluginManifest } from "./manifest-schema.js"; diff --git a/packages/plugin-types/src/manifest-schema.ts b/packages/plugin-types/src/manifest-schema.ts new file mode 100644 index 0000000000..953cca793a --- /dev/null +++ b/packages/plugin-types/src/manifest-schema.ts @@ -0,0 +1,351 @@ +/** + * Zod schema for PluginManifest validation + * + * Used to validate manifest.json from plugin bundles at every parse site: + * - Client-side download (marketplace.ts extractBundle) + * - R2 load (api/handlers/marketplace.ts loadBundleFromR2) + * - CLI publish preview (cli/commands/publish.ts readManifestFromTarball) + * - Marketplace ingest extends this with publishing-specific fields + */ + +import { z } from "zod"; + +import { capabilitiesToDeclaredAccess, declaredAccessToCapabilities } from "./index.js"; +import type { PluginManifest } from "./index.js"; + +// ── Enum values (must stay in sync with types.ts) ─────────────── + +/** + * Current capability names — the ones authors should use going forward. + * See `PluginCapability` in `types.ts` for documentation of each. + */ +export const CURRENT_PLUGIN_CAPABILITIES = [ + "network:request", + "network:request:unrestricted", + "content:read", + "content:write", + "media:read", + "media:write", + "users:read", + "email:send", + "hooks.email-transport:register", + "hooks.email-events:register", + "hooks.page-fragments:register", +] as const; + +/** + * Legacy capability names accepted during the deprecation window. + * Normalized to current names via `normalizeCapability()` in types.ts + * before reaching the runtime. Plugin authors are warned at bundle/validate + * and hard-failed at publish. + */ +export const DEPRECATED_PLUGIN_CAPABILITIES = [ + "network:fetch", + "network:fetch:any", + "read:content", + "write:content", + "read:media", + "write:media", + "read:users", + "email:provide", + "email:intercept", + "page:inject", +] as const; + +/** + * Full set of accepted capability strings — current + deprecated. + * + * The manifest schema accepts both during the transition. The runtime only + * ever sees current names because `normalizeCapability()` rewrites legacy + * names at every external boundary (definePlugin, adaptSandboxEntry). + */ +export const PLUGIN_CAPABILITIES = [ + ...CURRENT_PLUGIN_CAPABILITIES, + ...DEPRECATED_PLUGIN_CAPABILITIES, +] as const; + +/** Must stay in sync with FieldType in schema/types.ts */ +const FIELD_TYPES = [ + "string", + "text", + "number", + "integer", + "boolean", + "datetime", + "select", + "multiSelect", + "portableText", + "image", + "file", + "reference", + "json", + "slug", + "repeater", +] as const; + +export const HOOK_NAMES = [ + "plugin:install", + "plugin:activate", + "plugin:deactivate", + "plugin:uninstall", + "content:beforeSave", + "content:afterSave", + "content:beforeDelete", + "content:afterDelete", + "content:afterPublish", + "content:afterUnpublish", + "content:afterRestore", + "content:afterSchedule", + "content:afterUnschedule", + "media:beforeUpload", + "media:afterUpload", + "cron", + "email:beforeSend", + "email:deliver", + "email:afterSend", + "comment:beforeCreate", + "comment:moderate", + "comment:afterCreate", + "comment:afterModerate", + "page:metadata", + "page:fragments", +] as const; + +/** + * Structured hook entry for manifest — name plus optional metadata. + * During a transition period, both plain strings and objects are accepted. + */ +const manifestHookEntrySchema = z.object({ + name: z.enum(HOOK_NAMES), + exclusive: z.boolean().optional(), + priority: z.number().int().optional(), + timeout: z.number().int().positive().optional(), +}); + +/** + * Structured route entry for manifest — name plus optional metadata. + * Both plain strings and objects are accepted; strings are normalized + * to `{ name }` objects via `normalizeManifestRoute()`. + */ +/** Route names must be safe path segments — alphanumeric, hyphens, underscores, forward slashes */ +const routeNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9_\-/]*$/; + +const manifestRouteEntrySchema = z.object({ + name: z.string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"), + public: z.boolean().optional(), +}); + +// ── Sub-schemas ───────────────────────────────────────────────── + +/** Index field names must be valid identifiers to prevent SQL injection via JSON path expressions */ +const indexFieldName = z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]*$/); + +const storageCollectionSchema = z.object({ + indexes: z.array(z.union([indexFieldName, z.array(indexFieldName)])), + uniqueIndexes: z.array(z.union([indexFieldName, z.array(indexFieldName)])).optional(), +}); + +const baseSettingFields = { + label: z.string(), + description: z.string().optional(), +}; + +const settingFieldSchema = z.discriminatedUnion("type", [ + z.object({ + ...baseSettingFields, + type: z.literal("string"), + default: z.string().optional(), + multiline: z.boolean().optional(), + }), + z.object({ + ...baseSettingFields, + type: z.literal("number"), + default: z.number().optional(), + min: z.number().optional(), + max: z.number().optional(), + }), + z.object({ ...baseSettingFields, type: z.literal("boolean"), default: z.boolean().optional() }), + z.object({ + ...baseSettingFields, + type: z.literal("select"), + options: z.array(z.object({ value: z.string(), label: z.string() })), + default: z.string().optional(), + }), + z.object({ ...baseSettingFields, type: z.literal("secret") }), + z.object({ + ...baseSettingFields, + type: z.literal("url"), + default: z.string().optional(), + placeholder: z.string().optional(), + }), + z.object({ + ...baseSettingFields, + type: z.literal("email"), + default: z.string().optional(), + placeholder: z.string().optional(), + }), +]); + +const adminPageSchema = z.object({ + path: z.string(), + label: z.string(), + icon: z.string().optional(), +}); + +const dashboardWidgetSchema = z.object({ + id: z.string(), + size: z.enum(["full", "half", "third"]).optional(), + title: z.string().optional(), +}); + +const pluginAdminConfigSchema = z.object({ + entry: z.string().optional(), + settingsSchema: z.record(z.string(), settingFieldSchema).optional(), + pages: z.array(adminPageSchema).optional(), + widgets: z.array(dashboardWidgetSchema).optional(), + fieldWidgets: z + .array( + z.object({ + name: z.string().min(1), + label: z.string().min(1), + fieldTypes: z.array(z.enum(FIELD_TYPES)), + elements: z + .array( + z + .object({ + type: z.string(), + action_id: z.string(), + label: z.string().optional(), + }) + .passthrough(), + ) + .optional(), + }), + ) + .optional(), +}); + +// ── declaredAccess ────────────────────────────────────────────── + +/** + * An operation's constraint object. Open vocabulary: keys the runtime + * recognises are enforced, others are advisory. The bundler emits `{}` for a + * granted operation; presence (not value) signals the grant. + */ +const accessConstraints = z.record(z.string(), z.unknown()); + +/** + * Structured trust contract embedded in the bundle manifest. Mirrors + * `DeclaredAccess` in `@emdash-cms/plugin-types`. Categories are host + * subsystems; operations are modes of participation. + */ +const declaredAccessSchema = z.object({ + content: z + .object({ read: accessConstraints.optional(), write: accessConstraints.optional() }) + .optional(), + media: z + .object({ read: accessConstraints.optional(), write: accessConstraints.optional() }) + .optional(), + network: z + .object({ + // allowedHosts: absent = unrestricted; present = host-restricted. Reject + // an empty array (which the decoder would otherwise have to treat as + // deny-all) to match the record lexicon's `minLength: 1` and keep the + // "absent vs empty" distinction from ever reaching enforcement ambiguous. + request: z.object({ allowedHosts: z.array(z.string()).min(1).optional() }).optional(), + }) + .optional(), + email: z + .object({ + send: accessConstraints.optional(), + events: accessConstraints.optional(), + transport: accessConstraints.optional(), + }) + .optional(), + page: z.object({ fragments: accessConstraints.optional() }).optional(), + users: z.object({ read: accessConstraints.optional() }).optional(), +}); + +// ── Main schema ───────────────────────────────────────────────── + +/** + * Zod schema matching the PluginManifest interface from types.ts. + * + * Every JSON.parse of a manifest.json should validate through this. + * + * `declaredAccess` is the trust contract; `capabilities`/`allowedHosts` are the + * runtime's enforcement currency. Apply `reconcileManifestAccess` after parsing + * to make them consistent (declaredAccess authoritative when present). Kept a + * plain object (no `.transform`) because callers `.pick()`/`.extend()` it. + */ +export const pluginManifestSchema = z.object({ + id: z.string().min(1), + version: z.string().min(1), + declaredAccess: declaredAccessSchema.optional(), + capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)), + allowedHosts: z.array(z.string()), + storage: z.record(z.string(), storageCollectionSchema), + /** + * Hook declarations — accepts both plain name strings (legacy) and + * structured objects with exclusive/priority/timeout metadata. + * Plain strings are normalized to `{ name }` objects after parsing. + */ + hooks: z.array(z.union([z.enum(HOOK_NAMES), manifestHookEntrySchema])), + /** + * Route declarations — accepts both plain name strings and + * structured objects with public metadata. + * Plain strings are normalized to `{ name }` objects after parsing. + */ + routes: z.array( + z.union([ + z.string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"), + manifestRouteEntrySchema, + ]), + ), + admin: pluginAdminConfigSchema, +}); + +export type ValidatedPluginManifest = z.infer; + +/** + * Reconcile a parsed manifest's trust contract with its enforcement currency. + * `declaredAccess` is authoritative: when present, `capabilities`/`allowedHosts` + * are re-derived from it so what the runtime enforces always matches what was + * recorded and consented to. A pre-migration bundle without `declaredAccess` + * has it derived from the legacy capability list instead. The result always + * carries both, mutually consistent. Apply this at every bundle-parse site. + */ +export function reconcileManifestAccess(manifest: ValidatedPluginManifest): PluginManifest { + const reconciled: ValidatedPluginManifest = manifest.declaredAccess + ? { ...manifest, ...declaredAccessToCapabilities(manifest.declaredAccess) } + : { + ...manifest, + declaredAccess: capabilitiesToDeclaredAccess(manifest.capabilities, manifest.allowedHosts), + }; + return reconciled; +} + +/** + * Normalize a manifest hook entry — plain strings become `{ name }` objects. + */ +export function normalizeManifestHook( + entry: string | { name: string; exclusive?: boolean; priority?: number; timeout?: number }, +): { name: string; exclusive?: boolean; priority?: number; timeout?: number } { + if (typeof entry === "string") { + return { name: entry }; + } + return entry; +} + +/** + * Normalize a manifest route entry — plain strings become `{ name }` objects. + */ +export function normalizeManifestRoute(entry: string | { name: string; public?: boolean }): { + name: string; + public?: boolean; +} { + if (typeof entry === "string") { + return { name: entry }; + } + return entry; +} diff --git a/packages/registry-verification/package.json b/packages/registry-verification/package.json index fdadcf4498..3c5d5157a8 100644 --- a/packages/registry-verification/package.json +++ b/packages/registry-verification/package.json @@ -44,5 +44,9 @@ "url": "git+https://github.com/emdash-cms/emdash.git", "directory": "packages/registry-verification" }, - "homepage": "https://github.com/emdash-cms/emdash" + "homepage": "https://github.com/emdash-cms/emdash", + "dependencies": { + "@emdash-cms/plugin-types": "workspace:*", + "modern-tar": "^0.7.6" + } } diff --git a/packages/registry-verification/src/bundle-limits.ts b/packages/registry-verification/src/bundle-limits.ts new file mode 100644 index 0000000000..7ad5013a6b --- /dev/null +++ b/packages/registry-verification/src/bundle-limits.ts @@ -0,0 +1,21 @@ +/** Maximum accepted gzip payload size. */ +export const MAX_BUNDLE_COMPRESSED_BYTES = 384 * 1024; + +/** Maximum aggregate size of regular-file contents. */ +export const MAX_BUNDLE_SIZE = 256 * 1024; + +/** Maximum size of one regular file. */ +export const MAX_BUNDLE_FILE_BYTES = 128 * 1024; + +/** Maximum number of regular files. */ +export const MAX_BUNDLE_FILE_COUNT = 20; + +/** Maximum total tar entries, including harmless directory entries. */ +export const MAX_BUNDLE_TAR_ENTRY_COUNT = 32; + +/** + * Maximum tar stream size after decompression. This includes USTAR headers, + * file padding, and the end marker in addition to the regular-file contents. + */ +export const MAX_BUNDLE_DECOMPRESSED_BYTES = + MAX_BUNDLE_SIZE + MAX_BUNDLE_TAR_ENTRY_COUNT * 512 + MAX_BUNDLE_FILE_COUNT * 511 + 2 * 512; diff --git a/packages/registry-verification/src/bundle.ts b/packages/registry-verification/src/bundle.ts new file mode 100644 index 0000000000..cd0e737d4b --- /dev/null +++ b/packages/registry-verification/src/bundle.ts @@ -0,0 +1,324 @@ +import type { DeclaredAccess, PluginManifest } from "@emdash-cms/plugin-types"; +import { pluginManifestSchema, reconcileManifestAccess } from "@emdash-cms/plugin-types"; +import { createGzipDecoder } from "modern-tar"; + +import { + MAX_BUNDLE_COMPRESSED_BYTES, + MAX_BUNDLE_DECOMPRESSED_BYTES, + MAX_BUNDLE_FILE_BYTES, + MAX_BUNDLE_FILE_COUNT, + MAX_BUNDLE_SIZE, + MAX_BUNDLE_TAR_ENTRY_COUNT, +} from "./bundle-limits.js"; +import { verificationError } from "./errors.js"; +import type { VerificationResult } from "./errors.js"; + +const TAR_BLOCK_BYTES = 512; +const TAR_END_BYTES = TAR_BLOCK_BYTES * 2; +const OCTAL_PATTERN = /^[0-7]+$/; +const WINDOWS_DRIVE_PATTERN = /^[a-zA-Z]:/; +const decoder = new TextDecoder("utf-8", { fatal: true }); + +export interface ValidatePluginBundleOptions { + expectedSlug?: string; + expectedVersion?: string; +} + +export interface ValidatedPluginBundle { + manifest: PluginManifest; + declaredAccess: DeclaredAccess; + backend: Uint8Array; + admin?: Uint8Array; +} + +interface ParsedFile { + name: string; + data: Uint8Array; +} + +export async function validatePluginBundle( + compressed: Uint8Array, + options: ValidatePluginBundleOptions = {}, +): Promise> { + if (compressed.byteLength > MAX_BUNDLE_COMPRESSED_BYTES) { + return verificationError( + "BUNDLE_COMPRESSED_SIZE_EXCEEDED", + "The compressed plugin bundle exceeds the size limit.", + ); + } + + const decompressed = await decompressBundle(compressed); + if (!decompressed.success) return decompressed; + const files = parseTar(decompressed.value); + if (!files.success) return files; + + const manifestFile = files.value.get("manifest.json"); + if (!manifestFile) { + return verificationError( + "BUNDLE_MISSING_MANIFEST", + "The plugin bundle is missing manifest.json.", + ); + } + const backend = files.value.get("backend.js"); + if (!backend) { + return verificationError("BUNDLE_MISSING_BACKEND", "The plugin bundle is missing backend.js."); + } + + let parsed: unknown; + try { + parsed = JSON.parse(decoder.decode(manifestFile.data)); + } catch { + return verificationError( + "BUNDLE_INVALID_MANIFEST", + "The plugin bundle manifest is not valid JSON.", + ); + } + const validated = pluginManifestSchema.safeParse(parsed); + if (!validated.success) { + return verificationError( + "BUNDLE_INVALID_MANIFEST", + "The plugin bundle manifest failed schema validation.", + ); + } + const manifest = reconcileManifestAccess(validated.data); + if (options.expectedSlug !== undefined && manifest.id !== options.expectedSlug) { + return verificationError( + "BUNDLE_ID_MISMATCH", + "The plugin bundle manifest id does not match the expected plugin.", + ); + } + if (options.expectedVersion !== undefined && manifest.version !== options.expectedVersion) { + return verificationError( + "BUNDLE_VERSION_MISMATCH", + "The plugin bundle manifest version does not match the expected version.", + ); + } + + const result: ValidatedPluginBundle = { + manifest, + declaredAccess: manifest.declaredAccess ?? {}, + backend: backend.data, + }; + const admin = files.value.get("admin.js"); + if (admin) result.admin = admin.data; + return { success: true, value: result }; +} + +async function decompressBundle(bytes: Uint8Array): Promise> { + const source = new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); + let reader: ReadableStreamDefaultReader; + try { + reader = source.pipeThrough(createGzipDecoder()).getReader(); + } catch { + return verificationError("BUNDLE_INVALID_ARCHIVE", "The plugin bundle is not valid gzip data."); + } + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > MAX_BUNDLE_DECOMPRESSED_BYTES) { + await reader.cancel().catch(() => undefined); + return verificationError( + "BUNDLE_DECOMPRESSED_SIZE_EXCEEDED", + "The decompressed plugin bundle exceeds the size limit.", + ); + } + chunks.push(value); + } + } catch { + return verificationError("BUNDLE_INVALID_ARCHIVE", "The plugin bundle is not valid gzip data."); + } + const output = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return { success: true, value: output }; +} + +function parseTar(bytes: Uint8Array): VerificationResult> { + if (bytes.byteLength < TAR_END_BYTES || bytes.byteLength % TAR_BLOCK_BYTES !== 0) { + return invalidArchive(); + } + const files = new Map(); + const rawPaths = new Set(); + const normalizedPaths = new Set(); + let offset = 0; + let fileCount = 0; + let entryCount = 0; + let totalFileBytes = 0; + let ended = false; + + while (offset + TAR_BLOCK_BYTES <= bytes.byteLength) { + const header = bytes.subarray(offset, offset + TAR_BLOCK_BYTES); + if (isZeroBlock(header)) { + if (!isZeroBlock(bytes.subarray(offset + TAR_BLOCK_BYTES, offset + TAR_END_BYTES))) { + return invalidArchive(); + } + if (bytes.subarray(offset + TAR_END_BYTES).some((byte) => byte !== 0)) + return invalidArchive(); + ended = true; + break; + } + if (!validChecksum(header)) return invalidArchive(); + entryCount += 1; + if (entryCount > MAX_BUNDLE_TAR_ENTRY_COUNT) { + return verificationError( + "BUNDLE_FILE_COUNT_EXCEEDED", + "The plugin bundle contains too many archive entries.", + ); + } + + const rawName = readTarPath(header); + if (!rawName.success) return rawName; + const typeFlag = header[156]; + const type = typeFlag === 0 ? "file" : String.fromCharCode(typeFlag ?? 0); + const isDirectory = type === "5"; + const normalized = normalizePath(rawName.value, isDirectory); + if (!normalized.success) return normalized; + if (rawPaths.has(rawName.value) || normalizedPaths.has(normalized.value)) { + return verificationError( + "BUNDLE_PATH_COLLISION", + "The plugin bundle contains duplicate or ambiguous paths.", + ); + } + rawPaths.add(rawName.value); + normalizedPaths.add(normalized.value); + + const size = readOctal(header.subarray(124, 136)); + if (size === null || !Number.isSafeInteger(size)) return invalidArchive(); + const bodyStart = offset + TAR_BLOCK_BYTES; + const paddedSize = Math.ceil(size / TAR_BLOCK_BYTES) * TAR_BLOCK_BYTES; + const nextOffset = bodyStart + paddedSize; + if (nextOffset > bytes.byteLength) return invalidArchive(); + + if (isDirectory) { + if (size !== 0) return invalidArchive(); + } else if (type === "file" || type === "0") { + fileCount += 1; + if (fileCount > MAX_BUNDLE_FILE_COUNT) { + return verificationError( + "BUNDLE_FILE_COUNT_EXCEEDED", + "The plugin bundle contains too many files.", + ); + } + if (size > MAX_BUNDLE_FILE_BYTES) { + return verificationError( + "BUNDLE_FILE_SIZE_EXCEEDED", + "A file in the plugin bundle exceeds the per-file size limit.", + ); + } + totalFileBytes += size; + if (totalFileBytes > MAX_BUNDLE_SIZE) { + return verificationError( + "BUNDLE_DECOMPRESSED_SIZE_EXCEEDED", + "The plugin bundle file contents exceed the size limit.", + ); + } + files.set(normalized.value, { + name: normalized.value, + data: bytes.slice(bodyStart, bodyStart + size), + }); + } else { + return verificationError( + "BUNDLE_UNSUPPORTED_ENTRY", + "The plugin bundle contains an unsupported archive entry type.", + ); + } + offset = nextOffset; + } + if (!ended) return invalidArchive(); + return { success: true, value: files }; +} + +function readTarPath(header: Uint8Array): VerificationResult { + const name = readTarString(header.subarray(0, 100)); + const prefix = readTarString(header.subarray(345, 500)); + if (name === null || prefix === null || name.length === 0) { + return verificationError("BUNDLE_INVALID_PATH", "The plugin bundle contains a malformed path."); + } + return { success: true, value: prefix ? `${prefix}/${name}` : name }; +} + +function readTarString(field: Uint8Array): string | null { + const terminator = field.indexOf(0); + const end = terminator === -1 ? field.length : terminator; + if (terminator !== -1 && field.subarray(terminator + 1).some((byte) => byte !== 0)) return null; + try { + return decoder.decode(field.subarray(0, end)); + } catch { + return null; + } +} + +function normalizePath(raw: string, isDirectory: boolean): VerificationResult { + if ( + raw.includes("\\") || + raw.startsWith("/") || + WINDOWS_DRIVE_PATTERN.test(raw) || + hasControlCharacter(raw) || + raw.endsWith("/") !== isDirectory + ) { + return verificationError("BUNDLE_INVALID_PATH", "The plugin bundle contains an unsafe path."); + } + const parts = raw.split("/"); + if (isDirectory) parts.pop(); + const normalized: string[] = []; + for (const part of parts) { + if (part === ".") continue; + if (part === "" || part === "..") { + return verificationError("BUNDLE_INVALID_PATH", "The plugin bundle contains an unsafe path."); + } + normalized.push(part.normalize("NFC")); + } + if (normalized.length === 0) { + return verificationError("BUNDLE_INVALID_PATH", "The plugin bundle contains a malformed path."); + } + return { success: true, value: normalized.join("/") }; +} + +function hasControlCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0); + if (codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f)) return true; + } + return false; +} + +function readOctal(field: Uint8Array): number | null { + const text = new TextDecoder().decode(field).replaceAll("\0", "").trim(); + if (!OCTAL_PATTERN.test(text)) return null; + const value = Number.parseInt(text, 8); + return Number.isSafeInteger(value) ? value : null; +} + +function validChecksum(header: Uint8Array): boolean { + const expected = readOctal(header.subarray(148, 156)); + if (expected === null) return false; + let actual = 0; + for (let index = 0; index < header.length; index += 1) { + actual += index >= 148 && index < 156 ? 0x20 : (header[index] ?? 0); + } + return actual === expected; +} + +function isZeroBlock(block: Uint8Array): boolean { + return block.byteLength === TAR_BLOCK_BYTES && block.every((byte) => byte === 0); +} + +function invalidArchive(): VerificationResult { + return verificationError( + "BUNDLE_INVALID_ARCHIVE", + "The plugin bundle is not a valid tar archive.", + ); +} diff --git a/packages/registry-verification/src/errors.ts b/packages/registry-verification/src/errors.ts index 70b37cf7a6..794262548f 100644 --- a/packages/registry-verification/src/errors.ts +++ b/packages/registry-verification/src/errors.ts @@ -1,5 +1,18 @@ /** Stable, machine-readable failures returned by this package. */ export type VerificationErrorCode = + | "BUNDLE_COMPRESSED_SIZE_EXCEEDED" + | "BUNDLE_DECOMPRESSED_SIZE_EXCEEDED" + | "BUNDLE_FILE_COUNT_EXCEEDED" + | "BUNDLE_FILE_SIZE_EXCEEDED" + | "BUNDLE_ID_MISMATCH" + | "BUNDLE_INVALID_ARCHIVE" + | "BUNDLE_INVALID_MANIFEST" + | "BUNDLE_INVALID_PATH" + | "BUNDLE_MISSING_BACKEND" + | "BUNDLE_MISSING_MANIFEST" + | "BUNDLE_PATH_COLLISION" + | "BUNDLE_UNSUPPORTED_ENTRY" + | "BUNDLE_VERSION_MISMATCH" | "CHECKSUM_MISMATCH" | "FETCH_FAILED" | "HOST_REJECTED" diff --git a/packages/registry-verification/src/index.ts b/packages/registry-verification/src/index.ts index 0cf243a108..6e94f16a58 100644 --- a/packages/registry-verification/src/index.ts +++ b/packages/registry-verification/src/index.ts @@ -5,6 +5,15 @@ export { verifyMultihash, } from "./checksum.js"; export { DEFAULT_FETCH_LIMITS, fetchVerifiedResource } from "./fetch.js"; +export { + MAX_BUNDLE_COMPRESSED_BYTES, + MAX_BUNDLE_DECOMPRESSED_BYTES, + MAX_BUNDLE_FILE_BYTES, + MAX_BUNDLE_FILE_COUNT, + MAX_BUNDLE_SIZE, + MAX_BUNDLE_TAR_ENTRY_COUNT, +} from "./bundle-limits.js"; +export { validatePluginBundle } from "./bundle.js"; export type { DecodedMultihash, MultihashAlgorithm } from "./checksum.js"; export type { FetchImplementation, @@ -13,3 +22,4 @@ export type { VerifiedResource, } from "./fetch.js"; export type { VerificationError, VerificationErrorCode, VerificationResult } from "./errors.js"; +export type { ValidatePluginBundleOptions, ValidatedPluginBundle } from "./bundle.js"; diff --git a/packages/registry-verification/tests/bundle.test.ts b/packages/registry-verification/tests/bundle.test.ts new file mode 100644 index 0000000000..ecd17412de --- /dev/null +++ b/packages/registry-verification/tests/bundle.test.ts @@ -0,0 +1,305 @@ +import { gzipSync } from "node:zlib"; + +import { packTar, type TarEntry, type TarHeader } from "modern-tar"; +import { describe, expect, it } from "vitest"; + +import { + MAX_BUNDLE_COMPRESSED_BYTES, + MAX_BUNDLE_DECOMPRESSED_BYTES, + MAX_BUNDLE_FILE_BYTES, + MAX_BUNDLE_FILE_COUNT, + MAX_BUNDLE_SIZE, + MAX_BUNDLE_TAR_ENTRY_COUNT, + validatePluginBundle, +} from "../src/index.js"; +import type { VerificationErrorCode } from "../src/index.js"; + +const encoder = new TextEncoder(); +const manifest = { + id: "test-plugin", + version: "1.0.0", + capabilities: ["write:content"], + allowedHosts: [], + storage: {}, + hooks: [], + routes: [], + admin: {}, +}; + +function file(name: string, body: string | Uint8Array): TarEntry { + const bytes = typeof body === "string" ? encoder.encode(body) : body; + return { header: { name, size: bytes.byteLength, type: "file" }, body: bytes }; +} + +function directory(name: string): TarEntry { + return { header: { name, size: 0, type: "directory" }, body: new Uint8Array() }; +} + +async function bundle(entries: TarEntry[]): Promise { + return new Uint8Array(gzipSync(await packTar(entries))); +} + +async function canonicalBundle(extra: TarEntry[] = []): Promise { + return bundle([ + file("manifest.json", JSON.stringify(manifest)), + file("backend.js", "export default {};"), + ...extra, + ]); +} + +async function expectCode(bytes: Uint8Array, code: VerificationErrorCode): Promise { + const result = await validatePluginBundle(bytes); + expect(result).toMatchObject({ success: false, error: { code } }); +} + +describe("validatePluginBundle", () => { + it("returns the manifest, canonical access, backend, and optional admin", async () => { + const result = await validatePluginBundle( + await canonicalBundle([file("admin.js", "export default {};")]), + { expectedSlug: "test-plugin", expectedVersion: "1.0.0" }, + ); + expect(result).toMatchObject({ + success: true, + value: { + manifest: { + id: "test-plugin", + declaredAccess: { content: { read: {}, write: {} } }, + capabilities: ["write:content"], + }, + declaredAccess: { content: { read: {}, write: {} } }, + }, + }); + if (result.success) { + expect(new TextDecoder().decode(result.value.backend)).toBe("export default {};"); + expect(new TextDecoder().decode(result.value.admin)).toBe("export default {};"); + } + }); + + it("rejects expected slug and version mismatches", async () => { + const bytes = await canonicalBundle(); + expect(await validatePluginBundle(bytes, { expectedSlug: "other" })).toMatchObject({ + success: false, + error: { code: "BUNDLE_ID_MISMATCH" }, + }); + expect(await validatePluginBundle(bytes, { expectedVersion: "2.0.0" })).toMatchObject({ + success: false, + error: { code: "BUNDLE_VERSION_MISMATCH" }, + }); + }); + + it.each([ + "/manifest.json", + "../manifest.json", + "dir/../manifest.json", + "dir\\manifest.json", + "C:/manifest.json", + "manifest.json/", + "manifest.json\n", + ])("rejects unsafe path %s", async (name) => { + await expectCode( + await bundle([file(name, JSON.stringify(manifest)), file("backend.js", "x")]), + "BUNDLE_INVALID_PATH", + ); + }); + + it("rejects NUL and malformed UTF-8 names", async () => { + await expectCode( + await bundle([ + file("manifest.json\0alias", JSON.stringify(manifest)), + file("backend.js", "x"), + ]), + "BUNDLE_INVALID_PATH", + ); + const tar = await packTar([ + file("manifest.json", JSON.stringify(manifest)), + file("backend.js", "x"), + ]); + tar[0] = 0xff; + await expectCode(new Uint8Array(gzipSync(tar)), "BUNDLE_INVALID_ARCHIVE"); + }); + + it("rejects duplicate raw and normalized paths", async () => { + await expectCode( + await bundle([ + file("manifest.json", JSON.stringify(manifest)), + file("manifest.json", JSON.stringify(manifest)), + file("backend.js", "x"), + ]), + "BUNDLE_PATH_COLLISION", + ); + await expectCode( + await bundle([ + file("manifest.json", JSON.stringify(manifest)), + file("./manifest.json", JSON.stringify(manifest)), + file("backend.js", "x"), + ]), + "BUNDLE_PATH_COLLISION", + ); + await expectCode( + await bundle([ + file("manifest.json", JSON.stringify(manifest)), + file("backend.js", "x"), + file("./backend.js", "y"), + ]), + "BUNDLE_PATH_COLLISION", + ); + await expectCode( + await bundle([ + file("manifest.json", JSON.stringify(manifest)), + file("backend.js", "x"), + file("caf\u00e9.js", "x"), + file("cafe\u0301.js", "x"), + ]), + "BUNDLE_PATH_COLLISION", + ); + }); + + it("rejects non-zero data after the tar end marker", async () => { + const tar = await packTar([ + file("manifest.json", JSON.stringify(manifest)), + file("backend.js", "x"), + ]); + const ambiguous = new Uint8Array(tar.byteLength + 512); + ambiguous.set(tar); + ambiguous[tar.byteLength] = 1; + await expectCode(new Uint8Array(gzipSync(ambiguous)), "BUNDLE_INVALID_ARCHIVE"); + }); + + it.each([ + "link", + "symlink", + "character-device", + "block-device", + "fifo", + "pax-header", + ] satisfies TarHeader["type"][])("rejects %s entries", async (type) => { + await expectCode( + await canonicalBundle([{ header: { name: "unsupported", size: 0, type } }]), + "BUNDLE_UNSUPPORTED_ENTRY", + ); + }); + + it("accepts harmless directory entries without counting them as files", async () => { + const directories: TarEntry[] = Array.from({ length: 10 }, (_, index) => ({ + header: { name: `dir-${index}/`, size: 0, type: "directory" }, + })); + const result = await validatePluginBundle(await canonicalBundle(directories)); + expect(result.success).toBe(true); + }); + + it("requires root manifest.json and backend.js", async () => { + await expectCode( + await bundle([file("nested/manifest.json", "{}"), file("backend.js", "x")]), + "BUNDLE_MISSING_MANIFEST", + ); + await expectCode( + await bundle([ + file("manifest.json", JSON.stringify(manifest)), + file("nested/backend.js", "x"), + ]), + "BUNDLE_MISSING_BACKEND", + ); + }); + + it("rejects invalid gzip, invalid tar, malformed JSON, and schema-invalid manifests", async () => { + await expectCode(encoder.encode("not gzip"), "BUNDLE_INVALID_ARCHIVE"); + const malformedTar = new Uint8Array(1024); + malformedTar[0] = 1; + await expectCode(new Uint8Array(gzipSync(malformedTar)), "BUNDLE_INVALID_ARCHIVE"); + await expectCode( + await bundle([file("manifest.json", "{"), file("backend.js", "x")]), + "BUNDLE_INVALID_MANIFEST", + ); + await expectCode( + await bundle([ + file("manifest.json", JSON.stringify({ id: "test" })), + file("backend.js", "x"), + ]), + "BUNDLE_INVALID_MANIFEST", + ); + }); + + it("enforces compressed size at and over the boundary", async () => { + await expectCode(new Uint8Array(MAX_BUNDLE_COMPRESSED_BYTES), "BUNDLE_INVALID_ARCHIVE"); + await expectCode( + new Uint8Array(MAX_BUNDLE_COMPRESSED_BYTES + 1), + "BUNDLE_COMPRESSED_SIZE_EXCEEDED", + ); + }); + + it("accepts per-file and aggregate file sizes at their boundaries", async () => { + const manifestBytes = encoder.encode(JSON.stringify(manifest)); + const atFileLimit = await bundle([ + file("manifest.json", manifestBytes), + file("backend.js", new Uint8Array(MAX_BUNDLE_FILE_BYTES)), + ]); + expect((await validatePluginBundle(atFileLimit)).success).toBe(true); + + const atTotalLimit = await bundle([ + file("manifest.json", manifestBytes), + file("backend.js", new Uint8Array(MAX_BUNDLE_FILE_BYTES)), + file( + "data.bin", + new Uint8Array(MAX_BUNDLE_SIZE - MAX_BUNDLE_FILE_BYTES - manifestBytes.byteLength), + ), + ]); + expect((await validatePluginBundle(atTotalLimit)).success).toBe(true); + }); + + it("accepts all file and tar entries at the aggregate size boundary", async () => { + const manifestBytes = encoder.encode(JSON.stringify(manifest)); + const remainingFiles = Array.from({ length: MAX_BUNDLE_FILE_COUNT - 3 }, (_, index) => + file(`empty-${index}.bin`, new Uint8Array()), + ); + const directories = Array.from( + { length: MAX_BUNDLE_TAR_ENTRY_COUNT - MAX_BUNDLE_FILE_COUNT }, + (_, index) => directory(`directory-${index}/`), + ); + const bytes = await bundle([ + file("manifest.json", manifestBytes), + file("backend.js", new Uint8Array(MAX_BUNDLE_FILE_BYTES)), + file( + "payload.bin", + new Uint8Array(MAX_BUNDLE_SIZE - MAX_BUNDLE_FILE_BYTES - manifestBytes.byteLength), + ), + ...remainingFiles, + ...directories, + ]); + + expect((await validatePluginBundle(bytes)).success).toBe(true); + }); + + it("rejects per-file and aggregate file sizes over their boundaries", async () => { + await expectCode( + await bundle([ + file("manifest.json", JSON.stringify(manifest)), + file("backend.js", new Uint8Array(MAX_BUNDLE_FILE_BYTES + 1)), + ]), + "BUNDLE_FILE_SIZE_EXCEEDED", + ); + await expectCode( + await bundle([ + file("manifest.json", JSON.stringify(manifest)), + file("backend.js", new Uint8Array(MAX_BUNDLE_FILE_BYTES)), + file("data.bin", new Uint8Array(MAX_BUNDLE_FILE_BYTES)), + ]), + "BUNDLE_DECOMPRESSED_SIZE_EXCEEDED", + ); + }); + + it("enforces file count at and over the boundary", async () => { + const fillers = Array.from({ length: MAX_BUNDLE_FILE_COUNT - 2 }, (_, index) => + file(`file-${index}.js`, "x"), + ); + expect((await validatePluginBundle(await canonicalBundle(fillers))).success).toBe(true); + await expectCode( + await canonicalBundle([...fillers, file("one-too-many.js", "x")]), + "BUNDLE_FILE_COUNT_EXCEEDED", + ); + }); + + it("bounds decompressed collection before buffering a gzip bomb", async () => { + const bomb = new Uint8Array(MAX_BUNDLE_DECOMPRESSED_BYTES + 1); + await expectCode(new Uint8Array(gzipSync(bomb)), "BUNDLE_DECOMPRESSED_SIZE_EXCEEDED"); + }); +}); diff --git a/packages/registry-verification/tests/workerd/smoke.test.ts b/packages/registry-verification/tests/workerd/smoke.test.ts index 29dd6fe161..331eaed0d9 100644 --- a/packages/registry-verification/tests/workerd/smoke.test.ts +++ b/packages/registry-verification/tests/workerd/smoke.test.ts @@ -1,9 +1,15 @@ +import { packTar } from "modern-tar"; import { describe, expect, it } from "vitest"; -import { computeMultihash, fetchVerifiedResource } from "../../src/index.js"; +import { computeMultihash, fetchVerifiedResource, validatePluginBundle } from "../../src/index.js"; const encoder = new TextEncoder(); +async function gzip(bytes: Uint8Array): Promise { + const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + describe("registry verification in workerd", () => { it("computes checksums and fetches through injected dependencies", async () => { const checksum = await computeMultihash(encoder.encode("hello")); @@ -19,4 +25,31 @@ describe("registry verification in workerd", () => { expect(resource).toMatchObject({ success: true, value: { status: 200 } }); if (resource.success) expect(new TextDecoder().decode(resource.value.bytes)).toBe("artifact"); }); + + it("validates a canonical plugin bundle", async () => { + const manifest = encoder.encode( + JSON.stringify({ + id: "workerd-plugin", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storage: {}, + hooks: [], + routes: [], + admin: {}, + }), + ); + const tar = await packTar([ + { + header: { name: "manifest.json", size: manifest.byteLength, type: "file" }, + body: manifest, + }, + { header: { name: "backend.js", size: 1, type: "file" }, body: encoder.encode("x") }, + ]); + const result = await validatePluginBundle(await gzip(tar), { + expectedSlug: "workerd-plugin", + expectedVersion: "1.0.0", + }); + expect(result).toMatchObject({ success: true, value: { manifest: { id: "workerd-plugin" } } }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52f2d0483e..6ee2676f77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1599,6 +1599,9 @@ importers: '@emdash-cms/registry-client': specifier: workspace:* version: link:../registry-client + '@emdash-cms/registry-verification': + specifier: workspace:* + version: link:../registry-verification '@floating-ui/react': specifier: ^0.27.16 version: 0.27.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1886,6 +1889,9 @@ importers: '@emdash-cms/registry-lexicons': specifier: workspace:* version: link:../registry-lexicons + '@emdash-cms/registry-verification': + specifier: workspace:* + version: link:../registry-verification '@oslojs/crypto': specifier: 'catalog:' version: 1.0.1 @@ -1934,6 +1940,10 @@ importers: 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)(yaml@2.9.0)) packages/plugin-types: + dependencies: + zod: + specifier: 'catalog:' + version: 4.4.1 devDependencies: '@arethetypeswrong/cli': specifier: 'catalog:' @@ -2233,6 +2243,13 @@ importers: version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) packages/registry-verification: + dependencies: + '@emdash-cms/plugin-types': + specifier: workspace:* + version: link:../plugin-types + modern-tar: + specifier: ^0.7.6 + version: 0.7.6 devDependencies: '@arethetypeswrong/cli': specifier: 'catalog:' From 5c8f2eeb26e716e03937afbbd29d0f3013eaee10 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 11 Jul 2026 07:39:33 +0100 Subject: [PATCH 10/24] feat(plugin-types): add declared access escalation (#1937) --- .changeset/calm-access-rules.md | 5 + packages/plugin-types/src/declared-access.ts | 398 ++++++++++++++++++ packages/plugin-types/src/index.ts | 15 + .../tests/declared-access.test.ts | 310 ++++++++++++++ 4 files changed, 728 insertions(+) create mode 100644 .changeset/calm-access-rules.md create mode 100644 packages/plugin-types/src/declared-access.ts create mode 100644 packages/plugin-types/tests/declared-access.test.ts diff --git a/.changeset/calm-access-rules.md b/.changeset/calm-access-rules.md new file mode 100644 index 0000000000..551af698c4 --- /dev/null +++ b/.changeset/calm-access-rules.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/plugin-types": minor +--- + +Adds canonical declared-access comparison, structured escalation diffs, and stable digest input generation. diff --git a/packages/plugin-types/src/declared-access.ts b/packages/plugin-types/src/declared-access.ts new file mode 100644 index 0000000000..6d907fa86c --- /dev/null +++ b/packages/plugin-types/src/declared-access.ts @@ -0,0 +1,398 @@ +import type { DeclaredAccess } from "./index.js"; + +export type CanonicalJsonValue = + | null + | boolean + | number + | string + | readonly CanonicalJsonValue[] + | { readonly [key: string]: CanonicalJsonValue }; + +export type CanonicalAccessConstraints = Readonly>; + +export interface CanonicalDeclaredAccess { + readonly content?: Readonly<{ + read?: CanonicalAccessConstraints; + write?: CanonicalAccessConstraints; + }>; + readonly email?: Readonly<{ + events?: CanonicalAccessConstraints; + send?: CanonicalAccessConstraints; + transport?: CanonicalAccessConstraints; + }>; + readonly media?: Readonly<{ + read?: CanonicalAccessConstraints; + write?: CanonicalAccessConstraints; + }>; + readonly network?: Readonly<{ + request?: CanonicalAccessConstraints & { readonly allowedHosts?: readonly string[] }; + }>; + readonly page?: Readonly<{ fragments?: CanonicalAccessConstraints }>; + readonly users?: Readonly<{ read?: CanonicalAccessConstraints }>; +} + +export type AccessChangeKind = + | "category-added" + | "category-removed" + | "operation-added" + | "operation-removed" + | "constraint-added" + | "constraint-removed" + | "constraint-changed"; + +export interface AccessChange { + readonly kind: AccessChangeKind; + readonly category: string; + readonly operation?: string; + readonly path: readonly string[]; + readonly previous?: CanonicalJsonValue; + readonly next?: CanonicalJsonValue; + readonly escalation: boolean; +} + +export interface AccessDiff { + readonly changes: readonly AccessChange[]; + readonly escalation: boolean; +} + +type CanonicalObject = Readonly>; + +const DIGEST_DOMAIN = "@emdash-cms/plugin-types/declared-access"; +const DIGEST_VERSION = 1; + +function isObject(value: CanonicalJsonValue | undefined): value is CanonicalObject { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function defineDataProperty( + target: Record, + key: string, + value: CanonicalJsonValue, +): void { + Object.defineProperty(target, key, { + value, + writable: true, + enumerable: true, + configurable: true, + }); +} + +function canonicalizeJson( + value: unknown, + ancestors: Set, + path: readonly string[], +): CanonicalJsonValue { + if (value === null || typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number") { + if (!Number.isFinite(value)) + throw new TypeError(`Non-finite number at ${path.join(".") || "root"}`); + return Object.is(value, -0) ? 0 : value; + } + if (typeof value !== "object") { + throw new TypeError(`Non-JSON value at ${path.join(".") || "root"}`); + } + if (Object.getOwnPropertySymbols(value).length > 0) { + throw new TypeError(`Symbol-keyed property at ${path.join(".") || "root"}`); + } + if (ancestors.has(value)) throw new TypeError(`Circular value at ${path.join(".") || "root"}`); + ancestors.add(value); + + let result: CanonicalJsonValue; + if (Array.isArray(value)) { + const output: CanonicalJsonValue[] = []; + for (let index = 0; index < value.length; index++) { + if (!Object.hasOwn(value, index)) { + throw new TypeError(`Sparse array at ${[...path, String(index)].join(".")}`); + } + output.push(canonicalizeJson(value[index], ancestors, [...path, String(index)])); + } + result = Object.freeze(output); + } else { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError(`Non-JSON object at ${path.join(".") || "root"}`); + } + const output: Record = {}; + for (const key of Object.keys(value).toSorted(compareStrings)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + throw new TypeError(`Non-JSON property at ${[...path, key].join(".")}`); + } + defineDataProperty( + output, + key, + canonicalizeJson(descriptor.value, ancestors, [...path, key]), + ); + } + result = Object.freeze(output); + } + + ancestors.delete(value); + return result; +} + +function requireObject( + value: CanonicalJsonValue | undefined, + path: readonly string[], +): CanonicalObject { + if (!isObject(value)) throw new TypeError(`Expected object at ${path.join(".") || "root"}`); + return value; +} + +function normalizeDeclaredAccess(value: DeclaredAccess): CanonicalObject { + const canonical = requireObject(canonicalizeJson(value, new Set(), []), []); + const output: Record = {}; + + for (const category of Object.keys(canonical)) { + const operations = requireObject(canonical[category], [category]); + const normalizedOperations: Record = {}; + for (const operation of Object.keys(operations)) { + const constraints = requireObject(operations[operation], [category, operation]); + const normalizedConstraints: Record = { ...constraints }; + if ( + category === "network" && + operation === "request" && + Object.hasOwn(constraints, "allowedHosts") + ) { + const hosts = constraints.allowedHosts; + if (!isStringArray(hosts)) { + throw new TypeError("Expected network.request.allowedHosts to be an array of strings"); + } + normalizedConstraints.allowedHosts = Object.freeze( + [...new Set(hosts)].toSorted(compareStrings), + ); + } + defineDataProperty(normalizedOperations, operation, Object.freeze(normalizedConstraints)); + } + if ( + (category === "content" || category === "media") && + Object.hasOwn(normalizedOperations, "write") + ) { + normalizedOperations.read ??= Object.freeze({}); + } + defineDataProperty( + output, + category, + Object.freeze( + Object.fromEntries( + Object.entries(normalizedOperations).toSorted(([left], [right]) => + compareStrings(left, right), + ), + ), + ), + ); + } + + return Object.freeze(output); +} + +/** + * Returns an immutable, implication-closed declared-access value with recursively + * sorted keys and canonical host sets. Non-JSON runtime values throw TypeError. + */ +export function canonicalizeDeclaredAccess(value: DeclaredAccess): CanonicalDeclaredAccess { + return normalizeDeclaredAccess(value) as CanonicalDeclaredAccess; +} + +function canonicalString(value: DeclaredAccess): string { + return JSON.stringify(canonicalizeDeclaredAccess(value)); +} + +export function declaredAccessEqual(previous: DeclaredAccess, next: DeclaredAccess): boolean { + return canonicalString(previous) === canonicalString(next); +} + +function change( + kind: AccessChangeKind, + category: string, + operation: string | undefined, + path: readonly string[], + previous: CanonicalJsonValue | undefined, + next: CanonicalJsonValue | undefined, + escalation: boolean, +): AccessChange { + return Object.freeze({ + kind, + category, + operation, + path: Object.freeze([...path]), + previous, + next, + escalation, + }); +} + +function patternCovers(oldPattern: string, newPattern: string): boolean { + if (oldPattern === "*") return true; + if (oldPattern === newPattern) return true; + if (!oldPattern.startsWith("*.")) return oldPattern === newPattern; + const oldSuffix = oldPattern.slice(1); + if (newPattern.startsWith("*.")) { + const newSuffix = newPattern.slice(1); + return newSuffix !== oldSuffix && newSuffix.endsWith(oldSuffix); + } + return newPattern.endsWith(oldSuffix) && newPattern.length > oldSuffix.length; +} + +function hostsEscalate(previous: readonly string[], next: readonly string[]): boolean { + return next.some( + (nextPattern) => !previous.some((oldPattern) => patternCovers(oldPattern, nextPattern)), + ); +} + +function valuesEqual(previous: CanonicalJsonValue, next: CanonicalJsonValue): boolean { + return JSON.stringify(previous) === JSON.stringify(next); +} + +function diffConstraints( + previous: CanonicalObject, + next: CanonicalObject, + category: string, + operation: string, +): AccessChange[] { + const changes: AccessChange[] = []; + const keys = [...new Set([...Object.keys(previous), ...Object.keys(next)])].toSorted( + compareStrings, + ); + for (const key of keys) { + const hadOldValue = Object.hasOwn(previous, key); + const hasNewValue = Object.hasOwn(next, key); + const oldValue = previous[key]; + const newValue = next[key]; + const path = [category, operation, key]; + const knownHosts = category === "network" && operation === "request" && key === "allowedHosts"; + if (!hadOldValue) { + changes.push( + change("constraint-added", category, operation, path, undefined, newValue, !knownHosts), + ); + } else if (!hasNewValue) { + changes.push( + change("constraint-removed", category, operation, path, oldValue, undefined, true), + ); + } else { + if (oldValue === undefined || newValue === undefined) { + throw new TypeError(`Non-JSON constraint at ${path.join(".")}`); + } + if (valuesEqual(oldValue, newValue)) continue; + let escalation = true; + if (knownHosts) { + if (!isStringArray(oldValue) || !isStringArray(newValue)) { + throw new TypeError("Expected network.request.allowedHosts to be arrays of strings"); + } + escalation = hostsEscalate(oldValue, newValue); + } + changes.push( + change("constraint-changed", category, operation, path, oldValue, newValue, escalation), + ); + } + } + return changes; +} + +export function diffDeclaredAccess(previous: DeclaredAccess, next: DeclaredAccess): AccessDiff { + const oldAccess = normalizeDeclaredAccess(previous); + const newAccess = normalizeDeclaredAccess(next); + const changes: AccessChange[] = []; + const categories = [...new Set([...Object.keys(oldAccess), ...Object.keys(newAccess)])].toSorted( + compareStrings, + ); + + for (const category of categories) { + const hadOldCategory = Object.hasOwn(oldAccess, category); + const hasNewCategory = Object.hasOwn(newAccess, category); + const oldCategory = oldAccess[category]; + const newCategory = newAccess[category]; + if (!hadOldCategory) { + changes.push( + change("category-added", category, undefined, [category], undefined, newCategory, true), + ); + continue; + } + if (!hasNewCategory) { + changes.push( + change("category-removed", category, undefined, [category], oldCategory, undefined, false), + ); + continue; + } + if (oldCategory === undefined || newCategory === undefined) { + throw new TypeError(`Non-JSON category at ${category}`); + } + + const oldOperations = requireObject(oldCategory, [category]); + const newOperations = requireObject(newCategory, [category]); + const operations = [ + ...new Set([...Object.keys(oldOperations), ...Object.keys(newOperations)]), + ].toSorted(compareStrings); + for (const operation of operations) { + const hadOldOperation = Object.hasOwn(oldOperations, operation); + const hasNewOperation = Object.hasOwn(newOperations, operation); + const oldOperation = oldOperations[operation]; + const newOperation = newOperations[operation]; + if (!hadOldOperation) { + changes.push( + change( + "operation-added", + category, + operation, + [category, operation], + undefined, + newOperation, + true, + ), + ); + } else if (!hasNewOperation) { + changes.push( + change( + "operation-removed", + category, + operation, + [category, operation], + oldOperation, + undefined, + false, + ), + ); + } else { + if (oldOperation === undefined || newOperation === undefined) { + throw new TypeError(`Non-JSON operation at ${category}.${operation}`); + } + changes.push( + ...diffConstraints( + requireObject(oldOperation, [category, operation]), + requireObject(newOperation, [category, operation]), + category, + operation, + ), + ); + } + } + } + + return Object.freeze({ + changes: Object.freeze(changes), + escalation: changes.some((item) => item.escalation), + }); +} + +export function isDeclaredAccessEscalation( + previous: DeclaredAccess, + next: DeclaredAccess, +): boolean { + return diffDeclaredAccess(previous, next).escalation; +} + +/** + * Returns the canonical JSON preimage for a declared-access digest. The format + * is domain-separated and versioned; callers choose and apply the hash. + */ +export function declaredAccessDigestInput(value: DeclaredAccess): string { + return `{"declaredAccess":${canonicalString(value)},"domain":${JSON.stringify(DIGEST_DOMAIN)},"version":${DIGEST_VERSION}}`; +} diff --git a/packages/plugin-types/src/index.ts b/packages/plugin-types/src/index.ts index 03b1c4fae7..01a6b026d8 100644 --- a/packages/plugin-types/src/index.ts +++ b/packages/plugin-types/src/index.ts @@ -471,3 +471,18 @@ export { reconcileManifestAccess, } from "./manifest-schema.js"; export type { ValidatedPluginManifest } from "./manifest-schema.js"; +export { + canonicalizeDeclaredAccess, + declaredAccessDigestInput, + declaredAccessEqual, + diffDeclaredAccess, + isDeclaredAccessEscalation, +} from "./declared-access.js"; +export type { + AccessChange, + AccessChangeKind, + AccessDiff, + CanonicalAccessConstraints, + CanonicalDeclaredAccess, + CanonicalJsonValue, +} from "./declared-access.js"; diff --git a/packages/plugin-types/tests/declared-access.test.ts b/packages/plugin-types/tests/declared-access.test.ts new file mode 100644 index 0000000000..99e56cb565 --- /dev/null +++ b/packages/plugin-types/tests/declared-access.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from "vitest"; + +import { + canonicalizeDeclaredAccess, + declaredAccessDigestInput, + declaredAccessEqual, + diffDeclaredAccess, + isDeclaredAccessEscalation, +} from "../src/index.js"; +import type { DeclaredAccess } from "../src/index.js"; + +const access = (allowedHosts?: string[]): DeclaredAccess => ({ + network: { request: allowedHosts === undefined ? {} : { allowedHosts } }, +}); +const sparseArray: unknown[] = []; +sparseArray.length = 1; + +describe("declared access escalation decision table", () => { + const cases: Array<{ + name: string; + previous: DeclaredAccess; + next: DeclaredAccess; + escalation: boolean; + }> = [ + { name: "equal empty access", previous: {}, next: {}, escalation: false }, + { + name: "first release with access", + previous: {}, + next: { users: { read: {} } }, + escalation: true, + }, + { + name: "category removal", + previous: { users: { read: {} } }, + next: {}, + escalation: false, + }, + { + name: "operation addition", + previous: { email: { send: {} } }, + next: { email: { send: {}, events: {} } }, + escalation: true, + }, + { + name: "operation removal", + previous: { email: { send: {}, events: {} } }, + next: { email: { send: {} } }, + escalation: false, + }, + { + name: "restricted to unrestricted", + previous: access(["api.example.com"]), + next: access(), + escalation: true, + }, + { + name: "unrestricted to restricted", + previous: access(), + next: access(["api.example.com"]), + escalation: false, + }, + { + name: "deny-all to restricted", + previous: access([]), + next: access(["api.example.com"]), + escalation: true, + }, + { + name: "restricted to deny-all", + previous: access(["api.example.com"]), + next: access([]), + escalation: false, + }, + { + name: "wildcard covers exact subdomain", + previous: access(["*.example.com"]), + next: access(["api.example.com"]), + escalation: false, + }, + { + name: "wildcard excludes bare domain", + previous: access(["*.example.com"]), + next: access(["example.com"]), + escalation: true, + }, + { + name: "wildcard covers narrower wildcard", + previous: access(["*.example.com"]), + next: access(["*.api.example.com"]), + escalation: false, + }, + { + name: "exact does not cover wildcard", + previous: access(["api.example.com"]), + next: access(["*.api.example.com"]), + escalation: true, + }, + { + name: "exact covers itself", + previous: access(["api.example.com"]), + next: access(["api.example.com"]), + escalation: false, + }, + { + name: "exact excludes another host", + previous: access(["api.example.com"]), + next: access(["cdn.example.com"]), + escalation: true, + }, + { + name: "star covers every host", + previous: access(["*"]), + next: access(["example.com", "*.api.example.com"]), + escalation: false, + }, + ]; + + it.each(cases)("$name", ({ previous, next, escalation }) => { + const diff = diffDeclaredAccess(previous, next); + expect(diff.escalation).toBe(escalation); + expect(isDeclaredAccessEscalation(previous, next)).toBe(diff.escalation); + }); +}); + +describe("canonicalizeDeclaredAccess", () => { + it("materializes write implications without mutating input and is idempotent", () => { + const input: DeclaredAccess = { media: { write: {} }, content: { write: {} } }; + const canonical = canonicalizeDeclaredAccess(input); + expect(canonical).toEqual({ content: { read: {}, write: {} }, media: { read: {}, write: {} } }); + expect(input).toEqual({ media: { write: {} }, content: { write: {} } }); + expect(canonicalizeDeclaredAccess(canonical)).toEqual(canonical); + expect(Object.isFrozen(canonical)).toBe(true); + expect( + declaredAccessEqual({ content: { write: {} } }, { content: { read: {}, write: {} } }), + ).toBe(true); + }); + + it("sorts keys recursively and host sets while preserving other array order", () => { + const first = { + network: { + request: { z: { b: 2, a: 1 }, list: [2, 1], allowedHosts: ["b.test", "a.test", "b.test"] }, + }, + } as DeclaredAccess; + const second = { + network: { request: { allowedHosts: ["a.test", "b.test"], list: [2, 1], z: { a: 1, b: 2 } } }, + } as DeclaredAccess; + expect(declaredAccessEqual(first, second)).toBe(true); + expect(canonicalizeDeclaredAccess(first).network?.request?.allowedHosts).toEqual([ + "a.test", + "b.test", + ]); + expect( + declaredAccessEqual(first, { + network: { request: { ...second.network?.request, list: [1, 2] } }, + } as DeclaredAccess), + ).toBe(false); + }); + + it.each([ + ["undefined", { users: { read: { value: undefined } } }], + ["function", { users: { read: { value: () => undefined } } }], + ["NaN", { users: { read: { value: Number.NaN } } }], + ["sparse array", { users: { read: { value: sparseArray } } }], + ])("rejects malformed %s runtime values", (_, value) => { + expect(() => declaredAccessDigestInput(value as DeclaredAccess)).toThrow(TypeError); + }); +}); + +describe("unknown constraints", () => { + const unknown = (constraints: Record): DeclaredAccess => ({ + users: { read: constraints }, + }); + const fromJson = (constraints: string): DeclaredAccess => + JSON.parse(`{"users":{"read":${constraints}}}`) as DeclaredAccess; + + it.each([ + ["equal", unknown({ policy: { b: 2, a: 1 } }), unknown({ policy: { a: 1, b: 2 } }), false], + ["added", unknown({}), unknown({ policy: true }), true], + ["removed", unknown({ policy: true }), unknown({}), true], + ["changed", unknown({ policy: 1 }), unknown({ policy: 2 }), true], + [ + "nested array equal", + unknown({ policy: { values: [1, 2] } }), + unknown({ policy: { values: [1, 2] } }), + false, + ], + [ + "nested array reordered", + unknown({ policy: { values: [1, 2] } }), + unknown({ policy: { values: [2, 1] } }), + true, + ], + ] as const)("handles %s unknown constraints conservatively", (_, previous, next, escalation) => { + expect(diffDeclaredAccess(previous, next).escalation).toBe(escalation); + }); + + it("preserves JSON-derived __proto__ constraints without equality or digest collisions", () => { + const constrained = fromJson('{"__proto__":{"scope":"all"}}'); + const empty = unknown({}); + + expect(declaredAccessEqual(constrained, empty)).toBe(false); + expect(declaredAccessDigestInput(constrained)).not.toBe(declaredAccessDigestInput(empty)); + }); + + it("reports changed and removed __proto__ constraints as deterministic escalations", () => { + const previous = fromJson('{"__proto__":{"level":1}}'); + const next = fromJson('{"__proto__":{"level":2}}'); + + expect(diffDeclaredAccess(previous, next)).toEqual({ + changes: [ + { + kind: "constraint-changed", + category: "users", + operation: "read", + path: ["users", "read", "__proto__"], + previous: { level: 1 }, + next: { level: 2 }, + escalation: true, + }, + ], + escalation: true, + }); + expect(diffDeclaredAccess(previous, unknown({}))).toEqual({ + changes: [ + { + kind: "constraint-removed", + category: "users", + operation: "read", + path: ["users", "read", "__proto__"], + previous: { level: 1 }, + next: undefined, + escalation: true, + }, + ], + escalation: true, + }); + }); + + it("keeps canonical __proto__ values idempotent with safe object prototypes", () => { + const canonical = canonicalizeDeclaredAccess(fromJson('{"__proto__":{"enabled":true}}')); + const constraints = canonical.users?.read; + + expect(Object.hasOwn(constraints ?? {}, "__proto__")).toBe(true); + expect(Object.getPrototypeOf(constraints)).toBe(Object.prototype); + expect(canonicalizeDeclaredAccess(canonical)).toEqual(canonical); + }); + + it("preserves and compares constructor and prototype constraints", () => { + const previous = fromJson('{"constructor":{"mode":"old"},"prototype":{"enabled":true}}'); + const next = fromJson('{"constructor":{"mode":"new"},"prototype":{"enabled":true}}'); + + expect(canonicalizeDeclaredAccess(previous).users?.read).toEqual({ + constructor: { mode: "old" }, + prototype: { enabled: true }, + }); + expect(declaredAccessEqual(previous, next)).toBe(false); + expect(diffDeclaredAccess(previous, next).changes).toEqual([ + { + kind: "constraint-changed", + category: "users", + operation: "read", + path: ["users", "read", "constructor"], + previous: { mode: "old" }, + next: { mode: "new" }, + escalation: true, + }, + ]); + }); + + it("rejects symbol-keyed own properties", () => { + const constraints = {}; + Object.defineProperty(constraints, Symbol("constraint"), { + value: true, + enumerable: true, + }); + + expect(() => canonicalizeDeclaredAccess(unknown(constraints))).toThrow(TypeError); + }); +}); + +describe("structured diff", () => { + it("has deterministic ordering, machine-readable paths, and no display strings", () => { + const diff = diffDeclaredAccess( + { users: { read: { z: 1 } }, email: { send: {} } }, + { users: { read: { a: 1 } }, content: { read: {} }, email: { events: {} } }, + ); + expect(diff.changes.map(({ kind, path, escalation }) => ({ kind, path, escalation }))).toEqual([ + { kind: "category-added", path: ["content"], escalation: true }, + { kind: "operation-added", path: ["email", "events"], escalation: true }, + { kind: "operation-removed", path: ["email", "send"], escalation: false }, + { kind: "constraint-added", path: ["users", "read", "a"], escalation: true }, + { kind: "constraint-removed", path: ["users", "read", "z"], escalation: true }, + ]); + for (const item of diff.changes) { + expect(Object.keys(item).toSorted()).toEqual( + ["category", "escalation", "kind", "next", "operation", "path", "previous"].toSorted(), + ); + } + }); +}); + +describe("declaredAccessDigestInput", () => { + it("is domain/version separated and stable for semantic equality", () => { + const implied = declaredAccessDigestInput({ content: { write: {} } }); + expect(implied).toBe(declaredAccessDigestInput({ content: { read: {}, write: {} } })); + expect(implied).toContain('"domain":"@emdash-cms/plugin-types/declared-access"'); + expect(implied).toContain('"version":1'); + expect(implied).not.toBe(declaredAccessDigestInput({ content: { read: {} } })); + }); +}); From 2ae4c5d1584956ee3f7690033cbdbee744beadb0 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 11 Jul 2026 09:41:12 +0100 Subject: [PATCH 11/24] docs: record Sigstore workerd spike (#1943) --- .../sigstore-workerd-spike.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .opencode/plans/delegated-release-service/sigstore-workerd-spike.md diff --git a/.opencode/plans/delegated-release-service/sigstore-workerd-spike.md b/.opencode/plans/delegated-release-service/sigstore-workerd-spike.md new file mode 100644 index 0000000000..30ce6b8a28 --- /dev/null +++ b/.opencode/plans/delegated-release-service/sigstore-workerd-spike.md @@ -0,0 +1,50 @@ +# W0.5 Sigstore verification in workerd + +This is a Gate 0 decision record. Fixture acquisition, dependencies, and test harnesses remain external until W2.5. + +## Proven facts + +The external spike used `@sigstore/verify@4.1.0`, `@sigstore/bundle@5.0.0`, and `@sigstore/protobuf-specs@0.5.1` with workerd, compatibility date `2026-07-11`, and `nodejs_compat`. It exercised `bundleFromJSON`, `TrustedRoot.fromJSON`, `toTrustMaterial`, `toSignedEntity`, and `Verifier.verify` offline against a vendored trust root. + +The fixture was the public Sigstore SLSA v1 provenance for `@sigstore/core@4.0.1`, generated by `sigstore/sigstore-js` `.github/workflows/release.yml` through `actions/attest-build-provenance`. Exact sources: + +- Archive: `https://registry.npmjs.org/@sigstore/core/-/core-4.0.1.tgz` +- Attestation API: `https://registry.npmjs.org/-/npm/v1/attestations/@sigstore%2fcore@4.0.1` +- Source commit: `https://github.com/sigstore/sigstore-js/tree/d406ea60b342ca37cdeecd7afedb992cd189db92` +- Workflow: `https://github.com/sigstore/sigstore-js/blob/d406ea60b342ca37cdeecd7afedb992cd189db92/.github/workflows/release.yml` +- Archive SHA-256: `8cc190e4385ee18399950148723aecd8db6e835668c361e4b6014435ba202643` +- Archive SHA-512 and DSSE subject digest: `f6fe61463ba39f9357abca3b5c511480bc80b5daf9222b1be29cccd39bb72bad484b9ab784fde5b96027764d1190f3cb4d41684db83b55bf38510d5941e6a359` +- Bundle SHA-256: `f925318b749b898f2cdfb7bdf59542c34e970c016488ecbcfa67ec5b7ffb1452` +- `gh attestation trusted-root` output from 2026-07-11 SHA-256: `65ca537f6ed8a47fd0e560c421baa1f6c1efb8b25fc200d8c5c02c0e92eb2b9c` + +With `tlogThreshold: 1`, `ctlogThreshold: 1`, `timestampThreshold: 1`, the exact certificate SAN, and issuer `https://token.actions.githubusercontent.com`, the Sigstore library verified the fixture's DSSE signature, certificate chain, Rekor SET and inclusion proof, signed checkpoint, CT evidence, and identity. The surrounding spike separately hashed the archive and matched it to the signed statement's subject digest; `Verifier.verify` does not perform that artifact binding. The timestamp threshold was satisfied by the transparency-log integrated time; this fixture contained no RFC3161 timestamp and did not exercise a TSA. + +The selected packages do not verify this fixture unmodified in workerd. `@sigstore/core` omits the `node:crypto.verify` algorithm for the primary DSSE envelope signature, Rekor SET, and signed checkpoint. Node accepts that omitted argument for these P-256 keys; workerd throws. The external spike proved that explicitly supplying SHA-256 for each fixture P-256 verification makes the complete public Sigstore/Fulcio/Rekor path pass while tampered signatures, payloads, Merkle proofs, identities, issuers, and artifact digests still fail. + +Node documents `crypto.verify` algorithm selection at `https://nodejs.org/api/crypto.html#cryptoverifyalgorithm-data-key-signature-callback`: `null` or `undefined` is key-type-dependent, notably for Ed25519/Ed448. Sigstore `PublicKeyDetails` separately binds supported ECDSA curves to digest algorithms. A general `algorithm ?? "sha256"` substitution is therefore incorrect. + +## Identity mapping + +| Field | Fixture value | Authoritative binding and required agreement | +| ---------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| Repository ID | `495574555` | Fulcio OID `.15`; predicate `internalParameters.github.repository_id` agrees | +| Workflow path | `.github/workflows/release.yml` | Predicate `externalParameters.workflow.path`; certificate workflow identity supplies the authoritative full URI | +| Workflow ref | `refs/heads/main` | Predicate workflow ref; exact SAN, build-signer URI OID `.9`, and build-config URI OID `.18` agree | +| Commit SHA | `d406ea60b342ca37cdeecd7afedb992cd189db92` | Fulcio digest OIDs `.10`, `.13`, and `.19`; resolved dependency `gitCommit` agrees | +| Source ref | `refs/heads/main` | Fulcio OID `.14`; predicate workflow ref agrees | +| Certificate identity | `https://github.com/sigstore/sigstore-js/.github/workflows/release.yml@refs/heads/main` | Exact SAN; OIDs `.9` and `.18` agree | +| SLSA `builder.id` | `https://github.com/actions/runner/github-hosted` | Signed predicate runner class, not the RFC workflow identity | +| Invocation | `https://github.com/sigstore/sigstore-js/actions/runs/28204693054/attempts/1` | Fulcio OID `.21`; predicate invocation ID agrees | +| RFC `sourceRepository` | `https://github.com/sigstore/sigstore-js` | Authoritative SourceRepositoryURI OID `.12`; predicate workflow repository must agree | +| RFC `builderId` | `https://github.com/sigstore/sigstore-js/.github/workflows/release.yml@refs/heads/main` | Authoritative exact SAN/build-config URI OID `.18`; predicate repository, path, and ref must compose to the same URI | + +Predicate fields are signed but workflow-controlled. Production policy must safely decode the modern DER UTF8String Fulcio extensions and require predicate agreement. RFC `builderId` is not SLSA `builder.id`. + +## W2.5 requirements + +- Gate 1 is blocked until the algorithm-selection gap has a production-safe implementation and review. Prefer an upstream fix in `@sigstore/core` or `@sigstore/verify`. If no release is available, use a narrowly reviewed, lockfile-pinned `pnpm patch` or a small adapter/fork scoped to Sigstore verification. Do not monkeypatch `node:crypto` process-wide. +- Derive the algorithm from validated key type and curve: P-256 -> SHA-256, P-384 -> SHA-384, P-521 -> SHA-512, and Ed25519 -> `null`/`undefined` according to the Node API. Reject unsupported, deprecated, contradictory, or ambiguous key details. Regression-test every supported key algorithm in both Node and workerd, including DSSE, SET, and checkpoint paths. +- `@sigstore/verify@4.1.0` and `@sigstore/bundle@5.0.0` declare Node `^22.22.2 || ^24.15.0 || >=26.0.0`. Verify repository CI and every deployment runtime against that constraint before adopting them. +- Vendor reviewed, versioned trust roots. Refresh them explicitly, inspect key and validity-window changes, retain historical CAs/logs/TSAs needed for signatures made during their validity periods, and perform no runtime trust-API fetch. +- Implement the complete `ProvenanceVerifier` contract: bundle checksum and format, trust chain, required transparency evidence, exact identity, supported statement/predicate versions, artifact digest, certificate/predicate agreement, profile repository agreement, and derived RFC fields. Verification has one complete success or fails as unverifiable; there is no partial success, and failed supplied provenance is never treated as absent. +- This spike proves only public Sigstore/Fulcio/Rekor. GitHub-private Fulcio/TSA remains unproved and requires a real W2.5 fixture and trust-domain test if supported. Without that evidence, support remains limited to public Sigstore. From 982cceab16d6ce8460cfcee97d10b1a392442706 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 11 Jul 2026 15:52:05 +0100 Subject: [PATCH 12/24] feat(registry): verify Sigstore provenance (#1951) --- .changeset/provenance-verification.md | 5 + .../fixtures/provenance/README.md | 17 + .../sigstore-core-4.0.1-slsa.bundle.json | 61 +++ packages/registry-verification/package.json | 11 +- .../scripts/check-packed-output.mjs | 50 +++ packages/registry-verification/src/errors.ts | 1 + packages/registry-verification/src/index.ts | 7 + .../registry-verification/src/provenance.ts | 407 ++++++++++++++++++ .../trust-roots/sigstore-public-good-v1.json | 126 ++++++ .../tests/check-packed-output.test.ts | 18 + .../tests/provenance-contract.ts | 390 +++++++++++++++++ .../tests/provenance.test.ts | 3 + .../tests/workerd/packed-output.test.ts | 33 ++ .../tests/workerd/provenance.test.ts | 3 + .../registry-verification/tsdown.config.ts | 7 +- packages/registry-verification/wrangler.jsonc | 1 + patches/@sigstore__core@4.0.1.patch | 43 ++ pnpm-lock.yaml | 48 +++ pnpm-workspace.yaml | 3 + 19 files changed, 1232 insertions(+), 2 deletions(-) create mode 100644 .changeset/provenance-verification.md create mode 100644 packages/registry-verification/fixtures/provenance/README.md create mode 100644 packages/registry-verification/fixtures/provenance/sigstore-core-4.0.1-slsa.bundle.json create mode 100644 packages/registry-verification/scripts/check-packed-output.mjs create mode 100644 packages/registry-verification/src/provenance.ts create mode 100644 packages/registry-verification/src/trust-roots/sigstore-public-good-v1.json create mode 100644 packages/registry-verification/tests/check-packed-output.test.ts create mode 100644 packages/registry-verification/tests/provenance-contract.ts create mode 100644 packages/registry-verification/tests/provenance.test.ts create mode 100644 packages/registry-verification/tests/workerd/packed-output.test.ts create mode 100644 packages/registry-verification/tests/workerd/provenance.test.ts create mode 100644 patches/@sigstore__core@4.0.1.patch diff --git a/.changeset/provenance-verification.md b/.changeset/provenance-verification.md new file mode 100644 index 0000000000..0133c46c22 --- /dev/null +++ b/.changeset/provenance-verification.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-verification": minor +--- + +Adds offline GitHub Actions Sigstore SLSA v1 provenance verification for Node and Cloudflare Workers. diff --git a/packages/registry-verification/fixtures/provenance/README.md b/packages/registry-verification/fixtures/provenance/README.md new file mode 100644 index 0000000000..e5bc4605a2 --- /dev/null +++ b/packages/registry-verification/fixtures/provenance/README.md @@ -0,0 +1,17 @@ +# Provenance fixtures + +The bundle is the public Sigstore SLSA provenance for `@sigstore/core@4.0.1`, generated by `actions/attest-build-provenance` from `sigstore/sigstore-js` commit `d406ea60b342ca37cdeecd7afedb992cd189db92` and `.github/workflows/release.yml`. + +The v1 verifier intentionally supports the proved same-repository workflow model: the attested workflow repository, release `sourceRepository`, and signed profile repository must all agree. Reusable workflows hosted in a different repository are not supported. + +- Bundle source: `https://registry.npmjs.org/-/npm/v1/attestations/@sigstore%2fcore@4.0.1` +- Artifact source: `https://registry.npmjs.org/@sigstore/core/-/core-4.0.1.tgz` +- Bundle file SHA-256: `0f9609cf665d761909b505295389bb73c90a42659b407573cb467b5dd52b9958` +- Artifact SHA-256: `8cc190e4385ee18399950148723aecd8db6e835668c361e4b6014435ba202643` +- Artifact SHA-512: `f6fe61463ba39f9357abca3b5c511480bc80b5daf9222b1be29cccd39bb72bad484b9ab784fde5b96027764d1190f3cb4d41684db83b55bf38510d5941e6a359` + +`packages/registry-verification/src/trust-roots/sigstore-public-good-v1.json` is the reviewed public-good subset of `gh attestation trusted-root` fetched on 2026-07-11. The source command output had SHA-256 `65ca537f6ed8a47fd0e560c421baa1f6c1efb8b25fc200d8c5c02c0e92eb2b9c`; the formatted, filtered vendored root has SHA-256 `8a1bf665f1544d4bb57963e15b3207020e602c1e9cd12730e641b1d0185be2bf`. GitHub-private Fulcio and TSA authorities are deliberately excluded. + +## Trust-root updates + +Trust roots are immutable, versioned source files and are never fetched at runtime. To refresh them, obtain the current public root, remove non-public trust domains, add a new `sigstore-public-good-vN.json`, and review every added, removed, or changed key, service URL, key algorithm, and validity window. Verify the shared Node/workerd corpus before changing the verifier to the new version. Retain prior public CAs, logs, CT logs, and timestamp authorities while they are needed to validate signatures produced during their validity periods. Record the source date and both source and vendored SHA-256 hashes here for each update. diff --git a/packages/registry-verification/fixtures/provenance/sigstore-core-4.0.1-slsa.bundle.json b/packages/registry-verification/fixtures/provenance/sigstore-core-4.0.1-slsa.bundle.json new file mode 100644 index 0000000000..8597c6c201 --- /dev/null +++ b/packages/registry-verification/fixtures/provenance/sigstore-core-4.0.1-slsa.bundle.json @@ -0,0 +1,61 @@ +{ + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial": { + "certificate": { + "rawBytes": "MIIG9DCCBnqgAwIBAgIUCBgAJWPJE54lZEfXykGMQvfiksAwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjYwNjI1MjIzMzIzWhcNMjYwNjI1MjI0MzIzWjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfWj4Roujj585fQ5yBUx6LF0jcIWMMIJ8wc9AjMiSnFO55tKIiD+8u4reyeEjczsHbl6Bq+5gjDICm6uUTRtilqOCBZkwggWVMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUlmfQrHkm5zqZfYe8GJwUNZTULegwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wYwYDVR0RAQH/BFkwV4ZVaHR0cHM6Ly9naXRodWIuY29tL3NpZ3N0b3JlL3NpZ3N0b3JlLWpzLy5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sQHJlZnMvaGVhZHMvbWFpbjA5BgorBgEEAYO/MAEBBCtodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tMBIGCisGAQQBg78wAQIEBHB1c2gwNgYKKwYBBAGDvzABAwQoZDQwNmVhNjBiMzQyY2EzN2NkZWVjZDdhZmVkYjk5MmNkMTg5ZGI5MjAVBgorBgEEAYO/MAEEBAdSZWxlYXNlMCIGCisGAQQBg78wAQUEFHNpZ3N0b3JlL3NpZ3N0b3JlLWpzMB0GCisGAQQBg78wAQYED3JlZnMvaGVhZHMvbWFpbjA7BgorBgEEAYO/MAEIBC0MK2h0dHBzOi8vdG9rZW4uYWN0aW9ucy5naXRodWJ1c2VyY29udGVudC5jb20wZQYKKwYBBAGDvzABCQRXDFVodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUvc2lnc3RvcmUtanMvLmdpdGh1Yi93b3JrZmxvd3MvcmVsZWFzZS55bWxAcmVmcy9oZWFkcy9tYWluMDgGCisGAQQBg78wAQoEKgwoZDQwNmVhNjBiMzQyY2EzN2NkZWVjZDdhZmVkYjk5MmNkMTg5ZGI5MjAdBgorBgEEAYO/MAELBA8MDWdpdGh1Yi1ob3N0ZWQwNwYKKwYBBAGDvzABDAQpDCdodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUvc2lnc3RvcmUtanMwOAYKKwYBBAGDvzABDQQqDChkNDA2ZWE2MGIzNDJjYTM3Y2RlZWNkN2FmZWRiOTkyY2QxODlkYjkyMB8GCisGAQQBg78wAQ4EEQwPcmVmcy9oZWFkcy9tYWluMBkGCisGAQQBg78wAQ8ECwwJNDk1NTc0NTU1MCsGCisGAQQBg78wARAEHQwbaHR0cHM6Ly9naXRodWIuY29tL3NpZ3N0b3JlMBgGCisGAQQBg78wAREECgwINzEwOTYzNTMwZQYKKwYBBAGDvzABEgRXDFVodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUvc2lnc3RvcmUtanMvLmdpdGh1Yi93b3JrZmxvd3MvcmVsZWFzZS55bWxAcmVmcy9oZWFkcy9tYWluMDgGCisGAQQBg78wARMEKgwoZDQwNmVhNjBiMzQyY2EzN2NkZWVjZDdhZmVkYjk5MmNkMTg5ZGI5MjAUBgorBgEEAYO/MAEUBAYMBHB1c2gwWwYKKwYBBAGDvzABFQRNDEtodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUvc2lnc3RvcmUtanMvYWN0aW9ucy9ydW5zLzI4MjA0NjkzMDU0L2F0dGVtcHRzLzEwFgYKKwYBBAGDvzABFgQIDAZwdWJsaWMwPQYKKwYBBAGDvzABGAQvDC1yZXBvOnNpZ3N0b3JlL3NpZ3N0b3JlLWpzOnJlZjpyZWZzL2hlYWRzL21haW4wgYkGCisGAQQB1nkCBAIEewR5AHcAdQDdPTBqxscRMmMZHhyZZzcCokpeuN48rf+HinKALynujgAAAZ8A6pvbAAAEAwBGMEQCIG8of9jMz+kUFmVBhoGEmpNli87xidwIKY0GL0aluIplAiBMsKxIo4qTEx0hCGJPboy5752arC/xUhURpaS3XdKhCDAKBggqhkjOPQQDAwNoADBlAjAna+drbU69+wkk5JMS2tjU5yULxyymg7lfx7C2Tqlg8X8CN7Ty1SILx/69rBi45uACMQDugS9SIj5HBULkSSWlHavRYYavGsQ4vvvYmw5zz7dxgv3O9wfNtTtUZm+MkmWnLAI=" + }, + "tlogEntries": [ + { + "logIndex": "1958882830", + "logId": { "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" }, + "kindVersion": { "kind": "dsse", "version": "0.0.1" }, + "integratedTime": "1782426803", + "inclusionPromise": { + "signedEntryTimestamp": "MEYCIQDefe+qg6kUY0gmSpPP+HcHXBw9tH/+ffTD2trPa85tgwIhAM8ntg/l9xX5D+Rzb8wtNpdMTDFxLp8x37n98JKWM9lB" + }, + "inclusionProof": { + "logIndex": "1836978568", + "rootHash": "o6YVeOoiRdeEX1iczU9Cew+mYI6SI8YS54YkZb3sqPM=", + "treeSize": "1836978593", + "hashes": [ + "nHQHeEiAQ0IKhjr8o8Y9JDSXvJBTpj/RwVTOlGudZAs=", + "RuE4AHhLcW74n61bmv/Jc7YYmDUhdeptWlzK7Mhv5sM=", + "Ye6c8iGfvfgDdiue1gkuHPJQvNhOrad2DWQbIx/CicY=", + "CYjDP0meAJgNwymTFHnOm8fQNL5EDGxg1FH50Ke3Ip4=", + "qrlWBCxIJWv4FxS6yrN3uVKP69DAnN1AUsyjg9BGyMY=", + "IF8kAyfVWoSvSquafuHM/LrYRJ8tn8yJawpKGyaH+as=", + "kxLHvz1zg7yHpPJ/4T2SSYezD5QKMD0uxoss/gNMp5Q=", + "QWO4amEglpzy/12WLmp5KYp88M/9sA1BARzM16VRmz8=", + "lp3Emgbm6cn5P08AZXMNkfqxIOSs1uPke9iPSx8CF6U=", + "UWdiXetxgW4Wvt1XKeohCkrpLAkRq9yZriuLtnIiTBQ=", + "o76yxySAGMNCb/0v0rTcqPQqcYnZYBMbiAzswSc5QAQ=", + "7ntFg+mRb4ScyRl1oN/LkHDMZkrVSDMxoN1ylcujQCA=", + "YCJ2QS6P/+0+H681MlvnOVMh6XxTlZuIpiDqQF6IQvE=", + "4QUJJVtsyr79u+ff/yIj8YUeRFuimOHV/A279thL7fQ=", + "sGc8uz1JVL0Q47xVVljZewwOaSku25T5Vh172+itNns=", + "udCFLDaKb3Z/tAxJOyEbozux5jfZ1wcMgEZuBlh80FI=", + "zBViQsza/o62MhKl7o4JWnBX2kwGEPVYEl7i+HPv9MA=", + "NDIRlqxO0IaGr94q86MIU7VCAN3yEEgOHS4loya5lu4=", + "daxmZaajRpZV+JxHiOYZhJBiSKN5ucqjh2WnGbHhirw=", + "DOCeoSMovIvLExkhIvisow9AuNXgeWs4ECkyR6EcqYU=" + ], + "checkpoint": { + "envelope": "rekor.sigstore.dev - 1193050959916656506\n1836978593\no6YVeOoiRdeEX1iczU9Cew+mYI6SI8YS54YkZb3sqPM=\n\n\u2014 rekor.sigstore.dev wNI9ajBFAiEAtJvu1zMvZVv98ZooA34hFXuoAIZiKHbWxzX2mdlAT2wCIEqQVaoE4h8Rfr1TVcUf+pnu8SE99dv35+H/EQp4ARZm\n" + } + }, + "canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiNzc5YmY0MDM1YzY4MTliNzQxNThkMzdlZGZjYzE5NTA0YzJlOTNmZWQ5OWEyYmYzMjkxNzdhZmExMjY5OGY1OSJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6IjQwM2RhNDY4NTdhMDM4NTZkNWEwMmQ1MDE5ZDY1ZTYxZGRmNTYxNmQ2MDM0MTI2ODc0MDNjNTQ5N2NhOGIxYzcifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVVQ0lRQ3VoUTduSHVhY1ZxUzZybktQVE1UMitqMzNLSU14VTRUZHRsKzN4VWdNZ3dJZ0l6dGVkS1JtUWdQWExwZlRxeFRvNlpVa2djZmJhdTF0RUdEWWVXSmI0Kzg9IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VjNVJFTkRRbTV4WjBGM1NVSkJaMGxWUTBKblFVcFhVRXBGTlRSc1drVm1XSGxyUjAxUmRtWnBhM05CZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwWmQwNXFTVEZOYWtsNlRYcEplbGRvWTA1TmFsbDNUbXBKTVUxcVNUQk5la2w2VjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVm1WMm8wVW05MWFtbzFPRFZtVVRWNVFsVjROa3hHTUdwalNWZE5UVWxLT0hkak9VRUthazFwVTI1R1R6VTFkRXRKYVVRck9IVTBjbVY1WlVWcVkzcHpTR0pzTmtKeEt6Vm5ha1JKUTIwMmRWVlVVblJwYkhGUFEwSmFhM2RuWjFkV1RVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVnNiV1pSQ25KSWEyMDFlbkZhWmxsbE9FZEtkMVZPV2xSVlRHVm5kMGgzV1VSV1VqQnFRa0puZDBadlFWVXpPVkJ3ZWpGWmEwVmFZalZ4VG1wd1MwWlhhWGhwTkZrS1drUTRkMWwzV1VSV1VqQlNRVkZJTDBKR2EzZFdORnBXWVVoU01HTklUVFpNZVRsdVlWaFNiMlJYU1hWWk1qbDBURE5PY0ZvelRqQmlNMHBzVEROT2NBcGFNMDR3WWpOS2JFeFhjSHBNZVRWdVlWaFNiMlJYU1haa01qbDVZVEphYzJJelpIcE1NMHBzWWtkV2FHTXlWWFZsVnpGelVVaEtiRnB1VFhaaFIxWm9DbHBJVFhaaVYwWndZbXBCTlVKbmIzSkNaMFZGUVZsUEwwMUJSVUpDUTNSdlpFaFNkMk42YjNaTU0xSjJZVEpXZFV4dFJtcGtSMngyWW01TmRWb3liREFLWVVoV2FXUllUbXhqYlU1MlltNVNiR0p1VVhWWk1qbDBUVUpKUjBOcGMwZEJVVkZDWnpjNGQwRlJTVVZDU0VJeFl6Sm5kMDVuV1V0TGQxbENRa0ZIUkFwMmVrRkNRWGRSYjFwRVVYZE9iVlpvVG1wQ2FVMTZVWGxaTWtWNlRqSk9hMXBYVm1wYVJHUm9XbTFXYTFscWF6Vk5iVTVyVFZSbk5WcEhTVFZOYWtGV0NrSm5iM0pDWjBWRlFWbFBMMDFCUlVWQ1FXUlRXbGQ0YkZsWVRteE5RMGxIUTJselIwRlJVVUpuTnpoM1FWRlZSVVpJVG5CYU0wNHdZak5LYkV3elRuQUtXak5PTUdJelNteE1WM0I2VFVJd1IwTnBjMGRCVVZGQ1p6YzRkMEZSV1VWRU0wcHNXbTVOZG1GSFZtaGFTRTEyWWxkR2NHSnFRVGRDWjI5eVFtZEZSUXBCV1U4dlRVRkZTVUpETUUxTE1tZ3daRWhDZWs5cE9IWmtSemx5V2xjMGRWbFhUakJoVnpsMVkzazFibUZZVW05a1Ywb3hZekpXZVZreU9YVmtSMVoxQ21SRE5XcGlNakIzV2xGWlMwdDNXVUpDUVVkRWRucEJRa05SVWxoRVJsWnZaRWhTZDJONmIzWk1NbVJ3WkVkb01WbHBOV3BpTWpCMll6SnNibU16VW5ZS1kyMVZkbU15Ykc1ak0xSjJZMjFWZEdGdVRYWk1iV1J3WkVkb01WbHBPVE5pTTBweVdtMTRkbVF6VFhaamJWWnpXbGRHZWxwVE5UVmlWM2hCWTIxV2JRcGplVGx2V2xkR2EyTjVPWFJaVjJ4MVRVUm5SME5wYzBkQlVWRkNaemM0ZDBGUmIwVkxaM2R2V2tSUmQwNXRWbWhPYWtKcFRYcFJlVmt5UlhwT01rNXJDbHBYVm1wYVJHUm9XbTFXYTFscWF6Vk5iVTVyVFZSbk5WcEhTVFZOYWtGa1FtZHZja0puUlVWQldVOHZUVUZGVEVKQk9FMUVWMlJ3WkVkb01WbHBNVzhLWWpOT01GcFhVWGRPZDFsTFMzZFpRa0pCUjBSMmVrRkNSRUZSY0VSRFpHOWtTRkozWTNwdmRrd3laSEJrUjJneFdXazFhbUl5TUhaak1teHVZek5TZGdwamJWVjJZekpzYm1NelVuWmpiVlYwWVc1TmQwOUJXVXRMZDFsQ1FrRkhSSFo2UVVKRVVWRnhSRU5vYTA1RVFUSmFWMFV5VFVkSmVrNUVTbXBaVkUwekNsa3lVbXhhVjA1clRqSkdiVnBYVW1sUFZHdDVXVEpSZUU5RWJHdFphbXQ1VFVJNFIwTnBjMGRCVVZGQ1p6YzRkMEZSTkVWRlVYZFFZMjFXYldONU9XOEtXbGRHYTJONU9YUlpWMngxVFVKclIwTnBjMGRCVVZGQ1p6YzRkMEZST0VWRGQzZEtUa1JyTVU1VVl6Qk9WRlV4VFVOelIwTnBjMGRCVVZGQ1p6YzRkd3BCVWtGRlNGRjNZbUZJVWpCalNFMDJUSGs1Ym1GWVVtOWtWMGwxV1RJNWRFd3pUbkJhTTA0d1lqTktiRTFDWjBkRGFYTkhRVkZSUW1jM09IZEJVa1ZGQ2tObmQwbE9la1YzVDFSWmVrNVVUWGRhVVZsTFMzZFpRa0pCUjBSMmVrRkNSV2RTV0VSR1ZtOWtTRkozWTNwdmRrd3laSEJrUjJneFdXazFhbUl5TUhZS1l6SnNibU16VW5aamJWVjJZekpzYm1NelVuWmpiVlYwWVc1TmRreHRaSEJrUjJneFdXazVNMkl6U25KYWJYaDJaRE5OZG1OdFZuTmFWMFo2V2xNMU5RcGlWM2hCWTIxV2JXTjVPVzlhVjBaclkzazVkRmxYYkhWTlJHZEhRMmx6UjBGUlVVSm5OemgzUVZKTlJVdG5kMjlhUkZGM1RtMVdhRTVxUW1sTmVsRjVDbGt5UlhwT01rNXJXbGRXYWxwRVpHaGFiVlpyV1dwck5VMXRUbXROVkdjMVdrZEpOVTFxUVZWQ1oyOXlRbWRGUlVGWlR5OU5RVVZWUWtGWlRVSklRakVLWXpKbmQxZDNXVXRMZDFsQ1FrRkhSSFo2UVVKR1VWSk9SRVYwYjJSSVVuZGplbTkyVERKa2NHUkhhREZaYVRWcVlqSXdkbU15Ykc1ak0xSjJZMjFWZGdwak1teHVZek5TZG1OdFZYUmhiazEyV1ZkT01HRlhPWFZqZVRsNVpGYzFla3g2U1RSTmFrRXdUbXByZWsxRVZUQk1Na1l3WkVkV2RHTklVbnBNZWtWM0NrWm5XVXRMZDFsQ1FrRkhSSFo2UVVKR1oxRkpSRUZhZDJSWFNuTmhWMDEzVUZGWlMwdDNXVUpDUVVkRWRucEJRa2RCVVhaRVF6RjVXbGhDZGs5dVRuQUtXak5PTUdJelNteE1NMDV3V2pOT01HSXpTbXhNVjNCNlQyNUtiRnBxY0hsYVYxcDZUREpvYkZsWFVucE1NakZvWVZjMGQyZFphMGREYVhOSFFWRlJRZ294Ym10RFFrRkpSV1YzVWpWQlNHTkJaRkZFWkZCVVFuRjRjMk5TVFcxTldraG9lVnBhZW1ORGIydHdaWFZPTkRoeVppdElhVzVMUVV4NWJuVnFaMEZCQ2tGYU9FRTJjSFppUVVGQlJVRjNRa2ROUlZGRFNVYzRiMlk1YWsxNksydFZSbTFXUW1odlIwVnRjRTVzYVRnM2VHbGtkMGxMV1RCSFREQmhiSFZKY0d3S1FXbENUWE5MZUVsdk5IRlVSWGd3YUVOSFNsQmliM2sxTnpVeVlYSkRMM2hWYUZWU2NHRlRNMWhrUzJoRFJFRkxRbWRuY1docmFrOVFVVkZFUVhkT2J3cEJSRUpzUVdwQmJtRXJaSEppVlRZNUszZHJhelZLVFZNeWRHcFZOWGxWVEhoNWVXMW5OMnhtZURkRE1sUnhiR2M0V0RoRFRqZFVlVEZUU1V4NEx6WTVDbkpDYVRRMWRVRkRUVkZFZFdkVE9WTkphalZJUWxWTWExTlRWMnhJWVhaU1dWbGhka2R6VVRSMmRuWlpiWGMxZW5vM1pIaG5kak5QT1hkbVRuUlVkRlVLV20wclRXdHRWMjVNUVVrOUNpMHRMUzB0UlU1RUlFTkZVbFJKUmtsRFFWUkZMUzB0TFMwSyJ9XX19" + } + ], + "timestampVerificationData": { "rfc3161Timestamps": [] } + }, + "dsseEnvelope": { + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoicGtnOm5wbS8lNDBzaWdzdG9yZS9jb3JlQDQuMC4xIiwiZGlnZXN0Ijp7InNoYTUxMiI6ImY2ZmU2MTQ2M2JhMzlmOTM1N2FiY2EzYjVjNTExNDgwYmM4MGI1ZGFmOTIyMmIxYmUyOWNjY2QzOWJiNzJiYWQ0ODRiOWFiNzg0ZmRlNWI5NjAyNzc2NGQxMTkwZjNjYjRkNDE2ODRkYjgzYjU1YmYzODUxMGQ1OTQxZTZhMzU5In19XSwicHJlZGljYXRlVHlwZSI6Imh0dHBzOi8vc2xzYS5kZXYvcHJvdmVuYW5jZS92MSIsInByZWRpY2F0ZSI6eyJidWlsZERlZmluaXRpb24iOnsiYnVpbGRUeXBlIjoiaHR0cHM6Ly9zbHNhLWZyYW1ld29yay5naXRodWIuaW8vZ2l0aHViLWFjdGlvbnMtYnVpbGR0eXBlcy93b3JrZmxvdy92MSIsImV4dGVybmFsUGFyYW1ldGVycyI6eyJ3b3JrZmxvdyI6eyJyZWYiOiJyZWZzL2hlYWRzL21haW4iLCJyZXBvc2l0b3J5IjoiaHR0cHM6Ly9naXRodWIuY29tL3NpZ3N0b3JlL3NpZ3N0b3JlLWpzIiwicGF0aCI6Ii5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sIn19LCJpbnRlcm5hbFBhcmFtZXRlcnMiOnsiZ2l0aHViIjp7ImV2ZW50X25hbWUiOiJwdXNoIiwicmVwb3NpdG9yeV9pZCI6IjQ5NTU3NDU1NSIsInJlcG9zaXRvcnlfb3duZXJfaWQiOiI3MTA5NjM1MyJ9fSwicmVzb2x2ZWREZXBlbmRlbmNpZXMiOlt7InVyaSI6ImdpdCtodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUvc2lnc3RvcmUtanNAcmVmcy9oZWFkcy9tYWluIiwiZGlnZXN0Ijp7ImdpdENvbW1pdCI6ImQ0MDZlYTYwYjM0MmNhMzdjZGVlY2Q3YWZlZGI5OTJjZDE4OWRiOTIifX1dfSwicnVuRGV0YWlscyI6eyJidWlsZGVyIjp7ImlkIjoiaHR0cHM6Ly9naXRodWIuY29tL2FjdGlvbnMvcnVubmVyL2dpdGh1Yi1ob3N0ZWQifSwibWV0YWRhdGEiOnsiaW52b2NhdGlvbklkIjoiaHR0cHM6Ly9naXRodWIuY29tL3NpZ3N0b3JlL3NpZ3N0b3JlLWpzL2FjdGlvbnMvcnVucy8yODIwNDY5MzA1NC9hdHRlbXB0cy8xIn19fX0=", + "payloadType": "application/vnd.in-toto+json", + "signatures": [ + { + "sig": "MEUCIQCuhQ7nHuacVqS6rnKPTMT2+j33KIMxU4Tdtl+3xUgMgwIgIztedKRmQgPXLpfTqxTo6ZUkgcfbau1tEGDYeWJb4+8=", + "keyid": "" + } + ] + } +} diff --git a/packages/registry-verification/package.json b/packages/registry-verification/package.json index 3c5d5157a8..4f365f2673 100644 --- a/packages/registry-verification/package.json +++ b/packages/registry-verification/package.json @@ -19,12 +19,17 @@ "prepublishOnly": "node --run build", "typecheck": "tsgo --noEmit", "test": "vitest run && pnpm run test:workerd", - "test:workerd": "vitest run --config vitest.workerd.config.ts", + "test:package": "node scripts/check-packed-output.mjs", + "test:workerd": "pnpm run build && pnpm run test:package && vitest run --config vitest.workerd.config.ts", "check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm --ignore-rules=no-resolution" }, "devDependencies": { "@arethetypeswrong/cli": "catalog:", "@cloudflare/vitest-pool-workers": "catalog:", + "@sigstore/bundle": "5.0.0", + "@sigstore/core": "4.0.1", + "@sigstore/protobuf-specs": "0.5.1", + "@sigstore/verify": "4.1.0", "publint": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", @@ -45,8 +50,12 @@ "directory": "packages/registry-verification" }, "homepage": "https://github.com/emdash-cms/emdash", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, "dependencies": { "@emdash-cms/plugin-types": "workspace:*", + "@emdash-cms/registry-lexicons": "workspace:*", "modern-tar": "^0.7.6" } } diff --git a/packages/registry-verification/scripts/check-packed-output.mjs b/packages/registry-verification/scripts/check-packed-output.mjs new file mode 100644 index 0000000000..fb2d251ffe --- /dev/null +++ b/packages/registry-verification/scripts/check-packed-output.mjs @@ -0,0 +1,50 @@ +import { execFileSync } from "node:child_process"; +import { createReadStream } from "node:fs"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pipeline } from "node:stream/promises"; +import { createGunzip } from "node:zlib"; + +import { unpackTar } from "modern-tar/fs"; + +const packageManagerEntrypoint = process.env.npm_execpath; +if (!packageManagerEntrypoint) { + throw new Error("Cannot run pnpm pack because npm_execpath is unavailable"); +} + +const temporaryDirectory = await mkdtemp(join(tmpdir(), "registry-verification-pack-")); + +try { + const output = execFileSync( + process.execPath, + [packageManagerEntrypoint, "pack", "--pack-destination", temporaryDirectory, "--json"], + { cwd: new URL("..", import.meta.url), encoding: "utf8" }, + ); + const { filename } = JSON.parse(output); + if (typeof filename !== "string") throw new Error("pnpm pack did not return a tarball filename"); + + const extracted = join(temporaryDirectory, "extracted"); + await pipeline(createReadStream(filename), createGunzip(), unpackTar(extracted)); + const publishedOutput = await readFile(join(extracted, "package", "dist", "index.js"), "utf8"); + + if ( + /from ["']@sigstore\//.test(publishedOutput) || + /require\(["']@sigstore\//.test(publishedOutput) + ) { + throw new Error("Packed output still imports an external Sigstore implementation"); + } + for (const requiredCode of [ + "prime256v1", + "secp384r1", + "secp521r1", + "ed25519", + "asymmetricKeyDetails", + ]) { + if (!publishedOutput.includes(requiredCode)) { + throw new Error(`Packed output is missing patched algorithm selection: ${requiredCode}`); + } + } +} finally { + await rm(temporaryDirectory, { recursive: true, force: true }); +} diff --git a/packages/registry-verification/src/errors.ts b/packages/registry-verification/src/errors.ts index 794262548f..c78bad8a36 100644 --- a/packages/registry-verification/src/errors.ts +++ b/packages/registry-verification/src/errors.ts @@ -18,6 +18,7 @@ export type VerificationErrorCode = | "HOST_REJECTED" | "INVALID_MULTIHASH" | "INVALID_URL" + | "PROVENANCE_UNVERIFIABLE" | "REDIRECT_LIMIT_EXCEEDED" | "REDIRECT_LOCATION_MISSING" | "RESOURCE_SIZE_EXCEEDED" diff --git a/packages/registry-verification/src/index.ts b/packages/registry-verification/src/index.ts index 6e94f16a58..3f3f20336f 100644 --- a/packages/registry-verification/src/index.ts +++ b/packages/registry-verification/src/index.ts @@ -14,6 +14,7 @@ export { MAX_BUNDLE_TAR_ENTRY_COUNT, } from "./bundle-limits.js"; export { validatePluginBundle } from "./bundle.js"; +export { GitHubProvenanceVerifier } from "./provenance.js"; export type { DecodedMultihash, MultihashAlgorithm } from "./checksum.js"; export type { FetchImplementation, @@ -23,3 +24,9 @@ export type { } from "./fetch.js"; export type { VerificationError, VerificationErrorCode, VerificationResult } from "./errors.js"; export type { ValidatePluginBundleOptions, ValidatedPluginBundle } from "./bundle.js"; +export type { + ProvenanceVerificationInput, + ProvenanceVerifier, + ReleaseProvenance, + VerifiedProvenance, +} from "./provenance.js"; diff --git a/packages/registry-verification/src/provenance.ts b/packages/registry-verification/src/provenance.ts new file mode 100644 index 0000000000..436969d00e --- /dev/null +++ b/packages/registry-verification/src/provenance.ts @@ -0,0 +1,407 @@ +import type { PackageReleaseExtension } from "@emdash-cms/registry-lexicons"; +import { + BUNDLE_V03_LEGACY_MEDIA_TYPE, + BUNDLE_V03_MEDIA_TYPE, + bundleFromJSON, +} from "@sigstore/bundle"; +import { TrustedRoot } from "@sigstore/protobuf-specs"; +import type { ObjectIdentifierValuePair } from "@sigstore/protobuf-specs"; +import { toSignedEntity, toTrustMaterial, Verifier } from "@sigstore/verify"; + +import { compareDigestBytes, verifyMultihash } from "./checksum.js"; +import { verificationError } from "./errors.js"; +import type { VerificationResult } from "./errors.js"; +import trustedRootJson from "./trust-roots/sigstore-public-good-v1.json"; + +const STATEMENT_TYPE = "https://in-toto.io/Statement/v1"; +const PREDICATE_TYPE = "https://slsa.dev/provenance/v1"; +const GITHUB_WORKFLOW_BUILD_TYPE = + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1"; +const GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com"; +const DSSE_PAYLOAD_TYPE = "application/vnd.in-toto+json"; +const FULCIO_OID_PREFIX = "1.3.6.1.4.1.57264.1."; +const GIT_COMMIT_RE = /^[0-9a-f]{40}$/; +const GITHUB_WORKFLOW_PATH_RE = /^\.github\/workflows\/.+\.ya?ml$/; +const TRAILING_SLASHES_RE = /\/+$/; +const DECIMAL_RE = /^[1-9][0-9]*$/; +const BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +const HEX_RE = /^[0-9a-f]+$/; +const REGEX_META_RE = /[\\^$.*+?()[\]{}|]/g; +const SUPPORTED_SUBJECT_DIGESTS = new Map([ + ["sha256", 32], + ["sha384", 48], + ["sha512", 64], +]); +const decoder = new TextDecoder("utf-8", { fatal: true }); +const trustedRoot = TrustedRoot.fromJSON(trustedRootJson); +const verifier = new Verifier(toTrustMaterial(trustedRoot), { + tlogThreshold: 1, + ctlogThreshold: 1, + timestampThreshold: 1, +}); + +export type ReleaseProvenance = PackageReleaseExtension.Provenance; + +export interface ProvenanceVerificationInput { + document: Uint8Array; + reference: ReleaseProvenance; + artifactDigest: Uint8Array; + profileRepository: string; +} + +export interface VerifiedProvenance { + predicateType: typeof PREDICATE_TYPE; + artifactDigest: Uint8Array; + sourceRepository: string; + builderId: string; +} + +export interface ProvenanceVerifier { + verify(input: ProvenanceVerificationInput): Promise>; +} + +export class GitHubProvenanceVerifier implements ProvenanceVerifier { + async verify( + input: ProvenanceVerificationInput, + ): Promise> { + try { + const snapshot = snapshotInput(input); + const checksum = await verifyMultihash(snapshot.document, snapshot.reference.checksum); + if (!checksum.success) return unverifiable(); + + const serializedBundle = parseJson(snapshot.document); + const statement = parseStatement(serializedBundle); + const expected = validateStatement(statement, snapshot); + const bundle = bundleFromJSON(serializedBundle); + const signer = verifier.verify(toSignedEntity(bundle), { + subjectAlternativeName: exactRegexPattern(snapshot.reference.builderId), + extensions: { issuer: GITHUB_OIDC_ISSUER }, + }); + validateSignerIdentity(signer.identity, expected); + + return { + success: true, + value: { + predicateType: PREDICATE_TYPE, + artifactDigest: snapshot.artifactDigest, + sourceRepository: expected.repository, + builderId: expected.builderId, + }, + }; + } catch { + return unverifiable(); + } + } +} + +function snapshotInput(input: ProvenanceVerificationInput): ProvenanceVerificationInput { + return { + document: new Uint8Array(input.document), + reference: { + builderId: input.reference.builderId, + checksum: input.reference.checksum, + predicateType: input.reference.predicateType, + sourceRepository: input.reference.sourceRepository, + url: input.reference.url, + }, + artifactDigest: new Uint8Array(input.artifactDigest), + profileRepository: input.profileRepository, + }; +} + +function exactRegexPattern(value: string): string { + return `^${value.replace(REGEX_META_RE, "\\$&")}$`; +} + +interface ExpectedIdentity { + repository: string; + builderId: string; + workflowRef: string; + commitSha: string; + repositoryId: string; + invocationId: string; +} + +interface Statement { + _type: string; + subject: unknown[]; + predicateType: string; + predicate: Record; +} + +function parseJson(document: Uint8Array): unknown { + return JSON.parse(decoder.decode(document)); +} + +function parseStatement(serializedBundle: unknown): Statement { + const bundle = requireObject(serializedBundle); + const mediaType = requireString(bundle.mediaType); + if (mediaType !== BUNDLE_V03_MEDIA_TYPE && mediaType !== BUNDLE_V03_LEGACY_MEDIA_TYPE) { + throw new Error("Unsupported Sigstore bundle format"); + } + if ("messageSignature" in bundle || !("dsseEnvelope" in bundle)) { + throw new Error("Only DSSE Sigstore bundles are supported"); + } + const envelope = requireObject(bundle.dsseEnvelope); + if (requireString(envelope.payloadType) !== DSSE_PAYLOAD_TYPE) { + throw new Error("Unsupported DSSE payload type"); + } + const statement = requireObject( + JSON.parse(decoder.decode(decodeBase64(requireString(envelope.payload)))), + ); + const statementType = requireString(statement._type); + const predicateType = requireString(statement.predicateType); + if ( + statementType !== STATEMENT_TYPE || + predicateType !== PREDICATE_TYPE || + !Array.isArray(statement.subject) + ) { + throw new Error("Unsupported provenance statement"); + } + return { + _type: statementType, + subject: statement.subject, + predicateType, + predicate: requireObject(statement.predicate), + }; +} + +function validateStatement( + statement: Statement, + input: ProvenanceVerificationInput, +): ExpectedIdentity { + if (input.reference.predicateType !== PREDICATE_TYPE) { + throw new Error("Provenance predicate reference mismatch"); + } + validateArtifactSubject(statement.subject, input.artifactDigest); + + const profileRepository = canonicaliseRepository(input.profileRepository); + const referenceRepository = canonicaliseRepository(input.reference.sourceRepository); + const buildDefinition = requireObject(statement.predicate.buildDefinition); + if (requireString(buildDefinition.buildType) !== GITHUB_WORKFLOW_BUILD_TYPE) { + throw new Error("Unsupported SLSA build type"); + } + const workflow = requireObject(requireObject(buildDefinition.externalParameters).workflow); + const attestedRepository = canonicaliseRepository(requireString(workflow.repository)); + if (attestedRepository !== referenceRepository || referenceRepository !== profileRepository) { + throw new Error("Repository identity mismatch"); + } + + const workflowPath = requireString(workflow.path); + const workflowRef = requireString(workflow.ref); + if (!GITHUB_WORKFLOW_PATH_RE.test(workflowPath)) { + throw new Error("Unsupported GitHub workflow path"); + } + if (!workflowRef.startsWith("refs/")) throw new Error("Invalid GitHub workflow ref"); + const builderId = `${attestedRepository}/${workflowPath}@${workflowRef}`; + if (builderId !== input.reference.builderId) { + throw new Error("Workflow identity mismatch"); + } + + const github = requireObject(requireObject(buildDefinition.internalParameters).github); + const repositoryId = requireDecimalString(github.repository_id); + const runDetails = requireObject(statement.predicate.runDetails); + const invocationId = requireHttpsUrl( + requireString(requireObject(runDetails.metadata).invocationId), + ); + const slsaBuilderId = requireString(requireObject(runDetails.builder).id); + if (slsaBuilderId !== "https://github.com/actions/runner/github-hosted") { + throw new Error("Unsupported GitHub Actions runner class"); + } + + const dependencies = buildDefinition.resolvedDependencies; + if (!Array.isArray(dependencies)) throw new Error("Missing resolved dependency"); + const expectedUri = `git+${attestedRepository}@${workflowRef}`; + const matching = dependencies.filter( + (dependency) => requireString(requireObject(dependency).uri) === expectedUri, + ); + if (matching.length !== 1) throw new Error("Ambiguous resolved dependency"); + const commitSha = requireString(requireObject(requireObject(matching[0]).digest).gitCommit); + if (!GIT_COMMIT_RE.test(commitSha)) throw new Error("Invalid Git commit digest"); + + return { + repository: attestedRepository, + builderId, + workflowRef, + commitSha, + repositoryId, + invocationId, + }; +} + +function validateArtifactSubject(subjects: unknown[], artifactDigest: Uint8Array): void { + let matched = false; + for (const value of subjects) { + const digest = requireObject(requireObject(value).digest); + for (const [algorithm, expectedLength] of SUPPORTED_SUBJECT_DIGESTS) { + if (!(algorithm in digest)) continue; + const bytes = decodeHex(requireString(digest[algorithm])); + if (bytes.byteLength !== expectedLength) throw new Error("Invalid subject digest length"); + matched ||= compareDigestBytes(bytes, artifactDigest); + } + } + if (!matched) throw new Error("Artifact digest mismatch"); +} + +function validateSignerIdentity( + identity: + | { + subjectAlternativeName?: string; + extensions?: { issuer?: string }; + oids?: ObjectIdentifierValuePair[]; + } + | undefined, + expected: ExpectedIdentity, +): void { + if ( + identity?.subjectAlternativeName !== expected.builderId || + identity.extensions?.issuer !== GITHUB_OIDC_ISSUER + ) { + throw new Error("Certificate identity mismatch"); + } + const oids = identity.oids ?? []; + const issuer = readRequiredOid(oids, 8); + const buildSignerUri = readRequiredOid(oids, 9); + const buildSignerDigest = readRequiredOid(oids, 10); + const sourceRepository = canonicaliseRepository(readRequiredOid(oids, 12)); + const sourceDigest = readRequiredOid(oids, 13); + const sourceRef = readRequiredOid(oids, 14); + const repositoryId = readRequiredOid(oids, 15); + const buildConfigUri = readRequiredOid(oids, 18); + const buildConfigDigest = readRequiredOid(oids, 19); + const invocationId = readRequiredOid(oids, 21); + + if ( + issuer !== GITHUB_OIDC_ISSUER || + buildSignerUri !== expected.builderId || + buildConfigUri !== expected.builderId || + buildSignerDigest !== expected.commitSha || + sourceDigest !== expected.commitSha || + buildConfigDigest !== expected.commitSha || + sourceRepository !== expected.repository || + sourceRef !== expected.workflowRef || + repositoryId !== expected.repositoryId || + invocationId !== expected.invocationId + ) { + throw new Error("Certificate and predicate identity mismatch"); + } +} + +function readRequiredOid(oids: ObjectIdentifierValuePair[], suffix: number): string { + const expectedOid = `${FULCIO_OID_PREFIX}${suffix}`; + const matches = oids.filter((pair) => pair.oid?.id.join(".") === expectedOid); + if (matches.length !== 1) throw new Error("Missing or duplicate Fulcio identity OID"); + const value = matches[0]?.value; + if (!value) throw new Error("Missing Fulcio identity OID value"); + return decodeDerUtf8String(value); +} + +function decodeDerUtf8String(value: Uint8Array): string { + if (value[0] !== 0x0c || value.byteLength < 2) throw new Error("Malformed DER UTF8String"); + const firstLength = value[1]; + if (firstLength === undefined) throw new Error("Malformed DER UTF8String"); + let offset = 2; + let length: number; + if (firstLength < 0x80) { + length = firstLength; + } else { + const lengthBytes = firstLength & 0x7f; + if (lengthBytes === 0 || lengthBytes > 2 || value.byteLength < 2 + lengthBytes) { + throw new Error("Malformed DER UTF8String length"); + } + length = 0; + for (let index = 0; index < lengthBytes; index += 1) { + length = length * 256 + (value[offset + index] ?? 0); + } + if (length < 0x80 || (lengthBytes === 2 && length < 0x100)) { + throw new Error("Non-canonical DER UTF8String length"); + } + offset += lengthBytes; + } + if (length === 0 || offset + length !== value.byteLength) { + throw new Error("Malformed DER UTF8String value"); + } + return decoder.decode(value.subarray(offset)); +} + +function canonicaliseRepository(value: string): string { + const url = new URL(value); + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.search || + url.hash || + url.port + ) { + throw new Error("Invalid repository URL"); + } + let path = url.pathname; + if (path !== "/") { + path = path.replace(TRAILING_SLASHES_RE, ""); + if (path === "") path = "/"; + } + return `https://${url.hostname.toLowerCase()}${path}`; +} + +function requireHttpsUrl(value: string): string { + const url = new URL(value); + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.hash || + url.port || + url.toString() !== value + ) { + throw new Error("Invalid HTTPS URL"); + } + return value; +} + +function requireDecimalString(value: unknown): string { + const result = requireString(value); + if (!DECIMAL_RE.test(result)) throw new Error("Invalid decimal identity"); + return result; +} + +function requireObject(value: unknown): Record { + if (!isRecord(value)) throw new Error("Expected an object"); + return value; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function requireString(value: unknown): string { + if (typeof value !== "string" || value.length === 0) throw new Error("Expected a string"); + return value; +} + +function decodeBase64(value: string): Uint8Array { + if (!BASE64_RE.test(value)) { + throw new Error("Invalid base64"); + } + return Uint8Array.from(atob(value), (character) => character.charCodeAt(0)); +} + +function decodeHex(value: string): Uint8Array { + if (!HEX_RE.test(value) || value.length % 2 !== 0) { + throw new Error("Invalid hexadecimal digest"); + } + return Uint8Array.from(value.match(/.{2}/g) ?? [], (byte) => Number.parseInt(byte, 16)); +} + +function unverifiable(): VerificationResult { + return verificationError( + "PROVENANCE_UNVERIFIABLE", + "The supplied provenance could not be verified.", + ); +} + +export const provenanceTestInternals = { + decodeDerUtf8String, + exactRegexPattern, + readRequiredOid, +}; diff --git a/packages/registry-verification/src/trust-roots/sigstore-public-good-v1.json b/packages/registry-verification/src/trust-roots/sigstore-public-good-v1.json new file mode 100644 index 0000000000..e64f1611a5 --- /dev/null +++ b/packages/registry-verification/src/trust-roots/sigstore-public-good-v1.json @@ -0,0 +1,126 @@ +{ + "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1", + "tlogs": [ + { + "baseUrl": "https://rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-01-12T11:53:27Z" + } + }, + "logId": { + "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" + } + }, + { + "baseUrl": "https://log2025-1.rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MCowBQYDK2VwAyEAt8rlp1knGwjfbcXAYPYAkn0XiLz1x8O4t0YkEhie244=", + "keyDetails": "PKIX_ED25519", + "validFor": { + "start": "2025-09-23T00:00:00Z" + } + }, + "logId": { + "keyId": "zxGZFVvd0FEmjR8WrFwMdcAJ9vtaY/QXf44Y1wUeP6A=" + } + } + ], + "certificateAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ==" + } + ] + }, + "validFor": { + "start": "2021-03-07T03:20:29Z", + "end": "2022-12-31T23:59:59.999Z" + } + }, + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow=" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ" + } + ] + }, + "validFor": { + "start": "2022-04-13T20:06:15Z" + } + } + ], + "ctlogs": [ + { + "baseUrl": "https://ctfe.sigstore.dev/test", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-03-14T00:00:00Z", + "end": "2022-10-31T23:59:59.999Z" + } + }, + "logId": { + "keyId": "CGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3I=" + } + }, + { + "baseUrl": "https://ctfe.sigstore.dev/2022", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2022-10-20T00:00:00Z" + } + }, + "logId": { + "keyId": "3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4=" + } + } + ], + "timestampAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore-tsa-selfsigned" + }, + "uri": "https://timestamp.sigstore.dev/api/v1/timestamp", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICEDCCAZagAwIBAgIUOhNULwyQYe68wUMvy4qOiyojiwwwCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMC4xFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEVMBMGA1UEAxMMc2lnc3RvcmUtdHNhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE4ra2Z8hKNig2T9kFjCAToGG30jky+WQv3BzL+mKvh1SKNR/UwuwsfNCg4sryoYAd8E6isovVA3M4aoNdm9QDi50Z8nTEyvqgfDPtTIwXItfiW/AFf1V7uwkbkAoj0xxco2owaDAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0OBBYEFIn9eUOHz9BlRsMCRscsc1t9tOsDMB8GA1UdIwQYMBaAFJjsAe9/u1H/1JUeb4qImFMHic6/MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMAoGCCqGSM49BAMDA2gAMGUCMDtpsV/6KaO0qyF/UMsX2aSUXKQFdoGTptQGc0ftq1csulHPGG6dsmyMNd3JB+G3EQIxAOajvBcjpJmKb4Nv+2Taoj8Uc5+b6ih6FXCCKraSqupe07zqswMcXJTe1cExvHvvlw==" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUV7f0GLDOoEzIh8LXSW80OJiUp14wCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQUQNtfRT/ou3YATa6wB/kKTe70cfJwyRIBovMnt8RcJph/COE82uyS6FmppLLL1VBPGcPfpQPYJNXzWwi8icwhKQ6W/Qe2h3oebBb2FHpwNJDqo+TMaC/tdfkv/ElJB72jRTBDMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSY7AHvf7tR/9SVHm+KiJhTB4nOvzAKBggqhkjOPQQDAwNpADBmAjEAwGEGrfGZR1cen1R8/DTVMI943LssZmJRtDp/i7SfGHmGRP6gRbuj9vOK3b67Z0QQAjEAuT2H673LQEaHTcyQSZrkp4mX7WwkmF+sVbkYY5mXN+RMH13KUEHHOqASaemYWK/E" + } + ] + }, + "validFor": { + "start": "2025-07-04T00:00:00Z" + } + } + ] +} diff --git a/packages/registry-verification/tests/check-packed-output.test.ts b/packages/registry-verification/tests/check-packed-output.test.ts new file mode 100644 index 0000000000..5992c8d34e --- /dev/null +++ b/packages/registry-verification/tests/check-packed-output.test.ts @@ -0,0 +1,18 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { expect, it } from "vitest"; + +it("fails closed when the active package manager entrypoint is unavailable", () => { + const environment = { ...process.env }; + delete environment.npm_execpath; + + const result = spawnSync( + process.execPath, + [fileURLToPath(new URL("../scripts/check-packed-output.mjs", import.meta.url))], + { encoding: "utf8", env: environment }, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("npm_execpath is unavailable"); +}); diff --git a/packages/registry-verification/tests/provenance-contract.ts b/packages/registry-verification/tests/provenance-contract.ts new file mode 100644 index 0000000000..22a3935ccd --- /dev/null +++ b/packages/registry-verification/tests/provenance-contract.ts @@ -0,0 +1,390 @@ +import type { TransparencyLogEntry } from "@sigstore/bundle"; +import { crypto as sigstoreCrypto } from "@sigstore/core"; +import type { TLogAuthority } from "@sigstore/verify"; +import { verifyCheckpoint } from "@sigstore/verify/dist/tlog/checkpoint.js"; +import { describe, expect, it } from "vitest"; + +import bundleFixture from "../fixtures/provenance/sigstore-core-4.0.1-slsa.bundle.json"; +import { computeMultihash, GitHubProvenanceVerifier } from "../src/index.js"; +import { provenanceTestInternals } from "../src/provenance.js"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const artifactDigest = decodeHex( + "f6fe61463ba39f9357abca3b5c511480bc80b5daf9222b1be29cccd39bb72bad484b9ab784fde5b96027764d1190f3cb4d41684db83b55bf38510d5941e6a359", +); +const sourceRepository = "https://github.com/sigstore/sigstore-js"; +const builderId = + "https://github.com/sigstore/sigstore-js/.github/workflows/release.yml@refs/heads/main"; +const predicateType = "https://slsa.dev/provenance/v1"; + +const algorithmVectors = [ + { + name: "P-256 DSSE and Rekor SET", + publicKey: + "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEaR9S4pCcthbA6Wn4xE6l7QWvsHZ8\nrlTJdRNUfTXZI0NQhLxEO+xu7nvx8AbdMZwB/tgE+nQDlihGULqaH41uKg==\n-----END PUBLIC KEY-----\n", + signature: + "MEUCIQCT7gKkdVKUso50JKlPnNXAzaLCfAfqrMEROImh/07JcwIgf0a2WyUWfDUXuKMkypX4LIcJccjTlaaCn2P0wjws41c=", + }, + { + name: "P-384", + publicKey: + "-----BEGIN PUBLIC KEY-----\nMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVY4tPR0XC5w8RzdsUXihLEk+G9buag+E\npQzu18CT6Z/h+dXCwARAQ2WTYm9/lvRJ1cDEU7/iJYnGbEAxOL71aLMDO9aChRxr\nGJHBNkEaRQJXZBfAOwKbIWcT4qNa3KCK\n-----END PUBLIC KEY-----\n", + signature: + "MGYCMQCt19J0Sk849EJCBDz9pp5/WrEvv3ctdBEgZxqUX6qWA9Tbs8KqMb36fQ2ivavN2TgCMQCsEdtmgDk2xbw0QKusQOLuEe2qygOjUg3gNmUYNl5Ff7+sZZzMzIzHTWRzjR0OApE=", + }, + { + name: "P-521", + publicKey: + "-----BEGIN PUBLIC KEY-----\nMIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQAZaAP050ZJWaG/VPG6OZYyqjHEwTo\n9zWNlKJN5SWdkZ1V0xRsA/gZ3wkWlopuLqhkq8XdYg+8xRkzHv7LUObsHLoAB+GJ\n0ICOgeIzTGxuxLKSLeFgon3ZS5fogW2kH9Fw4tOkFWbjKr1KrNKgXpIKHw6p+aFw\nJSsauROC/ObfFcaaADw=\n-----END PUBLIC KEY-----\n", + signature: + "MIGIAkIAk3w/mjUAG4CoUGgSoyhnJOmpVaKjVbzfVl+7MpqQd0yOBmgBWsx6jlPRBLnFJCI4QPNMniM8H3BtDuM1OKf6tYMCQgFOgfr3fls3dgxYrbuaZ0rAvj6CR0UDcDhIf1shQZzOZXoy7GJxooOuAkr/EKNr7KRXKnPh/uAFTT+CoRASGb8wbA==", + }, + { + name: "Ed25519 checkpoint", + publicKey: + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAqxa7LJtwfaKrveTZrXxQCqmOtKk0LbRqenSNyqzdy30=\n-----END PUBLIC KEY-----\n", + signature: + "pyTySg8eOsb/IGiUbJfMrCUcL3i1/NOuOSEoDR5Nd/fRXIWE1fEP7exliZXWLak4Lx2Ukec/u5KdTvgcsblOAg==", + }, +] as const; +const unsupportedRsaVector = { + publicKey: + "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0faCSJXV48bMihPd44jq\n1mvYTphLf2k04RXNPn777/sF50HddbbXjW4bT3zSawx232ohILxnAZ4BL39QLB67\n86/PfP7kKaMtKsxWDavUc5eIrLlLTWJIqG8zJEcY1/Cb3jHgupunRdyA8ju3LZ3W\n+KiAkJhTgKJZ7Ze3Yj+wl5VOiHD9uv5sA9DLVMqhQ5tfDntRyeVxJNpU3JDQFBX1\nMa6Dh8IUfVLXnGRt+y+IvsAjD3MVzwHuRgE6UGCmvXgZxJmNQRst8Bo+4GH5ilU5\nUUiqGunG42s/OR0DFtuSvwcCDHxuCE5ynPXY1+WVFTv7DYufDjdA16uo8wDzR8MV\n2QIDAQAB\n-----END PUBLIC KEY-----\n", + signature: + "rvbTsc/ZR5BfCU6L4do/7rqyrdbXRjuw1XJRBkqqg9tB7h2thcwH55pxiOexqOiw+8ng/sEnn+0L6eycXDpIUqTVl+4ZAMOQCxUduwiU8pdgyddxig2++5zilqMMK3C95c2E9Bh6+nNvFo5dbEqAY8+TIs/mCQR0EZJVjrBjSMWBlDWuSBsyna03NfGq5crI57l4/DKqn9NccbSit6mt+B72X57DRPC5pHmJ5qyBKjHPxQMzBuoCHmgvyXJ2zB9qumap+Z4h3p2c2oK7uIXkTgBLDr5KZp6Z2kLCRv/trgkFTxFsx9uSgUOK+up6GN+T3bOKFhlm2z1AtFxxM3SxYQ==", +} as const; +const ed25519Checkpoint = { + publicKey: + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAITobQsIt45oxsOpPAePZrZZQ//881SN3vfdJ7Od7fVU=\n-----END PUBLIC KEY-----\n", + logId: "5KlPCSRCu7a+d4+UHIX7+Kc8jducDUnjqe3BJ70j1N0=", + envelope: + "ed25519.example\n42\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n\n— ed25519.example 5KlPCQhhuoZe7IjLHVcPymostUlwREMmssMQLkDxW816g948TgdfgkgzeN98btzfuXGPVXHGMdCkBvumO/VdXx7gyQ4=\n", +} as const; + +export function provenanceContract(): void { + describe("GitHub Sigstore SLSA v1 provenance", () => { + it("verifies the reviewed public Sigstore bundle all-or-nothing", async () => { + const document = fixtureDocument(); + const result = await verify(document); + + expect(result).toEqual({ + success: true, + value: { + artifactDigest, + builderId, + predicateType, + sourceRepository, + }, + }); + }); + + it("snapshots mutable inputs before checksum verification yields", async () => { + const document = fixtureDocument(); + const digest = new Uint8Array(artifactDigest); + const checksum = await computeMultihash(document); + if (!checksum.success) throw new Error("Test fixture checksum could not be computed"); + const reference = { + builderId, + checksum: checksum.value, + predicateType, + sourceRepository, + url: "https://registry.npmjs.org/-/npm/v1/attestations/@sigstore%2fcore@4.0.1", + }; + const resultPromise = new GitHubProvenanceVerifier().verify({ + document, + reference, + artifactDigest: digest, + profileRepository: sourceRepository, + }); + + document.fill(0); + digest.fill(0); + reference.builderId = "https://example.test/substituted"; + + expect(await resultPromise).toMatchObject({ + success: true, + value: { builderId, artifactDigest }, + }); + }); + + it("rejects checksum substitution before bundle verification", async () => { + const result = await verify(fixtureDocument(), { + checksum: "bciqcz4snxjp3biyoe3udwkwfxhrj4gywdzob7j2clzzqim3csofzqja", + }); + expectUnverifiable(result); + }); + + it("rejects a bad DSSE signature", async () => { + const document = mutatedDocument((bundle) => { + const signature = bundle.dsseEnvelope.signatures[0]; + if (signature) signature.sig = replaceBase64Byte(signature.sig); + }); + expectUnverifiable(await verify(document)); + }); + + it("rejects a tampered signed payload", async () => { + const document = mutatedStatement((statement) => { + statement.subject[0].name = "pkg:npm/substituted@1.0.0"; + }); + expectUnverifiable(await verify(document)); + }); + + it("rejects an artifact digest absent from every subject", async () => { + expectUnverifiable(await verify(fixtureDocument(), {}, new Uint8Array(64))); + }); + + it.each([ + [ + "release reference", + { sourceRepository: "https://github.com/example/other" }, + sourceRepository, + ], + ["signed profile", {}, "https://github.com/example/other"], + ])("rejects a repository mismatch in the %s", async (_name, reference, profile) => { + expectUnverifiable(await verify(fixtureDocument(), reference, artifactDigest, profile)); + }); + + it("rejects an attested repository mismatch", async () => { + const document = mutatedStatement((statement) => { + statement.predicate.buildDefinition.externalParameters.workflow.repository = + "https://github.com/example/other"; + }); + expectUnverifiable(await verify(document)); + }); + + it("rejects a workflow builder mismatch", async () => { + expectUnverifiable( + await verify(fixtureDocument(), { + builderId: + "https://github.com/sigstore/sigstore-js/.github/workflows/other.yml@refs/heads/main", + }), + ); + }); + + it("rejects an attested workflow mismatch", async () => { + const document = mutatedStatement((statement) => { + statement.predicate.buildDefinition.externalParameters.workflow.path = + ".github/workflows/other.yml"; + }); + expectUnverifiable(await verify(document)); + }); + + it("rejects an unknown predicate", async () => { + const document = mutatedStatement((statement) => { + statement.predicateType = "https://example.test/provenance/v2"; + }); + expectUnverifiable(await verify(document)); + }); + + it("rejects an unsupported bundle format", async () => { + const document = mutatedDocument((bundle) => { + bundle.mediaType = "application/vnd.dev.sigstore.bundle.v0.4+json"; + }); + expectUnverifiable(await verify(document)); + }); + + it("rejects untrusted certificate material", async () => { + const document = mutatedDocument((bundle) => { + bundle.verificationMaterial.certificate.rawBytes = replaceLastBase64Byte( + bundle.verificationMaterial.certificate.rawBytes, + ); + }); + expectUnverifiable(await verify(document)); + }); + + it("rejects a certificate outside its validity period", async () => { + const document = mutatedDocument((bundle) => { + bundle.verificationMaterial.tlogEntries[0].integratedTime = "1893456000"; + }); + expectUnverifiable(await verify(document)); + }); + }); + + describe("@sigstore/core omitted verification algorithm mapping", () => { + it.each(algorithmVectors)("verifies $name fail-closed from KeyObject details", (vector) => { + const key = sigstoreCrypto.createPublicKey(vector.publicKey); + expect( + sigstoreCrypto.verify( + Buffer.from("sigstore algorithm mapping regression"), + key, + Buffer.from(vector.signature, "base64"), + ), + ).toBe(true); + }); + + it("rejects an unsupported RSA key when the algorithm is omitted", () => { + const key = sigstoreCrypto.createPublicKey(unsupportedRsaVector.publicKey); + expect( + sigstoreCrypto.verify( + Buffer.from("sigstore algorithm mapping regression"), + key, + Buffer.from(unsupportedRsaVector.signature, "base64"), + ), + ).toBe(false); + }); + + it("verifies an Ed25519 signed-checkpoint path with an omitted algorithm", () => { + const logId = Buffer.from(ed25519Checkpoint.logId, "base64"); + const tlog: TLogAuthority = { + logID: logId, + baseURL: "https://ed25519.example", + publicKey: sigstoreCrypto.createPublicKey(ed25519Checkpoint.publicKey), + validFor: { start: new Date(0), end: new Date("9999-12-31T23:59:59Z") }, + }; + const entry = { + inclusionProof: { checkpoint: { envelope: ed25519Checkpoint.envelope } }, + } as unknown as TransparencyLogEntry; + + expect(verifyCheckpoint(entry, [tlog])).toMatchObject({ + origin: "ed25519.example", + logSize: 42n, + }); + }); + }); + + describe("Fulcio identity DER decoding", () => { + it("decodes one canonical UTF8String value", () => { + expect(provenanceTestInternals.decodeDerUtf8String(derUtf8("refs/heads/main"))).toBe( + "refs/heads/main", + ); + }); + + it.each([ + Uint8Array.of(0x16, 1, 0x61), + Uint8Array.of(0x0c, 0x80), + Uint8Array.of(0x0c, 0x81, 1, 0x61), + Uint8Array.of(0x0c, 2, 0x61), + Uint8Array.of(0x0c, 1, 0xff), + ])("rejects malformed or non-canonical DER %#", (value) => { + expect(() => provenanceTestInternals.decodeDerUtf8String(value)).toThrow(); + }); + + it("rejects missing and duplicate authoritative OIDs", () => { + const oid = { + oid: { id: [1, 3, 6, 1, 4, 1, 57264, 1, 18] }, + value: Buffer.from(derUtf8(builderId)), + }; + expect(() => provenanceTestInternals.readRequiredOid([], 18)).toThrow(); + expect(() => provenanceTestInternals.readRequiredOid([oid, oid], 18)).toThrow(); + }); + }); + + describe("Sigstore SAN policy", () => { + it.each([ + "https://github.com/example/repo/.github/workflows/release.yml@refs/heads/release+1", + "https://github.com/example/repo/.github/workflows/release.yml@refs/heads/release.*", + "https://github.com/example/repo/.github/workflows/release.yml@refs/heads/release[1", + ])("constructs an anchored literal policy for %s", (identity) => { + const pattern = provenanceTestInternals.exactRegexPattern(identity); + const expression = new RegExp(pattern); + + expect(expression.test(identity)).toBe(true); + expect(expression.test(`${identity}-substituted`)).toBe(false); + expect(expression.test(identity.replace("example", "attacker"))).toBe(false); + }); + }); +} + +type FixtureBundle = typeof bundleFixture; +type FixtureStatement = { + _type: string; + subject: { name: string; digest: Record }[]; + predicateType: string; + predicate: { + buildDefinition: { + externalParameters: { + workflow: { repository: string; path: string; ref: string }; + }; + }; + }; +}; + +function fixtureDocument(): Uint8Array { + return encoder.encode(JSON.stringify(bundleFixture)); +} + +function mutatedDocument(mutate: (bundle: FixtureBundle) => void): Uint8Array { + const bundle = structuredClone(bundleFixture); + mutate(bundle); + return encoder.encode(JSON.stringify(bundle)); +} + +function mutatedStatement(mutate: (statement: FixtureStatement) => void): Uint8Array { + return mutatedDocument((bundle) => { + const statement = JSON.parse(decoder.decode(decodeBase64(bundle.dsseEnvelope.payload))); + mutate(statement); + bundle.dsseEnvelope.payload = encodeBase64(encoder.encode(JSON.stringify(statement))); + }); +} + +async function verify( + document: Uint8Array, + referenceOverrides: Partial<{ + builderId: string; + checksum: string; + predicateType: string; + sourceRepository: string; + url: string; + }> = {}, + digest = artifactDigest, + profileRepository = sourceRepository, +) { + const checksum = await computeMultihash(document); + if (!checksum.success) throw new Error("Test fixture checksum could not be computed"); + return new GitHubProvenanceVerifier().verify({ + document, + reference: { + builderId, + checksum: checksum.value, + predicateType, + sourceRepository, + url: "https://registry.npmjs.org/-/npm/v1/attestations/@sigstore%2fcore@4.0.1", + ...referenceOverrides, + }, + artifactDigest: digest, + profileRepository, + }); +} + +function expectUnverifiable(result: Awaited>): void { + expect(result).toEqual({ + success: false, + error: { + code: "PROVENANCE_UNVERIFIABLE", + message: "The supplied provenance could not be verified.", + }, + }); +} + +function decodeHex(value: string): Uint8Array { + if (!/^[0-9a-f]+$/.test(value) || value.length % 2 !== 0) throw new Error("Invalid hex fixture"); + return Uint8Array.from(value.match(/.{2}/g) ?? [], (byte) => Number.parseInt(byte, 16)); +} + +function decodeBase64(value: string): Uint8Array { + return Uint8Array.from(atob(value), (character) => character.charCodeAt(0)); +} + +function encodeBase64(value: Uint8Array): string { + return btoa(String.fromCharCode(...value)); +} + +function replaceBase64Byte(value: string): string { + const bytes = decodeBase64(value); + bytes[0] = (bytes[0] ?? 0) ^ 1; + return encodeBase64(bytes); +} + +function replaceLastBase64Byte(value: string): string { + const bytes = decodeBase64(value); + const index = bytes.length - 1; + bytes[index] = (bytes[index] ?? 0) ^ 1; + return encodeBase64(bytes); +} + +function derUtf8(value: string): Uint8Array { + const bytes = encoder.encode(value); + if (bytes.byteLength >= 0x80) throw new Error("Test DER helper only supports short values"); + return Uint8Array.of(0x0c, bytes.byteLength, ...bytes); +} diff --git a/packages/registry-verification/tests/provenance.test.ts b/packages/registry-verification/tests/provenance.test.ts new file mode 100644 index 0000000000..a6b78d231a --- /dev/null +++ b/packages/registry-verification/tests/provenance.test.ts @@ -0,0 +1,3 @@ +import { provenanceContract } from "./provenance-contract.js"; + +provenanceContract(); diff --git a/packages/registry-verification/tests/workerd/packed-output.test.ts b/packages/registry-verification/tests/workerd/packed-output.test.ts new file mode 100644 index 0000000000..830af27ca4 --- /dev/null +++ b/packages/registry-verification/tests/workerd/packed-output.test.ts @@ -0,0 +1,33 @@ +import { expect, it } from "vitest"; + +import { computeMultihash, GitHubProvenanceVerifier } from "../../dist/index.js"; +import bundleFixture from "../../fixtures/provenance/sigstore-core-4.0.1-slsa.bundle.json"; + +const encoder = new TextEncoder(); + +it("executes the published verifier bundle in workerd", async () => { + const document = encoder.encode(JSON.stringify(bundleFixture)); + const checksum = await computeMultihash(document); + if (!checksum.success) throw new Error("Fixture checksum failed"); + + const result = await new GitHubProvenanceVerifier().verify({ + document, + reference: { + builderId: + "https://github.com/sigstore/sigstore-js/.github/workflows/release.yml@refs/heads/main", + checksum: checksum.value, + predicateType: "https://slsa.dev/provenance/v1", + sourceRepository: "https://github.com/sigstore/sigstore-js", + url: "https://registry.npmjs.org/-/npm/v1/attestations/@sigstore%2fcore@4.0.1", + }, + artifactDigest: Uint8Array.from( + "f6fe61463ba39f9357abca3b5c511480bc80b5daf9222b1be29cccd39bb72bad484b9ab784fde5b96027764d1190f3cb4d41684db83b55bf38510d5941e6a359".match( + /.{2}/g, + ) ?? [], + (byte) => Number.parseInt(byte, 16), + ), + profileRepository: "https://github.com/sigstore/sigstore-js", + }); + + expect(result).toMatchObject({ success: true }); +}); diff --git a/packages/registry-verification/tests/workerd/provenance.test.ts b/packages/registry-verification/tests/workerd/provenance.test.ts new file mode 100644 index 0000000000..137eb6fa27 --- /dev/null +++ b/packages/registry-verification/tests/workerd/provenance.test.ts @@ -0,0 +1,3 @@ +import { provenanceContract } from "../provenance-contract.js"; + +provenanceContract(); diff --git a/packages/registry-verification/tsdown.config.ts b/packages/registry-verification/tsdown.config.ts index 6df95bede2..8b3617b807 100644 --- a/packages/registry-verification/tsdown.config.ts +++ b/packages/registry-verification/tsdown.config.ts @@ -3,8 +3,13 @@ import { defineConfig } from "tsdown"; export default defineConfig({ entry: ["src/index.ts"], format: ["esm"], + outExtensions: () => ({ js: ".js" }), dts: true, clean: true, - platform: "neutral", + platform: "node", target: "es2024", + // Sigstore is bundled so the published workerd path carries our pinned + // @sigstore/core algorithm-selection fix instead of resolving a pristine copy. + inlineOnly: false, + external: ["@emdash-cms/plugin-types", "modern-tar"], }); diff --git a/packages/registry-verification/wrangler.jsonc b/packages/registry-verification/wrangler.jsonc index 87766b2710..42d9142fce 100644 --- a/packages/registry-verification/wrangler.jsonc +++ b/packages/registry-verification/wrangler.jsonc @@ -3,4 +3,5 @@ "name": "emdash-registry-verification-test", "main": "./tests/workerd/worker.ts", "compatibility_date": "2026-05-14", + "compatibility_flags": ["nodejs_compat"], } diff --git a/patches/@sigstore__core@4.0.1.patch b/patches/@sigstore__core@4.0.1.patch new file mode 100644 index 0000000000..bf35851ed9 --- /dev/null +++ b/patches/@sigstore__core@4.0.1.patch @@ -0,0 +1,43 @@ +diff --git a/dist/crypto.js b/dist/crypto.js +index d972696..dad0c76 100644 +--- a/dist/crypto.js ++++ b/dist/crypto.js +@@ -47,10 +47,38 @@ function digest(algorithm, ...data) { + } + return hash.digest(); + } ++function algorithmForKey(key) { ++ if (!(key instanceof crypto_1.default.KeyObject) || key.type !== 'public') { ++ return false; ++ } ++ switch (key.asymmetricKeyType) { ++ case 'ec': ++ switch (key.asymmetricKeyDetails?.namedCurve) { ++ case 'prime256v1': ++ return 'sha256'; ++ case 'secp384r1': ++ return 'sha384'; ++ case 'secp521r1': ++ return 'sha512'; ++ default: ++ return false; ++ } ++ case 'ed25519': ++ return undefined; ++ default: ++ return false; ++ } ++} + function verify(data, key, signature, algorithm) { + // The try/catch is to work around an issue in Node 14.x where verify throws + // an error in some scenarios if the signature is invalid. + try { ++ if (algorithm === undefined) { ++ algorithm = algorithmForKey(key); ++ if (algorithm === false) { ++ return false; ++ } ++ } + return crypto_1.default.verify(algorithm, data, key, signature); + } + catch (e) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ee2676f77..8a05264660 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,9 @@ catalogs: overrides: '@ungap/structured-clone': ^1.3.1 +patchedDependencies: + '@sigstore/core@4.0.1': 2900ec704c3abfc6962eb2edecaa946b783a97e13084c4c30b1be0df5b30ae4d + importers: .: @@ -2247,6 +2250,9 @@ importers: '@emdash-cms/plugin-types': specifier: workspace:* version: link:../plugin-types + '@emdash-cms/registry-lexicons': + specifier: workspace:* + version: link:../registry-lexicons modern-tar: specifier: ^0.7.6 version: 0.7.6 @@ -2257,6 +2263,18 @@ 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@25.9.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))) + '@sigstore/bundle': + specifier: 5.0.0 + version: 5.0.0 + '@sigstore/core': + specifier: 4.0.1 + version: 4.0.1(patch_hash=2900ec704c3abfc6962eb2edecaa946b783a97e13084c4c30b1be0df5b30ae4d) + '@sigstore/protobuf-specs': + specifier: 0.5.1 + version: 0.5.1 + '@sigstore/verify': + specifier: 4.1.0 + version: 4.1.0 publint: specifier: 'catalog:' version: 0.3.17 @@ -6284,6 +6302,22 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sigstore/bundle@5.0.0': + resolution: {integrity: sha512-wefjygudENbzbQMks1t5u34EP0fFoD0XvaEP7DOUP/sXKvogzEJYFw5E6pegGyp3onGWzVEYKVa3bNZWyTYX+A==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@sigstore/core@4.0.1': + resolution: {integrity: sha512-9v5hRjujn5NXq8o7XFEUgLyAtdr5Iisb4pzM05u3K61IS5q3hP3luWAndk0RkPPLTUFoTbg7Vb84UQ1ZQeajWQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@sigstore/protobuf-specs@0.5.1': + resolution: {integrity: sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g==} + engines: {node: ^18.17.0 || >=20.5.0} + + '@sigstore/verify@4.1.0': + resolution: {integrity: sha512-p/s720RiWxLG8XtmfdPfEJOlATA6H/2knFqmtQbFkHKN3IrhWGUwPfpQAf1UnQIEES9IaH6zzhfjkrhTfeSdZw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + '@simple-git/args-pathspec@1.0.2': resolution: {integrity: sha512-nEFVejViHUoL8wU8GTcwqrvqfUG40S5ts6S4fr1u1Ki5CklXlRDYThPVA/qurTmCYFGnaX3XpVUmICLHdvhLaA==} @@ -16074,6 +16108,20 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sigstore/bundle@5.0.0': + dependencies: + '@sigstore/protobuf-specs': 0.5.1 + + '@sigstore/core@4.0.1(patch_hash=2900ec704c3abfc6962eb2edecaa946b783a97e13084c4c30b1be0df5b30ae4d)': {} + + '@sigstore/protobuf-specs@0.5.1': {} + + '@sigstore/verify@4.1.0': + dependencies: + '@sigstore/bundle': 5.0.0 + '@sigstore/core': 4.0.1(patch_hash=2900ec704c3abfc6962eb2edecaa946b783a97e13084c4c30b1be0df5b30ae4d) + '@sigstore/protobuf-specs': 0.5.1 + '@simple-git/args-pathspec@1.0.2': {} '@simple-git/argv-parser@1.0.3': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 96fe3db7cb..98f5140508 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -46,6 +46,9 @@ overrides: # 1.3.0 is deprecated (CWE-502); pin the patched line. "@ungap/structured-clone": "^1.3.1" +patchedDependencies: + "@sigstore/core@4.0.1": patches/@sigstore__core@4.0.1.patch + enablePrePostScripts: true packages: From b40f0d603be4e2bf346683cc76e46c6ca5f08800 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 11 Jul 2026 18:07:39 +0100 Subject: [PATCH 13/24] docs: rebaseline delegated release plan --- .../implementation-plan.md | 341 ++++++++++-------- .../sigstore-workerd-spike.md | 15 +- .../plans/delegated-release-service/spec.md | 54 ++- 3 files changed, 232 insertions(+), 178 deletions(-) diff --git a/.opencode/plans/delegated-release-service/implementation-plan.md b/.opencode/plans/delegated-release-service/implementation-plan.md index eedfb6769a..8870338964 100644 --- a/.opencode/plans/delegated-release-service/implementation-plan.md +++ b/.opencode/plans/delegated-release-service/implementation-plan.md @@ -2,22 +2,41 @@ Companion: [Implementation spec](./spec.md) -Status: execution plan pending Gate 0 external validation +Status: implementation in progress; service and history feasibility validation remain open This plan turns the delegated release service spec into independently deliverable workstreams. It defines ownership boundaries, dependencies, integration gates, and completion criteria. It intentionally contains no time estimates. ## Stage Deliverables -| Stage | Deliverable | Repository change allowed | -| ------ | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| Gate 0 | RFC implementation clarifications, supported-platform matrix, and documented external research conclusions | Spec and plan updates only | -| Gate 1 | Experimental lexicons, generated types, and one shared verification contract | Production contract code and tests | -| Gate 2 | Secure delegated release service vertical slice | Service, client, and installer code and tests | -| Gate 3 | Independent install and minimum discovery enforcement | Installer and aggregator code and tests | -| Gate 4 | Hosted beta product and operational readiness | Console, notifications, tooling, operations, and conformance code | -| Gate 5 | Accurate historical policy enforcement and production launch evidence | Aggregator history implementation and production verification | +| Stage | Deliverable | Repository change allowed | +| ------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Gate 0A | Service feasibility: create-only PDS support and confidential OAuth custody | Spec and plan updates only | +| Gate 0B | History feasibility: event-specific, verifiable aggregator input | Spec and plan updates only | +| Gate 1 | Experimental lexicons, generated types, and one shared verification contract | Production contract code and tests | +| Gate 2 | Secure delegated release service vertical slice | Service, client, and installer code and tests | +| Gate 3 | Independent install and minimum discovery enforcement | Installer and aggregator code and tests | +| Gate 4 | Hosted beta product and operational readiness | Console, notifications, tooling, operations, and conformance code | +| Gate 5 | Accurate historical policy enforcement and production launch evidence | Aggregator history implementation and production verification | -Gate 0 is deliberately not an implementation stage. RFC #1870 is the product and protocol decision; Gate 0 records only implementation clarifications and external validation required to begin implementation. Do not reopen RFC decisions unless its text is ambiguous, contradicts an existing constraint, or an external result makes it impossible. Do not add test harnesses, prototype services, package dependencies, root scripts, CI wiring, or production code for Gate 0. If external research changes an assumption, commit only the resulting spec or plan update. +Gates 0A and 0B are deliberately not implementation stages. RFC #1870 is the product and protocol decision; these gates record only implementation clarifications and external validation. Gate 0A blocks service custody and publication work. Gate 0B blocks historical aggregator enforcement and production launch, but does not block shared verification, installer work, or the service. Do not reopen RFC decisions unless the text is ambiguous, contradicts an existing constraint, or an external result makes it impossible. Do not add repository test harnesses, prototype services, package dependencies, root scripts, or CI wiring for these gates. Commit concise conclusions directly to the integration branch. + +## Implementation Baseline + +The integration branch includes these completed merge units: + +| Work items | Evidence | Result | +| -------------------------- | -------- | ---------------------------------------------------------------------- | +| `W0.1`, `W0.2`, `W0.7` | #1915 | RFC acceptance criteria and `emdash-plugin` command decision recorded. | +| `W1.1` through `W1.3` | #1918 | Experimental profile and release contracts and generated types landed. | +| `W1.4` | #1920 | Existing profile writers preserve extensions. | +| `W1.5` | #1925 | Local profile-policy command landed. | +| `W2.1`, `W2.2` foundations | #1929 | Shared verification package, checksums, and safe fetching landed. | +| `W2.3` | #1932 | Canonical plugin bundle validation landed. | +| `W2.4` | #1937 | Declared-access canonicalization and escalation landed. | +| `W0.5` | #1943 | Public Sigstore verification feasibility in workerd proved. | +| `W2.5` | #1951 | Production public Sigstore provenance verification landed. | + +`W0.3`, `W0.4`, and `W0.6` remain external-validation work. The next shared-verification merge unit is combined `W2.6` and `W2.7`; `W1.6` and `W1.7` land together after `W0.3` establishes the exact supported scope contract. ## Outcomes @@ -69,7 +88,8 @@ The implementation is complete when: ```mermaid flowchart TD - W0[W0 Decisions and feasibility] + W0A[W0A Service feasibility] + W0B[W0B History feasibility] W1[W1 Protocol and lexicons] W2[W2 Shared verification] W3[W3 OAuth, crypto, passkeys] @@ -79,23 +99,22 @@ flowchart TD W7[W7 Notifications] W8[W8 CLI and GitHub Action] W9[W9 Installer enforcement] - W10[W10 Aggregator enforcement] + W10M[W10 Minimum aggregator enforcement] + W10H[W10 Historical aggregator enforcement] W11[W11 Operations and self-hosting] W12[W12 Conformance and security] - W0 --> W1 - W0 --> W2 - W0 --> W3 - W0 --> W4 - W0 --> W10 + W0A --> W1 + W0A --> W3 + W0B --> W10H W1 --> W2 W1 --> W5 W1 --> W8 W1 --> W9 - W1 --> W10 + W1 --> W10M W2 --> W5 W2 --> W9 - W2 --> W10 + W2 --> W10M W3 --> W5 W3 --> W6 W4 --> W5 @@ -107,48 +126,54 @@ flowchart TD W7 --> W6 W8 --> W12 W9 --> W12 - W10 --> W12 + W10M --> W10H + W10M --> W12 + W10H --> W12 W11 --> W12 ``` ### Critical Path ```text -W0 record/auth/provenance feasibility --> W1 record contracts --> W2 shared artifact and provenance verification --> W5 intent validation and PDS publication --> W9 independent installer enforcement --> W10 accurate aggregator policy history --> W12 end-to-end conformance and production launch gate +Service path: W0A -> W1 -> W2 -> W5 -> W9 -> W12 +Launch-history path: W0B -> W10 -> W12 ``` -`W3` and `W4` run in parallel with `W1` and `W2` after their respective Gate 0 spikes. `W6`, `W7`, and most of `W8` begin once the service API and lifecycle contracts are frozen, without waiting for aggregator history work. +`W2`, `W3.1`, `W3.6`, and `W4` do not depend on every external-validation result. OAuth custody and publication depend on Gate 0A; historical aggregator work depends on Gate 0B. `W6`, `W7`, and most of `W8` begin once the service API and lifecycle contracts are frozen, without waiting for aggregator history work. ## Integration Gates -### Gate 0: Design and Platform Feasibility +### Gate 0A: Service Feasibility -Required before protocol or service implementation is treated as production work: +Required before durable delegation, session refresh, or PDS publication is treated as production work: -- Package profile extension and repository anchor have an implementation acceptance table derived from the RFC. -- Release baseline and declared-access escalation semantics have an implementation acceptance table derived from the RFC. -- Public CLI spelling is `emdash-plugin`. - Create-only repo scope works on each supported PDS without broad fallback. - Confidential OAuth sessions can be restored and refreshed safely in workerd. -- The selected Sigstore implementation verifies a real GitHub provenance bundle in workerd. + +Gate owner: `W0`. + +Gate 0A evidence is a supported-PDS matrix and an OAuth custody decision in the tracked spec/plan and, where useful, a link to an external reproduction or upstream issue. It is not a repository test suite. + +### Gate 0B: Historical Ingest Feasibility + +Required before `W10.1` and production historical-policy claims: + - An aggregator event source can recover intermediate profile values with verifiable ordering. +- The source supplies event-specific record values, CIDs, revisions, ordering keys, and proof material sufficient for the selected trust model. Gate owner: `W0`. -Gate 0 evidence is an implementation-clarification note in the tracked spec/plan and, where useful, a link to an external reproduction or upstream issue. It is not a repository test suite. The umbrella PR records the accepted external-validation summary before implementation begins. +Gate 0B evidence is a source-selection decision and explicit `W10.1` constraints. Failure changes the RFC's historical-policy and cooldown guarantees before `W10.1` through `W10.3` and `W10.5` through `W10.7` proceed; it does not block minimum current-policy filtering in `W10.4` or invalidate the service or installer architecture. ### Gate 1: Protocol and Verification Foundation - New profile and release extension fixtures round-trip through generated lexicon types. - Existing profile writers preserve unknown and policy extensions. - Shared checksum, fetch, bundle, access-diff, and provenance tests pass in Node and workerd. -- Installer, service, and aggregator can consume the same fixture corpus. +- The active release collection and exact create-only scope are exposed by one typed contract. +- One narrow registry-client helper can create, but cannot update or delete, a delegated release. +- Direct PDS reads and record/policy verification produce one structured report and stable error codes for every consumer. +- Installer, service, and aggregator can consume the same fixture corpus and report contract. Gate owners: `W1`, `W2`. @@ -286,7 +311,7 @@ Dependencies: none. ### W0 Completion -Gate 0 passes after `W0.1`, `W0.2`, and `W0.7` accurately reflect the RFC and established command decision, and the recorded conclusions for `W0.3` through `W0.6` reveal no incompatible external constraint. Any incompatible external result changes the RFC or architecture before downstream work proceeds. +The RFC-derived work (`W0.1`, `W0.2`, `W0.5`, and `W0.7`) is complete. Gate 0A passes when `W0.3` and `W0.4` reveal no incompatible service constraint. Gate 0B passes independently when `W0.6` selects a viable historical event source. An incompatible result changes only the affected RFC guarantee or downstream architecture before that work proceeds. ## Workstream W1: Protocol and Lexicons @@ -355,6 +380,8 @@ Dependencies: `W1.3`, `W1.4`. Dependencies: `W0.3`, `W1.3`. +Merge boundary: land with `W1.7` so the exact scope contract is proved by the only API allowed to exercise it. + ### `W1.7` Add create-only release publishing helper Extend `registry-client` with a narrow delegated-release helper that: @@ -381,11 +408,11 @@ Create `packages/registry-verification` and make its APIs usable in Workers and - Establish canonical fixture directories for records, tarballs, checksums, and Sigstore bundles. - Define stable verification error codes shared by all consumers. -Dependencies: Gate 0. +Dependencies: none; complete. ### `W2.2` Checksums and safe resource fetching -Extract and reconcile existing checksum behavior. Add the dedicated verifier-Worker boundary for untrusted outbound fetches, manual redirects, byte/time limits, URL validation, DNS defense-in-depth, and injectable test transport. +Extract and reconcile existing checksum behavior. Add manual redirects, byte/time limits, URL validation, DNS defense-in-depth, and injectable test transport. The dedicated verifier-Worker deployment boundary belongs to `W5.1a`; this package provides the transport-neutral safe-fetch primitive it invokes. Dependencies: `W2.1`. @@ -428,6 +455,8 @@ Add helpers that: Dependencies: `W1.3`, `W2.2`, `W2.4`, `W2.5`. +Merge boundary: land with `W2.7`; the report contract is incomplete without authoritative direct-PDS inputs. + ### `W2.7` Direct PDS read helpers Extend `registry-client` with unauthenticated direct-PDS profile/release reads, bounded rkey enumeration, lexicon validation, and semver baseline selection. Do not route these helpers through the aggregator. @@ -444,7 +473,7 @@ Given the same profile, release, artifact, and provenance fixtures, service, ins Implement versioned AES-GCM envelope encryption with HKDF-derived purpose keys and associated row identity. Cover OAuth session blobs, DPoP keys, emails, webhook destinations, and webhook secrets. -Dependencies: Gate 0. +Dependencies: none. ### `W3.2` Confidential client metadata and JWKS @@ -468,7 +497,7 @@ Dependencies: `W3.1`, `W3.2`. Implement `PublisherCoordinator` with D1 leases, rotated-session CAS persistence, proactive refresh, jitter, reauthorization state, revocation, and ambiguous refresh recovery. -Dependencies: `W0.4`, `W3.3`, `W5.2` repository primitives. +Dependencies: `W0.4`, `W3.3`, `W5.2a`. ### `W3.5` Console and approver identity sessions @@ -483,7 +512,7 @@ Dependencies: `W3.3`, `W5.1` app scaffold. Extend `packages/auth` additively with configurable user verification and typed challenge context. Preserve existing CMS behavior by default. -Dependencies: none after Gate 0. +Dependencies: none. ### `W3.7` Service passkey repository and ceremonies @@ -494,8 +523,11 @@ Dependencies: none after Gate 0. - Atomic challenge consumption and counter update. - Individual revocation requires fresh atproto identity proof and, when another active credential exists, an assertion from another credential. - Last-credential recovery may proceed from fresh atproto proof alone, but emits a high-severity audit event and notifications to every affected publisher. +- Define minimal versioned credential-security events for enrolment, credential addition/removal, and recovery; write audit and outbox rows transactionally with each ceremony. + +Dependencies: `W3.5`, `W3.6`. -Dependencies: `W3.5`, `W3.6`, `W5.2`. +Merge boundary: land with `W5.2c`; the repository is part of this service vertical, not a prerequisite merge. ### W3 Completion @@ -507,7 +539,9 @@ The service can prove publisher and approver DIDs, hold only the exact writer gr Define `WorkloadIssuer`, `VerifiedWorkload`, policy matcher, and stable failure codes without GitHub-specific types leaking into intent orchestration. -Dependencies: Gate 0. +Dependencies: none. + +Merge boundary: land with `W4.2` so the abstraction is proved by its first production adapter. ### `W4.2` GitHub JWT verification @@ -519,11 +553,12 @@ Dependencies: `W4.1`. Implement D1 repository and semantic matcher for repository, workflow, refs, and environments. No arbitrary expressions. -Dependencies: `W4.1`, `W5.2`. +Dependencies: `W4.1`, `W5.1`. -### `W4.4` Replay and cancellation identity +Merge boundary: land with `W5.2b`; the repository is part of this workload-policy vertical. + +### `W4.4` Submission evidence and cancellation identity -- Hash and reserve each raw JWT until expiry. - Bind submission evidence to policy ID/version. - Require matching repository, workflow, run ID, and run attempt for workload cancellation. - Allow separately audited publisher-console cancellation. @@ -540,32 +575,41 @@ A verified token maps to exactly one normalized workload identity and either one Create `apps/release-service` using the aggregator's Cloudflare Vite and workers-vitest patterns. Add D1, Queues, DLQs, cron, static assets, generated Worker types, health route, and fail-closed configuration validation. -Dependencies: Gate 0. +Dependencies: none. Durable OAuth and publication routes remain unreachable until Gate 0A passes. + +### `W5.1a` Dedicated verifier Worker -### `W5.2` D1 schema and repositories +Implement the isolated Worker used for artifact, provenance, and webhook egress. It exposes a narrow typed service binding, applies `W2.2` safe-fetch limits, and has no D1, Queue, service-secret, VPC, or private-origin bindings. Add workerd tests for redirects, size/time limits, malformed responses, and service-binding failure behavior. -Land migrations and typed repositories for: +Dependencies: `W2.2`, `W5.1`. -- Publisher accounts and delegations. -- Console/OAuth transactions. -- Workload policies and JWT replay. -- Approver identities, credentials, invitations, and challenges. -- Release targets, intents, and approvals. -- Notification endpoints, outbox, deliveries, and audit. +### `W5.2` D1 repository slices -Include unique constraints, owner columns, indexes, CAS state version, expiring leases, and a cryptographically random `public_intent_id` distinct from any internal row identifier. Public APIs, approval URLs, CLI output, notifications, and audit links use only the opaque public ID. +Do not land the entire service schema as one horizontal merge. Each slice includes only the migrations, repository methods, real-D1 tests, ownership constraints, and indexes required by its first service operation: -Dependencies: `W5.1`, data contracts from `W1`, `W3`, and `W4` may land incrementally. +- `W5.2a`: publisher accounts, OAuth transactions, console sessions, and delegations. Lands with `W3.2` and `W3.3`. +- `W5.2b`: workload policies. Lands with `W4.3`. +- `W5.2c`: approver identities, credentials, invitations, challenges, and the shared audit/outbox foundation needed for credential-security events. Lands with `W3.7`. +- `W5.2d`: workload JWT replay reservations, release targets, intents, submission outbox, and audit. Lands with `W5.3` and `W5.7a`. +- `W5.2e`: notification endpoints and delivery attempts. Lands with `W7.2`. +- `W5.2f`: append-only approvals and approval-lifecycle outbox. Lands with `W5.5`. + +Every applicable slice includes owner columns, unique constraints, indexes, and CAS/lease fields. `W5.2d` introduces a cryptographically random `public_intent_id` distinct from internal row identifiers; no external surface exposes the internal ID. + +Dependencies: `W5.1` and the contracts consumed by each slice. ### `W5.3` Intent submission - Verify OIDC before remote fetch. - Reject publishers excluded by the deployment's allowed-publisher policy before creating state or fetching user-controlled URLs. -- Validate request and reserve JWT, idempotency key, and release target atomically. +- Hash and reserve the raw JWT until expiry, then reserve the idempotency key and release target atomically. - Create intent, audit event, and validation outbox row in one D1 batch. - Return stable `202`, duplicate, and conflict responses. +- Keep the public submission route unregistered until `W5.6` lands the publication consumer. + +Dependencies: `W1.3`, `W4.2`, `W4.3`. -Dependencies: `W1.3`, `W4.2`, `W4.3`, `W5.2`. +Merge boundary: land with `W5.2d` and `W5.7a`. This merge proves transactional outbox draining but exposes no route that can accept an intent before a validation consumer exists. ### `W5.4` Validation worker @@ -575,7 +619,7 @@ Dependencies: `W1.3`, `W4.2`, `W4.3`, `W5.2`. - Derive signed policy, escalation, approval requirement, approval digest inputs, and expiry. - Transition by CAS and write outbox events. -Dependencies: `W2.3`, `W2.6`, `W2.7`, `W5.3`. +Dependencies: `W2.3`, `W2.6`, `W2.7`, `W5.1a`, `W5.3`, `W5.7a`. ### `W5.5` Approval and rejection lifecycle @@ -585,37 +629,47 @@ Dependencies: `W2.3`, `W2.6`, `W2.7`, `W5.3`. - Store append-only approval/rejection evidence. - Invalidate approval when any bound fact changes. - Transition approved intent to publish queue. +- Keep approval and rejection mutation routes unregistered until `W5.6` lands the publication consumer. Dependencies: `W3.7`, `W5.4`. +Merge boundary: include `W5.2f` approval persistence in this lifecycle vertical. + ### `W5.6` Final verification and PDS publication +- Add the idempotent publication Queue consumer. - Re-run full verification. - Acquire publisher publication/session lease. - Create one deterministic release record. - Reconcile timeout or transport ambiguity by direct read and canonical comparison. - Distinguish published, confirmed absent/retryable, immutable conflict, and reauthorization-required. +- Register submission, approval, and rejection routes only after validation and publication consumers are active. Dependencies: `W1.7`, `W3.4`, `W5.4`, `W5.5`. ### `W5.7` Outbox, Queues, cron, and recovery -- Transactional outbox drainer. -- Idempotent Queue consumers. -- DLQ forensics. -- Stage expiry. -- Lease reclamation. -- Publishing reconciliation. -- Proactive OAuth refresh. -- Bounded pruning of consumed tokens and challenges. +This work lands as two merge units rather than one horizontal infrastructure change. + +#### `W5.7a` Outbox and Queue infrastructure + +Land the transactional outbox drainer, Queue dispatch plumbing, DLQ forensics, and bounded retry primitives before connecting lifecycle operations to real queues. + +Dependencies: `W5.1`. + +Merge boundary: land with `W5.2d` and `W5.3`; lifecycle-specific Queue consumers still land with their operations. + +#### `W5.7b` Lifecycle recovery and maintenance -Dependencies: `W5.2`, `W5.4`, `W5.6`. +Add stage expiry, lease reclamation, publishing reconciliation, proactive OAuth refresh, and bounded pruning after publication semantics are stable. -### `W5.8` Versioned JSON API +Dependencies: `W3.4`, `W5.6`, `W5.7a`. -Implement CI, publisher, and approver endpoints from the spec with shared response envelopes, stable error codes, request IDs, owner checks, pagination, CSRF protection, and generated API-client types. Enforce the deployment's allowed-publisher policy on publisher login/delegation and every CI submission. Expose only `public_intent_id` for intent addressing; internal row IDs never cross the API boundary. +### `W5.8` Versioned JSON API foundation -Dependencies: endpoint-specific service operations from `W3` through `W5.7`. +Land only shared response envelopes, stable error-code serialization, request IDs, authentication/CSRF primitives, owner checks, pagination conventions, and API-schema generation here. CI, publisher, and approver endpoints land vertically with the operation they expose. Enforce the deployment's allowed-publisher policy on publisher login/delegation and every CI submission. Expose only `public_intent_id` for intent addressing; internal row IDs never cross the API boundary. + +Dependencies: `W5.1`. Each endpoint depends on its own service operation and repository slice. ### W5 Completion @@ -694,9 +748,9 @@ Every service-local publisher and approver operation is available through the co ### `W7.1` Notification event contract -Define versioned event types and safe payloads. Separate internal event data from the intentionally minimal email/webhook payload. +Extend the credential-security event contract from `W3.7` with versioned release-lifecycle event types and safe payloads. Separate internal event data from the intentionally minimal email/webhook payload. -Dependencies: lifecycle contract from `W5.4` through `W5.7`. +Dependencies: `W3.7` and lifecycle contract from `W5.4` through `W5.7`. ### `W7.2` Endpoint ownership and verification @@ -706,7 +760,9 @@ Dependencies: lifecycle contract from `W5.4` through `W5.7`. - Webhook URL validation and test delivery through the shared verifier/egress boundary. - Event allowlists and disabled state. -Dependencies: `W2.2`, `W3.1`, `W5.2`. +Dependencies: `W2.2`, `W3.1`, `W5.1`, `W5.1a`. + +Merge boundary: land with `W5.2e`; the repository is part of the notification-endpoint vertical. ### `W7.3` Email adapter @@ -747,11 +803,9 @@ Open the service authorization flow, wait for completion, display exact granted Dependencies: `W3.2` through `W3.5`, `W8.1`. -### `W8.3` Profile policy command - -Implement the ratified local profile-policy editor from `W1.5`, including add/remove approver, confirmation, provenance requirement, conflict handling, and JSON output. +### `W8.3` Profile policy command (complete) -Dependencies: `W1.5`, `W0.7`. +Delivered by `W1.5` in #1925, including add/remove approver, confirmation, provenance requirement, conflict handling, and JSON output. This is not a future merge unit. ### `W8.4` Enrol and approve commands @@ -839,18 +893,18 @@ Dependencies: `W0.6`. Dependencies: `W1.3`, `W10.1`. -### `W10.3` Release provenance verification pipeline +### `W10.3` Historical policy reclassification -- Persist provenance reference and policy event. -- Queue shared verification. -- CAS `pending -> valid|invalid|unverifiable`. -- Record stable reasons and verification time. +- Associate each release with its event-specific policy state. +- Combine the existing provenance verification result with the historical policy in force at publication. +- Reclassify policy compliance by CAS without repeating cryptographic verification when inputs are unchanged. +- Record stable reasons and the associated policy-event ID. -Dependencies: `W2.6`, `W10.2`. +Dependencies: `W10.2`, `W10.4`. ### `W10.4` Minimum default filtering -Before hosted beta, expose policy status and exclude releases that are pending, invalid, or unverifiable when current signed policy requires provenance. This may use current policy as a conservative floor until historical ingest is complete. +Before hosted beta, persist the release provenance reference and current signed policy digest, queue shared verification, and CAS provenance status from `pending` to `valid`, `invalid`, or `unverifiable`. Expose status and exclude releases that are pending, invalid, or unverifiable when current signed policy requires provenance. This is a conservative current-policy floor and does not claim historical policy-at-publication accuracy. Dependencies: `W1.3`, `W2.6`; does not wait for `W10.1`. @@ -885,7 +939,7 @@ Gate 5 aggregator criteria pass under event reordering, rapid profile transition Define fail-closed Worker bindings and variables for D1, Queues, DLQs, static assets, verifier Worker, email, public origin, OAuth metadata, GitHub audience, encryption keys, and allowed publisher policy. -Dependencies: `W5.1`, `W7` binding choices. +Dependencies: `W5.1`, `W5.1a`, `W7` binding choices. ### `W11.2` Key and session operations @@ -983,7 +1037,7 @@ Dependencies: Gates 2 and 3. First provision a fresh Workers/D1 self-host from `W11.6` and run the same delegated-release conformance suite used for the hosted service. Then use a controlled GitHub repository, real OIDC, supported PDS, hosted service, production aggregator, and disposable EmDash site. Verify rollback disables new submissions without invalidating published records or losing staged audit data. -Dependencies: `W11.6` and Gates 0 through 4; final hosted production run after Gate 5 implementation. +Dependencies: `W11.6`, Gate 0A, and Gates 1 through 4; final hosted production run after Gate 0B and Gate 5 implementation. ### W12 Completion @@ -991,52 +1045,40 @@ No unresolved critical/high security findings, all conformance suites pass, and ## Recommended Merge Sequence -The following sequence preserves parallelism while keeping each merge independently coherent. Items on the same row may proceed concurrently after their dependencies land. - -| Sequence | Merge unit | Depends on | -| -------- | ---------------------------------------------------------------------------------- | --------------------------------------------------- | -| 1 | Gate 0 RFC decisions and CLI naming | None | -| 2 | Create-only OAuth/PDS spike | Draft record NSID | -| 3 | Confidential OAuth/workerd spike | None | -| 4 | Sigstore/workerd spike | Draft provenance shape | -| 5 | Aggregator historical-event prototype | None | -| 6 | Profile and release lexicons plus generated types | Gate 0 record decisions | -| 7 | Profile-extension preservation regression fix | Profile lexicon | -| 8 | Create-only release publishing helper | Generated types, scope contract | -| 9 | Shared package scaffold, checksum, fetch, and bundle validation | Gate 0 | -| 10 | Declared-access canonical diff | Escalation decision, generated types | -| 11 | Shared provenance and record verification | Sigstore spike, lexicons, safe fetch | -| 12 | Passkey required-UV and challenge-context extension | Gate 0 | -| 13 | Release-service scaffold and initial D1 migrations | Gate 0 | -| 14 | Encryption, confidential metadata, and OAuth stores | OAuth spike, scaffold | -| 15 | GitHub verifier and workload-policy repository | Scaffold | -| 16 | Intent submission and validation worker | Shared verification, GitHub policy, D1 repositories | -| 17 | Enrolment and multiple-passkey service flow | OAuth identity, passkey extension, D1 repositories | -| 18 | Approval digest and approval/rejection lifecycle | Validation worker, passkey flow | -| 19 | Publication, refresh coordination, and reconciliation | Create-only helper, approval lifecycle, OAuth store | -| 20 | Outbox, Queues, cron, expiry, and recovery | Core lifecycle | -| 21 | Versioned API client and CI endpoints | Core lifecycle/API | -| 22 | Installer shared-verification migration and enforcement | Shared verification, lexicons | -| 23 | Minimum aggregator policy status and filtering | Shared verification, lexicons | -| 24 | Console foundation, delegation, policy, intent, passkey, approval, and audit pages | Stable service API and service flows | -| 25 | Email, webhook, and delivery workers | Outbox lifecycle | -| 26 | Notification console pages | Notification API and delivery workers | -| 27 | CLI delegation, policy, enrolment, approval, and release commands | API client and service flows | -| 28 | Official GitHub Action | CI API and GitHub verifier | -| 29 | Event-specific aggregator ingest and policy history | Historical-event prototype | -| 30 | Aggregator provenance pipeline, historical views, and cooldown | Policy history, notifications | -| 31 | Operations, self-hosting, and conformance hardening | Feature-complete service | -| 32 | Self-host conformance, production smoke, and launch gate | All prior launch dependencies | +Completed work is recorded in the implementation baseline instead of remaining in the future queue. The next merge units are: + +| Sequence | Merge unit | Depends on | +| -------- | ------------------------------------------------------------ | ----------------------------------------- | +| 1 | `W2.6` + `W2.7` record verification and direct-PDS reads | Completed W1/W2 contracts | +| 2 | `W1.6` + `W1.7` exact scope and create-only publishing | `W0.3` | +| 3 | `W3.6` required-UV and typed challenge primitives | None | +| 4 | `W4.1` + `W4.2` issuer contract and GitHub verifier | None | +| 5 | `W5.1` service scaffold and `W5.8` API foundation | None; sensitive routes remain unreachable | +| 6 | `W5.1a` dedicated verifier Worker | `W2.2`, `W5.1` | +| 7 | `W3.1` encryption | None | +| 8 | `W3.2` + `W3.3` confidential OAuth and `W5.2a` custody slice | Gate 0A, `W1.6`, `W3.1`, `W5.1` | +| 9 | `W4.3` workload policy and `W5.2b` repository slice | `W4.1`, `W5.1` | +| 10 | `W5.2d` + `W5.3` + `W5.7a` unreachable submission pipeline | `W4.2`, `W4.3`, API foundation | +| 11 | `W5.4` validation consumer, routes remain unreachable | `W2.6`, `W2.7`, `W5.1a`, `W5.3`, `W5.7a` | + +Later work continues as vertical merge units: passkey storage with ceremonies, approval storage with lifecycle, publication with reconciliation, notification storage with adapters, and API endpoints with their operations. `W10.1` begins only after Gate 0B; minimum current-policy filtering in `W10.4` may proceed earlier. ## Parallelization Map -After Gate 0: +Before Gate 0A: + +- `W2.6` + `W2.7`, `W3.1`, `W3.6`, `W4.1` + `W4.2`, `W5.1`, and then `W5.1a` may proceed independently. +- `W0.3`, `W0.4`, and `W0.6` external research may proceed concurrently and commit only conclusions to this branch. +- Durable OAuth custody and publication remain unreachable. + +After Gate 0A: + +- `W1.6` + `W1.7` and `W3.2` + `W3.3` close the exact delegation boundary. +- `W4.3`, then `W5.2d` + `W5.3` + `W5.7a`, establish the unreachable submission pipeline. `W5.4` adds validation while routes remain unreachable until publication lands in `W5.6`. + +After Gate 0B: -- Team A can execute `W1` protocol and lexicons. -- Team B can start `W2.1` through `W2.3` and finish provenance after `W1.2`. -- Team C can execute `W3.1`, `W3.6`, and service OAuth after the OAuth spike. -- Team D can scaffold `W5.1`, define generic repositories, and implement `W4`. -- Team E can implement `W10.1` from the historical-event prototype independently of the service. +- `W10.1` and historical policy association may proceed independently of service orchestration. After Gate 1: @@ -1052,16 +1094,15 @@ After Gate 2: ## Dependency Risks -| Risk | Impacted work | Required response | -| --------------------------------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------- | -| Supported PDS rejects create-only scope | `W3`, `W5`, `W8` | Change RFC/support matrix; never add broad fallback. | -| Sigstore verifier cannot run in workerd | `W2`, `W5`, `W9`, `W10` | Resolve runtime compatibility or controlled cryptographic worker design before Gate 1. | -| Historical profile values cannot be recovered | `W10`, production launch | Redesign history source or revise RFC downgrade guarantees before Gate 5. | -| Profile extension shape changes after implementation | `W1`, all consumers | Block downstream schema work until ratification; regenerate fixtures together. | -| D1 refresh lease proves unsafe under real atcute behavior | `W3`, `W5` | Introduce per-publisher DO coordinator behind `PublisherCoordinator`, keeping D1 canonical. | -| Current CLI profile update strips extensions | `W1`, policy security | Land preservation regression fix with the lexicon, before policy can be set. | -| GitHub attestation fields differ from RFC assumptions | `W0`, `W2`, `W4` | Ratify mapping from real bundle and update RFC fields before verifier implementation. | -| Verifier Worker cannot enforce required egress policy | `W2`, `W7`, self-hosting | Require controlled egress proxy for deployments with private connectivity. | +| Risk | Impacted work | Required response | +| ---------------------------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------- | +| Supported PDS rejects create-only scope | `W3`, `W5`, `W8` | Change RFC/support matrix; never add broad fallback. | +| Patched Sigstore dependency regresses or cannot be updated | `W2`, `W5`, `W9`, `W10` | Retain packed-output workerd tests; replace the pinned patch only with reviewed upstream behavior. | +| Historical profile values cannot be recovered | `W10`, production launch | Redesign history source or revise RFC downgrade guarantees before Gate 5. | +| Profile extension shape changes after implementation | `W1`, all consumers | Block downstream schema work until ratification; regenerate fixtures together. | +| D1 refresh lease proves unsafe under real atcute behavior | `W3`, `W5` | Introduce per-publisher DO coordinator behind `PublisherCoordinator`, keeping D1 canonical. | +| GitHub changes attestation identity or predicate fields | `W2`, `W4` | Fail closed, add a real fixture, and ratify the mapping before accepting the new shape. | +| Verifier Worker cannot enforce required egress policy | `W2`, `W7`, self-hosting | Require controlled egress proxy for deployments with private connectivity. | ## Definition of Done for Every Work Item @@ -1076,15 +1117,15 @@ After Gate 2: - `pnpm build`, targeted tests, `pnpm lint:quick`, and relevant typechecks pass. - The workstream's integration gate documentation is updated with actual verification evidence. -## First Execution Set +## Current Execution Set -Start these immediately and in parallel: +Start these independently, with at most three implementation branches active at once: -1. `W0.1` and `W0.2`: ratify protocol record and escalation contracts. -2. `W0.3`: create-only PDS compatibility spike. -3. `W0.4`: confidential OAuth and D1 refresh-lock spike. -4. `W0.5`: real GitHub Sigstore bundle verification in workerd. -5. `W0.6`: event-specific aggregator history prototype. -6. `W0.7`: settle CLI spelling. +1. `W2.6` + `W2.7`: structured record/policy verification over authoritative direct-PDS reads. +2. `W0.3` and `W0.4`: complete service-feasibility research and record Gate 0A conclusions directly on this branch. +3. `W0.6`: select historical aggregator input and record Gate 0B constraints independently. +4. `W3.6`: required-UV passkey primitives. +5. `W4.1` + `W4.2`: issuer-neutral workload identity and GitHub verification. +6. `W5.1` + `W5.8`, then `W5.1a`: unreachable service/API foundations and the isolated verifier Worker. -Do not start the production service state machine until `W0.3`, `W0.4`, and `W0.5` pass. Protocol fixture work may begin from draft shapes, but publishing lexicons waits for `W0.1` and `W0.2` ratification. +Do not implement durable OAuth custody or PDS publication until Gate 0A passes. Do not implement historical policy association or cooldown claims until Gate 0B passes. Shared verification, passkey primitives, workload verification, and unreachable service scaffolding do not wait for either gate. diff --git a/.opencode/plans/delegated-release-service/sigstore-workerd-spike.md b/.opencode/plans/delegated-release-service/sigstore-workerd-spike.md index 30ce6b8a28..ceab2d9ea9 100644 --- a/.opencode/plans/delegated-release-service/sigstore-workerd-spike.md +++ b/.opencode/plans/delegated-release-service/sigstore-workerd-spike.md @@ -1,6 +1,6 @@ # W0.5 Sigstore verification in workerd -This is a Gate 0 decision record. Fixture acquisition, dependencies, and test harnesses remain external until W2.5. +This is the completed W0.5 decision record. W2.5 production implementation landed in #1951. ## Proven facts @@ -40,11 +40,10 @@ Node documents `crypto.verify` algorithm selection at `https://nodejs.org/api/cr Predicate fields are signed but workflow-controlled. Production policy must safely decode the modern DER UTF8String Fulcio extensions and require predicate agreement. RFC `builderId` is not SLSA `builder.id`. -## W2.5 requirements +## W2.5 resolution -- Gate 1 is blocked until the algorithm-selection gap has a production-safe implementation and review. Prefer an upstream fix in `@sigstore/core` or `@sigstore/verify`. If no release is available, use a narrowly reviewed, lockfile-pinned `pnpm patch` or a small adapter/fork scoped to Sigstore verification. Do not monkeypatch `node:crypto` process-wide. -- Derive the algorithm from validated key type and curve: P-256 -> SHA-256, P-384 -> SHA-384, P-521 -> SHA-512, and Ed25519 -> `null`/`undefined` according to the Node API. Reject unsupported, deprecated, contradictory, or ambiguous key details. Regression-test every supported key algorithm in both Node and workerd, including DSSE, SET, and checkpoint paths. -- `@sigstore/verify@4.1.0` and `@sigstore/bundle@5.0.0` declare Node `^22.22.2 || ^24.15.0 || >=26.0.0`. Verify repository CI and every deployment runtime against that constraint before adopting them. -- Vendor reviewed, versioned trust roots. Refresh them explicitly, inspect key and validity-window changes, retain historical CAs/logs/TSAs needed for signatures made during their validity periods, and perform no runtime trust-API fetch. -- Implement the complete `ProvenanceVerifier` contract: bundle checksum and format, trust chain, required transparency evidence, exact identity, supported statement/predicate versions, artifact digest, certificate/predicate agreement, profile repository agreement, and derived RFC fields. Verification has one complete success or fails as unverifiable; there is no partial success, and failed supplied provenance is never treated as absent. -- This spike proves only public Sigstore/Fulcio/Rekor. GitHub-private Fulcio/TSA remains unproved and requires a real W2.5 fixture and trust-domain test if supported. Without that evidence, support remains limited to public Sigstore. +- #1951 resolves the algorithm-selection gap with a narrowly scoped, lockfile-pinned `@sigstore/core@4.0.1` patch. The published package bundles the patched behavior; packed-output tests run in workerd so consumers do not depend on workspace patch application. +- Verification derives algorithms from validated key details and rejects unsupported or contradictory keys. Node and workerd exercise the same contract, including DSSE, SET, checkpoint, identity, predicate, and artifact bindings. +- The package vendors a reviewed public trust root and performs no runtime trust-API fetch. Trust-root refresh remains an explicit reviewed maintenance operation. +- `ProvenanceVerifier` implements all-or-nothing bundle checksum, trust, transparency, identity, predicate, artifact, repository, and builder agreement. Failed supplied provenance is never treated as absent. +- Support remains limited to public Sigstore/Fulcio/Rekor. GitHub-private Fulcio/TSA requires a separate real fixture and trust-domain test before support can be claimed. diff --git a/.opencode/plans/delegated-release-service/spec.md b/.opencode/plans/delegated-release-service/spec.md index 934e1734c3..bc4fc224be 100644 --- a/.opencode/plans/delegated-release-service/spec.md +++ b/.opencode/plans/delegated-release-service/spec.md @@ -1,6 +1,6 @@ # Delegated Release Service Implementation Spec -Status: design draft pending Phase 0 external validation +Status: implementation in progress; create-only PDS, OAuth custody, and aggregator-history validation remain open Source: [RFC PR #1870](https://github.com/emdash-cms/emdash/pull/1870), Attested Automated Publishing @@ -23,6 +23,17 @@ This spec covers the complete feature: protocol records, shared verification, th | Stage TTL | 24 hours by default, operator-configurable with a bounded range of 15 minutes to 7 days. | | Service tenancy | Multi-tenant by default. A v1 self-host deploys the same Worker app in its own Cloudflare account and can restrict allowed publisher DIDs. | +## Implementation Baseline + +The integration branch has implemented the RFC-derived profile and release contracts, extension-preserving profile writes, the local profile-policy command, and the shared verification foundations through production public Sigstore provenance verification. See implementation PRs #1915, #1918, #1920, #1925, #1929, #1932, #1937, #1943, and #1951. + +The remaining external validation is split by impact: + +- Service feasibility: prove exact create-only scope on supported PDS implementations and confidential OAuth custody/refresh behavior in workerd. This blocks durable delegation and publication, not shared verification. +- History feasibility: select an event source that preserves verifiable intermediate profile values. This blocks historical aggregator enforcement and production launch, not the service or installer. + +The next protocol/verification closure is two coherent merge units: exact scope plus create-only publishing (`W1.6` + `W1.7`), and direct-PDS reads plus structured record/policy verification (`W2.6` + `W2.7`). + ## Non-Negotiable Security Invariants 1. CI never receives or stores an atproto account credential, refresh token, DPoP private key, or delegated release session. @@ -185,6 +196,14 @@ apps/release-service/ vitest.config.ts wrangler.jsonc +apps/release-verifier/ + src/ + index.ts + test/ + package.json + vitest.config.ts + wrangler.jsonc + packages/registry-verification/ src/ access.ts @@ -287,7 +306,7 @@ Update every existing profile writer at the same time. In particular, `packages/ Artifact and provenance hosts are untrusted. The same restrictions apply to webhook registration probes and redirects. -Workers `fetch()` does not expose or pin the IP used for the actual connection, so DNS pre-resolution cannot eliminate rebinding TOCTOU. The hosted deployment performs untrusted fetches in a dedicated verifier Worker with no service bindings, VPC connectivity, credentials, or private origin access. If a deployment exposes private network connectivity, it must route these requests through an egress proxy that resolves, validates, and pins the destination. The threat model and self-hosting docs state this residual explicitly. +Workers `fetch()` does not expose or pin the IP used for the actual connection, so DNS pre-resolution cannot eliminate rebinding TOCTOU. The hosted deployment performs untrusted fetches in `apps/release-verifier`, a dedicated Worker reached through a narrow release-service binding, with no public route, outbound service bindings, D1, Queues, service secrets, VPC connectivity, credentials, or private origin access. If a deployment exposes private network connectivity, it must route these requests through an egress proxy that resolves, validates, and pins the destination. The threat model and self-hosting docs state this residual explicitly. ### Checksum handling @@ -366,9 +385,7 @@ The GitHub/Sigstore implementation verifies: The implementation must not do partial verification. Unknown predicates and unsupported bundle formats produce `PROVENANCE_UNVERIFIABLE`, never "absent". -An early spike must prove that the selected Sigstore verifier bundles and runs in workerd. If the upstream package depends on unsupported Node APIs, this blocks the hosted service phase; do not replace cryptographic verification with an external trust API. - -That spike must use an actual bundle from `actions/attest-build-provenance` and document the exact mapping for repository ID, workflow ref, commit SHA, ref, certificate identity, SLSA `builder.id`, and the RFC's `builderId`. Implementation must follow the observed bundle schema rather than assuming the workflow ref is always stored in `builder.id`. +W0.5 proved public Sigstore verification in workerd with a real `actions/attest-build-provenance` bundle and documented the exact repository, workflow, commit, certificate, SLSA builder, and RFC `builderId` mapping. W2.5 then landed production verification with a vendored trust root, real fixture, packed-output workerd coverage, and a version-pinned `@sigstore/core` key-aware algorithm patch. The implementation fails closed and does not delegate cryptographic verification to an external trust API. ## Service Data Model @@ -1042,20 +1059,20 @@ Run a second suite against real GitHub OIDC and a real supported PDS in a contro ### Phase 0: RFC clarification and external validation -- Record implementation acceptance criteria for the profile extension, repository anchor, release provenance, and escalation contracts already decided by RFC #1870. -- Validate create-only permission support on target PDSes outside this repository. -- Confirm atcute confidential-client persistence, refresh-lock, and key-rotation constraints through external research or a disposable reproduction. -- Inspect a real GitHub provenance bundle and select the Workers-compatible Sigstore verifier plus exact field mapping. -- Select an aggregator history source that can retain event-specific signed profile values; document the constraints for the later W10.1 implementation. -- Use `emdash-plugin` as the v1 public command and update RFC examples accordingly. +- Complete: record implementation acceptance criteria for the profile extension, repository anchor, release provenance, and escalation contracts already decided by RFC #1870. +- Pending Gate 0A: validate create-only permission support on target PDSes outside this repository. +- Pending Gate 0A: confirm atcute confidential-client persistence, refresh-lock, and key-rotation constraints through external research or a disposable reproduction. +- Complete: inspect a real GitHub provenance bundle and land the Workers-compatible Sigstore verifier plus exact field mapping. +- Pending Gate 0B: select an aggregator history source that can retain event-specific signed profile values and document `W10.1` constraints. +- Complete: use `emdash-plugin` as the v1 public command. -Exit criterion: implementation acceptance criteria accurately reflect RFC #1870 and external validation reveals no incompatible constraint. Gate 0 adds no repository harnesses, production code, test scripts, package dependencies, or CI wiring. If research changes an assumption, commit only the corresponding spec or plan update. +Exit criteria are independent. Gate 0A passes when create-only PDS and confidential OAuth validation reveal no incompatible service constraint. Gate 0B passes when historical ingest has a viable source and trust model. External validation adds no repository harnesses, production code, test scripts, package dependencies, or CI wiring; commit conclusions directly to the integration branch. ### Phase 1: Protocol and verification foundation -- Land profile and release lexicon additions. -- Land `@emdash-cms/registry-verification`. -- Land declared-access canonical diff. +- Complete: land profile and release lexicon additions. +- In progress: complete `@emdash-cms/registry-verification` with direct-PDS record/policy verification. +- Complete: land declared-access canonical diff. - Extend passkey primitives with required UV and bound challenge context. - Extract create-only release record construction into `registry-client`. - Switch existing installer integrity checks to shared verification where behavior is equivalent. @@ -1129,8 +1146,5 @@ Phase 4 is a production launch gate for the default public service and registry, ## Implementation Blockers to Resolve First 1. Confirm the exact permission NSID and create-only scope against deployed PDS implementations. -2. Select and prove a Workers-compatible Sigstore verifier. -3. Decide the authoritative historical event source for aggregator policy ordering. -4. Ratify the package-profile repository field and extension shape. -5. Ratify the baseline definition and declared-access escalation rules in this spec. -6. Settle the public CLI spelling before adding new command documentation. +2. Confirm confidential OAuth persistence, refresh locking, and key-rotation behavior under workerd. +3. Decide the authoritative historical event source and trust model for aggregator policy ordering. From e8ffe1da3ad379f1ff1a0e9b3b398369ed43a60c Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 11 Jul 2026 19:44:35 +0100 Subject: [PATCH 14/24] docs: sequence workload verification after service scaffold --- .../delegated-release-service/implementation-plan.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.opencode/plans/delegated-release-service/implementation-plan.md b/.opencode/plans/delegated-release-service/implementation-plan.md index 8870338964..2b08c7f3b3 100644 --- a/.opencode/plans/delegated-release-service/implementation-plan.md +++ b/.opencode/plans/delegated-release-service/implementation-plan.md @@ -539,7 +539,7 @@ The service can prove publisher and approver DIDs, hold only the exact writer gr Define `WorkloadIssuer`, `VerifiedWorkload`, policy matcher, and stable failure codes without GitHub-specific types leaking into intent orchestration. -Dependencies: none. +Dependencies: `W5.1`. Merge boundary: land with `W4.2` so the abstraction is proved by its first production adapter. @@ -1052,8 +1052,8 @@ Completed work is recorded in the implementation baseline instead of remaining i | 1 | `W2.6` + `W2.7` record verification and direct-PDS reads | Completed W1/W2 contracts | | 2 | `W1.6` + `W1.7` exact scope and create-only publishing | `W0.3` | | 3 | `W3.6` required-UV and typed challenge primitives | None | -| 4 | `W4.1` + `W4.2` issuer contract and GitHub verifier | None | -| 5 | `W5.1` service scaffold and `W5.8` API foundation | None; sensitive routes remain unreachable | +| 4 | `W5.1` service scaffold and `W5.8` API foundation | None; sensitive routes remain unreachable | +| 5 | `W4.1` + `W4.2` issuer contract and GitHub verifier | `W5.1` | | 6 | `W5.1a` dedicated verifier Worker | `W2.2`, `W5.1` | | 7 | `W3.1` encryption | None | | 8 | `W3.2` + `W3.3` confidential OAuth and `W5.2a` custody slice | Gate 0A, `W1.6`, `W3.1`, `W5.1` | @@ -1067,7 +1067,7 @@ Later work continues as vertical merge units: passkey storage with ceremonies, a Before Gate 0A: -- `W2.6` + `W2.7`, `W3.1`, `W3.6`, `W4.1` + `W4.2`, `W5.1`, and then `W5.1a` may proceed independently. +- `W2.6` + `W2.7`, `W3.1`, `W3.6`, and `W5.1` may proceed independently. `W4.1` + `W4.2` and `W5.1a` begin after `W5.1` establishes their app and binding boundaries. - `W0.3`, `W0.4`, and `W0.6` external research may proceed concurrently and commit only conclusions to this branch. - Durable OAuth custody and publication remain unreachable. @@ -1125,7 +1125,7 @@ Start these independently, with at most three implementation branches active at 2. `W0.3` and `W0.4`: complete service-feasibility research and record Gate 0A conclusions directly on this branch. 3. `W0.6`: select historical aggregator input and record Gate 0B constraints independently. 4. `W3.6`: required-UV passkey primitives. -5. `W4.1` + `W4.2`: issuer-neutral workload identity and GitHub verification. -6. `W5.1` + `W5.8`, then `W5.1a`: unreachable service/API foundations and the isolated verifier Worker. +5. `W5.1` + `W5.8`: unreachable service and API foundations. +6. After `W5.1`, `W4.1` + `W4.2` and `W5.1a`: workload verification and the isolated verifier Worker. Do not implement durable OAuth custody or PDS publication until Gate 0A passes. Do not implement historical policy association or cooldown claims until Gate 0B passes. Shared verification, passkey primitives, workload verification, and unreachable service scaffolding do not wait for either gate. From 8b3e5ea3426b089b695e9cd516cd7ca402d876c9 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 12 Jul 2026 09:18:09 +0100 Subject: [PATCH 15/24] feat(registry): verify authoritative release records (#1969) * feat(registry): verify authoritative release records * fix(registry): harden direct PDS verification --- .changeset/direct-pds-record-verification.md | 6 + packages/registry-client/package.json | 6 + .../registry-client/src/direct-pds/index.ts | 518 ++++++++++++++++++ packages/registry-client/src/index.ts | 13 + .../registry-client/tests/direct-pds.test.ts | 462 ++++++++++++++++ packages/registry-client/tsdown.config.ts | 3 + .../fixtures/records/profile.json | 15 + .../fixtures/records/release.json | 17 + packages/registry-verification/src/errors.ts | 14 + packages/registry-verification/src/index.ts | 12 + .../registry-verification/src/provenance.ts | 32 +- packages/registry-verification/src/records.ts | 335 +++++++++++ .../registry-verification/src/repository.ts | 23 + .../tests/records.test.ts | 316 +++++++++++ pnpm-lock.yaml | 21 +- 15 files changed, 1758 insertions(+), 35 deletions(-) create mode 100644 .changeset/direct-pds-record-verification.md create mode 100644 packages/registry-client/src/direct-pds/index.ts create mode 100644 packages/registry-client/tests/direct-pds.test.ts create mode 100644 packages/registry-verification/fixtures/records/profile.json create mode 100644 packages/registry-verification/fixtures/records/release.json create mode 100644 packages/registry-verification/src/records.ts create mode 100644 packages/registry-verification/src/repository.ts create mode 100644 packages/registry-verification/tests/records.test.ts diff --git a/.changeset/direct-pds-record-verification.md b/.changeset/direct-pds-record-verification.md new file mode 100644 index 0000000000..3f46ee8cdb --- /dev/null +++ b/.changeset/direct-pds-record-verification.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/registry-client": minor +"@emdash-cms/registry-verification": minor +--- + +Adds authoritative direct-PDS package reads and shared signed record, policy, and provenance verification reports. diff --git a/packages/registry-client/package.json b/packages/registry-client/package.json index 1dc92c93be..ef495ca58c 100644 --- a/packages/registry-client/package.json +++ b/packages/registry-client/package.json @@ -21,6 +21,10 @@ "types": "./dist/discovery/index.d.ts", "default": "./dist/discovery/index.js" }, + "./direct-pds": { + "types": "./dist/direct-pds/index.d.ts", + "default": "./dist/direct-pds/index.js" + }, "./env": { "types": "./dist/env/index.d.ts", "default": "./dist/env/index.js" @@ -39,6 +43,8 @@ }, "dependencies": { "@atcute/atproto": "catalog:", + "@atcute/cbor": "catalog:", + "@atcute/cid": "catalog:", "@atcute/client": "catalog:", "@atcute/lexicons": "catalog:", "@emdash-cms/registry-lexicons": "workspace:*" diff --git a/packages/registry-client/src/direct-pds/index.ts b/packages/registry-client/src/direct-pds/index.ts new file mode 100644 index 0000000000..b774bddba6 --- /dev/null +++ b/packages/registry-client/src/direct-pds/index.ts @@ -0,0 +1,518 @@ +// 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 +import type {} from "@atcute/atproto"; +import { encode } from "@atcute/cbor"; +import * as CID from "@atcute/cid"; +import { Client, ok, simpleFetchHandler } from "@atcute/client"; +import { parseCanonicalResourceUri } from "@atcute/lexicons"; +import { safeParse } from "@atcute/lexicons/validations"; +import { NSID, PackageProfile, PackageRelease } from "@emdash-cms/registry-lexicons"; +import compare from "semver/functions/compare.js"; +import valid from "semver/functions/valid.js"; + +import type { Did } from "../credentials/types.js"; + +export const DEFAULT_DIRECT_PDS_MAX_RECORDS = 1_000; +export const DEFAULT_DIRECT_PDS_PAGE_SIZE = 100; +export const DEFAULT_DIRECT_PDS_REQUEST_TIMEOUT_MS = 10_000; +export const DEFAULT_DIRECT_PDS_MAX_RESPONSE_BYTES = 5 * 1024 * 1024; +const DIGITS_RE = /^\d+$/; +const MAX_TIMEOUT_MS = 2_147_483_647; + +export type DirectPdsReadErrorCode = + | "ENUMERATION_CURSOR_REPEATED" + | "ENUMERATION_TRUNCATED" + | "PDS_REQUEST_ABORTED" + | "PDS_REQUEST_FAILED" + | "PDS_REQUEST_TIMEOUT" + | "PDS_RESPONSE_TOO_LARGE" + | "PROFILE_IDENTITY_MISMATCH" + | "PROFILE_LEXICON_INVALID" + | "RECORD_CID_INVALID" + | "RECORD_CID_MISMATCH" + | "RECORD_CID_MISSING" + | "RELEASE_ENUMERATION_AMBIGUOUS" + | "RELEASE_IDENTITY_MISMATCH" + | "RELEASE_LEXICON_INVALID" + | "RELEASE_VERSION_INVALID"; + +export class DirectPdsReadError extends Error { + readonly code: DirectPdsReadErrorCode; + + constructor(code: DirectPdsReadErrorCode, message: string) { + super(message); + this.name = "DirectPdsReadError"; + this.code = code; + } +} + +export interface DirectPdsClientOptions { + did: Did; + pds: string; + fetch?: typeof fetch; + requestTimeoutMs?: number; + maxResponseBytes?: number; + signal?: AbortSignal; +} + +export interface DirectPdsProfileRecord { + uri: string; + cid: string; + rkey: string; + value: PackageProfile.Main; +} + +export interface DirectPdsReleaseRecord { + uri: string; + cid: string; + rkey: string; + value: PackageRelease.Main; +} + +export interface DirectPdsEnumerationOptions { + maxRecords?: number; + pageSize?: number; +} + +/** Unauthenticated reads against one explicitly configured publisher PDS. */ +export class DirectPdsClient { + readonly did: Did; + readonly pds: string; + readonly #client: Client; + + constructor(options: DirectPdsClientOptions) { + validatePdsOrigin(options.pds); + const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_DIRECT_PDS_REQUEST_TIMEOUT_MS; + const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_DIRECT_PDS_MAX_RESPONSE_BYTES; + validatePositiveSafeInteger(requestTimeoutMs, "requestTimeoutMs"); + if (requestTimeoutMs > MAX_TIMEOUT_MS) { + throw new RangeError(`requestTimeoutMs must not exceed ${MAX_TIMEOUT_MS}`); + } + validatePositiveSafeInteger(maxResponseBytes, "maxResponseBytes"); + this.did = options.did; + this.pds = options.pds; + this.#client = new Client({ + handler: simpleFetchHandler({ + service: options.pds, + fetch: createBoundedFetch(options.fetch ?? globalThis.fetch, { + requestTimeoutMs, + maxResponseBytes, + signal: options.signal, + }), + }), + }); + } + + async getPackageProfile(packageSlug: string): Promise { + validatePackageSlug(packageSlug); + const data = await ok( + this.#client.get("com.atproto.repo.getRecord", { + params: { repo: this.did, collection: NSID.packageProfile, rkey: packageSlug }, + }), + ); + const identity = parseIdentity(data.uri, this.did, NSID.packageProfile); + if (!identity || identity.rkey !== packageSlug) { + throw new DirectPdsReadError( + "PROFILE_IDENTITY_MISMATCH", + "The PDS returned a profile with a different record identity.", + ); + } + const parsed = safeParse(PackageProfile.mainSchema, data.value); + if (!parsed.ok) { + throw new DirectPdsReadError( + "PROFILE_LEXICON_INVALID", + "The PDS returned a malformed package profile.", + ); + } + if (parsed.value.id !== data.uri || (parsed.value.slug && parsed.value.slug !== packageSlug)) { + throw new DirectPdsReadError( + "PROFILE_IDENTITY_MISMATCH", + "The signed profile does not match its authoritative record identity.", + ); + } + const cid = await verifyRecordCid(data.cid, parsed.value); + return { uri: data.uri, cid, rkey: identity.rkey, value: parsed.value }; + } + + async getPackageRelease(packageSlug: string, version: string): Promise { + validatePackageSlug(packageSlug); + const rkey = `${packageSlug}:${version}`; + const data = await ok( + this.#client.get("com.atproto.repo.getRecord", { + params: { repo: this.did, collection: NSID.packageRelease, rkey }, + }), + ); + return await validateReleaseRecord(data, this.did, packageSlug, rkey); + } + + async listPackageReleases( + packageSlug: string, + options: DirectPdsEnumerationOptions = {}, + ): Promise { + validatePackageSlug(packageSlug); + const maxRecords = options.maxRecords ?? DEFAULT_DIRECT_PDS_MAX_RECORDS; + const pageSize = options.pageSize ?? DEFAULT_DIRECT_PDS_PAGE_SIZE; + if (!Number.isSafeInteger(maxRecords) || maxRecords < 1) { + throw new RangeError("maxRecords must be a positive safe integer"); + } + if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100) { + throw new RangeError("pageSize must be an integer from 1 to 100"); + } + + const releases: DirectPdsReleaseRecord[] = []; + const seenCursors = new Set(); + const seenRkeys = new Set(); + let enumerated = 0; + let cursor: string | undefined; + + while (true) { + const data = await ok( + this.#client.get("com.atproto.repo.listRecords", { + params: { + repo: this.did, + collection: NSID.packageRelease, + limit: Math.min(pageSize, maxRecords - enumerated), + ...(cursor ? { cursor } : {}), + }, + }), + ); + enumerated += data.records.length; + if (enumerated > maxRecords) { + throw new DirectPdsReadError( + "ENUMERATION_TRUNCATED", + "The PDS returned more records than the configured bound.", + ); + } + + for (const record of data.records) { + const identity = parseIdentity(record.uri, this.did, NSID.packageRelease); + if (!identity) { + throw new DirectPdsReadError( + "RELEASE_ENUMERATION_AMBIGUOUS", + "The PDS returned a release with a non-authoritative identity.", + ); + } + if (!identity.rkey.startsWith(`${packageSlug}:`)) continue; + if (seenRkeys.has(identity.rkey)) { + throw new DirectPdsReadError( + "RELEASE_ENUMERATION_AMBIGUOUS", + "The PDS returned a duplicate release record key.", + ); + } + seenRkeys.add(identity.rkey); + releases.push(await validateReleaseRecord(record, this.did, packageSlug, identity.rkey)); + } + + if (!data.cursor) return releases; + if (data.records.length === 0 || enumerated >= maxRecords) { + throw new DirectPdsReadError( + "ENUMERATION_TRUNCATED", + "Release enumeration exceeded its configured bound.", + ); + } + if (seenCursors.has(data.cursor)) { + throw new DirectPdsReadError( + "ENUMERATION_CURSOR_REPEATED", + "The PDS repeated an enumeration cursor.", + ); + } + seenCursors.add(data.cursor); + cursor = data.cursor; + } + } +} + +/** Select the highest-semver current release, excluding the proposed record key. */ +export function selectSemverBaseline( + releases: readonly DirectPdsReleaseRecord[], + options: { excludeRkey: string }, +): DirectPdsReleaseRecord | null { + const separator = options.excludeRkey.indexOf(":"); + const packageSlug = options.excludeRkey.slice(0, separator); + if (separator < 1 || !PACKAGE_SLUG_RE.test(packageSlug)) { + throw new DirectPdsReadError( + "RELEASE_IDENTITY_MISMATCH", + "The proposed release key has no canonical package identity.", + ); + } + let baseline: DirectPdsReleaseRecord | null = null; + const versions = new Set(); + for (const release of releases) { + if (release.rkey === options.excludeRkey) continue; + const version = release.value.version; + if (release.value.package !== packageSlug || release.rkey !== `${packageSlug}:${version}`) { + throw new DirectPdsReadError( + "RELEASE_IDENTITY_MISMATCH", + "A baseline release belongs to a different package identity.", + ); + } + if (!isCanonicalVersion(version)) { + throw new DirectPdsReadError( + "RELEASE_VERSION_INVALID", + "A release cannot participate in semver baseline selection.", + ); + } + if (versions.has(version)) { + throw new DirectPdsReadError( + "RELEASE_ENUMERATION_AMBIGUOUS", + "Multiple releases have the same semantic version.", + ); + } + versions.add(version); + if (!baseline || compare(version, baseline.value.version) > 0) baseline = release; + } + return baseline; +} + +async function validateReleaseRecord( + record: { uri: string; cid?: string; value: unknown }, + did: string, + packageSlug: string, + expectedRkey: string, +): Promise { + const identity = parseIdentity(record.uri, did, NSID.packageRelease); + if (!identity || identity.rkey !== expectedRkey) { + throw new DirectPdsReadError( + "RELEASE_IDENTITY_MISMATCH", + "The PDS returned a release with a different record identity.", + ); + } + const parsed = safeParse(PackageRelease.mainSchema, record.value); + if (!parsed.ok) { + throw new DirectPdsReadError( + "RELEASE_LEXICON_INVALID", + "The PDS returned a malformed package release.", + ); + } + if ( + parsed.value.package !== packageSlug || + !isCanonicalVersion(parsed.value.version) || + expectedRkey !== `${packageSlug}:${parsed.value.version}` + ) { + throw new DirectPdsReadError( + "RELEASE_IDENTITY_MISMATCH", + "The signed release does not match its package, version, and record key.", + ); + } + const cid = await verifyRecordCid(record.cid, parsed.value); + return { uri: record.uri, cid, rkey: identity.rkey, value: parsed.value }; +} + +function parseIdentity(uri: string, did: string, collection: string): { rkey: string } | null { + try { + const parsed = parseCanonicalResourceUri(uri); + return parsed.repo === did && parsed.collection === collection && parsed.rkey + ? { rkey: parsed.rkey } + : null; + } catch { + return null; + } +} + +function isCanonicalVersion(version: string): boolean { + return !version.includes("+") && valid(version) === version; +} + +const PACKAGE_SLUG_RE = /^[a-z][a-z0-9_-]{0,63}$/; + +function validatePackageSlug(value: string): void { + if (!PACKAGE_SLUG_RE.test(value)) { + throw new DirectPdsReadError( + "RELEASE_IDENTITY_MISMATCH", + "The package slug is not valid for an authoritative release identity.", + ); + } +} + +function validatePdsOrigin(value: string): void { + const url = new URL(value); + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) { + throw new TypeError("pds must be an HTTPS origin"); + } +} + +async function verifyRecordCid(cid: string | undefined, value: unknown): Promise { + if (!cid) throw missingCid(); + let claimed: CID.Cid; + try { + claimed = CID.fromString(cid); + if (claimed.codec !== CID.CODEC_DCBOR || CID.toString(claimed) !== cid) throw new Error(); + } catch { + throw new DirectPdsReadError("RECORD_CID_INVALID", "The PDS returned an invalid record CID."); + } + let computed: CID.Cid; + try { + computed = await CID.create(CID.CODEC_DCBOR, encode(value)); + } catch { + throw new DirectPdsReadError( + "RECORD_CID_MISMATCH", + "The PDS record value does not match its claimed CID.", + ); + } + if (!CID.equals(claimed, computed)) { + throw new DirectPdsReadError( + "RECORD_CID_MISMATCH", + "The PDS record value does not match its claimed CID.", + ); + } + return cid; +} + +interface BoundedFetchOptions { + requestTimeoutMs: number; + maxResponseBytes: number; + signal?: AbortSignal; +} + +function createBoundedFetch(fetchImplementation: typeof fetch, options: BoundedFetchOptions) { + return async (input: string | URL | Request, init: RequestInit = {}): Promise => { + const controller = new AbortController(); + let timedOut = false; + const cleanupSignals = forwardAbortSignals( + [options.signal, init.signal].filter((signal): signal is AbortSignal => signal !== undefined), + controller, + ); + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, options.requestTimeoutMs); + + try { + if (controller.signal.aborted) throw new DOMException("Aborted", "AbortError"); + const response = await withAbortSignal( + Promise.resolve().then(() => + fetchImplementation(input, { ...init, signal: controller.signal }), + ), + controller.signal, + ); + const contentLength = response.headers.get("content-length"); + if (contentLength !== null) { + const declaredLength = Number(contentLength); + if ( + !DIGITS_RE.test(contentLength) || + !Number.isSafeInteger(declaredLength) || + declaredLength > options.maxResponseBytes + ) { + void response.body?.cancel().catch(() => undefined); + throw responseTooLarge(); + } + } + const body = await readBoundedBody( + response.body, + options.maxResponseBytes, + controller.signal, + ); + return new Response(body.length === 0 ? null : body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } catch (error) { + if (error instanceof DirectPdsReadError) throw error; + if (timedOut) { + throw new DirectPdsReadError("PDS_REQUEST_TIMEOUT", "The direct PDS request timed out."); + } + if (controller.signal.aborted) { + throw new DirectPdsReadError("PDS_REQUEST_ABORTED", "The direct PDS request was aborted."); + } + throw new DirectPdsReadError("PDS_REQUEST_FAILED", "The direct PDS request failed."); + } finally { + clearTimeout(timeout); + cleanupSignals(); + } + }; +} + +async function readBoundedBody( + body: ReadableStream | null, + maximumBytes: number, + signal: AbortSignal, +): Promise { + if (body === null) return new Uint8Array(); + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + let completed = false; + try { + for (;;) { + const chunk = await withAbortSignal(reader.read(), signal); + if (chunk.done) { + completed = true; + break; + } + length += chunk.value.length; + if (length > maximumBytes) throw responseTooLarge(); + chunks.push(chunk.value); + } + } finally { + if (!completed) void reader.cancel().catch(() => undefined); + reader.releaseLock(); + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return bytes; +} + +function withAbortSignal(operation: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const abort = () => reject(new DOMException("Aborted", "AbortError")); + if (signal.aborted) { + abort(); + } else { + signal.addEventListener("abort", abort, { once: true }); + } + void operation.then( + (value) => { + signal.removeEventListener("abort", abort); + resolve(value); + return undefined; + }, + (error: unknown) => { + signal.removeEventListener("abort", abort); + reject(error); + return undefined; + }, + ); + }); +} + +function forwardAbortSignals(signals: AbortSignal[], controller: AbortController): () => void { + const abort = () => controller.abort(); + for (const signal of signals) { + if (signal.aborted) controller.abort(); + else signal.addEventListener("abort", abort, { once: true }); + } + return () => { + for (const signal of signals) signal.removeEventListener("abort", abort); + }; +} + +function validatePositiveSafeInteger(value: number, name: string): void { + if (!Number.isSafeInteger(value) || value < 1) { + throw new RangeError(`${name} must be a positive safe integer`); + } +} + +function responseTooLarge(): DirectPdsReadError { + return new DirectPdsReadError( + "PDS_RESPONSE_TOO_LARGE", + "The direct PDS response exceeded its byte limit.", + ); +} + +function missingCid(): DirectPdsReadError { + return new DirectPdsReadError( + "RECORD_CID_MISSING", + "The PDS response omitted the authoritative record CID.", + ); +} diff --git a/packages/registry-client/src/index.ts b/packages/registry-client/src/index.ts index 680adcc2dc..0f31b3aa2d 100644 --- a/packages/registry-client/src/index.ts +++ b/packages/registry-client/src/index.ts @@ -45,6 +45,19 @@ export { type PublishingClientFromHandlerOptions, PublishingClient } from "./pub export { type DiscoveryClientOptions, DiscoveryClient } from "./discovery/index.js"; +export { + type DirectPdsClientOptions, + type DirectPdsEnumerationOptions, + type DirectPdsProfileRecord, + type DirectPdsReadErrorCode, + type DirectPdsReleaseRecord, + DEFAULT_DIRECT_PDS_MAX_RECORDS, + DEFAULT_DIRECT_PDS_PAGE_SIZE, + DirectPdsClient, + DirectPdsReadError, + selectSemverBaseline, +} from "./direct-pds/index.js"; + export { type EnvMismatch, type HostEnv, diff --git a/packages/registry-client/tests/direct-pds.test.ts b/packages/registry-client/tests/direct-pds.test.ts new file mode 100644 index 0000000000..0b5d5b8f86 --- /dev/null +++ b/packages/registry-client/tests/direct-pds.test.ts @@ -0,0 +1,462 @@ +import { encode } from "@atcute/cbor"; +import * as CID from "@atcute/cid"; +import type { PackageRelease } from "@emdash-cms/registry-lexicons"; +import { describe, expect, it, vi } from "vitest"; + +import profileFixture from "../../registry-verification/fixtures/records/profile.json"; +import releaseFixture from "../../registry-verification/fixtures/records/release.json"; +import { + DirectPdsClient, + DirectPdsReadError, + selectSemverBaseline, +} from "../src/direct-pds/index.js"; + +const did = "did:plc:publisher"; +const pds = "https://pds.example.com"; +const profileCid = await recordCid(profileFixture); +const releaseCid = await recordCid(releaseFixture); + +describe("DirectPdsClient", () => { + it("reads and validates a profile directly from the configured PDS", async () => { + const fetch = routeFetch(() => profileResponse()); + const client = new DirectPdsClient({ did, pds, fetch }); + + const result = await client.getPackageProfile("gallery"); + + expect(result).toMatchObject({ cid: profileCid, rkey: "gallery", value: { name: "Gallery" } }); + expect(fetch).toHaveBeenCalledOnce(); + const request = new URL(String(fetch.mock.calls[0]?.[0])); + expect(request.origin).toBe(pds); + expect(request.pathname).toBe("/xrpc/com.atproto.repo.getRecord"); + expect(request.searchParams.get("repo")).toBe(did); + }); + + it("never returns malformed profile values as typed records", async () => { + const client = new DirectPdsClient({ + did, + pds, + fetch: routeFetch(() => profileResponse({ nope: true })), + }); + await expect(client.getPackageProfile("gallery")).rejects.toMatchObject({ + code: "PROFILE_LEXICON_INVALID", + }); + }); + + it("requires an authoritative CID and canonical package identity", async () => { + const client = new DirectPdsClient({ + did, + pds, + fetch: routeFetch(() => + jsonResponse({ + uri: `at://${did}/com.emdashcms.experimental.package.profile/gallery`, + value: profileFixture, + }), + ), + }); + await expect(client.getPackageProfile("gallery")).rejects.toMatchObject({ + code: "RECORD_CID_MISSING", + }); + await expect(client.listPackageReleases("gallery:other")).rejects.toMatchObject({ + code: "RELEASE_IDENTITY_MISMATCH", + }); + }); + + it("rejects malformed record CIDs", async () => { + const client = new DirectPdsClient({ + did, + pds, + fetch: routeFetch(() => profileResponse(profileFixture, "not-a-cid")), + }); + + await expect(client.getPackageProfile("gallery")).rejects.toMatchObject({ + code: "RECORD_CID_INVALID", + }); + }); + + it("rejects profile values that do not match their claimed CID", async () => { + const value = { ...structuredClone(profileFixture), name: "Tampered Gallery" }; + const client = new DirectPdsClient({ + did, + pds, + fetch: routeFetch(() => profileResponse(value, profileCid)), + }); + + await expect(client.getPackageProfile("gallery")).rejects.toMatchObject({ + code: "RECORD_CID_MISMATCH", + }); + }); + + it("reads one release and enforces its package, version, and rkey", async () => { + const client = new DirectPdsClient({ + did, + pds, + fetch: routeFetch(() => releaseResponse(releaseFixture)), + }); + await expect(client.getPackageRelease("gallery", "1.2.3")).resolves.toMatchObject({ + rkey: "gallery:1.2.3", + value: { package: "gallery", version: "1.2.3" }, + }); + }); + + it("rejects malformed and identity-confused releases", async () => { + const client = new DirectPdsClient({ + did, + pds, + fetch: routeFetch(() => releaseResponse({ ...releaseFixture, package: "other" })), + }); + await expect(client.getPackageRelease("gallery", "1.2.3")).rejects.toMatchObject({ + code: "RELEASE_IDENTITY_MISMATCH", + }); + }); + + it("rejects a single release value that does not match its claimed CID", async () => { + const value = structuredClone(releaseFixture); + value.artifacts.package.url = "https://github.com/example/gallery/releases/tampered.tar.gz"; + const client = new DirectPdsClient({ + did, + pds, + fetch: routeFetch(() => releaseResponse(value, releaseCid)), + }); + + await expect(client.getPackageRelease("gallery", "1.2.3")).rejects.toMatchObject({ + code: "RECORD_CID_MISMATCH", + }); + }); + + it("enumerates bounded pages and selects the highest-semver baseline regardless of order", async () => { + const [oneTwo, prerelease, oneTen, two, zeroNine] = await Promise.all([ + releaseRecord("1.2.0"), + releaseRecord("2.0.0-rc.1"), + releaseRecord("1.10.0"), + releaseRecord("2.0.0"), + releaseRecord("0.9.0"), + ]); + const pages = new Map([ + [null, listResponse([oneTwo, prerelease], "next-page")], + ["next-page", listResponse([oneTen, two, zeroNine])], + ]); + const client = new DirectPdsClient({ + did, + pds, + fetch: routeFetch((url) => jsonResponse(pages.get(url.searchParams.get("cursor")))), + }); + + const releases = await client.listPackageReleases("gallery", { + maxRecords: 10, + pageSize: 2, + }); + expect(selectSemverBaseline(releases, { excludeRkey: "gallery:9.0.0" })).toMatchObject({ + value: { version: "2.0.0" }, + }); + }); + + it("uses an empty baseline for a first release", () => { + expect(selectSemverBaseline([], { excludeRkey: "gallery:1.0.0" })).toBeNull(); + }); + + it("excludes the proposed release key before selecting a baseline", () => { + const releases = [typedRelease("1.0.0"), typedRelease("2.0.0")]; + expect(selectSemverBaseline(releases, { excludeRkey: "gallery:2.0.0" })).toMatchObject({ + value: { version: "1.0.0" }, + }); + }); + + it("rejects a baseline candidate from another package", () => { + const release = typedRelease("9.0.0"); + release.rkey = "other:9.0.0"; + release.value.package = "other"; + expect(() => selectSemverBaseline([release], { excludeRkey: "gallery:10.0.0" })).toThrowError( + expect.objectContaining({ code: "RELEASE_IDENTITY_MISMATCH" }), + ); + }); + + it("rejects truncation instead of selecting from an incomplete enumeration", async () => { + const record = await releaseRecord("1.0.0"); + const client = new DirectPdsClient({ + did, + pds, + fetch: routeFetch(() => jsonResponse(listResponse([record], "more"))), + }); + await expect( + client.listPackageReleases("gallery", { maxRecords: 1, pageSize: 1 }), + ).rejects.toMatchObject({ code: "ENUMERATION_TRUNCATED" }); + }); + + it("accepts legal short pages until enumeration completes", async () => { + const [one, two, three] = await Promise.all([ + releaseRecord("1.0.0"), + releaseRecord("2.0.0"), + releaseRecord("3.0.0"), + ]); + const pages = new Map([ + [null, listResponse([one], "second")], + ["second", listResponse([two], "third")], + ["third", listResponse([three])], + ]); + const client = new DirectPdsClient({ + did, + pds, + fetch: routeFetch((url) => jsonResponse(pages.get(url.searchParams.get("cursor")))), + }); + + await expect( + client.listPackageReleases("gallery", { maxRecords: 3, pageSize: 3 }), + ).resolves.toHaveLength(3); + }); + + it("accepts an exact-boundary enumeration without a continuation cursor", async () => { + const records = await Promise.all([releaseRecord("1.0.0"), releaseRecord("2.0.0")]); + const client = new DirectPdsClient({ + did, + pds, + fetch: routeFetch(() => jsonResponse(listResponse(records))), + }); + + await expect( + client.listPackageReleases("gallery", { maxRecords: 2, pageSize: 2 }), + ).resolves.toHaveLength(2); + }); + + it("rejects a PDS response that exceeds the remaining record bound", async () => { + const records = await Promise.all([ + releaseRecord("1.0.0"), + releaseRecord("2.0.0"), + releaseRecord("3.0.0"), + ]); + const client = new DirectPdsClient({ + did, + pds, + fetch: routeFetch(() => jsonResponse(listResponse(records))), + }); + + await expect( + client.listPackageReleases("gallery", { maxRecords: 2, pageSize: 2 }), + ).rejects.toMatchObject({ code: "ENUMERATION_TRUNCATED" }); + }); + + it("rejects a continuation cursor that makes no record progress", async () => { + const client = new DirectPdsClient({ + did, + pds, + fetch: routeFetch(() => jsonResponse(listResponse([], "same"))), + }); + await expect( + client.listPackageReleases("gallery", { maxRecords: 10, pageSize: 5 }), + ).rejects.toMatchObject({ code: "ENUMERATION_TRUNCATED" }); + }); + + it("rejects repeated cursors even when each page contains records", async () => { + let page = 0; + const client = new DirectPdsClient({ + did, + pds, + fetch: routeFetch(async () => + jsonResponse(listResponse([await releaseRecord(`${++page}.0.0`)], "same")), + ), + }); + await expect( + client.listPackageReleases("gallery", { maxRecords: 10, pageSize: 5 }), + ).rejects.toMatchObject({ code: "ENUMERATION_CURSOR_REPEATED" }); + }); + + it("rejects duplicate rkeys and malformed target records as ambiguous", async () => { + const duplicate = await releaseRecord("1.0.0"); + const client = new DirectPdsClient({ + did, + pds, + fetch: routeFetch(() => jsonResponse(listResponse([duplicate, duplicate]))), + }); + await expect(client.listPackageReleases("gallery")).rejects.toMatchObject({ + code: "RELEASE_ENUMERATION_AMBIGUOUS", + }); + + const malformed = await releaseRecord("1.0.0"); + malformed.value = { ...malformed.value, version: "not-semver" }; + const malformedClient = new DirectPdsClient({ + did, + pds, + fetch: routeFetch(() => jsonResponse(listResponse([malformed]))), + }); + await expect(malformedClient.listPackageReleases("gallery")).rejects.toBeInstanceOf( + DirectPdsReadError, + ); + }); + + it("rejects an enumerated release value that does not match its claimed CID", async () => { + const record = await releaseRecord("1.0.0"); + record.value.artifacts.package.url = + "https://github.com/example/gallery/releases/tampered.tar.gz"; + const client = new DirectPdsClient({ + did, + pds, + fetch: routeFetch(() => jsonResponse(listResponse([record]))), + }); + + await expect(client.listPackageReleases("gallery")).rejects.toMatchObject({ + code: "RECORD_CID_MISMATCH", + }); + }); + + it("rejects oversized declared and streamed response bodies", async () => { + const declaredClient = new DirectPdsClient({ + did, + pds, + maxResponseBytes: 10, + fetch: routeFetch( + () => + new Response("{}", { + headers: { "content-length": "11", "content-type": "application/json" }, + }), + ), + }); + await expect(declaredClient.getPackageProfile("gallery")).rejects.toMatchObject({ + code: "PDS_RESPONSE_TOO_LARGE", + }); + + const streamedClient = new DirectPdsClient({ + did, + pds, + maxResponseBytes: 3, + fetch: routeFetch(() => streamedResponse([Uint8Array.of(1, 2), Uint8Array.of(3, 4)])), + }); + await expect(streamedClient.getPackageProfile("gallery")).rejects.toMatchObject({ + code: "PDS_RESPONSE_TOO_LARGE", + }); + }); + + it("times out a stalled direct-PDS request", async () => { + const client = new DirectPdsClient({ + did, + pds, + requestTimeoutMs: 10, + fetch: vi.fn(() => new Promise(() => undefined)) as typeof fetch, + }); + + await expect(client.getPackageProfile("gallery")).rejects.toMatchObject({ + code: "PDS_REQUEST_TIMEOUT", + }); + }); + + it("times out a stalled direct-PDS response body", async () => { + const client = new DirectPdsClient({ + did, + pds, + requestTimeoutMs: 10, + fetch: routeFetch(() => stalledResponse()), + }); + + await expect(client.getPackageProfile("gallery")).rejects.toMatchObject({ + code: "PDS_REQUEST_TIMEOUT", + }); + }); + + it("composes an external abort signal without starting a pre-aborted request", async () => { + const controller = new AbortController(); + controller.abort(); + const fetch = routeFetch(() => profileResponse()); + const client = new DirectPdsClient({ did, pds, signal: controller.signal, fetch }); + + await expect(client.getPackageProfile("gallery")).rejects.toMatchObject({ + code: "PDS_REQUEST_ABORTED", + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("accepts valid responses within explicit request bounds", async () => { + const client = new DirectPdsClient({ + did, + pds, + requestTimeoutMs: 1_000, + maxResponseBytes: 100_000, + fetch: routeFetch(() => profileResponse()), + }); + + await expect(client.getPackageProfile("gallery")).resolves.toMatchObject({ cid: profileCid }); + }); + + it.each([ + { requestTimeoutMs: 0 }, + { requestTimeoutMs: Number.POSITIVE_INFINITY }, + { requestTimeoutMs: 2_147_483_648 }, + { maxResponseBytes: 0 }, + { maxResponseBytes: 1.5 }, + ])("rejects invalid request limits: %o", (options) => { + expect(() => new DirectPdsClient({ did, pds, ...options })).toThrow(RangeError); + }); +}); + +function routeFetch(respond: (url: URL) => Response | Promise) { + return vi.fn(async (input: string | URL | Request) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.origin !== pds) throw new Error(`Unexpected non-PDS request: ${url}`); + return await respond(url); + }) as unknown as ReturnType & typeof fetch; +} + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +async function profileResponse(value: unknown = profileFixture, cid?: string): Promise { + return jsonResponse({ + uri: `at://${did}/com.emdashcms.experimental.package.profile/gallery`, + cid: cid ?? (await recordCid(value)), + value, + }); +} + +async function releaseResponse(value: unknown, cid?: string): Promise { + return jsonResponse({ + uri: `at://${did}/com.emdashcms.experimental.package.release/gallery:1.2.3`, + cid: cid ?? (await recordCid(value)), + value, + }); +} + +function listResponse(records: unknown[], cursor?: string) { + return { records, ...(cursor ? { cursor } : {}) }; +} + +async function releaseRecord(version: string) { + const value = { ...structuredClone(releaseFixture), version }; + return { + uri: `at://${did}/com.emdashcms.experimental.package.release/gallery:${version}`, + cid: await recordCid(value), + value, + }; +} + +function typedRelease(version: string) { + return { + uri: `at://${did}/com.emdashcms.experimental.package.release/gallery:${version}`, + cid: releaseCid, + rkey: `gallery:${version}`, + value: { ...structuredClone(releaseFixture), version } as PackageRelease.Main, + }; +} + +async function recordCid(value: unknown): Promise { + return CID.toString(await CID.create(CID.CODEC_DCBOR, encode(value))); +} + +function streamedResponse(chunks: Uint8Array[]): Response { + return new Response( + new ReadableStream({ + pull(controller) { + const chunk = chunks.shift(); + if (chunk) controller.enqueue(chunk); + else controller.close(); + }, + }), + { headers: { "content-type": "application/json" } }, + ); +} + +function stalledResponse(): Response { + return new Response(new ReadableStream({ start() {} }), { + headers: { "content-type": "application/json" }, + }); +} diff --git a/packages/registry-client/tsdown.config.ts b/packages/registry-client/tsdown.config.ts index 988f2d06c8..9e25617c02 100644 --- a/packages/registry-client/tsdown.config.ts +++ b/packages/registry-client/tsdown.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ "src/index.ts", "src/credentials/index.ts", "src/discovery/index.ts", + "src/direct-pds/index.ts", "src/env/index.ts", "src/publishing/index.ts", ], @@ -20,6 +21,8 @@ export default defineConfig({ inlineOnly: false, external: [ "@atcute/atproto", + "@atcute/cbor", + "@atcute/cid", "@atcute/client", "@atcute/lexicons", "@atcute/lexicons/syntax", diff --git a/packages/registry-verification/fixtures/records/profile.json b/packages/registry-verification/fixtures/records/profile.json new file mode 100644 index 0000000000..303a5bc94c --- /dev/null +++ b/packages/registry-verification/fixtures/records/profile.json @@ -0,0 +1,15 @@ +{ + "$type": "com.emdashcms.experimental.package.profile", + "id": "at://did:plc:publisher/com.emdashcms.experimental.package.profile/gallery", + "type": "emdash-plugin", + "name": "Gallery", + "license": "MIT", + "authors": [{ "name": "Example Publisher" }], + "security": [{ "email": "security@example.com" }], + "extensions": { + "com.emdashcms.experimental.package.profileExtension": { + "$type": "com.emdashcms.experimental.package.profileExtension", + "repository": "https://github.com/example/gallery" + } + } +} diff --git a/packages/registry-verification/fixtures/records/release.json b/packages/registry-verification/fixtures/records/release.json new file mode 100644 index 0000000000..623b653219 --- /dev/null +++ b/packages/registry-verification/fixtures/records/release.json @@ -0,0 +1,17 @@ +{ + "$type": "com.emdashcms.experimental.package.release", + "package": "gallery", + "version": "1.2.3", + "artifacts": { + "package": { + "url": "https://github.com/example/gallery/releases/download/v1.2.3/gallery.tar.gz", + "checksum": "bciqkkpvkbtfcwq6kjkbq3kgjxe5j6ihzkxlfxkzqhwzaaaa3wkbq3a" + } + }, + "extensions": { + "com.emdashcms.experimental.package.releaseExtension": { + "$type": "com.emdashcms.experimental.package.releaseExtension", + "declaredAccess": {} + } + } +} diff --git a/packages/registry-verification/src/errors.ts b/packages/registry-verification/src/errors.ts index c78bad8a36..c8355a0ce8 100644 --- a/packages/registry-verification/src/errors.ts +++ b/packages/registry-verification/src/errors.ts @@ -18,12 +18,26 @@ export type VerificationErrorCode = | "HOST_REJECTED" | "INVALID_MULTIHASH" | "INVALID_URL" + | "PROFILE_EXTENSION_INVALID" + | "PROFILE_EXTENSION_MISSING" + | "PROFILE_ID_MISMATCH" + | "PROFILE_LEXICON_INVALID" + | "PROFILE_POLICY_INVALID" + | "PROFILE_REPOSITORY_INVALID" + | "PROVENANCE_REQUIRED" | "PROVENANCE_UNVERIFIABLE" | "REDIRECT_LIMIT_EXCEEDED" | "REDIRECT_LOCATION_MISSING" | "RESOURCE_SIZE_EXCEEDED" | "RESOURCE_STATUS_ERROR" | "RESOURCE_TIMEOUT" + | "RELEASE_EXTENSION_INVALID" + | "RELEASE_EXTENSION_MISSING" + | "RELEASE_LEXICON_INVALID" + | "RELEASE_PACKAGE_MISMATCH" + | "RELEASE_RKEY_MISMATCH" + | "RELEASE_VERSION_INVALID" + | "RELEASE_VERSION_MISMATCH" | "UNSUPPORTED_MULTIHASH"; export interface VerificationError { diff --git a/packages/registry-verification/src/index.ts b/packages/registry-verification/src/index.ts index 3f3f20336f..919140eaf0 100644 --- a/packages/registry-verification/src/index.ts +++ b/packages/registry-verification/src/index.ts @@ -15,6 +15,8 @@ export { } from "./bundle-limits.js"; export { validatePluginBundle } from "./bundle.js"; export { GitHubProvenanceVerifier } from "./provenance.js"; +export { canonicalizeRepositoryUrl } from "./repository.js"; +export { verifyPackageReleaseRecords } from "./records.js"; export type { DecodedMultihash, MultihashAlgorithm } from "./checksum.js"; export type { FetchImplementation, @@ -30,3 +32,13 @@ export type { ReleaseProvenance, VerifiedProvenance, } from "./provenance.js"; +export type { + NormalizedReleasePolicy, + ProvenanceEvidence, + ProvenanceStatus, + RecordVerificationCode, + RecordVerificationInput, + RecordVerificationReason, + RecordVerificationReport, + VerifiedRecordContext, +} from "./records.js"; diff --git a/packages/registry-verification/src/provenance.ts b/packages/registry-verification/src/provenance.ts index 436969d00e..b4be733bcf 100644 --- a/packages/registry-verification/src/provenance.ts +++ b/packages/registry-verification/src/provenance.ts @@ -11,6 +11,7 @@ import { toSignedEntity, toTrustMaterial, Verifier } from "@sigstore/verify"; import { compareDigestBytes, verifyMultihash } from "./checksum.js"; import { verificationError } from "./errors.js"; import type { VerificationResult } from "./errors.js"; +import { canonicalizeRepositoryUrl } from "./repository.js"; import trustedRootJson from "./trust-roots/sigstore-public-good-v1.json"; const STATEMENT_TYPE = "https://in-toto.io/Statement/v1"; @@ -22,7 +23,6 @@ const DSSE_PAYLOAD_TYPE = "application/vnd.in-toto+json"; const FULCIO_OID_PREFIX = "1.3.6.1.4.1.57264.1."; const GIT_COMMIT_RE = /^[0-9a-f]{40}$/; const GITHUB_WORKFLOW_PATH_RE = /^\.github\/workflows\/.+\.ya?ml$/; -const TRAILING_SLASHES_RE = /\/+$/; const DECIMAL_RE = /^[1-9][0-9]*$/; const BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; const HEX_RE = /^[0-9a-f]+$/; @@ -175,14 +175,14 @@ function validateStatement( } validateArtifactSubject(statement.subject, input.artifactDigest); - const profileRepository = canonicaliseRepository(input.profileRepository); - const referenceRepository = canonicaliseRepository(input.reference.sourceRepository); + const profileRepository = requireRepository(input.profileRepository); + const referenceRepository = requireRepository(input.reference.sourceRepository); const buildDefinition = requireObject(statement.predicate.buildDefinition); if (requireString(buildDefinition.buildType) !== GITHUB_WORKFLOW_BUILD_TYPE) { throw new Error("Unsupported SLSA build type"); } const workflow = requireObject(requireObject(buildDefinition.externalParameters).workflow); - const attestedRepository = canonicaliseRepository(requireString(workflow.repository)); + const attestedRepository = requireRepository(requireString(workflow.repository)); if (attestedRepository !== referenceRepository || referenceRepository !== profileRepository) { throw new Error("Repository identity mismatch"); } @@ -263,7 +263,7 @@ function validateSignerIdentity( const issuer = readRequiredOid(oids, 8); const buildSignerUri = readRequiredOid(oids, 9); const buildSignerDigest = readRequiredOid(oids, 10); - const sourceRepository = canonicaliseRepository(readRequiredOid(oids, 12)); + const sourceRepository = requireRepository(readRequiredOid(oids, 12)); const sourceDigest = readRequiredOid(oids, 13); const sourceRef = readRequiredOid(oids, 14); const repositoryId = readRequiredOid(oids, 15); @@ -324,24 +324,10 @@ function decodeDerUtf8String(value: Uint8Array): string { return decoder.decode(value.subarray(offset)); } -function canonicaliseRepository(value: string): string { - const url = new URL(value); - if ( - url.protocol !== "https:" || - url.username || - url.password || - url.search || - url.hash || - url.port - ) { - throw new Error("Invalid repository URL"); - } - let path = url.pathname; - if (path !== "/") { - path = path.replace(TRAILING_SLASHES_RE, ""); - if (path === "") path = "/"; - } - return `https://${url.hostname.toLowerCase()}${path}`; +function requireRepository(value: string): string { + const repository = canonicalizeRepositoryUrl(value); + if (!repository) throw new Error("Invalid repository URL"); + return repository; } function requireHttpsUrl(value: string): string { diff --git a/packages/registry-verification/src/records.ts b/packages/registry-verification/src/records.ts new file mode 100644 index 0000000000..fbc417be42 --- /dev/null +++ b/packages/registry-verification/src/records.ts @@ -0,0 +1,335 @@ +import { + canonicalizeDeclaredAccess, + isPluginSlug, + isPluginVersion, +} from "@emdash-cms/plugin-types"; +import type { CanonicalDeclaredAccess } from "@emdash-cms/plugin-types"; +import { + NSID, + PackageProfile, + PackageProfileExtension, + PackageRelease, + PackageReleaseExtension, +} from "@emdash-cms/registry-lexicons"; + +import { compareDigestBytes, decodeMultihash } from "./checksum.js"; +import type { VerificationErrorCode } from "./errors.js"; +import { GitHubProvenanceVerifier } from "./provenance.js"; +import type { ProvenanceVerifier, VerifiedProvenance } from "./provenance.js"; +import { canonicalizeRepositoryUrl } from "./repository.js"; + +export interface NormalizedReleasePolicy { + requireProvenance: boolean; + confirmation: "escalation-only" | "always"; + approvers: string[]; +} + +export interface ProvenanceEvidence { + document: Uint8Array; + artifactDigest: Uint8Array; + verifier?: ProvenanceVerifier; +} + +export interface RecordVerificationInput { + publisherDid: string; + package: string; + version: string; + rkey: string; + profile: unknown; + release: unknown; + provenance?: ProvenanceEvidence; +} + +export type RecordVerificationCode = + | VerificationErrorCode + | "PROVENANCE_ABSENT_OPTIONAL" + | "VERIFIED"; + +export type ProvenanceStatus = + | "not-checked" + | "absent-optional" + | "absent-required" + | "verified" + | "failed"; + +export interface RecordVerificationReason { + code: RecordVerificationCode; + message: string; +} + +export interface VerifiedRecordContext { + profile: PackageProfile.Main; + release: PackageRelease.Main; + profileExtension: PackageProfileExtension.Main; + releaseExtension: PackageReleaseExtension.Main; + repository: string; + policy: NormalizedReleasePolicy; + declaredAccess: CanonicalDeclaredAccess; + verifiedProvenance?: VerifiedProvenance; +} + +export type RecordVerificationReport = + | { + success: true; + status: "verified" | "unattested"; + code: "VERIFIED" | "PROVENANCE_ABSENT_OPTIONAL"; + reasons: RecordVerificationReason[]; + provenance: { status: "verified" | "absent-optional" }; + value: VerifiedRecordContext; + } + | { + success: false; + status: "failed"; + code: VerificationErrorCode; + reasons: RecordVerificationReason[]; + provenance: { status: ProvenanceStatus }; + }; + +const DEFAULT_POLICY: NormalizedReleasePolicy = { + requireProvenance: false, + confirmation: "escalation-only", + approvers: [], +}; + +/** Validate signed profile/release records and apply the complete provenance policy. */ +export async function verifyPackageReleaseRecords( + input: RecordVerificationInput, +): Promise { + const profile = await parseLexicon(PackageProfile.mainSchema, input.profile); + if (!profile) return failed("PROFILE_LEXICON_INVALID", "The package profile is malformed."); + + if (!isPluginSlug(input.package)) { + return failed("RELEASE_PACKAGE_MISMATCH", "The expected package slug is invalid."); + } + const expectedProfileId = `at://${input.publisherDid}/${NSID.packageProfile}/${input.package}`; + if ( + profile.id !== expectedProfileId || + (profile.slug !== undefined && profile.slug !== input.package) + ) { + return failed( + "PROFILE_ID_MISMATCH", + "The profile does not match its authoritative record identity.", + ); + } + + if (profile.extensions === undefined) { + return failed("PROFILE_EXTENSION_MISSING", "The signed repository extension is absent."); + } + if (!isRecord(profile.extensions)) { + return failed("PROFILE_EXTENSION_INVALID", "The signed repository extension is malformed."); + } + const profileExtensions = profile.extensions; + const rawProfileExtension = profileExtensions[NSID.packageProfileExtension]; + if (rawProfileExtension === undefined) { + return failed("PROFILE_EXTENSION_MISSING", "The signed repository extension is absent."); + } + const profileExtension = await parseLexicon( + PackageProfileExtension.mainSchema, + rawProfileExtension, + ); + if (!profileExtension) { + return failed("PROFILE_EXTENSION_INVALID", "The signed repository extension is malformed."); + } + const repository = canonicalizeRepositoryUrl(profileExtension.repository); + if (!repository || repository !== profileExtension.repository) { + return failed( + "PROFILE_REPOSITORY_INVALID", + "The signed repository anchor is not canonical HTTPS.", + ); + } + const policy = normalizePolicy(profileExtension.releasePolicy); + if (!policy) return failed("PROFILE_POLICY_INVALID", "The signed release policy is invalid."); + + const release = await parseLexicon(PackageRelease.mainSchema, input.release); + if (!release) return failed("RELEASE_LEXICON_INVALID", "The package release is malformed."); + if (release.package !== input.package) { + return failed( + "RELEASE_PACKAGE_MISMATCH", + "The release package does not match the requested package.", + ); + } + if (!isComparablePluginVersion(release.version)) { + return failed("RELEASE_VERSION_INVALID", "The release version is not canonical semver."); + } + if (release.version !== input.version) { + return failed( + "RELEASE_VERSION_MISMATCH", + "The release version does not match the requested version.", + ); + } + if (input.rkey !== `${input.package}:${release.version}`) { + return failed( + "RELEASE_RKEY_MISMATCH", + "The release record key does not match package and version.", + ); + } + + if (release.extensions === undefined) { + return failed("RELEASE_EXTENSION_MISSING", "The EmDash release extension is absent."); + } + if (!isRecord(release.extensions)) { + return failed("RELEASE_EXTENSION_INVALID", "The EmDash release extension is malformed."); + } + const releaseExtensions = release.extensions; + const rawReleaseExtension = releaseExtensions[NSID.packageReleaseExtension]; + if (rawReleaseExtension === undefined) { + return failed("RELEASE_EXTENSION_MISSING", "The EmDash release extension is absent."); + } + const releaseExtension = await parseLexicon( + PackageReleaseExtension.mainSchema, + rawReleaseExtension, + ); + if (!releaseExtension) { + return failed("RELEASE_EXTENSION_INVALID", "The EmDash release extension is malformed."); + } + const declaredAccess = canonicalizeDeclaredAccess(releaseExtension.declaredAccess); + + if (!releaseExtension.provenance) { + if (policy.requireProvenance) { + return failed( + "PROVENANCE_REQUIRED", + "The signed policy requires provenance, but the release has none.", + "absent-required", + ); + } + return { + success: true, + status: "unattested", + code: "PROVENANCE_ABSENT_OPTIONAL", + reasons: [ + { + code: "PROVENANCE_ABSENT_OPTIONAL", + message: "The release has no provenance and the signed policy permits it.", + }, + ], + provenance: { status: "absent-optional" }, + value: { + profile, + release, + profileExtension, + releaseExtension, + repository, + policy, + declaredAccess, + }, + }; + } + + if (!input.provenance) { + return failed( + "PROVENANCE_UNVERIFIABLE", + "The release supplies provenance, but no verification evidence was provided.", + "failed", + ); + } + const artifactChecksum = decodeMultihash(release.artifacts.package.checksum); + if (!artifactChecksum.success) { + return failed(artifactChecksum.error.code, artifactChecksum.error.message, "failed"); + } + if (!compareDigestBytes(input.provenance.artifactDigest, artifactChecksum.value.digest)) { + return failed( + "CHECKSUM_MISMATCH", + "The artifact digest does not match the signed package checksum.", + "failed", + ); + } + const verifier = input.provenance.verifier ?? new GitHubProvenanceVerifier(); + let provenanceResult: Awaited>; + try { + provenanceResult = await verifier.verify({ + document: input.provenance.document, + reference: releaseExtension.provenance, + artifactDigest: input.provenance.artifactDigest, + profileRepository: repository, + }); + } catch { + return failed( + "PROVENANCE_UNVERIFIABLE", + "The supplied provenance could not be verified.", + "failed", + ); + } + if (!provenanceResult.success) { + return failed(provenanceResult.error.code, provenanceResult.error.message, "failed"); + } + + return { + success: true, + status: "verified", + code: "VERIFIED", + reasons: [{ code: "VERIFIED", message: "The signed records and provenance are valid." }], + provenance: { status: "verified" }, + value: { + profile, + release, + profileExtension, + releaseExtension, + repository, + policy, + declaredAccess, + verifiedProvenance: provenanceResult.value, + }, + }; +} + +function normalizePolicy( + value: PackageProfileExtension.ReleasePolicy | undefined, +): NormalizedReleasePolicy | null { + if (!value) return { ...DEFAULT_POLICY, approvers: [] }; + const confirmation = value.confirmation ?? DEFAULT_POLICY.confirmation; + if (!isConfirmation(confirmation)) return null; + const approvers = value.approvers ?? []; + if (new Set(approvers).size !== approvers.length) return null; + return { + requireProvenance: value.requireProvenance ?? false, + confirmation, + approvers: [...approvers], + }; +} + +function isConfirmation(value: string): value is NormalizedReleasePolicy["confirmation"] { + return value === "always" || value === "escalation-only"; +} + +function isComparablePluginVersion(value: string): boolean { + if (!isPluginVersion(value)) return false; + const prereleaseSeparator = value.indexOf("-"); + const core = value.slice(0, prereleaseSeparator === -1 ? undefined : prereleaseSeparator); + return core.split(".").every((component) => Number.isSafeInteger(Number(component))); +} + +function failed( + code: VerificationErrorCode, + message: string, + provenance: ProvenanceStatus = "not-checked", +): RecordVerificationReport { + return { + success: false, + status: "failed", + code, + reasons: [{ code, message }], + provenance: { status: provenance }, + }; +} + +interface StandardSchema { + readonly "~standard": { + validate( + value: unknown, + ): + | { value: T; issues?: undefined } + | { value?: undefined; issues: readonly unknown[] } + | Promise< + { value: T; issues?: undefined } | { value?: undefined; issues: readonly unknown[] } + >; + }; +} + +async function parseLexicon(schema: StandardSchema, value: unknown): Promise { + const result = await schema["~standard"].validate(value); + return result.issues ? null : result.value; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/registry-verification/src/repository.ts b/packages/registry-verification/src/repository.ts new file mode 100644 index 0000000000..71a1b003b8 --- /dev/null +++ b/packages/registry-verification/src/repository.ts @@ -0,0 +1,23 @@ +const TRAILING_SLASHES_RE = /\/+$/; + +/** Canonicalize a signed source repository URL, or return null when it is unsafe. */ +export function canonicalizeRepositoryUrl(value: string): string | null { + try { + const url = new URL(value); + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.search || + url.hash || + url.port + ) { + return null; + } + let path = url.pathname; + if (path !== "/") path = path.replace(TRAILING_SLASHES_RE, "") || "/"; + return `https://${url.hostname.toLowerCase()}${path}`; + } catch { + return null; + } +} diff --git a/packages/registry-verification/tests/records.test.ts b/packages/registry-verification/tests/records.test.ts new file mode 100644 index 0000000000..268d702972 --- /dev/null +++ b/packages/registry-verification/tests/records.test.ts @@ -0,0 +1,316 @@ +import type { PackageProfile, PackageRelease } from "@emdash-cms/registry-lexicons"; +import { describe, expect, it, vi } from "vitest"; + +import profileFixture from "../fixtures/records/profile.json"; +import releaseFixture from "../fixtures/records/release.json"; +import type { + ProvenanceVerifier, + RecordVerificationInput, + ReleaseProvenance, + VerifiedProvenance, +} from "../src/index.js"; +import { verifyPackageReleaseRecords } from "../src/index.js"; + +const publisherDid = "did:plc:publisher"; +const packageSlug = "gallery"; +const version = "1.2.3"; +const rkey = `${packageSlug}:${version}`; +const repository = "https://github.com/example/gallery"; +const provenance: ReleaseProvenance = { + predicateType: "https://slsa.dev/provenance/v1", + url: "https://github.com/example/gallery/attestation.sigstore.json", + checksum: "bciqkkpvkbtfcwq6kjkbq3kgjxe5j6ihzkxlfxkzqhwzaaaa3wkbq3a", + sourceRepository: repository, + builderId: `${repository}/.github/workflows/release.yml@refs/heads/main`, +}; +const artifactChecksum = "bciqcz4snxjp3biyoe3udwkwfxhrj4gywdzob7j2clzzqim3csofzqja"; +const artifactDigest = Uint8Array.of( + 44, + 242, + 77, + 186, + 95, + 176, + 163, + 14, + 38, + 232, + 59, + 42, + 197, + 185, + 226, + 158, + 27, + 22, + 30, + 92, + 31, + 167, + 66, + 94, + 115, + 4, + 51, + 98, + 147, + 139, + 152, + 36, +); +const provenanceDocument = Uint8Array.of(4, 5, 6); + +describe("verifyPackageReleaseRecords", () => { + it("validates records and normalizes an absent policy to its defaults", async () => { + const report = await verify(); + + expect(report).toMatchObject({ + success: true, + status: "unattested", + code: "PROVENANCE_ABSENT_OPTIONAL", + provenance: { status: "absent-optional" }, + value: { + repository, + policy: { + requireProvenance: false, + confirmation: "escalation-only", + approvers: [], + }, + }, + }); + }); + + it.each([ + ["profile", { profile: { nope: true } }, "PROFILE_LEXICON_INVALID"], + ["release", { release: { nope: true } }, "RELEASE_LEXICON_INVALID"], + ])("fails closed on malformed %s data", async (_name, override, code) => { + expect(await verify(override)).toMatchObject({ success: false, status: "failed", code }); + }); + + it("requires the profile id to resolve to the expected signed record", async () => { + const profile = cloneProfile(); + profile.id = profile.id.replace("/gallery", "/other"); + expect(await verify({ profile })).toMatchObject({ + success: false, + code: "PROFILE_ID_MISMATCH", + }); + }); + + it.each([ + ["package", { release: { ...releaseFixture, package: "other" } }, "RELEASE_PACKAGE_MISMATCH"], + ["rkey", { rkey: "gallery:9.9.9" }, "RELEASE_RKEY_MISMATCH"], + ["version", { version: "9.9.9" }, "RELEASE_VERSION_MISMATCH"], + ])("rejects a release %s mismatch", async (_name, override, code) => { + expect(await verify(override)).toMatchObject({ success: false, code }); + }); + + it("rejects malformed semver even when the rkey matches", async () => { + const release = cloneRelease(); + release.version = "01.2.3"; + expect(await verify({ release, version: "01.2.3", rkey: "gallery:01.2.3" })).toMatchObject({ + success: false, + code: "RELEASE_VERSION_INVALID", + }); + }); + + it("rejects semver that cannot participate in baseline comparison", async () => { + const unsafeVersion = "9007199254740992.0.0"; + const release = cloneRelease(); + release.version = unsafeVersion; + expect( + await verify({ release, version: unsafeVersion, rkey: `gallery:${unsafeVersion}` }), + ).toMatchObject({ + success: false, + code: "RELEASE_VERSION_INVALID", + }); + }); + + it.each([ + "http://github.com/example/gallery", + "https://github.com/example/gallery/", + "https://GITHUB.com/example/gallery", + "https://github.com/example/gallery?ref=main", + ])("rejects a non-canonical repository anchor: %s", async (repositoryValue) => { + const profile = cloneProfile(); + profile.extensions["com.emdashcms.experimental.package.profileExtension"].repository = + repositoryValue; + expect(await verify({ profile })).toMatchObject({ + success: false, + code: "PROFILE_REPOSITORY_INVALID", + }); + }); + + it("rejects unknown policy values and duplicate approvers", async () => { + const profile = cloneProfile(); + profile.extensions["com.emdashcms.experimental.package.profileExtension"].releasePolicy = { + confirmation: "manual-review", + approvers: [publisherDid, publisherDid], + }; + expect(await verify({ profile })).toMatchObject({ + success: false, + code: "PROFILE_POLICY_INVALID", + }); + }); + + it("distinguishes absent required provenance", async () => { + const profile = cloneProfile(); + profile.extensions["com.emdashcms.experimental.package.profileExtension"].releasePolicy = { + requireProvenance: true, + }; + expect(await verify({ profile })).toMatchObject({ + success: false, + code: "PROVENANCE_REQUIRED", + provenance: { status: "absent-required" }, + }); + }); + + it("accepts present and valid provenance", async () => { + const release = withProvenance(); + const verifier = verifierReturning({ + success: true, + value: { + predicateType: "https://slsa.dev/provenance/v1", + artifactDigest, + sourceRepository: repository, + builderId: provenance.builderId, + }, + }); + + const report = await verify({ + release, + provenance: { document: provenanceDocument, artifactDigest, verifier }, + }); + + expect(report).toMatchObject({ + success: true, + status: "verified", + code: "VERIFIED", + provenance: { status: "verified" }, + }); + expect(verifier.verify).toHaveBeenCalledWith({ + document: provenanceDocument, + reference: provenance, + artifactDigest, + profileRepository: repository, + }); + }); + + it("binds provenance evidence to the signed package artifact checksum", async () => { + const verifier = verifierReturning({ + success: true, + value: { + predicateType: "https://slsa.dev/provenance/v1", + artifactDigest, + sourceRepository: repository, + builderId: provenance.builderId, + }, + }); + + expect( + await verify({ + release: withProvenance(), + provenance: { + document: provenanceDocument, + artifactDigest: Uint8Array.of(1, 2, 3), + verifier, + }, + }), + ).toMatchObject({ success: false, code: "CHECKSUM_MISMATCH" }); + expect(verifier.verify).not.toHaveBeenCalled(); + }); + + it.each([ + ["missing verification evidence", undefined], + [ + "failed verifier result", + { + document: provenanceDocument, + artifactDigest, + verifier: verifierReturning({ + success: false, + error: { + code: "PROVENANCE_UNVERIFIABLE", + message: "The supplied provenance could not be verified.", + }, + }), + }, + ], + ] as const)("rejects present provenance with %s", async (_name, evidence) => { + expect(await verify({ release: withProvenance(), provenance: evidence })).toMatchObject({ + success: false, + code: "PROVENANCE_UNVERIFIABLE", + provenance: { status: "failed" }, + }); + }); + + it("converts a throwing provenance adapter into the stable failed report", async () => { + const verifier: ProvenanceVerifier = { + verify: vi.fn().mockRejectedValue(new Error("upstream details")), + }; + expect( + await verify({ + release: withProvenance(), + provenance: { document: provenanceDocument, artifactDigest, verifier }, + }), + ).toMatchObject({ + success: false, + code: "PROVENANCE_UNVERIFIABLE", + provenance: { status: "failed" }, + }); + }); +}); + +function verify(override: Partial = {}) { + return verifyPackageReleaseRecords({ + publisherDid, + package: packageSlug, + version, + rkey, + profile: cloneProfile(), + release: cloneRelease(), + ...override, + }); +} + +function cloneProfile() { + return structuredClone(profileFixture) as PackageProfile.Main & { + extensions: Record< + string, + { + repository: string; + releasePolicy?: { + requireProvenance?: boolean; + confirmation?: string; + approvers?: string[]; + }; + } + >; + }; +} + +function cloneRelease() { + return structuredClone(releaseFixture) as PackageRelease.Main & { + extensions: Record; + }; +} + +function withProvenance() { + const release = cloneRelease(); + release.artifacts.package.checksum = artifactChecksum; + release.extensions["com.emdashcms.experimental.package.releaseExtension"].provenance = provenance; + return release; +} + +function verifierReturning( + result: + | { success: true; value: VerifiedProvenance } + | { + success: false; + error: { + code: "PROVENANCE_UNVERIFIABLE"; + message: string; + }; + }, +): ProvenanceVerifier & { verify: ReturnType } { + return { verify: vi.fn().mockResolvedValue(result) }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a05264660..a7e02af5fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2182,6 +2182,12 @@ importers: '@atcute/atproto': specifier: 'catalog:' version: 4.0.2(@atcute/lexicons@2.0.0) + '@atcute/cbor': + specifier: 'catalog:' + version: 2.3.3(@atcute/cid@2.4.1) + '@atcute/cid': + specifier: 'catalog:' + version: 2.4.1 '@atcute/client': specifier: 'catalog:' version: 5.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3) @@ -2959,9 +2965,6 @@ packages: '@atcute/cbor': ^2.0.0 '@atcute/cid': ^2.0.0 - '@atcute/cbor@2.3.2': - resolution: {integrity: sha512-xP2SORSau/VVI00x2V4BjwIkHr6EQ7l/MXEOPaa4LGYtePFc4gnD4L1yN10dT5NEuUnvGEuCh6arLB7gz1smVQ==} - '@atcute/cbor@2.3.3': resolution: {integrity: sha512-zZ4nHOK837zTMWJtta35YD7pcukrTzDc8jkpIGlSgoDYzu3l4BX3WVgpPJtRn3K6h2v97uyiWfiVjSpM7JSFzQ==} peerDependencies: @@ -12713,7 +12716,7 @@ snapshots: '@atcute/car@5.1.1': dependencies: - '@atcute/cbor': 2.3.2 + '@atcute/cbor': 2.3.3(@atcute/cid@2.4.1) '@atcute/cid': 2.4.1 '@atcute/uint8array': 1.1.1 '@atcute/varint': 2.0.0 @@ -12725,12 +12728,6 @@ snapshots: '@atcute/uint8array': 1.1.1 '@atcute/varint': 2.0.0 - '@atcute/cbor@2.3.2': - dependencies: - '@atcute/cid': 2.4.1 - '@atcute/multibase': 1.2.0 - '@atcute/uint8array': 1.1.1 - '@atcute/cbor@2.3.3(@atcute/cid@2.4.1)': dependencies: '@atcute/cid': 2.4.1 @@ -12879,7 +12876,7 @@ snapshots: '@atcute/mst@1.0.0': dependencies: - '@atcute/cbor': 2.3.2 + '@atcute/cbor': 2.3.3(@atcute/cid@2.4.1) '@atcute/cid': 2.4.1 '@atcute/uint8array': 1.1.1 @@ -12974,7 +12971,7 @@ snapshots: '@atcute/repo@0.1.4': dependencies: '@atcute/car': 5.1.1 - '@atcute/cbor': 2.3.2 + '@atcute/cbor': 2.3.3(@atcute/cid@2.4.1) '@atcute/cid': 2.4.1 '@atcute/crypto': 2.4.1 '@atcute/lexicons': 1.3.0 From 5b8ae32286149668dea0c87710ce91da29dc2b94 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 12 Jul 2026 09:18:54 +0100 Subject: [PATCH 16/24] feat(auth): add required passkey verification (#1970) * feat(auth): add required passkey verification * fix(auth): require atomic challenge consumption --- .changeset/passkey-uv-context.md | 5 + .../auth/src/passkey/authenticate.test.ts | 188 ++++++++++++++- packages/auth/src/passkey/authenticate.ts | 85 ++++++- .../src/passkey/challenge-context.test.ts | 115 ++++++++++ .../auth/src/passkey/challenge-context.ts | 215 ++++++++++++++++++ packages/auth/src/passkey/index.ts | 17 ++ packages/auth/src/passkey/register.test.ts | 112 ++++++++- packages/auth/src/passkey/register.ts | 71 +++++- packages/auth/src/passkey/types.ts | 17 ++ 9 files changed, 799 insertions(+), 26 deletions(-) create mode 100644 .changeset/passkey-uv-context.md create mode 100644 packages/auth/src/passkey/challenge-context.test.ts create mode 100644 packages/auth/src/passkey/challenge-context.ts diff --git a/.changeset/passkey-uv-context.md b/.changeset/passkey-uv-context.md new file mode 100644 index 0000000000..89d303b1f8 --- /dev/null +++ b/.changeset/passkey-uv-context.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/auth": minor +--- + +Adds configurable passkey user verification and typed, versioned WebAuthn challenge context while preserving preferred verification by default. diff --git a/packages/auth/src/passkey/authenticate.test.ts b/packages/auth/src/passkey/authenticate.test.ts index 7e3cc7b6a1..bf2537fd03 100644 --- a/packages/auth/src/passkey/authenticate.test.ts +++ b/packages/auth/src/passkey/authenticate.test.ts @@ -8,8 +8,18 @@ import { import { describe, it, expect, vi } from "vitest"; import type { AuthAdapter, Credential } from "../types.js"; -import { authenticateWithPasskey, PasskeyAuthenticationError } from "./authenticate.js"; -import type { ChallengeStore } from "./types.js"; +import { + authenticateWithPasskey, + generateAuthenticationOptions, + PasskeyAuthenticationError, + verifyAuthenticationResponse, +} from "./authenticate.js"; +import { + bindChallengeContext, + defineChallengeContext, + encodeChallengeContext, +} from "./challenge-context.js"; +import type { AtomicChallengeStore, ChallengeStore } from "./types.js"; const credential: Credential = { id: "registered-credential", @@ -51,7 +61,9 @@ function base64url(bytes: Uint8Array): string { return Buffer.from(bytes).toString("base64url"); } -function createValidAssertion(opts: { rpId?: string; origin?: string } = {}) { +function createValidAssertion( + opts: { rpId?: string; origin?: string; userVerified?: boolean; challenge?: string } = {}, +) { const rpId = opts.rpId ?? config.rpId; const origin = opts.origin ?? config.origins[0]; if (!origin) throw new Error("origin must be defined for createValidAssertion"); @@ -66,7 +78,7 @@ function createValidAssertion(opts: { rpId?: string; origin?: string } = {}) { Buffer.from(jwk.x, "base64url"), Buffer.from(jwk.y, "base64url"), ]); - const challenge = base64url(Buffer.from("test-challenge")); + const challenge = opts.challenge ?? base64url(Buffer.from("test-challenge")); const clientDataJSON = Buffer.from( JSON.stringify({ type: "webauthn.get", @@ -77,7 +89,8 @@ function createValidAssertion(opts: { rpId?: string; origin?: string } = {}) { const rpIdHash = createHash("sha256").update(rpId).digest(); const signatureCounter = Buffer.alloc(4); signatureCounter.writeUInt32BE(1); - const authenticatorData = Buffer.concat([rpIdHash, Buffer.from([0x01]), signatureCounter]); + const flags = opts.userVerified ? 0x05 : 0x01; + const authenticatorData = Buffer.concat([rpIdHash, Buffer.from([flags]), signatureCounter]); const signatureMessage = createAssertionSignatureMessage(authenticatorData, clientDataJSON); const signatureBytes = sign("sha256", signatureMessage, privateKey); @@ -104,6 +117,18 @@ function createValidAssertion(opts: { rpId?: string; origin?: string } = {}) { }; } +const approvalContext = defineChallengeContext("release-approval", 1, (value) => { + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) || + typeof (value as Record).intentId !== "string" + ) { + throw new Error("Invalid approval context"); + } + return { intentId: (value as Record).intentId }; +}); + function createValidRS256Assertion(opts: { rpId?: string; origin?: string } = {}) { const rpId = opts.rpId ?? config.rpId; const origin = opts.origin ?? config.origins[0]; @@ -160,6 +185,159 @@ function createValidRS256Assertion(opts: { rpId?: string; origin?: string } = {} } describe("authenticateWithPasskey", () => { + it.each(["preferred", "required", "discouraged"] as const)( + "wires %s user verification into authentication options", + async (userVerification) => { + const options = await generateAuthenticationOptions( + { ...config, userVerification }, + [], + createChallengeStore(), + ); + + expect(options.userVerification).toBe(userVerification); + }, + ); + + it("defaults authentication options to preferred user verification", async () => { + const options = await generateAuthenticationOptions(config, [], createChallengeStore()); + + expect(options.userVerification).toBe("preferred"); + }); + + it("rejects an assertion without UV when user verification is required", async () => { + const { credential: validCredential, response, challengeStore } = createValidAssertion(); + + await expect( + verifyAuthenticationResponse( + { ...config, userVerification: "required" }, + response, + validCredential, + challengeStore, + ), + ).rejects.toMatchObject({ code: "user_verification_not_verified" }); + }); + + it("accepts an assertion with UV when user verification is required", async () => { + const { + credential: validCredential, + response, + challengeStore, + } = createValidAssertion({ + userVerified: true, + }); + + await expect( + verifyAuthenticationResponse( + { ...config, userVerification: "required" }, + response, + validCredential, + challengeStore, + ), + ).resolves.toMatchObject({ credentialId: validCredential.id }); + }); + + it("returns typed context after atomic challenge consumption", async () => { + const initial = createValidAssertion({ userVerified: true }); + const generated = await generateAuthenticationOptions( + { ...config, userVerification: "required" }, + [initial.credential], + initial.challengeStore, + bindChallengeContext(approvalContext, { intentId: "intent_1" }), + ); + const store: ChallengeStore = initial.challengeStore; + const stored = vi.mocked(store.set).mock.calls[0]?.[1]; + if (!stored) throw new Error("Expected challenge data to be stored"); + const { credential: validCredential, response } = createValidAssertion({ + userVerified: true, + challenge: generated.challenge, + }); + const atomicStore = { + ...initial.challengeStore, + consume: vi.fn(async () => stored), + } satisfies AtomicChallengeStore; + + await expect( + verifyAuthenticationResponse( + { ...config, userVerification: "required" }, + response, + validCredential, + atomicStore, + approvalContext, + ), + ).resolves.toMatchObject({ challengeContext: { intentId: "intent_1" } }); + expect(atomicStore.consume).toHaveBeenCalledWith(generated.challenge); + expect(initial.challengeStore.delete).not.toHaveBeenCalled(); + }); + + it.each([ + ["undefined", undefined], + ["non-callable", "not-a-function"], + ] as const)("rejects a typed-context store with %s consume", async (_label, consume) => { + const initial = createValidAssertion({ userVerified: true }); + const generated = await generateAuthenticationOptions( + { ...config, userVerification: "required" }, + [initial.credential], + initial.challengeStore, + bindChallengeContext(approvalContext, { intentId: "intent_1" }), + ); + const { credential: validCredential, response } = createValidAssertion({ + userVerified: true, + challenge: generated.challenge, + }); + const malformedStore = { + ...initial.challengeStore, + consume, + } as unknown as AtomicChallengeStore; + + await expect( + verifyAuthenticationResponse( + { ...config, userVerification: "required" }, + response, + validCredential, + malformedStore, + approvalContext, + ), + ).rejects.toMatchObject({ + code: "invalid_response", + message: "Typed challenge context requires an atomic challenge store", + }); + expect(initial.challengeStore.get).not.toHaveBeenCalled(); + expect(initial.challengeStore.delete).not.toHaveBeenCalled(); + }); + + it("rejects stored context that does not match the signed challenge", async () => { + const initial = createValidAssertion({ userVerified: true }); + const generated = await generateAuthenticationOptions( + { ...config, userVerification: "required" }, + [initial.credential], + initial.challengeStore, + bindChallengeContext(approvalContext, { intentId: "intent_1" }), + ); + const store: ChallengeStore = initial.challengeStore; + const stored = vi.mocked(store.set).mock.calls[0]?.[1]; + if (!stored) throw new Error("Expected challenge data to be stored"); + const { credential: validCredential, response } = createValidAssertion({ + userVerified: true, + challenge: generated.challenge, + }); + const atomicStore = { + ...initial.challengeStore, + consume: vi.fn(async () => ({ + ...stored, + context: encodeChallengeContext(approvalContext, { intentId: "intent_2" }), + })), + } satisfies AtomicChallengeStore; + + await expect( + verifyAuthenticationResponse( + { ...config, userVerification: "required" }, + response, + validCredential, + atomicStore, + approvalContext, + ), + ).rejects.toMatchObject({ code: "context_binding_mismatch" }); + }); it("throws a typed passkey auth error for malformed assertion payloads", async () => { try { await authenticateWithPasskey( diff --git a/packages/auth/src/passkey/authenticate.ts b/packages/auth/src/passkey/authenticate.ts index 713592a9b2..445f22725c 100644 --- a/packages/auth/src/passkey/authenticate.ts +++ b/packages/auth/src/passkey/authenticate.ts @@ -29,12 +29,21 @@ import { import { generateToken } from "../tokens.js"; import type { Credential, AuthAdapter, User } from "../types.js"; +import { + createContextBoundChallenge, + decodeChallengeContext, + encodeChallengeContext, + verifyContextBoundChallenge, +} from "./challenge-context.js"; +import type { ChallengeContextBinding, ChallengeContextCodec } from "./challenge-context.js"; import type { AuthenticationOptions, AuthenticationResponse, VerifiedAuthentication, ChallengeStore, PasskeyConfig, + AtomicChallengeStore, + VerifiedAuthenticationWithContext, } from "./types.js"; const CHALLENGE_TTL = 5 * 60 * 1000; // 5 minutes @@ -49,6 +58,7 @@ export type PasskeyAuthenticationErrorCode = | "invalid_origin" | "invalid_rp_id_hash" | "user_presence_not_verified" + | "user_verification_not_verified" | "invalid_signature_counter" | "invalid_signature" | "unsupported_algorithm" @@ -100,24 +110,32 @@ function decodeAssertionSignature(signature: Uint8Array) { /** * Generate authentication options for signing in with a passkey */ -export async function generateAuthenticationOptions( +export async function generateAuthenticationOptions( config: PasskeyConfig, credentials: Credential[], challengeStore: ChallengeStore, + challengeContext?: ChallengeContextBinding, ): Promise { - const challenge = generateToken(); + const serializedContext = challengeContext + ? encodeChallengeContext(challengeContext.codec, challengeContext.value) + : undefined; + const nonce = generateToken(); + const challenge = serializedContext + ? createContextBoundChallenge(nonce, serializedContext) + : nonce; // Store challenge for verification await challengeStore.set(challenge, { type: "authentication", expiresAt: Date.now() + CHALLENGE_TTL, + ...(serializedContext ? { context: serializedContext } : {}), }); return { challenge, rpId: config.rpId, timeout: 60000, - userVerification: "preferred", + userVerification: config.userVerification ?? "preferred", allowCredentials: credentials.length > 0 ? credentials.map((cred) => ({ @@ -132,12 +150,26 @@ export async function generateAuthenticationOptions( /** * Verify an authentication response */ -export async function verifyAuthenticationResponse( +export function verifyAuthenticationResponse( + config: PasskeyConfig, + response: AuthenticationResponse, + credential: Credential, + challengeStore: AtomicChallengeStore, + challengeContext: ChallengeContextCodec, +): Promise>; +export function verifyAuthenticationResponse( config: PasskeyConfig, response: AuthenticationResponse, credential: Credential, challengeStore: ChallengeStore, -): Promise { +): Promise; +export async function verifyAuthenticationResponse( + config: PasskeyConfig, + response: AuthenticationResponse, + credential: Credential, + challengeStore: ChallengeStore, + challengeContext?: ChallengeContextCodec, +): Promise> { const { clientDataJSON, authenticatorData, signature, clientData } = decodeAuthenticationResponse(response); @@ -148,7 +180,17 @@ export async function verifyAuthenticationResponse( // Verify challenge - convert Uint8Array back to base64url string (no padding, matching stored format) const challengeString = encodeBase64urlNoPadding(clientData.challenge); - const challengeData = await challengeStore.get(challengeString); + const consume = "consume" in challengeStore ? challengeStore.consume : undefined; + const consumesAtomically = typeof consume === "function"; + if (challengeContext && !consumesAtomically) { + throw new PasskeyAuthenticationError( + "invalid_response", + "Typed challenge context requires an atomic challenge store", + ); + } + const challengeData = consumesAtomically + ? await consume.call(challengeStore, challengeString) + : await challengeStore.get(challengeString); if (!challengeData) { throw new PasskeyAuthenticationError("challenge_not_found", "Challenge not found or expired"); } @@ -156,12 +198,30 @@ export async function verifyAuthenticationResponse( throw new PasskeyAuthenticationError("invalid_challenge_type", "Invalid challenge type"); } if (challengeData.expiresAt < Date.now()) { - await challengeStore.delete(challengeString); + if (!consumesAtomically) await challengeStore.delete(challengeString); throw new PasskeyAuthenticationError("challenge_expired", "Challenge expired"); } // Delete challenge (single-use) - await challengeStore.delete(challengeString); + if (!consumesAtomically) await challengeStore.delete(challengeString); + let contextResult: { present: false } | { present: true; value: Context } = { + present: false, + }; + if (challengeData.context !== undefined) { + if (!challengeContext) { + throw new PasskeyAuthenticationError( + "invalid_response", + "Challenge context decoder required", + ); + } + verifyContextBoundChallenge(challengeString, challengeData.context); + contextResult = { + present: true, + value: decodeChallengeContext(challengeData.context, challengeContext), + }; + } else if (challengeContext) { + throw new PasskeyAuthenticationError("invalid_response", "Challenge context not found"); + } // Verify origin against the accepted list if (!config.origins.includes(clientData.origin)) { @@ -186,6 +246,12 @@ export async function verifyAuthenticationResponse( "User presence not verified", ); } + if (config.userVerification === "required" && !authData.userVerified) { + throw new PasskeyAuthenticationError( + "user_verification_not_verified", + "User verification not verified", + ); + } // Verify counter (prevent replay attacks) if (authData.signatureCounter !== 0 && authData.signatureCounter <= credential.counter) { @@ -233,10 +299,11 @@ export async function verifyAuthenticationResponse( throw new PasskeyAuthenticationError("invalid_signature", "Invalid signature"); } - return { + const verified: VerifiedAuthentication = { credentialId: response.id, newCounter: authData.signatureCounter, }; + return !contextResult.present ? verified : { ...verified, challengeContext: contextResult.value }; } /** diff --git a/packages/auth/src/passkey/challenge-context.test.ts b/packages/auth/src/passkey/challenge-context.test.ts new file mode 100644 index 0000000000..7e4b1e707f --- /dev/null +++ b/packages/auth/src/passkey/challenge-context.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; + +import { + bindChallengeContext, + decodeChallengeContext, + defineChallengeContext, + encodeChallengeContext, +} from "./challenge-context.js"; +import type { SerializedChallengeContext } from "./challenge-context.js"; + +function parseObject(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Expected an object"); + } + return value as Record; +} + +const approvalContext = defineChallengeContext("release-approval", 1, (value) => { + const object = parseObject(value); + if (typeof object.intentId !== "string" || object.action !== "approve") { + throw new Error("Invalid approval context"); + } + return { action: "approve" as const, intentId: object.intentId }; +}); + +const enrolmentContext = defineChallengeContext("passkey-enrolment", 1, (value) => { + const object = parseObject(value); + if (typeof object.approverDid !== "string") throw new Error("Invalid enrolment context"); + return { approverDid: object.approverDid }; +}); + +describe("challenge context", () => { + it("round-trips typed context with deterministic serialization", () => { + const first = encodeChallengeContext(approvalContext, { + intentId: "intent_1", + action: "approve", + }); + const second = encodeChallengeContext(approvalContext, { + action: "approve", + intentId: "intent_1", + }); + + expect(first).toBe(second); + expect(decodeChallengeContext(first, approvalContext)).toEqual({ + action: "approve", + intentId: "intent_1", + }); + expectTypeOf(first).toEqualTypeOf>(); + const binding = bindChallengeContext(approvalContext, { + action: "approve", + intentId: "intent_1", + }); + expectTypeOf(binding.value.action).toEqualTypeOf<"approve">(); + + // @ts-expect-error Ceremony brands prevent typed serialized values being interchanged. + const wrongContext: SerializedChallengeContext<"passkey-enrolment"> = first; + expect(wrongContext).toBe(first); + }); + + it("rejects malformed serialized values", () => { + expect(() => decodeChallengeContext("not base64url!", approvalContext)).toThrow( + /Malformed challenge context/, + ); + expect(() => + decodeChallengeContext(Buffer.from("not json").toString("base64url"), approvalContext), + ).toThrow(/Malformed challenge context/); + }); + + it("rejects non-canonical serialized values", () => { + const nonCanonical = Buffer.from( + JSON.stringify({ + version: 1, + type: "release-approval", + context: { intentId: "intent_1", action: "approve" }, + }), + ).toString("base64url"); + + expect(() => decodeChallengeContext(nonCanonical, approvalContext)).toThrow( + /Malformed challenge context/, + ); + }); + + it("rejects version mismatches", () => { + const versionTwo = defineChallengeContext("release-approval", 2, approvalContext.parse); + const serialized = encodeChallengeContext(versionTwo, { + action: "approve", + intentId: "intent_1", + }); + + expect(() => decodeChallengeContext(serialized, approvalContext)).toThrow( + /Challenge context version mismatch/, + ); + }); + + it("rejects ceremony type mismatches", () => { + const serialized = encodeChallengeContext(enrolmentContext, { + approverDid: "did:plc:approver", + }); + + expect(() => decodeChallengeContext(serialized, approvalContext)).toThrow( + /Challenge context type mismatch/, + ); + }); + + it("validates decoded context with the codec", () => { + const invalid = encodeChallengeContext( + defineChallengeContext("release-approval", 1, (value) => value), + { action: "reject", intentId: "intent_1" }, + ); + + expect(() => decodeChallengeContext(invalid, approvalContext)).toThrow( + /Invalid approval context/, + ); + }); +}); diff --git a/packages/auth/src/passkey/challenge-context.ts b/packages/auth/src/passkey/challenge-context.ts new file mode 100644 index 0000000000..b5f08076cb --- /dev/null +++ b/packages/auth/src/passkey/challenge-context.ts @@ -0,0 +1,215 @@ +import { sha256 } from "@oslojs/crypto/sha2"; +import { decodeBase64urlIgnorePadding, encodeBase64urlNoPadding } from "@oslojs/encoding"; + +declare const challengeContextType: unique symbol; +const CONTEXT_TYPE_PATTERN = /^[a-z][a-z0-9._:-]{0,127}$/i; +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/; +const MAX_SERIALIZED_CONTEXT_LENGTH = 16 * 1024; +const CONTEXT_CHALLENGE_VERSION = 1; + +export type SerializedChallengeContext = string & { + readonly [challengeContextType]: Type; +}; + +export interface ChallengeContextCodec { + readonly type: Type; + readonly version: number; + readonly parse: (value: unknown) => Context; +} + +export interface ChallengeContextBinding { + readonly codec: ChallengeContextCodec; + readonly value: Context; +} + +export type ChallengeContextErrorCode = + | "malformed_context" + | "context_type_mismatch" + | "context_version_mismatch" + | "context_binding_mismatch"; + +export class ChallengeContextError extends Error { + constructor( + public readonly code: ChallengeContextErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "ChallengeContextError"; + } +} + +export function defineChallengeContext( + type: Type, + version: number, + parse: (value: unknown) => Context, +): ChallengeContextCodec { + if (!CONTEXT_TYPE_PATTERN.test(type)) { + throw new Error("Challenge context type must be a non-empty stable identifier"); + } + if (!Number.isSafeInteger(version) || version < 1) { + throw new Error("Challenge context version must be a positive safe integer"); + } + return { type, version, parse }; +} + +export function bindChallengeContext( + codec: ChallengeContextCodec, + value: NoInfer, +): ChallengeContextBinding { + return { codec, value }; +} + +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("Challenge context numbers must be finite"); + return value; + } + if (Array.isArray(value)) return value.map(canonicalize); + if (typeof value === "object") { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError("Challenge context must contain only plain objects"); + } + const result: Record = Object.create(null); + const entries = Object.entries(value).toSorted(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ); + for (const [key, item] of entries) { + if (item === undefined) throw new TypeError("Challenge context cannot contain undefined"); + result[key] = canonicalize(item); + } + return result; + } + throw new TypeError("Challenge context must be JSON-serializable"); +} + +function serializeEnvelope(type: string, version: number, context: unknown): string { + return JSON.stringify({ context: canonicalize(context), type, version }); +} + +export function encodeChallengeContext( + codec: ChallengeContextCodec, + context: NoInfer, +): SerializedChallengeContext { + const serialized = serializeEnvelope(codec.type, codec.version, context); + const encoded = encodeBase64urlNoPadding(new TextEncoder().encode(serialized)); + if (encoded.length > MAX_SERIALIZED_CONTEXT_LENGTH) { + throw new TypeError("Challenge context exceeds the maximum serialized size"); + } + // The runtime value remains a string; the brand prevents cross-purpose assignment. + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + return encoded as SerializedChallengeContext; +} + +export function createContextBoundChallenge(nonce: string, serializedContext: string): string { + const contextDigest = encodeBase64urlNoPadding( + sha256(new TextEncoder().encode(serializedContext)), + ); + const serialized = JSON.stringify({ + contextDigest, + nonce, + version: CONTEXT_CHALLENGE_VERSION, + }); + return encodeBase64urlNoPadding(new TextEncoder().encode(serialized)); +} + +export function verifyContextBoundChallenge(challenge: string, serializedContext: string): void { + let value: unknown; + try { + if (!BASE64URL_PATTERN.test(challenge)) throw malformedContext(); + const bytes = decodeBase64urlIgnorePadding(challenge); + if (encodeBase64urlNoPadding(bytes) !== challenge) throw malformedContext(); + value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + } catch (error) { + if (error instanceof ChallengeContextError) throw error; + throw malformedContext(error); + } + if (!isRecord(value)) { + throw malformedContext(); + } + const envelope = value; + if ( + Object.keys(envelope).length !== 3 || + typeof envelope.contextDigest !== "string" || + typeof envelope.nonce !== "string" || + envelope.nonce.length !== 43 || + !BASE64URL_PATTERN.test(envelope.nonce) || + envelope.version !== CONTEXT_CHALLENGE_VERSION + ) { + throw malformedContext(); + } + const canonical = createContextBoundChallenge(envelope.nonce, serializedContext); + if (canonical !== challenge) { + throw new ChallengeContextError( + "context_binding_mismatch", + "Challenge context does not match challenge", + ); + } +} + +function malformedContext(cause?: unknown): ChallengeContextError { + return new ChallengeContextError("malformed_context", "Malformed challenge context", { cause }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function decodeChallengeContext( + serialized: string, + codec: ChallengeContextCodec, +): Context { + let value: unknown; + try { + if (serialized.length > MAX_SERIALIZED_CONTEXT_LENGTH || !BASE64URL_PATTERN.test(serialized)) { + throw malformedContext(); + } + const bytes = decodeBase64urlIgnorePadding(serialized); + if (encodeBase64urlNoPadding(bytes) !== serialized) throw malformedContext(); + value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + } catch (error) { + if (error instanceof ChallengeContextError) throw error; + throw malformedContext(error); + } + + if (!isRecord(value)) { + throw malformedContext(); + } + const envelope = value; + if ( + Object.keys(envelope).length !== 3 || + !("context" in envelope) || + typeof envelope.type !== "string" || + typeof envelope.version !== "number" + ) { + throw malformedContext(); + } + if (envelope.type !== codec.type) { + throw new ChallengeContextError( + "context_type_mismatch", + `Challenge context type mismatch: expected ${codec.type}`, + ); + } + if (envelope.version !== codec.version) { + throw new ChallengeContextError( + "context_version_mismatch", + `Challenge context version mismatch: expected ${codec.version}`, + ); + } + + let canonical: string; + try { + canonical = encodeBase64urlNoPadding( + new TextEncoder().encode( + serializeEnvelope(envelope.type, envelope.version, envelope.context), + ), + ); + } catch (error) { + throw malformedContext(error); + } + if (canonical !== serialized) throw malformedContext(); + + return codec.parse(envelope.context); +} diff --git a/packages/auth/src/passkey/index.ts b/packages/auth/src/passkey/index.ts index 75bd6a286d..bd51ed4a10 100644 --- a/packages/auth/src/passkey/index.ts +++ b/packages/auth/src/passkey/index.ts @@ -6,14 +6,31 @@ export type { RegistrationOptions, RegistrationResponse, VerifiedRegistration, + VerifiedRegistrationWithContext, AuthenticationOptions, AuthenticationResponse, VerifiedAuthentication, + VerifiedAuthenticationWithContext, ChallengeStore, + AtomicChallengeStore, ChallengeData, PasskeyConfig, } from "./types.js"; +export type { + ChallengeContextBinding, + ChallengeContextCodec, + ChallengeContextErrorCode, + SerializedChallengeContext, +} from "./challenge-context.js"; +export { + bindChallengeContext, + ChallengeContextError, + decodeChallengeContext, + defineChallengeContext, + encodeChallengeContext, +} from "./challenge-context.js"; + export { generateRegistrationOptions, verifyRegistrationResponse, diff --git a/packages/auth/src/passkey/register.test.ts b/packages/auth/src/passkey/register.test.ts index 2a3cb61daf..8190b01e69 100644 --- a/packages/auth/src/passkey/register.test.ts +++ b/packages/auth/src/passkey/register.test.ts @@ -5,8 +5,9 @@ import { encodeBase64urlNoPadding } from "@oslojs/encoding"; import { parseAttestationObject, coseAlgorithmRS256, COSEKeyType } from "@oslojs/webauthn"; import { describe, expect, it, vi } from "vitest"; -import { verifyRegistrationResponse } from "./register.js"; -import type { ChallengeStore, PasskeyConfig } from "./types.js"; +import { bindChallengeContext, defineChallengeContext } from "./challenge-context.js"; +import { generateRegistrationOptions, verifyRegistrationResponse } from "./register.js"; +import type { AtomicChallengeStore, ChallengeStore, PasskeyConfig } from "./types.js"; /** * Locks in origin-check parity with `authenticate.ts`. The two functions @@ -22,6 +23,8 @@ const config: PasskeyConfig = { origins: ["https://example.com"], }; +const enrolmentContext = defineChallengeContext("passkey-enrolment", 1, (value) => value); + function base64url(bytes: Uint8Array): string { return Buffer.from(bytes).toString("base64url"); } @@ -47,6 +50,109 @@ vi.mock("@oslojs/webauthn", async (importOriginal) => { }); describe("verifyRegistrationResponse", () => { + it.each(["preferred", "required", "discouraged"] as const)( + "wires %s user verification into registration options", + async (userVerification) => { + const options = await generateRegistrationOptions( + { ...config, userVerification }, + { id: "user_1", email: "user@example.com", name: "User" }, + [], + makeChallengeStore(), + ); + + expect(options.authenticatorSelection?.userVerification).toBe(userVerification); + }, + ); + + it("defaults registration options to preferred user verification", async () => { + const options = await generateRegistrationOptions( + config, + { id: "user_1", email: "user@example.com", name: "User" }, + [], + makeChallengeStore(), + ); + + expect(options.authenticatorSelection?.userVerification).toBe("preferred"); + }); + + it.each([ + ["undefined", undefined], + ["non-callable", "not-a-function"], + ] as const)("rejects a typed-context store with %s consume", async (_label, consume) => { + const challengeStore = makeChallengeStore(); + const options = await generateRegistrationOptions( + config, + { id: "user_1", email: "user@example.com", name: "User" }, + [], + challengeStore, + bindChallengeContext(enrolmentContext, { approverDid: "did:plc:approver" }), + ); + const clientDataJSON = Buffer.from( + JSON.stringify({ + type: "webauthn.create", + challenge: options.challenge, + origin: "https://example.com", + }), + ); + const malformedStore = { + ...challengeStore, + consume, + } as unknown as AtomicChallengeStore; + + await expect( + verifyRegistrationResponse( + config, + { + id: "test-credential", + rawId: "test-credential", + type: "public-key", + response: { + clientDataJSON: base64url(clientDataJSON), + attestationObject: "AA", + }, + }, + malformedStore, + enrolmentContext, + ), + ).rejects.toThrow("Typed challenge context requires an atomic challenge store"); + expect(challengeStore.get).not.toHaveBeenCalled(); + expect(challengeStore.delete).not.toHaveBeenCalled(); + }); + + it("rejects registration without UV when user verification is required", async () => { + const challenge = encodeBase64urlNoPadding(new TextEncoder().encode("test-challenge")); + const clientDataJSON = Buffer.from( + JSON.stringify({ + type: "webauthn.create", + challenge, + origin: "https://example.com", + }), + ); + vi.mocked(parseAttestationObject).mockReturnValueOnce({ + authenticatorData: { + verifyRelyingPartyIdHash: () => true, + userPresent: true, + userVerified: false, + }, + attestationStatement: { format: "none" }, + } as any); + + await expect( + verifyRegistrationResponse( + { ...config, userVerification: "required" }, + { + id: "test-credential", + rawId: "test-credential", + type: "public-key", + response: { + clientDataJSON: base64url(clientDataJSON), + attestationObject: "AA", + }, + }, + makeChallengeStore(), + ), + ).rejects.toThrow("User verification not verified"); + }); it("rejects an origin not in the accepted list", async () => { const challenge = encodeBase64urlNoPadding(new TextEncoder().encode("test-challenge")); const clientDataJSON = Buffer.from( @@ -118,7 +224,7 @@ describe("verifyRegistrationResponse", () => { } as any); const result = await verifyRegistrationResponse( - config, + { ...config, userVerification: "required" }, { id: "test-credential", rawId: "test-credential", diff --git a/packages/auth/src/passkey/register.ts b/packages/auth/src/passkey/register.ts index b193e42e2d..454fdb4181 100644 --- a/packages/auth/src/passkey/register.ts +++ b/packages/auth/src/passkey/register.ts @@ -21,12 +21,21 @@ import { import { generateToken } from "../tokens.js"; import type { Credential, NewCredential, AuthAdapter, User, DeviceType } from "../types.js"; +import { + createContextBoundChallenge, + decodeChallengeContext, + encodeChallengeContext, + verifyContextBoundChallenge, +} from "./challenge-context.js"; +import type { ChallengeContextBinding, ChallengeContextCodec } from "./challenge-context.js"; import type { RegistrationOptions, RegistrationResponse, VerifiedRegistration, ChallengeStore, PasskeyConfig, + AtomicChallengeStore, + VerifiedRegistrationWithContext, } from "./types.js"; const CHALLENGE_TTL = 5 * 60 * 1000; // 5 minutes @@ -36,19 +45,27 @@ export type { PasskeyConfig }; /** * Generate registration options for creating a new passkey */ -export async function generateRegistrationOptions( +export async function generateRegistrationOptions( config: PasskeyConfig, user: Pick, existingCredentials: Credential[], challengeStore: ChallengeStore, + challengeContext?: ChallengeContextBinding, ): Promise { - const challenge = generateToken(); + const serializedContext = challengeContext + ? encodeChallengeContext(challengeContext.codec, challengeContext.value) + : undefined; + const nonce = generateToken(); + const challenge = serializedContext + ? createContextBoundChallenge(nonce, serializedContext) + : nonce; // Store challenge for verification await challengeStore.set(challenge, { type: "registration", userId: user.id, expiresAt: Date.now() + CHALLENGE_TTL, + ...(serializedContext ? { context: serializedContext } : {}), }); // Encode user ID as base64url @@ -74,7 +91,7 @@ export async function generateRegistrationOptions( attestation: "none", // We don't need attestation for our use case authenticatorSelection: { residentKey: "preferred", // Allow discoverable credentials - userVerification: "preferred", + userVerification: config.userVerification ?? "preferred", }, excludeCredentials: existingCredentials.map((cred) => ({ type: "public-key" as const, @@ -87,11 +104,23 @@ export async function generateRegistrationOptions( /** * Verify a registration response and extract credential data */ -export async function verifyRegistrationResponse( +export function verifyRegistrationResponse( + config: PasskeyConfig, + response: RegistrationResponse, + challengeStore: AtomicChallengeStore, + challengeContext: ChallengeContextCodec, +): Promise>; +export function verifyRegistrationResponse( + config: PasskeyConfig, + response: RegistrationResponse, + challengeStore: ChallengeStore, +): Promise; +export async function verifyRegistrationResponse( config: PasskeyConfig, response: RegistrationResponse, challengeStore: ChallengeStore, -): Promise { + challengeContext?: ChallengeContextCodec, +): Promise> { // Decode the response const clientDataJSON = decodeBase64urlIgnorePadding(response.response.clientDataJSON); const attestationObject = decodeBase64urlIgnorePadding(response.response.attestationObject); @@ -106,7 +135,14 @@ export async function verifyRegistrationResponse( // Verify challenge - convert Uint8Array back to base64url string (no padding, matching stored format) const challengeString = encodeBase64urlNoPadding(clientData.challenge); - const challengeData = await challengeStore.get(challengeString); + const consume = "consume" in challengeStore ? challengeStore.consume : undefined; + const consumesAtomically = typeof consume === "function"; + if (challengeContext && !consumesAtomically) { + throw new Error("Typed challenge context requires an atomic challenge store"); + } + const challengeData = consumesAtomically + ? await consume.call(challengeStore, challengeString) + : await challengeStore.get(challengeString); if (!challengeData) { throw new Error("Challenge not found or expired"); } @@ -114,12 +150,25 @@ export async function verifyRegistrationResponse( throw new Error("Invalid challenge type"); } if (challengeData.expiresAt < Date.now()) { - await challengeStore.delete(challengeString); + if (!consumesAtomically) await challengeStore.delete(challengeString); throw new Error("Challenge expired"); } // Delete challenge (single-use) - await challengeStore.delete(challengeString); + if (!consumesAtomically) await challengeStore.delete(challengeString); + let contextResult: { present: false } | { present: true; value: Context } = { + present: false, + }; + if (challengeData.context !== undefined) { + if (!challengeContext) throw new Error("Challenge context decoder required"); + verifyContextBoundChallenge(challengeString, challengeData.context); + contextResult = { + present: true, + value: decodeChallengeContext(challengeData.context, challengeContext), + }; + } else if (challengeContext) { + throw new Error("Challenge context not found"); + } // Verify origin against the accepted list if (!config.origins.includes(clientData.origin)) { @@ -146,6 +195,9 @@ export async function verifyRegistrationResponse( if (!authenticatorData.userPresent) { throw new Error("User presence not verified"); } + if (config.userVerification === "required" && !authenticatorData.userVerified) { + throw new Error("User verification not verified"); + } // Extract credential data if (!authenticatorData.credential) { @@ -192,7 +244,7 @@ export async function verifyRegistrationResponse( const deviceType: DeviceType = "singleDevice"; const backedUp = false; - return { + const verified: VerifiedRegistration = { credentialId: response.id, publicKey: encodedPublicKey, algorithm, @@ -201,6 +253,7 @@ export async function verifyRegistrationResponse( backedUp, transports: response.response.transports ?? [], }; + return !contextResult.present ? verified : { ...verified, challengeContext: contextResult.value }; } /** diff --git a/packages/auth/src/passkey/types.ts b/packages/auth/src/passkey/types.ts index d890c98a3e..7f4f1ae662 100644 --- a/packages/auth/src/passkey/types.ts +++ b/packages/auth/src/passkey/types.ts @@ -60,6 +60,10 @@ export interface VerifiedRegistration { transports: AuthenticatorTransport[]; } +export type VerifiedRegistrationWithContext = VerifiedRegistration & { + challengeContext: Context; +}; + // ============================================================================ // Authentication (Using an existing passkey) // ============================================================================ @@ -94,6 +98,10 @@ export interface VerifiedAuthentication { newCounter: number; } +export type VerifiedAuthenticationWithContext = VerifiedAuthentication & { + challengeContext: Context; +}; + // ============================================================================ // Challenge storage // ============================================================================ @@ -104,10 +112,17 @@ export interface ChallengeStore { delete(challenge: string): Promise; } +/** A store that removes and returns a challenge in one atomic operation. */ +export interface AtomicChallengeStore extends ChallengeStore { + consume(challenge: string): Promise; +} + export interface ChallengeData { type: "registration" | "authentication"; userId?: string; // For registration, the user being registered expiresAt: number; + /** Canonical output from `encodeChallengeContext`. */ + context?: string; } // ============================================================================ @@ -124,4 +139,6 @@ export interface PasskeyConfig { * sharing `rpId` (e.g. apex + preview subdomain). */ origins: string[]; + /** Defaults to `preferred` for backwards compatibility. */ + userVerification?: "discouraged" | "preferred" | "required"; } From e9f6c371c16e2d958185a3c76b5d01998133d556 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 12 Jul 2026 09:19:31 +0100 Subject: [PATCH 17/24] feat(release-service): scaffold Worker foundation (#1971) * feat(release-service): scaffold worker foundation * fix(release-service): contain async route failures --- apps/release-service/migrations/.gitkeep | 0 apps/release-service/package.json | 25 + apps/release-service/public/.gitkeep | 0 apps/release-service/src/api/auth.ts | 23 + apps/release-service/src/api/errors.ts | 45 + apps/release-service/src/api/owner.ts | 20 + apps/release-service/src/api/pagination.ts | 87 + apps/release-service/src/api/request-id.ts | 6 + apps/release-service/src/api/response.ts | 26 + apps/release-service/src/api/schema.ts | 65 + apps/release-service/src/api/security.ts | 109 + apps/release-service/src/config.ts | 122 + apps/release-service/src/index.ts | 78 + apps/release-service/src/routes.ts | 30 + .../test/api-foundation.test.ts | 260 + apps/release-service/test/worker.test.ts | 135 + apps/release-service/tsconfig.json | 9 + apps/release-service/vite.config.ts | 6 + apps/release-service/vitest.config.ts | 18 + .../release-service/worker-configuration.d.ts | 14495 ++++++++++++++++ apps/release-service/wrangler.jsonc | 63 + pnpm-lock.yaml | 143 +- 22 files changed, 15658 insertions(+), 107 deletions(-) create mode 100644 apps/release-service/migrations/.gitkeep create mode 100644 apps/release-service/package.json create mode 100644 apps/release-service/public/.gitkeep create mode 100644 apps/release-service/src/api/auth.ts create mode 100644 apps/release-service/src/api/errors.ts create mode 100644 apps/release-service/src/api/owner.ts create mode 100644 apps/release-service/src/api/pagination.ts create mode 100644 apps/release-service/src/api/request-id.ts create mode 100644 apps/release-service/src/api/response.ts create mode 100644 apps/release-service/src/api/schema.ts create mode 100644 apps/release-service/src/api/security.ts create mode 100644 apps/release-service/src/config.ts create mode 100644 apps/release-service/src/index.ts create mode 100644 apps/release-service/src/routes.ts create mode 100644 apps/release-service/test/api-foundation.test.ts create mode 100644 apps/release-service/test/worker.test.ts create mode 100644 apps/release-service/tsconfig.json create mode 100644 apps/release-service/vite.config.ts create mode 100644 apps/release-service/vitest.config.ts create mode 100644 apps/release-service/worker-configuration.d.ts create mode 100644 apps/release-service/wrangler.jsonc diff --git a/apps/release-service/migrations/.gitkeep b/apps/release-service/migrations/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/release-service/package.json b/apps/release-service/package.json new file mode 100644 index 0000000000..7ba3f95b7c --- /dev/null +++ b/apps/release-service/package.json @@ -0,0 +1,25 @@ +{ + "name": "@emdash-cms/release-service", + "version": "0.0.0", + "private": true, + "description": "Cloudflare Worker for delegated EmDash registry releases.", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "deploy": "vite build && wrangler deploy", + "typecheck": "tsgo --noEmit", + "test": "vitest run", + "types": "wrangler types" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "catalog:", + "@cloudflare/vitest-pool-workers": "catalog:", + "@types/node": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:", + "wrangler": "catalog:" + } +} diff --git a/apps/release-service/public/.gitkeep b/apps/release-service/public/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/release-service/src/api/auth.ts b/apps/release-service/src/api/auth.ts new file mode 100644 index 0000000000..a1da0f0811 --- /dev/null +++ b/apps/release-service/src/api/auth.ts @@ -0,0 +1,23 @@ +import { ApiError } from "./errors.js"; + +const BEARER_TOKEN_PATTERN = /^Bearer ([A-Za-z0-9._~+/-]+=*)$/i; + +export interface AuthenticatedActor { + subjectDid: string; +} + +export function requireBearerToken(request: Request): string { + const authorization = request.headers.get("authorization"); + const match = authorization?.match(BEARER_TOKEN_PATTERN); + if (!match?.[1]) { + throw new ApiError("UNAUTHENTICATED", 401, "Authentication required"); + } + return match[1]; +} + +export function requireAuthenticated(actor: AuthenticatedActor | null): AuthenticatedActor { + if (!actor) { + throw new ApiError("UNAUTHENTICATED", 401, "Authentication required"); + } + return actor; +} diff --git a/apps/release-service/src/api/errors.ts b/apps/release-service/src/api/errors.ts new file mode 100644 index 0000000000..0e0db03057 --- /dev/null +++ b/apps/release-service/src/api/errors.ts @@ -0,0 +1,45 @@ +export const API_ERROR_CODES = [ + "CONFIGURATION_ERROR", + "INTERNAL_ERROR", + "NOT_FOUND", + "METHOD_NOT_ALLOWED", + "UNAUTHENTICATED", + "FORBIDDEN", + "ORIGIN_NOT_ALLOWED", + "CSRF_INVALID", + "UNSUPPORTED_MEDIA_TYPE", + "PAYLOAD_TOO_LARGE", + "INVALID_JSON", + "INVALID_CURSOR", +] as const; + +export type ApiErrorCode = (typeof API_ERROR_CODES)[number]; + +export interface SerializedApiError { + code: ApiErrorCode; + message: string; + details?: unknown; +} + +export class ApiError extends Error { + readonly code: ApiErrorCode; + readonly status: number; + readonly details?: unknown; + + constructor(code: ApiErrorCode, status: number, message: string, details?: unknown) { + super(message); + this.name = "ApiError"; + this.code = code; + this.status = status; + this.details = details; + } +} + +export function serializeApiError(error: unknown): SerializedApiError { + if (error instanceof ApiError) { + return error.details === undefined + ? { code: error.code, message: error.message } + : { code: error.code, message: error.message, details: error.details }; + } + return { code: "INTERNAL_ERROR", message: "Internal server error" }; +} diff --git a/apps/release-service/src/api/owner.ts b/apps/release-service/src/api/owner.ts new file mode 100644 index 0000000000..3e878515cb --- /dev/null +++ b/apps/release-service/src/api/owner.ts @@ -0,0 +1,20 @@ +import type { AuthenticatedActor } from "./auth.js"; +import { ApiError } from "./errors.js"; + +export function requireOwner(actor: AuthenticatedActor, ownerDid: string): AuthenticatedActor { + if (actor.subjectDid !== ownerDid) { + throw new ApiError("FORBIDDEN", 403, "Not authorized for this resource"); + } + return actor; +} + +export function requireOwnerOr( + actor: AuthenticatedActor, + ownerDid: string, + isAuthorized: (actor: AuthenticatedActor) => boolean, +): AuthenticatedActor { + if (actor.subjectDid !== ownerDid && !isAuthorized(actor)) { + throw new ApiError("FORBIDDEN", 403, "Not authorized for this resource"); + } + return actor; +} diff --git a/apps/release-service/src/api/pagination.ts b/apps/release-service/src/api/pagination.ts new file mode 100644 index 0000000000..091d6a908d --- /dev/null +++ b/apps/release-service/src/api/pagination.ts @@ -0,0 +1,87 @@ +import { ApiError } from "./errors.js"; + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 100; +const MAX_CURSOR_LENGTH = 2048; +const MAX_CURSOR_PART_LENGTH = 512; +const BASE64_PADDING_PATTERN = /=+$/; +const BASE64_URL_PATTERN = /^[A-Za-z0-9_-]+$/; + +export interface PaginationCursor { + version: 1; + orderValue: string; + id: string; +} + +export interface Pagination { + limit: number; + cursor?: PaginationCursor; +} + +function toBase64Url(value: string): string { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(BASE64_PADDING_PATTERN, ""); +} + +function fromBase64Url(value: string): string { + if (!BASE64_URL_PATTERN.test(value) || value.length > MAX_CURSOR_LENGTH) { + throw new ApiError("INVALID_CURSOR", 400, "Invalid pagination cursor"); + } + const padded = value + .replaceAll("-", "+") + .replaceAll("_", "/") + .padEnd(Math.ceil(value.length / 4) * 4, "="); + const binary = atob(padded); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes); +} + +export function encodeCursor(orderValue: string, id: string): string { + if ( + orderValue.length === 0 || + orderValue.length > MAX_CURSOR_PART_LENGTH || + id.length === 0 || + id.length > MAX_CURSOR_PART_LENGTH + ) { + throw new ApiError("INVALID_CURSOR", 400, "Invalid pagination cursor"); + } + const cursor = toBase64Url(JSON.stringify([1, orderValue, id])); + if (cursor.length > MAX_CURSOR_LENGTH) { + throw new ApiError("INVALID_CURSOR", 400, "Invalid pagination cursor"); + } + return cursor; +} + +export function decodeCursor(value: string): PaginationCursor { + try { + const parsed: unknown = JSON.parse(fromBase64Url(value)); + if ( + !Array.isArray(parsed) || + parsed.length !== 3 || + parsed[0] !== 1 || + typeof parsed[1] !== "string" || + parsed[1].length === 0 || + parsed[1].length > MAX_CURSOR_PART_LENGTH || + typeof parsed[2] !== "string" || + parsed[2].length === 0 || + parsed[2].length > MAX_CURSOR_PART_LENGTH + ) { + throw new Error("invalid cursor shape"); + } + return { version: 1, orderValue: parsed[1], id: parsed[2] }; + } catch { + throw new ApiError("INVALID_CURSOR", 400, "Invalid pagination cursor"); + } +} + +export function parsePagination(searchParams: URLSearchParams): Pagination { + const rawLimit = searchParams.get("limit"); + const parsedLimit = rawLimit === null ? DEFAULT_LIMIT : Number.parseInt(rawLimit, 10); + const limit = Number.isFinite(parsedLimit) + ? Math.min(MAX_LIMIT, Math.max(1, parsedLimit)) + : DEFAULT_LIMIT; + const rawCursor = searchParams.get("cursor"); + return rawCursor ? { limit, cursor: decodeCursor(rawCursor) } : { limit }; +} diff --git a/apps/release-service/src/api/request-id.ts b/apps/release-service/src/api/request-id.ts new file mode 100644 index 0000000000..f3360f5127 --- /dev/null +++ b/apps/release-service/src/api/request-id.ts @@ -0,0 +1,6 @@ +const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/; + +export function getRequestId(request: Request): string { + const supplied = request.headers.get("x-request-id"); + return supplied && REQUEST_ID_PATTERN.test(supplied) ? supplied : crypto.randomUUID(); +} diff --git a/apps/release-service/src/api/response.ts b/apps/release-service/src/api/response.ts new file mode 100644 index 0000000000..f0c545e803 --- /dev/null +++ b/apps/release-service/src/api/response.ts @@ -0,0 +1,26 @@ +import { ApiError, serializeApiError } from "./errors.js"; + +export type ApiResponse = + | { data: T; requestId: string } + | { error: ReturnType; requestId: string }; + +const JSON_HEADERS = { + "cache-control": "no-store", + "content-type": "application/json; charset=utf-8", + "x-content-type-options": "nosniff", +} as const; + +export function apiSuccess(data: T, requestId: string, status = 200): Response { + return Response.json({ data, requestId } satisfies ApiResponse, { + status, + headers: { ...JSON_HEADERS, "x-request-id": requestId }, + }); +} + +export function apiFailure(error: unknown, requestId: string, fallbackStatus = 500): Response { + const status = error instanceof ApiError ? error.status : fallbackStatus; + return Response.json( + { error: serializeApiError(error), requestId } satisfies ApiResponse, + { status, headers: { ...JSON_HEADERS, "x-request-id": requestId } }, + ); +} diff --git a/apps/release-service/src/api/schema.ts b/apps/release-service/src/api/schema.ts new file mode 100644 index 0000000000..9d3ac3731b --- /dev/null +++ b/apps/release-service/src/api/schema.ts @@ -0,0 +1,65 @@ +import { ROUTES } from "../routes.js"; +import type { RouteDefinition } from "../routes.js"; +import { API_ERROR_CODES } from "./errors.js"; + +const requestIdSchema = { + type: "string", + pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", +} as const; + +const errorEnvelopeSchema = { + type: "object", + required: ["error", "requestId"], + additionalProperties: false, + properties: { + error: { + type: "object", + required: ["code", "message"], + additionalProperties: false, + properties: { + code: { type: "string", enum: API_ERROR_CODES }, + message: { type: "string" }, + details: {}, + }, + }, + requestId: requestIdSchema, + }, +} as const; + +export function generateApiSchema(routes: readonly RouteDefinition[] = ROUTES) { + const paths: Record> = {}; + for (const route of routes) { + const path = (paths[route.path] ??= {}); + path[route.method.toLowerCase()] = { + operationId: route.operationId, + summary: route.summary, + responses: { + [route.successStatus]: { + description: "Successful response", + content: { + "application/json": { + schema: { + type: "object", + required: ["data", "requestId"], + additionalProperties: false, + properties: { + data: route.successDataSchema, + requestId: requestIdSchema, + }, + }, + }, + }, + }, + default: { + description: "Error response", + content: { "application/json": { schema: errorEnvelopeSchema } }, + }, + }, + }; + } + return { + openapi: "3.1.0", + info: { title: "EmDash Delegated Release Service API", version: "1.0.0" }, + paths, + }; +} diff --git a/apps/release-service/src/api/security.ts b/apps/release-service/src/api/security.ts new file mode 100644 index 0000000000..e10dbe798c --- /dev/null +++ b/apps/release-service/src/api/security.ts @@ -0,0 +1,109 @@ +import type { ServiceConfiguration } from "../config.js"; +import { ApiError } from "./errors.js"; + +export const MAX_JSON_BODY_BYTES = 256 * 1024; + +const encoder = new TextEncoder(); + +async function secretsEqual(left: string, right: string): Promise { + const [leftHash, rightHash] = await Promise.all([ + crypto.subtle.digest("SHA-256", encoder.encode(left)), + crypto.subtle.digest("SHA-256", encoder.encode(right)), + ]); + return crypto.subtle.timingSafeEqual(leftHash, rightHash); +} + +function requireAllowedOrigin(request: Request, config: ServiceConfiguration): string { + const origin = request.headers.get("origin"); + if (!origin || !config.allowedOrigins.has(origin)) { + throw new ApiError("ORIGIN_NOT_ALLOWED", 403, "Request origin is not allowed"); + } + return origin; +} + +export function getCorsHeaders( + request: Request, + config: ServiceConfiguration, +): Record { + const origin = requireAllowedOrigin(request, config); + return { + "access-control-allow-credentials": "true", + "access-control-allow-origin": origin, + vary: "Origin", + }; +} + +export function getCorsPreflightHeaders( + request: Request, + config: ServiceConfiguration, + allowedMethods: readonly string[], +): Record { + return { + ...getCorsHeaders(request, config), + "access-control-allow-headers": + "Authorization, Content-Type, Idempotency-Key, X-EmDash-CSRF, X-Request-ID", + "access-control-allow-methods": allowedMethods.join(", "), + "access-control-max-age": "600", + }; +} + +async function readBoundedBody(request: Request, maxBytes: number): Promise { + const contentLength = request.headers.get("content-length"); + if (contentLength !== null) { + const declaredLength = Number(contentLength); + if (!Number.isSafeInteger(declaredLength) || declaredLength < 0 || declaredLength > maxBytes) { + throw new ApiError("PAYLOAD_TOO_LARGE", 413, "Request body is too large"); + } + } + if (!request.body) return new Uint8Array(); + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + throw new ApiError("PAYLOAD_TOO_LARGE", 413, "Request body is too large"); + } + chunks.push(value); + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +export async function parseJsonMutation( + request: Request, + config: ServiceConfiguration, + expectedCsrfToken: string | null, + maxBytes = MAX_JSON_BODY_BYTES, +): Promise { + if (!new Set(["POST", "PUT", "PATCH", "DELETE"]).has(request.method)) { + throw new ApiError("METHOD_NOT_ALLOWED", 405, "Mutation requires a state-changing HTTP method"); + } + requireAllowedOrigin(request, config); + const contentType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); + if (contentType !== "application/json") { + throw new ApiError("UNSUPPORTED_MEDIA_TYPE", 415, "Content-Type must be application/json"); + } + if (!expectedCsrfToken) { + throw new ApiError("UNAUTHENTICATED", 401, "Authentication required"); + } + const suppliedCsrfToken = request.headers.get("x-emdash-csrf"); + if (!suppliedCsrfToken || !(await secretsEqual(suppliedCsrfToken, expectedCsrfToken))) { + throw new ApiError("CSRF_INVALID", 403, "CSRF validation failed"); + } + const bytes = await readBoundedBody(request, maxBytes); + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes)); + } catch { + throw new ApiError("INVALID_JSON", 400, "Request body must be valid JSON"); + } +} diff --git a/apps/release-service/src/config.ts b/apps/release-service/src/config.ts new file mode 100644 index 0000000000..8c075c1865 --- /dev/null +++ b/apps/release-service/src/config.ts @@ -0,0 +1,122 @@ +export type ConfigurationBindings = Record< + keyof Pick, + string +>; + +export type DeploymentPolicy = "hosted" | "self-hosted"; + +const DID_PATTERN = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/; + +interface AllowAllPublishers { + mode: "all"; +} + +interface AllowlistedPublishers { + mode: "allowlist"; + dids: ReadonlySet; +} + +type AllowedPublisherPolicy = AllowAllPublishers | AllowlistedPublishers; + +export interface ServiceConfiguration { + publicOrigin: string; + allowedOrigins: ReadonlySet; + deploymentPolicy: DeploymentPolicy; + isPublisherAllowed(did: string): boolean; +} + +export class ConfigurationError extends Error { + readonly issues: readonly string[]; + + constructor(issues: readonly string[]) { + super("Invalid release-service configuration"); + this.name = "ConfigurationError"; + this.issues = issues; + } +} + +function parseOrigin(value: unknown): string | null { + if (typeof value !== "string" || value.length === 0) return null; + try { + const url = new URL(value); + if (url.protocol !== "https:" || url.origin !== value) return null; + return url.origin; + } catch { + return null; + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function parseAllowedOrigins(value: string): ReadonlySet | null { + try { + const parsed: unknown = JSON.parse(value); + if (!Array.isArray(parsed) || parsed.length === 0) return null; + const origins = parsed.map(parseOrigin); + if (origins.some((origin) => origin === null)) return null; + const validOrigins = new Set(); + for (const origin of origins) { + if (origin) validOrigins.add(origin); + } + return validOrigins; + } catch { + return null; + } +} + +function parseAllowedPublishers(value: string): AllowedPublisherPolicy | null { + try { + const parsed: unknown = JSON.parse(value); + if (!isRecord(parsed)) return null; + const record = parsed; + if (record["mode"] === "all" && Object.keys(record).length === 1) return { mode: "all" }; + const dids = record["dids"]; + if ( + record["mode"] !== "allowlist" || + Object.keys(record).some((key) => key !== "mode" && key !== "dids") || + !Array.isArray(dids) || + !dids.every((did) => typeof did === "string" && DID_PATTERN.test(did)) + ) { + return null; + } + return { mode: "allowlist", dids: new Set(dids) }; + } catch { + return null; + } +} + +export function loadConfiguration(bindings: ConfigurationBindings): ServiceConfiguration { + const issues: string[] = []; + const publicOrigin = parseOrigin(bindings.PUBLIC_ORIGIN); + if (!publicOrigin) issues.push("PUBLIC_ORIGIN_INVALID"); + const allowedOrigins = parseAllowedOrigins(bindings.ALLOWED_ORIGINS); + if (!allowedOrigins) issues.push("ALLOWED_ORIGINS_INVALID"); + else if (publicOrigin && !allowedOrigins.has(publicOrigin)) + issues.push("PUBLIC_ORIGIN_NOT_ALLOWED"); + const publisherPolicy = parseAllowedPublishers(bindings.ALLOWED_PUBLISHERS); + if (!publisherPolicy) issues.push("ALLOWED_PUBLISHERS_INVALID"); + const deploymentPolicy: DeploymentPolicy | null = + bindings.DEPLOYMENT_POLICY === "hosted" || bindings.DEPLOYMENT_POLICY === "self-hosted" + ? bindings.DEPLOYMENT_POLICY + : null; + if (!deploymentPolicy) { + issues.push("DEPLOYMENT_POLICY_INVALID"); + } + if ( + !publicOrigin || + !allowedOrigins || + !publisherPolicy || + !deploymentPolicy || + issues.length > 0 + ) { + throw new ConfigurationError(issues); + } + return { + publicOrigin, + allowedOrigins, + deploymentPolicy, + isPublisherAllowed: (did) => publisherPolicy.mode === "all" || publisherPolicy.dids.has(did), + }; +} diff --git a/apps/release-service/src/index.ts b/apps/release-service/src/index.ts new file mode 100644 index 0000000000..763abdb050 --- /dev/null +++ b/apps/release-service/src/index.ts @@ -0,0 +1,78 @@ +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 { ROUTES } from "./routes.js"; +import type { RouteDefinition } from "./routes.js"; + +const UNSUPPORTED_QUEUE_RETRY_SECONDS = 300; + +export interface RetryableQueueBatch { + queue: string; + messages: readonly unknown[]; + retryAll(options?: QueueRetryOptions): void; +} + +export async function handleRequest( + request: Request, + bindings: ConfigurationBindings, + routes: readonly RouteDefinition[] = ROUTES, +): Promise { + const requestId = getRequestId(request); + try { + loadConfiguration(bindings); + const url = new URL(request.url); + const route = routes.find( + (candidate) => candidate.path === url.pathname && candidate.method === request.method, + ); + if (route) return await route.handler(request, requestId); + if (routes.some((candidate) => candidate.path === url.pathname)) { + return apiFailure(new ApiError("METHOD_NOT_ALLOWED", 405, "Method not allowed"), requestId); + } + return apiFailure(new ApiError("NOT_FOUND", 404, "Not found"), requestId); + } catch (error) { + if (error instanceof ConfigurationError) { + console.error(JSON.stringify({ event: "configuration_error", issues: error.issues })); + return apiFailure( + new ApiError("CONFIGURATION_ERROR", 503, "Service is not configured"), + requestId, + ); + } + console.error( + JSON.stringify({ + event: "request_error", + requestId, + error: error instanceof Error ? (error.stack ?? error.message) : String(error), + }), + ); + return apiFailure(error, requestId); + } +} + +export function retryUnsupportedQueue(batch: RetryableQueueBatch): void { + console.error( + JSON.stringify({ + event: "unsupported_queue_message", + queue: batch.queue, + messageCount: batch.messages.length, + }), + ); + batch.retryAll({ delaySeconds: UNSUPPORTED_QUEUE_RETRY_SECONDS }); +} + +export function failInactiveSchedule(scheduledTime: number): never { + console.error(JSON.stringify({ event: "scheduled_lifecycle_not_active", scheduledTime })); + throw new Error("Scheduled lifecycle is not active"); +} + +export default { + fetch(request: Request, env: Env): Promise { + return handleRequest(request, env); + }, + queue(batch: MessageBatch): void { + retryUnsupportedQueue(batch); + }, + scheduled(event: ScheduledController): void { + failInactiveSchedule(event.scheduledTime); + }, +} satisfies ExportedHandler; diff --git a/apps/release-service/src/routes.ts b/apps/release-service/src/routes.ts new file mode 100644 index 0000000000..cc0e977fb3 --- /dev/null +++ b/apps/release-service/src/routes.ts @@ -0,0 +1,30 @@ +import { apiSuccess } from "./api/response.js"; + +export type RouteMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +export interface RouteDefinition { + method: RouteMethod; + path: string; + operationId: string; + summary: string; + successStatus: number; + successDataSchema: Readonly>; + handler(request: Request, requestId: string): Response | Promise; +} + +export const ROUTES = Object.freeze([ + { + method: "GET", + path: "/health", + operationId: "getHealth", + summary: "Check release-service health", + successStatus: 200, + successDataSchema: { + type: "object", + required: ["status"], + additionalProperties: false, + properties: { status: { const: "ok" } }, + }, + handler: (_request, requestId) => apiSuccess({ status: "ok" }, requestId), + }, +] as const satisfies readonly RouteDefinition[]); diff --git a/apps/release-service/test/api-foundation.test.ts b/apps/release-service/test/api-foundation.test.ts new file mode 100644 index 0000000000..88b0a0d94e --- /dev/null +++ b/apps/release-service/test/api-foundation.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from "vitest"; + +import { requireAuthenticated, requireBearerToken } from "../src/api/auth.js"; +import { ApiError, serializeApiError } from "../src/api/errors.js"; +import { requireOwner, requireOwnerOr } from "../src/api/owner.js"; +import { decodeCursor, encodeCursor, parsePagination } from "../src/api/pagination.js"; +import { getRequestId } from "../src/api/request-id.js"; +import { generateApiSchema } from "../src/api/schema.js"; +import { + MAX_JSON_BODY_BYTES, + getCorsHeaders, + getCorsPreflightHeaders, + parseJsonMutation, +} from "../src/api/security.js"; +import { loadConfiguration, type ConfigurationBindings } from "../src/config.js"; +import { ROUTES } from "../src/routes.js"; + +const BINDINGS = { + PUBLIC_ORIGIN: "https://release.example.com", + ALLOWED_ORIGINS: '["https://release.example.com","https://console.example.com"]', + ALLOWED_PUBLISHERS: '{"mode":"allowlist","dids":["did:plc:publisher"]}', + DEPLOYMENT_POLICY: "hosted", +} satisfies ConfigurationBindings; + +const config = loadConfiguration(BINDINGS); + +describe("request IDs and errors", () => { + it("accepts only bounded, header-safe inbound request IDs", () => { + expect( + getRequestId( + new Request("https://release.example.com", { headers: { "x-request-id": "run:abc-123" } }), + ), + ).toBe("run:abc-123"); + const generated = getRequestId( + new Request("https://release.example.com", { headers: { "x-request-id": "bad id value" } }), + ); + expect(generated).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("serializes stable errors without leaking exception messages", () => { + expect(serializeApiError(new ApiError("FORBIDDEN", 403, "Not allowed"))).toEqual({ + code: "FORBIDDEN", + message: "Not allowed", + }); + expect(serializeApiError(new Error("database password leaked"))).toEqual({ + code: "INTERNAL_ERROR", + message: "Internal server error", + }); + }); +}); + +describe("configuration", () => { + it("loads explicit origins, deployment policy, and publisher policy", () => { + expect(config.publicOrigin).toBe("https://release.example.com"); + expect(config.allowedOrigins.has("https://console.example.com")).toBe(true); + expect(config.isPublisherAllowed("did:plc:publisher")).toBe(true); + expect(config.isPublisherAllowed("did:plc:other")).toBe(false); + }); + + it.each([ + { ...BINDINGS, PUBLIC_ORIGIN: "" }, + { ...BINDINGS, PUBLIC_ORIGIN: "http://release.example.com" }, + { ...BINDINGS, ALLOWED_ORIGINS: "[]" }, + { ...BINDINGS, ALLOWED_ORIGINS: '["https://other.example.com"]' }, + { ...BINDINGS, ALLOWED_PUBLISHERS: '{"mode":"allowlist","dids":["not-a-did"]}' }, + { ...BINDINGS, DEPLOYMENT_POLICY: "preview" }, + ])("fails closed for invalid deployment configuration", (bindings) => { + expect(() => loadConfiguration(bindings)).toThrowError("Invalid release-service configuration"); + }); +}); + +describe("mutation security", () => { + it("parses an allowed JSON mutation with the matching CSRF token", async () => { + const request = new Request("https://release.example.com/v1/example", { + method: "POST", + headers: { + origin: "https://console.example.com", + "content-type": "application/json; charset=utf-8", + "x-emdash-csrf": "session-secret", + }, + body: '{"ok":true}', + }); + await expect(parseJsonMutation(request, config, "session-secret")).resolves.toEqual({ + ok: true, + }); + expect(getCorsHeaders(request, config)).toEqual({ + "access-control-allow-credentials": "true", + "access-control-allow-origin": "https://console.example.com", + vary: "Origin", + }); + expect(getCorsPreflightHeaders(request, config, ["POST", "DELETE"])).toMatchObject({ + "access-control-allow-methods": "POST, DELETE", + "access-control-allow-headers": expect.stringContaining("X-EmDash-CSRF"), + }); + }); + + it.each([ + { + name: "GET mutation", + request: new Request("https://release.example.com/v1/example", { + headers: { + origin: BINDINGS.PUBLIC_ORIGIN, + "content-type": "application/json", + "x-emdash-csrf": "x", + }, + }), + code: "METHOD_NOT_ALLOWED", + }, + { + name: "missing origin", + request: new Request("https://release.example.com/v1/example", { + method: "POST", + headers: { "content-type": "application/json", "x-emdash-csrf": "x" }, + body: "{}", + }), + code: "ORIGIN_NOT_ALLOWED", + }, + { + name: "foreign origin", + request: new Request("https://release.example.com/v1/example", { + method: "POST", + headers: { + origin: "https://evil.example", + "content-type": "application/json", + "x-emdash-csrf": "x", + }, + body: "{}", + }), + code: "ORIGIN_NOT_ALLOWED", + }, + { + name: "wrong content type", + request: new Request("https://release.example.com/v1/example", { + method: "POST", + headers: { + origin: BINDINGS.PUBLIC_ORIGIN, + "content-type": "text/plain", + "x-emdash-csrf": "x", + }, + body: "{}", + }), + code: "UNSUPPORTED_MEDIA_TYPE", + }, + { + name: "wrong CSRF token", + request: new Request("https://release.example.com/v1/example", { + method: "POST", + headers: { + origin: BINDINGS.PUBLIC_ORIGIN, + "content-type": "application/json", + "x-emdash-csrf": "wrong", + }, + body: "{}", + }), + code: "CSRF_INVALID", + }, + ])("rejects $name", async ({ request, code }) => { + await expect(parseJsonMutation(request, config, "x")).rejects.toMatchObject({ code }); + }); + + it("rejects bodies whose declared or streamed size exceeds the bound", async () => { + const headers = { + origin: BINDINGS.PUBLIC_ORIGIN, + "content-type": "application/json", + "x-emdash-csrf": "x", + }; + const declared = new Request("https://release.example.com/v1/example", { + method: "POST", + headers: { ...headers, "content-length": String(MAX_JSON_BODY_BYTES + 1) }, + body: "{}", + }); + await expect(parseJsonMutation(declared, config, "x")).rejects.toMatchObject({ + code: "PAYLOAD_TOO_LARGE", + }); + + const streamed = new Request("https://release.example.com/v1/example", { + method: "POST", + headers, + body: JSON.stringify({ value: "x".repeat(MAX_JSON_BODY_BYTES) }), + }); + await expect(parseJsonMutation(streamed, config, "x")).rejects.toMatchObject({ + code: "PAYLOAD_TOO_LARGE", + }); + }); +}); + +describe("authentication and ownership", () => { + it("parses strict bearer authentication", () => { + const request = new Request("https://release.example.com", { + headers: { authorization: "Bearer token-value" }, + }); + expect(requireBearerToken(request)).toBe("token-value"); + expect( + requireBearerToken( + new Request("https://release.example.com", { + headers: { authorization: "bearer another-token" }, + }), + ), + ).toBe("another-token"); + expect(() => requireBearerToken(new Request("https://release.example.com"))).toThrowError( + ApiError, + ); + }); + + it("requires identity ownership or an explicit authorization predicate", () => { + const actor = requireAuthenticated({ subjectDid: "did:plc:publisher" }); + expect(requireOwner(actor, "did:plc:publisher")).toBe(actor); + expect(() => requireOwner(actor, "did:plc:other")).toThrowError(ApiError); + expect(requireOwnerOr(actor, "did:plc:other", () => true)).toBe(actor); + }); +}); + +describe("pagination", () => { + it("clamps limits and round-trips opaque versioned cursors", () => { + expect( + parsePagination(new URL("https://release.example.com?limit=500").searchParams).limit, + ).toBe(100); + expect(parsePagination(new URL("https://release.example.com?limit=2").searchParams).limit).toBe( + 2, + ); + const cursor = encodeCursor("2026-07-11T12:00:00.000Z", "01J123"); + expect(cursor).not.toContain("2026-07-11"); + expect(decodeCursor(cursor)).toEqual({ + version: 1, + orderValue: "2026-07-11T12:00:00.000Z", + id: "01J123", + }); + }); + + it("rejects malformed and unsupported cursors", () => { + expect(() => decodeCursor("not-base64-json")).toThrowError(ApiError); + const unsupported = btoa(JSON.stringify([2, "order", "id"])).replaceAll("=", ""); + expect(() => decodeCursor(unsupported)).toThrowError(ApiError); + expect(() => encodeCursor("😀".repeat(512), "id")).toThrowError(ApiError); + }); +}); + +describe("API schema", () => { + it("generates OpenAPI 3.1 from only the reachable route registry", () => { + const schema = generateApiSchema(); + expect(schema.openapi).toBe("3.1.0"); + expect(Object.keys(schema.paths)).toEqual(["/health"]); + expect(schema.paths["/health"]).toHaveProperty("get"); + expect(JSON.stringify(schema)).toContain("requestId"); + expect(JSON.stringify(schema)).toContain("INTERNAL_ERROR"); + }); + + it("preserves multiple methods registered for the same path", () => { + const schema = generateApiSchema([ + ...ROUTES, + { + ...ROUTES[0], + method: "POST", + operationId: "postHealthTest", + }, + ]); + expect(schema.paths["/health"]).toHaveProperty("get"); + expect(schema.paths["/health"]).toHaveProperty("post"); + }); +}); diff --git a/apps/release-service/test/worker.test.ts b/apps/release-service/test/worker.test.ts new file mode 100644 index 0000000000..0d5e493f27 --- /dev/null +++ b/apps/release-service/test/worker.test.ts @@ -0,0 +1,135 @@ +import { SELF, env } from "cloudflare:test"; +import { describe, expect, it, vi } from "vitest"; + +import type { ConfigurationBindings } from "../src/config.js"; +import { failInactiveSchedule, handleRequest, retryUnsupportedQueue } from "../src/index.js"; +import { ROUTES, type RouteDefinition } from "../src/routes.js"; + +const BLOCKED_PATHS = [ + "/v1/release-intents", + "/v1/release-intents/public-id", + "/v1/release-intents/public-id/cancel", + "/v1/release-intents/public-id/approval", + "/v1/release-intents/public-id/approval/options", + "/v1/release-intents/public-id/approve", + "/v1/release-intents/public-id/reject", + "/v1/me", + "/v1/delegations", + "/v1/delegations/start", + "/v1/workload-policies", + "/v1/approver/oauth/start", + "/v1/passkeys", + "/v1/notification-endpoints", + "/v1/audit-events", +]; + +describe("release-service worker", () => { + it("serves a healthy versioned JSON envelope with a request ID", async () => { + const response = await SELF.fetch("https://release.example.invalid/health", { + headers: { "x-request-id": "health-check-1" }, + }); + expect(response.status).toBe(200); + expect(response.headers.get("x-request-id")).toBe("health-check-1"); + expect(response.headers.get("content-type")).toBe("application/json; charset=utf-8"); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toEqual({ + data: { status: "ok" }, + requestId: "health-check-1", + }); + }); + + it("fails health closed without exposing configuration details", async () => { + const bindings = { + PUBLIC_ORIGIN: "", + ALLOWED_ORIGINS: "[]", + ALLOWED_PUBLISHERS: '{"mode":"all"}', + DEPLOYMENT_POLICY: "hosted", + } satisfies ConfigurationBindings; + const response = await handleRequest(new Request("https://test/health"), bindings); + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ + error: { + code: "CONFIGURATION_ERROR", + message: "Service is not configured", + }, + }); + const secondResponse = await handleRequest(new Request("https://test/health"), bindings); + expect(await secondResponse.text()).not.toContain("PUBLIC_ORIGIN"); + }); + + it("provides the configured D1 binding", async () => { + const result = await env.DB.prepare("SELECT 1 AS healthy").first<{ healthy: number }>(); + expect(result).toEqual({ healthy: 1 }); + }); + + it("registers only the health route", () => { + expect(ROUTES.map(({ method, path }) => `${method} ${path}`)).toEqual(["GET /health"]); + }); + + it("catches async route failures without leaking them to clients", async () => { + const internalMessage = "database password leaked"; + const failingRoute: RouteDefinition = { + method: "GET", + path: "/__test/async-rejection", + operationId: "testAsyncRejection", + summary: "Test async rejection", + successStatus: 200, + successDataSchema: { type: "object" }, + async handler() { + await Promise.resolve(); + throw new Error(internalMessage); + }, + }; + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const response = await handleRequest( + new Request("https://release.example.invalid/__test/async-rejection", { + headers: { "x-request-id": "async-failure-1" }, + }), + env, + [failingRoute], + ); + expect(response.status).toBe(500); + expect(response.headers.get("x-request-id")).toBe("async-failure-1"); + const body = await response.json(); + expect(body).toEqual({ + error: { code: "INTERNAL_ERROR", message: "Internal server error" }, + requestId: "async-failure-1", + }); + expect(JSON.stringify(body)).not.toContain(internalMessage); + expect(errorLog).toHaveBeenCalledWith(expect.stringContaining(internalMessage)); + } finally { + errorLog.mockRestore(); + } + }); + + it.each(BLOCKED_PATHS)("does not expose %s", async (path) => { + for (const method of ["GET", "POST", "PATCH", "DELETE"]) { + const response = await SELF.fetch(`https://release.example.invalid${path}`, { method }); + expect(response.status).toBe(404); + const body = await response.json<{ error: { code: string } }>(); + expect(body.error.code).toBe("NOT_FOUND"); + } + }); + + it("does not expose static assets through the Worker catch-all", async () => { + const response = await SELF.fetch("https://release.example.invalid/.gitkeep"); + expect(response.status).toBe(404); + }); + + it("retries messages while lifecycle consumers are unavailable", () => { + let retryOptions: QueueRetryOptions | undefined; + retryUnsupportedQueue({ + queue: "emdash-release-service-releases", + messages: [{ id: "message-1" }], + retryAll(options) { + retryOptions = options; + }, + }); + expect(retryOptions).toEqual({ delaySeconds: 300 }); + }); + + it("fails scheduled invocations while lifecycle recovery is unavailable", () => { + expect(() => failInactiveSchedule(1234)).toThrowError("Scheduled lifecycle is not active"); + }); +}); diff --git a/apps/release-service/tsconfig.json b/apps/release-service/tsconfig.json new file mode 100644 index 0000000000..34ce731d08 --- /dev/null +++ b/apps/release-service/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["@cloudflare/vitest-pool-workers/types"], + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src/**/*", "test/**/*", "worker-configuration.d.ts"] +} diff --git a/apps/release-service/vite.config.ts b/apps/release-service/vite.config.ts new file mode 100644 index 0000000000..f6268f8e95 --- /dev/null +++ b/apps/release-service/vite.config.ts @@ -0,0 +1,6 @@ +import { cloudflare } from "@cloudflare/vite-plugin"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [cloudflare()], +}); diff --git a/apps/release-service/vitest.config.ts b/apps/release-service/vitest.config.ts new file mode 100644 index 0000000000..f36bc74948 --- /dev/null +++ b/apps/release-service/vitest.config.ts @@ -0,0 +1,18 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.jsonc" }, + miniflare: { + bindings: { + PUBLIC_ORIGIN: "https://release.example.invalid", + ALLOWED_ORIGINS: '["https://release.example.invalid"]', + ALLOWED_PUBLISHERS: '{"mode":"all"}', + DEPLOYMENT_POLICY: "hosted", + }, + }, + }), + ], +}); diff --git a/apps/release-service/worker-configuration.d.ts b/apps/release-service/worker-configuration.d.ts new file mode 100644 index 0000000000..99ffd040d0 --- /dev/null +++ b/apps/release-service/worker-configuration.d.ts @@ -0,0 +1,14495 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types` (hash: 559fb97ee17a634be71dc31b42c571f8) +// Runtime types generated with workerd@1.20260611.1 2026-05-14 nodejs_compat +interface __BaseEnv_Env { + DB: D1Database; + RELEASE_QUEUE: Queue; + NOTIFICATION_QUEUE: Queue; + ASSETS: Fetcher; + PUBLIC_ORIGIN: ""; + ALLOWED_ORIGINS: "[]"; + ALLOWED_PUBLISHERS: "{\"mode\":\"allowlist\",\"dids\":[]}"; + DEPLOYMENT_POLICY: ""; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./src/index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} +type StringifyValues> = { + [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; +}; +declare namespace NodeJS { + interface ProcessEnv extends StringifyValues> {} +} + +// Begin runtime types +/*! ***************************************************************************** +Copyright (c) Cloudflare. All rights reserved. +Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at http://www.apache.org/licenses/LICENSE-2.0 +THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED +WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, +MERCHANTABLITY OR NON-INFRINGEMENT. +See the Apache Version 2.0 License for specific language governing permissions +and limitations under the License. +***************************************************************************** */ +/* eslint-disable */ +// noinspection JSUnusedGlobalSymbols +declare var onmessage: never; +/** + * The **`DOMException`** interface represents an abnormal event (called an **exception**) that occurs as a result of calling a method or accessing a property of a web API. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException) + */ +declare class DOMException extends Error { + constructor(message?: string, name?: string); + /** + * The **`message`** read-only property of the a message or description associated with the given error name. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/message) + */ + readonly message: string; + /** + * The **`name`** read-only property of the one of the strings associated with an error name. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/name) + */ + readonly name: string; + /** + * The **`code`** read-only property of the DOMException interface returns one of the legacy error code constants, or `0` if none match. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/code) + */ + readonly code: number; + static readonly INDEX_SIZE_ERR: number; + static readonly DOMSTRING_SIZE_ERR: number; + static readonly HIERARCHY_REQUEST_ERR: number; + static readonly WRONG_DOCUMENT_ERR: number; + static readonly INVALID_CHARACTER_ERR: number; + static readonly NO_DATA_ALLOWED_ERR: number; + static readonly NO_MODIFICATION_ALLOWED_ERR: number; + static readonly NOT_FOUND_ERR: number; + static readonly NOT_SUPPORTED_ERR: number; + static readonly INUSE_ATTRIBUTE_ERR: number; + static readonly INVALID_STATE_ERR: number; + static readonly SYNTAX_ERR: number; + static readonly INVALID_MODIFICATION_ERR: number; + static readonly NAMESPACE_ERR: number; + static readonly INVALID_ACCESS_ERR: number; + static readonly VALIDATION_ERR: number; + static readonly TYPE_MISMATCH_ERR: number; + static readonly SECURITY_ERR: number; + static readonly NETWORK_ERR: number; + static readonly ABORT_ERR: number; + static readonly URL_MISMATCH_ERR: number; + static readonly QUOTA_EXCEEDED_ERR: number; + static readonly TIMEOUT_ERR: number; + static readonly INVALID_NODE_TYPE_ERR: number; + static readonly DATA_CLONE_ERR: number; + get stack(): any; + set stack(value: any); +} +type WorkerGlobalScopeEventMap = { + fetch: FetchEvent; + scheduled: ScheduledEvent; + queue: QueueEvent; + unhandledrejection: PromiseRejectionEvent; + rejectionhandled: PromiseRejectionEvent; +}; +declare abstract class WorkerGlobalScope extends EventTarget { + EventTarget: typeof EventTarget; +} +/* The **`console`** object provides access to the debugging console (e.g., the Web console in Firefox). * + * The **`console`** object provides access to the debugging console (e.g., the Web console in Firefox). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console) + */ +interface Console { + "assert"(condition?: boolean, ...data: any[]): void; + /** + * The **`console.clear()`** static method clears the console if possible. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/clear_static) + */ + clear(): void; + /** + * The **`console.count()`** static method logs the number of times that this particular call to `count()` has been called. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static) + */ + count(label?: string): void; + /** + * The **`console.countReset()`** static method resets counter used with console/count_static. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static) + */ + countReset(label?: string): void; + /** + * The **`console.debug()`** static method outputs a message to the console at the 'debug' log level. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static) + */ + debug(...data: any[]): void; + /** + * The **`console.dir()`** static method displays a list of the properties of the specified JavaScript object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir_static) + */ + dir(item?: any, options?: any): void; + /** + * The **`console.dirxml()`** static method displays an interactive tree of the descendant elements of the specified XML/HTML element. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml_static) + */ + dirxml(...data: any[]): void; + /** + * The **`console.error()`** static method outputs a message to the console at the 'error' log level. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static) + */ + error(...data: any[]): void; + /** + * The **`console.group()`** static method creates a new inline group in the Web console log, causing any subsequent console messages to be indented by an additional level, until console/groupEnd_static is called. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static) + */ + group(...data: any[]): void; + /** + * The **`console.groupCollapsed()`** static method creates a new inline group in the console. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static) + */ + groupCollapsed(...data: any[]): void; + /** + * The **`console.groupEnd()`** static method exits the current inline group in the console. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static) + */ + groupEnd(): void; + /** + * The **`console.info()`** static method outputs a message to the console at the 'info' log level. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static) + */ + info(...data: any[]): void; + /** + * The **`console.log()`** static method outputs a message to the console. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static) + */ + log(...data: any[]): void; + /** + * The **`console.table()`** static method displays tabular data as a table. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/table_static) + */ + table(tabularData?: any, properties?: string[]): void; + /** + * The **`console.time()`** static method starts a timer you can use to track how long an operation takes. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static) + */ + time(label?: string): void; + /** + * The **`console.timeEnd()`** static method stops a timer that was previously started by calling console/time_static. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static) + */ + timeEnd(label?: string): void; + /** + * The **`console.timeLog()`** static method logs the current value of a timer that was previously started by calling console/time_static. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static) + */ + timeLog(label?: string, ...data: any[]): void; + timeStamp(label?: string): void; + /** + * The **`console.trace()`** static method outputs a stack trace to the console. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/trace_static) + */ + trace(...data: any[]): void; + /** + * The **`console.warn()`** static method outputs a warning message to the console at the 'warning' log level. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static) + */ + warn(...data: any[]): void; +} +declare const console: Console; +type BufferSource = ArrayBufferView | ArrayBuffer; +type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array | BigInt64Array | BigUint64Array; +declare namespace WebAssembly { + class CompileError extends Error { + constructor(message?: string); + } + class RuntimeError extends Error { + constructor(message?: string); + } + type ValueType = "anyfunc" | "externref" | "f32" | "f64" | "i32" | "i64" | "v128"; + interface GlobalDescriptor { + value: ValueType; + mutable?: boolean; + } + class Global { + constructor(descriptor: GlobalDescriptor, value?: any); + value: any; + valueOf(): any; + } + type ImportValue = ExportValue | number; + type ModuleImports = Record; + type Imports = Record; + type ExportValue = Function | Global | Memory | Table; + type Exports = Record; + class Instance { + constructor(module: Module, imports?: Imports); + readonly exports: Exports; + } + interface MemoryDescriptor { + initial: number; + maximum?: number; + shared?: boolean; + } + class Memory { + constructor(descriptor: MemoryDescriptor); + readonly buffer: ArrayBuffer; + grow(delta: number): number; + } + type ImportExportKind = "function" | "global" | "memory" | "table"; + interface ModuleExportDescriptor { + kind: ImportExportKind; + name: string; + } + interface ModuleImportDescriptor { + kind: ImportExportKind; + module: string; + name: string; + } + abstract class Module { + static customSections(module: Module, sectionName: string): ArrayBuffer[]; + static exports(module: Module): ModuleExportDescriptor[]; + static imports(module: Module): ModuleImportDescriptor[]; + } + type TableKind = "anyfunc" | "externref"; + interface TableDescriptor { + element: TableKind; + initial: number; + maximum?: number; + } + class Table { + constructor(descriptor: TableDescriptor, value?: any); + readonly length: number; + get(index: number): any; + grow(delta: number, value?: any): number; + set(index: number, value?: any): void; + } + function instantiate(module: Module, imports?: Imports): Promise; + function validate(bytes: BufferSource): boolean; +} +/** + * The **`ServiceWorkerGlobalScope`** interface of the Service Worker API represents the global execution context of a service worker. + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ServiceWorkerGlobalScope) + */ +interface ServiceWorkerGlobalScope extends WorkerGlobalScope { + DOMException: typeof DOMException; + WorkerGlobalScope: typeof WorkerGlobalScope; + btoa(data: string): string; + atob(data: string): string; + setTimeout(callback: (...args: any[]) => void, msDelay?: number): number; + setTimeout(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number; + clearTimeout(timeoutId: number | null): void; + setInterval(callback: (...args: any[]) => void, msDelay?: number): number; + setInterval(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number; + clearInterval(timeoutId: number | null): void; + queueMicrotask(task: Function): void; + structuredClone(value: T, options?: StructuredSerializeOptions): T; + reportError(error: any): void; + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; + self: ServiceWorkerGlobalScope; + crypto: Crypto; + caches: CacheStorage; + scheduler: Scheduler; + performance: Performance; + Cloudflare: Cloudflare; + readonly origin: string; + Event: typeof Event; + ExtendableEvent: typeof ExtendableEvent; + CustomEvent: typeof CustomEvent; + PromiseRejectionEvent: typeof PromiseRejectionEvent; + FetchEvent: typeof FetchEvent; + TailEvent: typeof TailEvent; + TraceEvent: typeof TailEvent; + ScheduledEvent: typeof ScheduledEvent; + MessageEvent: typeof MessageEvent; + CloseEvent: typeof CloseEvent; + ReadableStreamDefaultReader: typeof ReadableStreamDefaultReader; + ReadableStreamBYOBReader: typeof ReadableStreamBYOBReader; + ReadableStream: typeof ReadableStream; + WritableStream: typeof WritableStream; + WritableStreamDefaultWriter: typeof WritableStreamDefaultWriter; + TransformStream: typeof TransformStream; + ByteLengthQueuingStrategy: typeof ByteLengthQueuingStrategy; + CountQueuingStrategy: typeof CountQueuingStrategy; + ErrorEvent: typeof ErrorEvent; + MessageChannel: typeof MessageChannel; + MessagePort: typeof MessagePort; + EventSource: typeof EventSource; + ReadableStreamBYOBRequest: typeof ReadableStreamBYOBRequest; + ReadableStreamDefaultController: typeof ReadableStreamDefaultController; + ReadableByteStreamController: typeof ReadableByteStreamController; + WritableStreamDefaultController: typeof WritableStreamDefaultController; + TransformStreamDefaultController: typeof TransformStreamDefaultController; + CompressionStream: typeof CompressionStream; + DecompressionStream: typeof DecompressionStream; + TextEncoderStream: typeof TextEncoderStream; + TextDecoderStream: typeof TextDecoderStream; + Headers: typeof Headers; + Body: typeof Body; + Request: typeof Request; + Response: typeof Response; + WebSocket: typeof WebSocket; + WebSocketPair: typeof WebSocketPair; + WebSocketRequestResponsePair: typeof WebSocketRequestResponsePair; + AbortController: typeof AbortController; + AbortSignal: typeof AbortSignal; + TextDecoder: typeof TextDecoder; + TextEncoder: typeof TextEncoder; + navigator: Navigator; + Navigator: typeof Navigator; + URL: typeof URL; + URLSearchParams: typeof URLSearchParams; + URLPattern: typeof URLPattern; + Blob: typeof Blob; + File: typeof File; + FormData: typeof FormData; + Crypto: typeof Crypto; + SubtleCrypto: typeof SubtleCrypto; + CryptoKey: typeof CryptoKey; + CacheStorage: typeof CacheStorage; + Cache: typeof Cache; + FixedLengthStream: typeof FixedLengthStream; + IdentityTransformStream: typeof IdentityTransformStream; + HTMLRewriter: typeof HTMLRewriter; +} +declare function addEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetAddEventListenerOptions | boolean): void; +declare function removeEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetEventListenerOptions | boolean): void; +/** + * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent) + */ +declare function dispatchEvent(event: WorkerGlobalScopeEventMap[keyof WorkerGlobalScopeEventMap]): boolean; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/btoa) */ +declare function btoa(data: string): string; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/atob) */ +declare function atob(data: string): string; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */ +declare function setTimeout(callback: (...args: any[]) => void, msDelay?: number): number; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */ +declare function setTimeout(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearTimeout) */ +declare function clearTimeout(timeoutId: number | null): void; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */ +declare function setInterval(callback: (...args: any[]) => void, msDelay?: number): number; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */ +declare function setInterval(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearInterval) */ +declare function clearInterval(timeoutId: number | null): void; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/queueMicrotask) */ +declare function queueMicrotask(task: Function): void; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/structuredClone) */ +declare function structuredClone(value: T, options?: StructuredSerializeOptions): T; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/reportError) */ +declare function reportError(error: any): void; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch) */ +declare function fetch(input: RequestInfo | URL, init?: RequestInit): Promise; +declare const self: ServiceWorkerGlobalScope; +/** +* The Web Crypto API provides a set of low-level functions for common cryptographic tasks. +* The Workers runtime implements the full surface of this API, but with some differences in +* the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms) +* compared to those implemented in most browsers. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/) +*/ +declare const crypto: Crypto; +/** +* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/) +*/ +declare const caches: CacheStorage; +declare const scheduler: Scheduler; +/** +* The Workers runtime supports a subset of the Performance API, used to measure timing and performance, +* as well as timing of subrequests and other operations. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/) +*/ +declare const performance: Performance; +declare const Cloudflare: Cloudflare; +declare const origin: string; +declare const navigator: Navigator; +interface TestController { +} +interface ExecutionContext { + waitUntil(promise: Promise): void; + passThroughOnException(): void; + readonly exports: Cloudflare.Exports; + readonly props: Props; + cache?: CacheContext; + tracing?: Tracing; +} +type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise; +type ExportedHandlerConnectHandler = (socket: Socket, env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerTailHandler = (events: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerTraceHandler = (traces: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerTailStreamHandler = (event: TailStream.TailEvent, env: Env, ctx: ExecutionContext) => TailStream.TailEventHandlerType | Promise; +type ExportedHandlerScheduledHandler = (controller: ScheduledController, env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerQueueHandler = (batch: MessageBatch, env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerTestHandler = (controller: TestController, env: Env, ctx: ExecutionContext) => void | Promise; +interface ExportedHandler { + fetch?: ExportedHandlerFetchHandler; + connect?: ExportedHandlerConnectHandler; + tail?: ExportedHandlerTailHandler; + trace?: ExportedHandlerTraceHandler; + tailStream?: ExportedHandlerTailStreamHandler; + scheduled?: ExportedHandlerScheduledHandler; + test?: ExportedHandlerTestHandler; + email?: EmailExportedHandler; + queue?: ExportedHandlerQueueHandler; +} +interface StructuredSerializeOptions { + transfer?: any[]; +} +declare abstract class Navigator { + sendBeacon(url: string, body?: BodyInit): boolean; + readonly userAgent: string; + readonly hardwareConcurrency: number; + readonly platform: string; + readonly language: string; + readonly languages: string[]; +} +interface AlarmInvocationInfo { + readonly isRetry: boolean; + readonly retryCount: number; + readonly scheduledTime: number; +} +interface Cloudflare { + readonly compatibilityFlags: Record; +} +interface CachePurgeError { + code: number; + message: string; +} +interface CachePurgeResult { + success: boolean; + errors: CachePurgeError[]; +} +interface CachePurgeOptions { + tags?: string[]; + pathPrefixes?: string[]; + purgeEverything?: boolean; +} +interface CacheContext { + purge(options: CachePurgeOptions): Promise; +} +declare abstract class ColoLocalActorNamespace { + get(actorId: string): Fetcher; +} +interface DurableObject { + fetch(request: Request): Response | Promise; + connect?(socket: Socket): void | Promise; + alarm?(alarmInfo?: AlarmInvocationInfo): void | Promise; + webSocketMessage?(ws: WebSocket, message: string | ArrayBuffer): void | Promise; + webSocketClose?(ws: WebSocket, code: number, reason: string, wasClean: boolean): void | Promise; + webSocketError?(ws: WebSocket, error: unknown): void | Promise; +} +type DurableObjectStub = Fetcher & { + readonly id: DurableObjectId; + readonly name?: string; +}; +interface DurableObjectId { + toString(): string; + equals(other: DurableObjectId): boolean; + readonly name?: string; + readonly jurisdiction?: string; +} +declare abstract class DurableObjectNamespace { + newUniqueId(options?: DurableObjectNamespaceNewUniqueIdOptions): DurableObjectId; + idFromName(name: string): DurableObjectId; + idFromString(id: string): DurableObjectId; + get(id: DurableObjectId, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub; + getByName(name: string, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub; + jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace; +} +type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high"; +interface DurableObjectNamespaceNewUniqueIdOptions { + jurisdiction?: DurableObjectJurisdiction; +} +type DurableObjectLocationHint = "wnam" | "enam" | "sam" | "weur" | "eeur" | "apac" | "oc" | "afr" | "me"; +type DurableObjectRoutingMode = "primary-only"; +interface DurableObjectNamespaceGetDurableObjectOptions { + locationHint?: DurableObjectLocationHint; + routingMode?: DurableObjectRoutingMode; +} +interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = undefined> { +} +interface DurableObjectState { + waitUntil(promise: Promise): void; + readonly exports: Cloudflare.Exports; + readonly props: Props; + readonly id: DurableObjectId; + readonly storage: DurableObjectStorage; + container?: Container; + facets: DurableObjectFacets; + blockConcurrencyWhile(callback: () => Promise): Promise; + acceptWebSocket(ws: WebSocket, tags?: string[]): void; + getWebSockets(tag?: string): WebSocket[]; + setWebSocketAutoResponse(maybeReqResp?: WebSocketRequestResponsePair): void; + getWebSocketAutoResponse(): WebSocketRequestResponsePair | null; + getWebSocketAutoResponseTimestamp(ws: WebSocket): Date | null; + setHibernatableWebSocketEventTimeout(timeoutMs?: number): void; + getHibernatableWebSocketEventTimeout(): number | null; + getTags(ws: WebSocket): string[]; + abort(reason?: string): void; +} +interface DurableObjectTransaction { + get(key: string, options?: DurableObjectGetOptions): Promise; + get(keys: string[], options?: DurableObjectGetOptions): Promise>; + list(options?: DurableObjectListOptions): Promise>; + put(key: string, value: T, options?: DurableObjectPutOptions): Promise; + put(entries: Record, options?: DurableObjectPutOptions): Promise; + delete(key: string, options?: DurableObjectPutOptions): Promise; + delete(keys: string[], options?: DurableObjectPutOptions): Promise; + rollback(): void; + getAlarm(options?: DurableObjectGetAlarmOptions): Promise; + setAlarm(scheduledTime: number | Date, options?: DurableObjectSetAlarmOptions): Promise; + deleteAlarm(options?: DurableObjectSetAlarmOptions): Promise; +} +interface DurableObjectStorage { + get(key: string, options?: DurableObjectGetOptions): Promise; + get(keys: string[], options?: DurableObjectGetOptions): Promise>; + list(options?: DurableObjectListOptions): Promise>; + put(key: string, value: T, options?: DurableObjectPutOptions): Promise; + put(entries: Record, options?: DurableObjectPutOptions): Promise; + delete(key: string, options?: DurableObjectPutOptions): Promise; + delete(keys: string[], options?: DurableObjectPutOptions): Promise; + deleteAll(options?: DurableObjectPutOptions): Promise; + transaction(closure: (txn: DurableObjectTransaction) => Promise): Promise; + getAlarm(options?: DurableObjectGetAlarmOptions): Promise; + setAlarm(scheduledTime: number | Date, options?: DurableObjectSetAlarmOptions): Promise; + deleteAlarm(options?: DurableObjectSetAlarmOptions): Promise; + sync(): Promise; + sql: SqlStorage; + kv: SyncKvStorage; + transactionSync(closure: () => T): T; + getCurrentBookmark(): Promise; + getBookmarkForTime(timestamp: number | Date): Promise; + onNextSessionRestoreBookmark(bookmark: string): Promise; +} +interface DurableObjectListOptions { + start?: string; + startAfter?: string; + end?: string; + prefix?: string; + reverse?: boolean; + limit?: number; + allowConcurrency?: boolean; + noCache?: boolean; +} +interface DurableObjectGetOptions { + allowConcurrency?: boolean; + noCache?: boolean; +} +interface DurableObjectGetAlarmOptions { + allowConcurrency?: boolean; +} +interface DurableObjectPutOptions { + allowConcurrency?: boolean; + allowUnconfirmed?: boolean; + noCache?: boolean; +} +interface DurableObjectSetAlarmOptions { + allowConcurrency?: boolean; + allowUnconfirmed?: boolean; +} +declare class WebSocketRequestResponsePair { + constructor(request: string, response: string); + get request(): string; + get response(): string; +} +interface DurableObjectFacets { + get(name: string, getStartupOptions: () => FacetStartupOptions | Promise>): Fetcher; + abort(name: string, reason: any): void; + delete(name: string): void; +} +interface FacetStartupOptions { + id?: DurableObjectId | string; + class: DurableObjectClass; +} +interface AnalyticsEngineDataset { + writeDataPoint(event?: AnalyticsEngineDataPoint): void; +} +interface AnalyticsEngineDataPoint { + indexes?: ((ArrayBuffer | string) | null)[]; + doubles?: number[]; + blobs?: ((ArrayBuffer | string) | null)[]; +} +/** + * The **`Event`** interface represents an event which takes place on an `EventTarget`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event) + */ +declare class Event { + constructor(type: string, init?: EventInit); + /** + * The **`type`** read-only property of the Event interface returns a string containing the event's type. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/type) + */ + get type(): string; + /** + * The **`eventPhase`** read-only property of the being evaluated. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/eventPhase) + */ + get eventPhase(): number; + /** + * The read-only **`composed`** property of the or not the event will propagate across the shadow DOM boundary into the standard DOM. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composed) + */ + get composed(): boolean; + /** + * The **`bubbles`** read-only property of the Event interface indicates whether the event bubbles up through the DOM tree or not. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/bubbles) + */ + get bubbles(): boolean; + /** + * The **`cancelable`** read-only property of the Event interface indicates whether the event can be canceled, and therefore prevented as if the event never happened. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelable) + */ + get cancelable(): boolean; + /** + * The **`defaultPrevented`** read-only property of the Event interface returns a boolean value indicating whether or not the call to Event.preventDefault() canceled the event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/defaultPrevented) + */ + get defaultPrevented(): boolean; + /** + * The Event property **`returnValue`** indicates whether the default action for this event has been prevented or not. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/returnValue) + */ + get returnValue(): boolean; + /** + * The **`currentTarget`** read-only property of the Event interface identifies the element to which the event handler has been attached. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/currentTarget) + */ + get currentTarget(): EventTarget | undefined; + /** + * The read-only **`target`** property of the dispatched. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/target) + */ + get target(): EventTarget | undefined; + /** + * The deprecated **`Event.srcElement`** is an alias for the Event.target property. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/srcElement) + */ + get srcElement(): EventTarget | undefined; + /** + * The **`timeStamp`** read-only property of the Event interface returns the time (in milliseconds) at which the event was created. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/timeStamp) + */ + get timeStamp(): number; + /** + * The **`isTrusted`** read-only property of the when the event was generated by the user agent (including via user actions and programmatic methods such as HTMLElement.focus()), and `false` when the event was dispatched via The only exception is the `click` event, which initializes the `isTrusted` property to `false` in user agents. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/isTrusted) + */ + get isTrusted(): boolean; + /** + * The **`cancelBubble`** property of the Event interface is deprecated. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble) + */ + get cancelBubble(): boolean; + /** + * The **`cancelBubble`** property of the Event interface is deprecated. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble) + */ + set cancelBubble(value: boolean); + /** + * The **`stopImmediatePropagation()`** method of the If several listeners are attached to the same element for the same event type, they are called in the order in which they were added. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopImmediatePropagation) + */ + stopImmediatePropagation(): void; + /** + * The **`preventDefault()`** method of the Event interface tells the user agent that if the event does not get explicitly handled, its default action should not be taken as it normally would be. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/preventDefault) + */ + preventDefault(): void; + /** + * The **`stopPropagation()`** method of the Event interface prevents further propagation of the current event in the capturing and bubbling phases. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopPropagation) + */ + stopPropagation(): void; + /** + * The **`composedPath()`** method of the Event interface returns the event's path which is an array of the objects on which listeners will be invoked. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composedPath) + */ + composedPath(): EventTarget[]; + static readonly NONE: number; + static readonly CAPTURING_PHASE: number; + static readonly AT_TARGET: number; + static readonly BUBBLING_PHASE: number; +} +interface EventInit { + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; +} +type EventListener = (event: EventType) => void; +interface EventListenerObject { + handleEvent(event: EventType): void; +} +type EventListenerOrEventListenerObject = EventListener | EventListenerObject; +/** + * The **`EventTarget`** interface is implemented by objects that can receive events and may have listeners for them. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget) + */ +declare class EventTarget = Record> { + constructor(); + /** + * The **`addEventListener()`** method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/addEventListener) + */ + addEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetAddEventListenerOptions | boolean): void; + /** + * The **`removeEventListener()`** method of the EventTarget interface removes an event listener previously registered with EventTarget.addEventListener() from the target. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/removeEventListener) + */ + removeEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetEventListenerOptions | boolean): void; + /** + * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent) + */ + dispatchEvent(event: EventMap[keyof EventMap]): boolean; +} +interface EventTargetEventListenerOptions { + capture?: boolean; +} +interface EventTargetAddEventListenerOptions { + capture?: boolean; + passive?: boolean; + once?: boolean; + signal?: AbortSignal; +} +interface EventTargetHandlerObject { + handleEvent: (event: Event) => any | undefined; +} +/** + * The **`AbortController`** interface represents a controller object that allows you to abort one or more Web requests as and when desired. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController) + */ +declare class AbortController { + constructor(); + /** + * The **`signal`** read-only property of the AbortController interface returns an AbortSignal object instance, which can be used to communicate with/abort an asynchronous operation as desired. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/signal) + */ + get signal(): AbortSignal; + /** + * The **`abort()`** method of the AbortController interface aborts an asynchronous operation before it has completed. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/abort) + */ + abort(reason?: any): void; +} +/** + * The **`AbortSignal`** interface represents a signal object that allows you to communicate with an asynchronous operation (such as a fetch request) and abort it if required via an AbortController object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal) + */ +declare abstract class AbortSignal extends EventTarget { + /** + * The **`AbortSignal.abort()`** static method returns an AbortSignal that is already set as aborted (and which does not trigger an AbortSignal/abort_event event). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_static) + */ + static abort(reason?: any): AbortSignal; + /** + * The **`AbortSignal.timeout()`** static method returns an AbortSignal that will automatically abort after a specified time. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/timeout_static) + */ + static timeout(delay: number): AbortSignal; + /** + * The **`AbortSignal.any()`** static method takes an iterable of abort signals and returns an AbortSignal. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/any_static) + */ + static any(signals: AbortSignal[]): AbortSignal; + /** + * The **`aborted`** read-only property returns a value that indicates whether the asynchronous operations the signal is communicating with are aborted (`true`) or not (`false`). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/aborted) + */ + get aborted(): boolean; + /** + * The **`reason`** read-only property returns a JavaScript value that indicates the abort reason. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/reason) + */ + get reason(): any; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */ + get onabort(): any | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */ + set onabort(value: any | null); + /** + * The **`throwIfAborted()`** method throws the signal's abort AbortSignal.reason if the signal has been aborted; otherwise it does nothing. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/throwIfAborted) + */ + throwIfAborted(): void; +} +interface Scheduler { + wait(delay: number, maybeOptions?: SchedulerWaitOptions): Promise; +} +interface SchedulerWaitOptions { + signal?: AbortSignal; +} +/** + * The **`ExtendableEvent`** interface extends the lifetime of the `install` and `activate` events dispatched on the global scope as part of the service worker lifecycle. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent) + */ +declare abstract class ExtendableEvent extends Event { + /** + * The **`ExtendableEvent.waitUntil()`** method tells the event dispatcher that work is ongoing. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent/waitUntil) + */ + waitUntil(promise: Promise): void; +} +/** + * The **`CustomEvent`** interface represents events initialized by an application for any purpose. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent) + */ +declare class CustomEvent extends Event { + constructor(type: string, init?: CustomEventCustomEventInit); + /** + * The read-only **`detail`** property of the CustomEvent interface returns any data passed when initializing the event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent/detail) + */ + get detail(): T; +} +interface CustomEventCustomEventInit { + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; + detail?: any; +} +/** + * The **`Blob`** interface represents a blob, which is a file-like object of immutable, raw data; they can be read as text or binary data, or converted into a ReadableStream so its methods can be used for processing the data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob) + */ +declare class Blob { + constructor(bits?: ((ArrayBuffer | ArrayBufferView) | string | Blob)[], options?: BlobOptions); + /** + * The **`size`** read-only property of the Blob interface returns the size of the Blob or File in bytes. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size) + */ + get size(): number; + /** + * The **`type`** read-only property of the Blob interface returns the MIME type of the file. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) + */ + get type(): string; + /** + * The **`slice()`** method of the Blob interface creates and returns a new `Blob` object which contains data from a subset of the blob on which it's called. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) + */ + slice(start?: number, end?: number, type?: string): Blob; + /** + * The **`arrayBuffer()`** method of the Blob interface returns a Promise that resolves with the contents of the blob as binary data contained in an ArrayBuffer. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/arrayBuffer) + */ + arrayBuffer(): Promise; + /** + * The **`bytes()`** method of the Blob interface returns a Promise that resolves with a Uint8Array containing the contents of the blob as an array of bytes. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/bytes) + */ + bytes(): Promise; + /** + * The **`text()`** method of the string containing the contents of the blob, interpreted as UTF-8. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) + */ + text(): Promise; + /** + * The **`stream()`** method of the Blob interface returns a ReadableStream which upon reading returns the data contained within the `Blob`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/stream) + */ + stream(): ReadableStream; +} +interface BlobOptions { + type?: string; +} +/** + * The **`File`** interface provides information about files and allows JavaScript in a web page to access their content. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File) + */ +declare class File extends Blob { + constructor(bits: ((ArrayBuffer | ArrayBufferView) | string | Blob)[] | undefined, name: string, options?: FileOptions); + /** + * The **`name`** read-only property of the File interface returns the name of the file represented by a File object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) + */ + get name(): string; + /** + * The **`lastModified`** read-only property of the File interface provides the last modified date of the file as the number of milliseconds since the Unix epoch (January 1, 1970 at midnight). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) + */ + get lastModified(): number; +} +interface FileOptions { + type?: string; + lastModified?: number; +} +/** +* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/) +*/ +declare abstract class CacheStorage { + /** + * The **`open()`** method of the the Cache object matching the `cacheName`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CacheStorage/open) + */ + open(cacheName: string): Promise; + readonly default: Cache; +} +/** +* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/) +*/ +declare abstract class Cache { + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#delete) */ + delete(request: RequestInfo | URL, options?: CacheQueryOptions): Promise; + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#match) */ + match(request: RequestInfo | URL, options?: CacheQueryOptions): Promise; + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#put) */ + put(request: RequestInfo | URL, response: Response): Promise; +} +interface CacheQueryOptions { + ignoreMethod?: boolean; +} +/** +* The Web Crypto API provides a set of low-level functions for common cryptographic tasks. +* The Workers runtime implements the full surface of this API, but with some differences in +* the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms) +* compared to those implemented in most browsers. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/) +*/ +declare abstract class Crypto { + /** + * The **`Crypto.subtle`** read-only property returns a cryptographic operations. + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/subtle) + */ + get subtle(): SubtleCrypto; + /** + * The **`Crypto.getRandomValues()`** method lets you get cryptographically strong random values. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/getRandomValues) + */ + getRandomValues(buffer: T): T; + /** + * The **`randomUUID()`** method of the Crypto interface is used to generate a v4 UUID using a cryptographically secure random number generator. + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/randomUUID) + */ + randomUUID(): string; + DigestStream: typeof DigestStream; +} +/** + * The **`SubtleCrypto`** interface of the Web Crypto API provides a number of low-level cryptographic functions. + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto) + */ +declare abstract class SubtleCrypto { + /** + * The **`encrypt()`** method of the SubtleCrypto interface encrypts data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/encrypt) + */ + encrypt(algorithm: string | SubtleCryptoEncryptAlgorithm, key: CryptoKey, plainText: ArrayBuffer | ArrayBufferView): Promise; + /** + * The **`decrypt()`** method of the SubtleCrypto interface decrypts some encrypted data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/decrypt) + */ + decrypt(algorithm: string | SubtleCryptoEncryptAlgorithm, key: CryptoKey, cipherText: ArrayBuffer | ArrayBufferView): Promise; + /** + * The **`sign()`** method of the SubtleCrypto interface generates a digital signature. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/sign) + */ + sign(algorithm: string | SubtleCryptoSignAlgorithm, key: CryptoKey, data: ArrayBuffer | ArrayBufferView): Promise; + /** + * The **`verify()`** method of the SubtleCrypto interface verifies a digital signature. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/verify) + */ + verify(algorithm: string | SubtleCryptoSignAlgorithm, key: CryptoKey, signature: ArrayBuffer | ArrayBufferView, data: ArrayBuffer | ArrayBufferView): Promise; + /** + * The **`digest()`** method of the SubtleCrypto interface generates a _digest_ of the given data, using the specified hash function. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/digest) + */ + digest(algorithm: string | SubtleCryptoHashAlgorithm, data: ArrayBuffer | ArrayBufferView): Promise; + /** + * The **`generateKey()`** method of the SubtleCrypto interface is used to generate a new key (for symmetric algorithms) or key pair (for public-key algorithms). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/generateKey) + */ + generateKey(algorithm: string | SubtleCryptoGenerateKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise; + /** + * The **`deriveKey()`** method of the SubtleCrypto interface can be used to derive a secret key from a master key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveKey) + */ + deriveKey(algorithm: string | SubtleCryptoDeriveKeyAlgorithm, baseKey: CryptoKey, derivedKeyAlgorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise; + /** + * The **`deriveBits()`** method of the key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveBits) + */ + deriveBits(algorithm: string | SubtleCryptoDeriveKeyAlgorithm, baseKey: CryptoKey, length?: number | null): Promise; + /** + * The **`importKey()`** method of the SubtleCrypto interface imports a key: that is, it takes as input a key in an external, portable format and gives you a CryptoKey object that you can use in the Web Crypto API. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/importKey) + */ + importKey(format: string, keyData: (ArrayBuffer | ArrayBufferView) | JsonWebKey, algorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise; + /** + * The **`exportKey()`** method of the SubtleCrypto interface exports a key: that is, it takes as input a CryptoKey object and gives you the key in an external, portable format. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/exportKey) + */ + exportKey(format: string, key: CryptoKey): Promise; + /** + * The **`wrapKey()`** method of the SubtleCrypto interface 'wraps' a key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/wrapKey) + */ + wrapKey(format: string, key: CryptoKey, wrappingKey: CryptoKey, wrapAlgorithm: string | SubtleCryptoEncryptAlgorithm): Promise; + /** + * The **`unwrapKey()`** method of the SubtleCrypto interface 'unwraps' a key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/unwrapKey) + */ + unwrapKey(format: string, wrappedKey: ArrayBuffer | ArrayBufferView, unwrappingKey: CryptoKey, unwrapAlgorithm: string | SubtleCryptoEncryptAlgorithm, unwrappedKeyAlgorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise; + timingSafeEqual(a: ArrayBuffer | ArrayBufferView, b: ArrayBuffer | ArrayBufferView): boolean; +} +/** + * The **`CryptoKey`** interface of the Web Crypto API represents a cryptographic key obtained from one of the SubtleCrypto methods SubtleCrypto.generateKey, SubtleCrypto.deriveKey, SubtleCrypto.importKey, or SubtleCrypto.unwrapKey. + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey) + */ +declare abstract class CryptoKey { + /** + * The read-only **`type`** property of the CryptoKey interface indicates which kind of key is represented by the object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/type) + */ + readonly type: string; + /** + * The read-only **`extractable`** property of the CryptoKey interface indicates whether or not the key may be extracted using `SubtleCrypto.exportKey()` or `SubtleCrypto.wrapKey()`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/extractable) + */ + readonly extractable: boolean; + /** + * The read-only **`algorithm`** property of the CryptoKey interface returns an object describing the algorithm for which this key can be used, and any associated extra parameters. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/algorithm) + */ + readonly algorithm: CryptoKeyKeyAlgorithm | CryptoKeyAesKeyAlgorithm | CryptoKeyHmacKeyAlgorithm | CryptoKeyRsaKeyAlgorithm | CryptoKeyEllipticKeyAlgorithm | CryptoKeyArbitraryKeyAlgorithm; + /** + * The read-only **`usages`** property of the CryptoKey interface indicates what can be done with the key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/usages) + */ + readonly usages: string[]; +} +interface CryptoKeyPair { + publicKey: CryptoKey; + privateKey: CryptoKey; +} +interface JsonWebKey { + kty: string; + use?: string; + key_ops?: string[]; + alg?: string; + ext?: boolean; + crv?: string; + x?: string; + y?: string; + d?: string; + n?: string; + e?: string; + p?: string; + q?: string; + dp?: string; + dq?: string; + qi?: string; + oth?: RsaOtherPrimesInfo[]; + k?: string; +} +interface RsaOtherPrimesInfo { + r?: string; + d?: string; + t?: string; +} +interface SubtleCryptoDeriveKeyAlgorithm { + name: string; + salt?: (ArrayBuffer | ArrayBufferView); + iterations?: number; + hash?: (string | SubtleCryptoHashAlgorithm); + $public?: CryptoKey; + info?: (ArrayBuffer | ArrayBufferView); +} +interface SubtleCryptoEncryptAlgorithm { + name: string; + iv?: (ArrayBuffer | ArrayBufferView); + additionalData?: (ArrayBuffer | ArrayBufferView); + tagLength?: number; + counter?: (ArrayBuffer | ArrayBufferView); + length?: number; + label?: (ArrayBuffer | ArrayBufferView); +} +interface SubtleCryptoGenerateKeyAlgorithm { + name: string; + hash?: (string | SubtleCryptoHashAlgorithm); + modulusLength?: number; + publicExponent?: (ArrayBuffer | ArrayBufferView); + length?: number; + namedCurve?: string; +} +interface SubtleCryptoHashAlgorithm { + name: string; +} +interface SubtleCryptoImportKeyAlgorithm { + name: string; + hash?: (string | SubtleCryptoHashAlgorithm); + length?: number; + namedCurve?: string; + compressed?: boolean; +} +interface SubtleCryptoSignAlgorithm { + name: string; + hash?: (string | SubtleCryptoHashAlgorithm); + dataLength?: number; + saltLength?: number; +} +interface CryptoKeyKeyAlgorithm { + name: string; +} +interface CryptoKeyAesKeyAlgorithm { + name: string; + length: number; +} +interface CryptoKeyHmacKeyAlgorithm { + name: string; + hash: CryptoKeyKeyAlgorithm; + length: number; +} +interface CryptoKeyRsaKeyAlgorithm { + name: string; + modulusLength: number; + publicExponent: ArrayBuffer | ArrayBufferView; + hash?: CryptoKeyKeyAlgorithm; +} +interface CryptoKeyEllipticKeyAlgorithm { + name: string; + namedCurve: string; +} +interface CryptoKeyArbitraryKeyAlgorithm { + name: string; + hash?: CryptoKeyKeyAlgorithm; + namedCurve?: string; + length?: number; +} +declare class DigestStream extends WritableStream { + constructor(algorithm: string | SubtleCryptoHashAlgorithm); + readonly digest: Promise; + get bytesWritten(): number | bigint; +} +/** + * The **`TextDecoder`** interface represents a decoder for a specific text encoding, such as `UTF-8`, `ISO-8859-2`, `KOI8-R`, `GBK`, etc. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder) + */ +declare class TextDecoder { + constructor(label?: string, options?: TextDecoderConstructorOptions); + /** + * The **`TextDecoder.decode()`** method returns a string containing text decoded from the buffer passed as a parameter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/decode) + */ + decode(input?: (ArrayBuffer | ArrayBufferView), options?: TextDecoderDecodeOptions): string; + get encoding(): string; + get fatal(): boolean; + get ignoreBOM(): boolean; +} +/** + * The **`TextEncoder`** interface takes a stream of code points as input and emits a stream of UTF-8 bytes. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder) + */ +declare class TextEncoder { + constructor(); + /** + * The **`TextEncoder.encode()`** method takes a string as input, and returns a Global_Objects/Uint8Array containing the text given in parameters encoded with the specific method for that TextEncoder object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encode) + */ + encode(input?: string): Uint8Array; + /** + * The **`TextEncoder.encodeInto()`** method takes a string to encode and a destination Uint8Array to put resulting UTF-8 encoded text into, and returns a dictionary object indicating the progress of the encoding. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encodeInto) + */ + encodeInto(input: string, buffer: Uint8Array): TextEncoderEncodeIntoResult; + get encoding(): string; +} +interface TextDecoderConstructorOptions { + fatal: boolean; + ignoreBOM: boolean; +} +interface TextDecoderDecodeOptions { + stream: boolean; +} +interface TextEncoderEncodeIntoResult { + read: number; + written: number; +} +/** + * The **`ErrorEvent`** interface represents events providing information related to errors in scripts or in files. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent) + */ +declare class ErrorEvent extends Event { + constructor(type: string, init?: ErrorEventErrorEventInit); + /** + * The **`filename`** read-only property of the ErrorEvent interface returns a string containing the name of the script file in which the error occurred. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/filename) + */ + get filename(): string; + /** + * The **`message`** read-only property of the ErrorEvent interface returns a string containing a human-readable error message describing the problem. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/message) + */ + get message(): string; + /** + * The **`lineno`** read-only property of the ErrorEvent interface returns an integer containing the line number of the script file on which the error occurred. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/lineno) + */ + get lineno(): number; + /** + * The **`colno`** read-only property of the ErrorEvent interface returns an integer containing the column number of the script file on which the error occurred. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/colno) + */ + get colno(): number; + /** + * The **`error`** read-only property of the ErrorEvent interface returns a JavaScript value, such as an Error or DOMException, representing the error associated with this event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/error) + */ + get error(): any; +} +interface ErrorEventErrorEventInit { + message?: string; + filename?: string; + lineno?: number; + colno?: number; + error?: any; +} +/** + * The **`MessageEvent`** interface represents a message received by a target object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent) + */ +declare class MessageEvent extends Event { + constructor(type: string, initializer: MessageEventInit); + /** + * The **`data`** read-only property of the The data sent by the message emitter; this can be any data type, depending on what originated this event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/data) + */ + readonly data: any; + /** + * The **`origin`** read-only property of the origin of the message emitter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/origin) + */ + readonly origin: string | null; + /** + * The **`lastEventId`** read-only property of the unique ID for the event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/lastEventId) + */ + readonly lastEventId: string; + /** + * The **`source`** read-only property of the a WindowProxy, MessagePort, or a `MessageEventSource` (which can be a WindowProxy, message emitter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/source) + */ + readonly source: MessagePort | null; + /** + * The **`ports`** read-only property of the containing all MessagePort objects sent with the message, in order. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/ports) + */ + readonly ports: MessagePort[]; +} +interface MessageEventInit { + data: ArrayBuffer | string; +} +/** + * The **`PromiseRejectionEvent`** interface represents events which are sent to the global script context when JavaScript Promises are rejected. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent) + */ +declare abstract class PromiseRejectionEvent extends Event { + /** + * The PromiseRejectionEvent interface's **`promise`** read-only property indicates the JavaScript rejected. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/promise) + */ + readonly promise: Promise; + /** + * The PromiseRejectionEvent **`reason`** read-only property is any JavaScript value or Object which provides the reason passed into Promise.reject(). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/reason) + */ + readonly reason: any; +} +/** + * The **`FormData`** interface provides a way to construct a set of key/value pairs representing form fields and their values, which can be sent using the Window/fetch, XMLHttpRequest.send() or navigator.sendBeacon() methods. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData) + */ +declare class FormData { + constructor(); + /** + * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) + */ + append(name: string, value: string | Blob): void; + /** + * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) + */ + append(name: string, value: string): void; + /** + * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) + */ + append(name: string, value: Blob, filename?: string): void; + /** + * The **`delete()`** method of the FormData interface deletes a key and its value(s) from a `FormData` object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/delete) + */ + delete(name: string): void; + /** + * The **`get()`** method of the FormData interface returns the first value associated with a given key from within a `FormData` object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/get) + */ + get(name: string): (File | string) | null; + /** + * The **`getAll()`** method of the FormData interface returns all the values associated with a given key from within a `FormData` object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/getAll) + */ + getAll(name: string): (File | string)[]; + /** + * The **`has()`** method of the FormData interface returns whether a `FormData` object contains a certain key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/has) + */ + has(name: string): boolean; + /** + * The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) + */ + set(name: string, value: string | Blob): void; + /** + * The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) + */ + set(name: string, value: string): void; + /** + * The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) + */ + set(name: string, value: Blob, filename?: string): void; + /* Returns an array of key, value pairs for every entry in the list. */ + entries(): IterableIterator<[ + key: string, + value: File | string + ]>; + /* Returns a list of keys in the list. */ + keys(): IterableIterator; + /* Returns a list of values in the list. */ + values(): IterableIterator<(File | string)>; + forEach(callback: (this: This, value: File | string, key: string, parent: FormData) => void, thisArg?: This): void; + [Symbol.iterator](): IterableIterator<[ + key: string, + value: File | string + ]>; +} +interface ContentOptions { + html?: boolean; +} +declare class HTMLRewriter { + constructor(); + on(selector: string, handlers: HTMLRewriterElementContentHandlers): HTMLRewriter; + onDocument(handlers: HTMLRewriterDocumentContentHandlers): HTMLRewriter; + transform(response: Response): Response; +} +interface HTMLRewriterElementContentHandlers { + element?(element: Element): void | Promise; + comments?(comment: Comment): void | Promise; + text?(element: Text): void | Promise; +} +interface HTMLRewriterDocumentContentHandlers { + doctype?(doctype: Doctype): void | Promise; + comments?(comment: Comment): void | Promise; + text?(text: Text): void | Promise; + end?(end: DocumentEnd): void | Promise; +} +interface Doctype { + readonly name: string | null; + readonly publicId: string | null; + readonly systemId: string | null; +} +interface Element { + tagName: string; + readonly attributes: IterableIterator; + readonly removed: boolean; + readonly namespaceURI: string; + getAttribute(name: string): string | null; + hasAttribute(name: string): boolean; + setAttribute(name: string, value: string): Element; + removeAttribute(name: string): Element; + before(content: string | ReadableStream | Response, options?: ContentOptions): Element; + after(content: string | ReadableStream | Response, options?: ContentOptions): Element; + prepend(content: string | ReadableStream | Response, options?: ContentOptions): Element; + append(content: string | ReadableStream | Response, options?: ContentOptions): Element; + replace(content: string | ReadableStream | Response, options?: ContentOptions): Element; + remove(): Element; + removeAndKeepContent(): Element; + setInnerContent(content: string | ReadableStream | Response, options?: ContentOptions): Element; + onEndTag(handler: (tag: EndTag) => void | Promise): void; +} +interface EndTag { + name: string; + before(content: string | ReadableStream | Response, options?: ContentOptions): EndTag; + after(content: string | ReadableStream | Response, options?: ContentOptions): EndTag; + remove(): EndTag; +} +interface Comment { + text: string; + readonly removed: boolean; + before(content: string, options?: ContentOptions): Comment; + after(content: string, options?: ContentOptions): Comment; + replace(content: string, options?: ContentOptions): Comment; + remove(): Comment; +} +interface Text { + readonly text: string; + readonly lastInTextNode: boolean; + readonly removed: boolean; + before(content: string | ReadableStream | Response, options?: ContentOptions): Text; + after(content: string | ReadableStream | Response, options?: ContentOptions): Text; + replace(content: string | ReadableStream | Response, options?: ContentOptions): Text; + remove(): Text; +} +interface DocumentEnd { + append(content: string, options?: ContentOptions): DocumentEnd; +} +/** + * This is the event type for `fetch` events dispatched on the ServiceWorkerGlobalScope. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent) + */ +declare abstract class FetchEvent extends ExtendableEvent { + /** + * The **`request`** read-only property of the the event handler. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/request) + */ + readonly request: Request; + /** + * The **`respondWith()`** method of allows you to provide a promise for a Response yourself. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/respondWith) + */ + respondWith(promise: Response | Promise): void; + passThroughOnException(): void; +} +type HeadersInit = Headers | Iterable> | Record; +/** + * The **`Headers`** interface of the Fetch API allows you to perform various actions on HTTP request and response headers. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers) + */ +declare class Headers { + constructor(init?: HeadersInit); + /** + * The **`get()`** method of the Headers interface returns a byte string of all the values of a header within a `Headers` object with a given name. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/get) + */ + get(name: string): string | null; + getAll(name: string): string[]; + /** + * The **`getSetCookie()`** method of the Headers interface returns an array containing the values of all Set-Cookie headers associated with a response. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/getSetCookie) + */ + getSetCookie(): string[]; + /** + * The **`has()`** method of the Headers interface returns a boolean stating whether a `Headers` object contains a certain header. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/has) + */ + has(name: string): boolean; + /** + * The **`set()`** method of the Headers interface sets a new value for an existing header inside a `Headers` object, or adds the header if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/set) + */ + set(name: string, value: string): void; + /** + * The **`append()`** method of the Headers interface appends a new value onto an existing header inside a `Headers` object, or adds the header if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/append) + */ + append(name: string, value: string): void; + /** + * The **`delete()`** method of the Headers interface deletes a header from the current `Headers` object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/delete) + */ + delete(name: string): void; + forEach(callback: (this: This, value: string, key: string, parent: Headers) => void, thisArg?: This): void; + /* Returns an iterator allowing to go through all key/value pairs contained in this object. */ + entries(): IterableIterator<[ + key: string, + value: string + ]>; + /* Returns an iterator allowing to go through all keys of the key/value pairs contained in this object. */ + keys(): IterableIterator; + /* Returns an iterator allowing to go through all values of the key/value pairs contained in this object. */ + values(): IterableIterator; + [Symbol.iterator](): IterableIterator<[ + key: string, + value: string + ]>; +} +type BodyInit = ReadableStream | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData | Iterable | AsyncIterable; +declare abstract class Body { + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */ + get body(): ReadableStream | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bodyUsed) */ + get bodyUsed(): boolean; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/arrayBuffer) */ + arrayBuffer(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bytes) */ + bytes(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/text) */ + text(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */ + json(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/formData) */ + formData(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/blob) */ + blob(): Promise; +} +/** + * The **`Response`** interface of the Fetch API represents the response to a request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response) + */ +declare var Response: { + prototype: Response; + new (body?: BodyInit | null, init?: ResponseInit): Response; + error(): Response; + redirect(url: string, status?: number): Response; + json(any: any, maybeInit?: (ResponseInit | Response)): Response; +}; +/** + * The **`Response`** interface of the Fetch API represents the response to a request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response) + */ +interface Response extends Body { + /** + * The **`clone()`** method of the Response interface creates a clone of a response object, identical in every way, but stored in a different variable. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/clone) + */ + clone(): Response; + /** + * The **`status`** read-only property of the Response interface contains the HTTP status codes of the response. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/status) + */ + status: number; + /** + * The **`statusText`** read-only property of the Response interface contains the status message corresponding to the HTTP status code in Response.status. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/statusText) + */ + statusText: string; + /** + * The **`headers`** read-only property of the with the response. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/headers) + */ + headers: Headers; + /** + * The **`ok`** read-only property of the Response interface contains a Boolean stating whether the response was successful (status in the range 200-299) or not. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/ok) + */ + ok: boolean; + /** + * The **`redirected`** read-only property of the Response interface indicates whether or not the response is the result of a request you made which was redirected. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/redirected) + */ + redirected: boolean; + /** + * The **`url`** read-only property of the Response interface contains the URL of the response. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/url) + */ + url: string; + webSocket: WebSocket | null; + cf: any | undefined; + /** + * The **`type`** read-only property of the Response interface contains the type of the response. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/type) + */ + type: "default" | "error"; +} +interface ResponseInit { + status?: number; + statusText?: string; + headers?: HeadersInit; + cf?: any; + webSocket?: (WebSocket | null); + encodeBody?: "automatic" | "manual"; +} +type RequestInfo> = Request | string; +/** + * The **`Request`** interface of the Fetch API represents a resource request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request) + */ +declare var Request: { + prototype: Request; + new >(input: RequestInfo | URL, init?: RequestInit): Request; +}; +/** + * The **`Request`** interface of the Fetch API represents a resource request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request) + */ +interface Request> extends Body { + /** + * The **`clone()`** method of the Request interface creates a copy of the current `Request` object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/clone) + */ + clone(): Request; + /** + * The **`method`** read-only property of the `POST`, etc.) A String indicating the method of the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/method) + */ + method: string; + /** + * The **`url`** read-only property of the Request interface contains the URL of the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/url) + */ + url: string; + /** + * The **`headers`** read-only property of the with the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/headers) + */ + headers: Headers; + /** + * The **`redirect`** read-only property of the Request interface contains the mode for how redirects are handled. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/redirect) + */ + redirect: string; + fetcher: Fetcher | null; + /** + * The read-only **`signal`** property of the Request interface returns the AbortSignal associated with the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/signal) + */ + signal: AbortSignal; + cf?: Cf; + /** + * The **`integrity`** read-only property of the Request interface contains the subresource integrity value of the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/integrity) + */ + integrity: string; + /** + * The **`keepalive`** read-only property of the Request interface contains the request's `keepalive` setting (`true` or `false`), which indicates whether the browser will keep the associated request alive if the page that initiated it is unloaded before the request is complete. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/keepalive) + */ + keepalive: boolean; + /** + * The **`cache`** read-only property of the Request interface contains the cache mode of the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/cache) + */ + cache?: "no-store" | "no-cache"; +} +interface RequestInit { + /* A string to set request's method. */ + method?: string; + /* A Headers object, an object literal, or an array of two-item arrays to set request's headers. */ + headers?: HeadersInit; + /* A BodyInit object or null to set request's body. */ + body?: BodyInit | null; + /* A string indicating whether request follows redirects, results in an error upon encountering a redirect, or returns the redirect (in an opaque fashion). Sets request's redirect. */ + redirect?: string; + fetcher?: (Fetcher | null); + cf?: Cf; + /* A string indicating how the request will interact with the browser's cache to set request's cache. */ + cache?: "no-store" | "no-cache"; + /* A cryptographic hash of the resource to be fetched by request. Sets request's integrity. */ + integrity?: string; + /* An AbortSignal to set request's signal. */ + signal?: (AbortSignal | null); + encodeResponseBody?: "automatic" | "manual"; +} +type Service Rpc.WorkerEntrypointBranded) | Rpc.WorkerEntrypointBranded | ExportedHandler | undefined = undefined> = T extends new (...args: any[]) => Rpc.WorkerEntrypointBranded ? Fetcher> : T extends Rpc.WorkerEntrypointBranded ? Fetcher : T extends Exclude ? never : Fetcher; +type Fetcher = (T extends Rpc.EntrypointBranded ? Rpc.Provider : unknown) & { + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; + connect(address: SocketAddress | string, options?: SocketOptions): Socket; +}; +interface KVNamespaceListKey { + name: Key; + expiration?: number; + metadata?: Metadata; +} +type KVNamespaceListResult = { + list_complete: false; + keys: KVNamespaceListKey[]; + cursor: string; + cacheStatus: string | null; +} | { + list_complete: true; + keys: KVNamespaceListKey[]; + cacheStatus: string | null; +}; +interface KVNamespace { + get(key: Key, options?: Partial>): Promise; + get(key: Key, type: "text"): Promise; + get(key: Key, type: "json"): Promise; + get(key: Key, type: "arrayBuffer"): Promise; + get(key: Key, type: "stream"): Promise; + get(key: Key, options?: KVNamespaceGetOptions<"text">): Promise; + get(key: Key, options?: KVNamespaceGetOptions<"json">): Promise; + get(key: Key, options?: KVNamespaceGetOptions<"arrayBuffer">): Promise; + get(key: Key, options?: KVNamespaceGetOptions<"stream">): Promise; + get(key: Array, type: "text"): Promise>; + get(key: Array, type: "json"): Promise>; + get(key: Array, options?: Partial>): Promise>; + get(key: Array, options?: KVNamespaceGetOptions<"text">): Promise>; + get(key: Array, options?: KVNamespaceGetOptions<"json">): Promise>; + list(options?: KVNamespaceListOptions): Promise>; + put(key: Key, value: string | ArrayBuffer | ArrayBufferView | ReadableStream, options?: KVNamespacePutOptions): Promise; + getWithMetadata(key: Key, options?: Partial>): Promise>; + getWithMetadata(key: Key, type: "text"): Promise>; + getWithMetadata(key: Key, type: "json"): Promise>; + getWithMetadata(key: Key, type: "arrayBuffer"): Promise>; + getWithMetadata(key: Key, type: "stream"): Promise>; + getWithMetadata(key: Key, options: KVNamespaceGetOptions<"text">): Promise>; + getWithMetadata(key: Key, options: KVNamespaceGetOptions<"json">): Promise>; + getWithMetadata(key: Key, options: KVNamespaceGetOptions<"arrayBuffer">): Promise>; + getWithMetadata(key: Key, options: KVNamespaceGetOptions<"stream">): Promise>; + getWithMetadata(key: Array, type: "text"): Promise>>; + getWithMetadata(key: Array, type: "json"): Promise>>; + getWithMetadata(key: Array, options?: Partial>): Promise>>; + getWithMetadata(key: Array, options?: KVNamespaceGetOptions<"text">): Promise>>; + getWithMetadata(key: Array, options?: KVNamespaceGetOptions<"json">): Promise>>; + delete(key: Key): Promise; +} +interface KVNamespaceListOptions { + limit?: number; + prefix?: (string | null); + cursor?: (string | null); +} +interface KVNamespaceGetOptions { + type: Type; + cacheTtl?: number; +} +interface KVNamespacePutOptions { + expiration?: number; + expirationTtl?: number; + metadata?: (any | null); +} +interface KVNamespaceGetWithMetadataResult { + value: Value | null; + metadata: Metadata | null; + cacheStatus: string | null; +} +type QueueContentType = "text" | "bytes" | "json" | "v8"; +interface Queue { + metrics(): Promise; + send(message: Body, options?: QueueSendOptions): Promise; + sendBatch(messages: Iterable>, options?: QueueSendBatchOptions): Promise; +} +interface QueueSendMetrics { + backlogCount: number; + backlogBytes: number; + oldestMessageTimestamp?: Date; +} +interface QueueSendMetadata { + metrics: QueueSendMetrics; +} +interface QueueSendResponse { + metadata: QueueSendMetadata; +} +interface QueueSendBatchMetrics { + backlogCount: number; + backlogBytes: number; + oldestMessageTimestamp?: Date; +} +interface QueueSendBatchMetadata { + metrics: QueueSendBatchMetrics; +} +interface QueueSendBatchResponse { + metadata: QueueSendBatchMetadata; +} +interface QueueSendOptions { + contentType?: QueueContentType; + delaySeconds?: number; +} +interface QueueSendBatchOptions { + delaySeconds?: number; +} +interface MessageSendRequest { + body: Body; + contentType?: QueueContentType; + delaySeconds?: number; +} +interface QueueMetrics { + backlogCount: number; + backlogBytes: number; + oldestMessageTimestamp?: Date; +} +interface MessageBatchMetrics { + backlogCount: number; + backlogBytes: number; + oldestMessageTimestamp?: Date; +} +interface MessageBatchMetadata { + metrics: MessageBatchMetrics; +} +interface QueueRetryOptions { + delaySeconds?: number; +} +interface Message { + readonly id: string; + readonly timestamp: Date; + readonly body: Body; + readonly attempts: number; + retry(options?: QueueRetryOptions): void; + ack(): void; +} +interface QueueEvent extends ExtendableEvent { + readonly messages: readonly Message[]; + readonly queue: string; + readonly metadata: MessageBatchMetadata; + retryAll(options?: QueueRetryOptions): void; + ackAll(): void; +} +interface MessageBatch { + readonly messages: readonly Message[]; + readonly queue: string; + readonly metadata: MessageBatchMetadata; + retryAll(options?: QueueRetryOptions): void; + ackAll(): void; +} +interface R2Error extends Error { + readonly name: string; + readonly code: number; + readonly message: string; + readonly action: string; + readonly stack: any; +} +interface R2ListOptions { + limit?: number; + prefix?: string; + cursor?: string; + delimiter?: string; + startAfter?: string; + include?: ("httpMetadata" | "customMetadata")[]; +} +interface R2Bucket { + head(key: string): Promise; + get(key: string, options: R2GetOptions & { + onlyIf: R2Conditional | Headers; + }): Promise; + get(key: string, options?: R2GetOptions): Promise; + put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob, options?: R2PutOptions & { + onlyIf: R2Conditional | Headers; + }): Promise; + put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob, options?: R2PutOptions): Promise; + createMultipartUpload(key: string, options?: R2MultipartOptions): Promise; + resumeMultipartUpload(key: string, uploadId: string): R2MultipartUpload; + delete(keys: string | string[]): Promise; + list(options?: R2ListOptions): Promise; +} +interface R2MultipartUpload { + readonly key: string; + readonly uploadId: string; + uploadPart(partNumber: number, value: ReadableStream | (ArrayBuffer | ArrayBufferView) | string | Blob, options?: R2UploadPartOptions): Promise; + abort(): Promise; + complete(uploadedParts: R2UploadedPart[]): Promise; +} +interface R2UploadedPart { + partNumber: number; + etag: string; +} +declare abstract class R2Object { + readonly key: string; + readonly version: string; + readonly size: number; + readonly etag: string; + readonly httpEtag: string; + readonly checksums: R2Checksums; + readonly uploaded: Date; + readonly httpMetadata?: R2HTTPMetadata; + readonly customMetadata?: Record; + readonly range?: R2Range; + readonly storageClass: string; + readonly ssecKeyMd5?: string; + writeHttpMetadata(headers: Headers): void; +} +interface R2ObjectBody extends R2Object { + get body(): ReadableStream; + get bodyUsed(): boolean; + arrayBuffer(): Promise; + bytes(): Promise; + text(): Promise; + json(): Promise; + blob(): Promise; +} +type R2Range = { + offset: number; + length?: number; +} | { + offset?: number; + length: number; +} | { + suffix: number; +}; +interface R2Conditional { + etagMatches?: string; + etagDoesNotMatch?: string; + uploadedBefore?: Date; + uploadedAfter?: Date; + secondsGranularity?: boolean; +} +interface R2GetOptions { + onlyIf?: (R2Conditional | Headers); + range?: (R2Range | Headers); + ssecKey?: (ArrayBuffer | string); +} +interface R2PutOptions { + onlyIf?: (R2Conditional | Headers); + httpMetadata?: (R2HTTPMetadata | Headers); + customMetadata?: Record; + md5?: ((ArrayBuffer | ArrayBufferView) | string); + sha1?: ((ArrayBuffer | ArrayBufferView) | string); + sha256?: ((ArrayBuffer | ArrayBufferView) | string); + sha384?: ((ArrayBuffer | ArrayBufferView) | string); + sha512?: ((ArrayBuffer | ArrayBufferView) | string); + storageClass?: string; + ssecKey?: (ArrayBuffer | string); +} +interface R2MultipartOptions { + httpMetadata?: (R2HTTPMetadata | Headers); + customMetadata?: Record; + storageClass?: string; + ssecKey?: (ArrayBuffer | string); +} +interface R2Checksums { + readonly md5?: ArrayBuffer; + readonly sha1?: ArrayBuffer; + readonly sha256?: ArrayBuffer; + readonly sha384?: ArrayBuffer; + readonly sha512?: ArrayBuffer; + toJSON(): R2StringChecksums; +} +interface R2StringChecksums { + md5?: string; + sha1?: string; + sha256?: string; + sha384?: string; + sha512?: string; +} +interface R2HTTPMetadata { + contentType?: string; + contentLanguage?: string; + contentDisposition?: string; + contentEncoding?: string; + cacheControl?: string; + cacheExpiry?: Date; +} +type R2Objects = { + objects: R2Object[]; + delimitedPrefixes: string[]; +} & ({ + truncated: true; + cursor: string; +} | { + truncated: false; +}); +interface R2UploadPartOptions { + ssecKey?: (ArrayBuffer | string); +} +declare abstract class ScheduledEvent extends ExtendableEvent { + readonly scheduledTime: number; + readonly cron: string; + noRetry(): void; +} +interface ScheduledController { + readonly scheduledTime: number; + readonly cron: string; + noRetry(): void; +} +interface QueuingStrategy { + highWaterMark?: (number | bigint); + size?: (chunk: T) => number | bigint; +} +interface UnderlyingSink { + type?: string; + start?: (controller: WritableStreamDefaultController) => void | Promise; + write?: (chunk: W, controller: WritableStreamDefaultController) => void | Promise; + abort?: (reason: any) => void | Promise; + close?: () => void | Promise; +} +interface UnderlyingByteSource { + type: "bytes"; + autoAllocateChunkSize?: number; + start?: (controller: ReadableByteStreamController) => void | Promise; + pull?: (controller: ReadableByteStreamController) => void | Promise; + cancel?: (reason: any) => void | Promise; +} +interface UnderlyingSource { + type?: "" | undefined; + start?: (controller: ReadableStreamDefaultController) => void | Promise; + pull?: (controller: ReadableStreamDefaultController) => void | Promise; + cancel?: (reason: any) => void | Promise; + expectedLength?: (number | bigint); +} +interface Transformer { + readableType?: string; + writableType?: string; + start?: (controller: TransformStreamDefaultController) => void | Promise; + transform?: (chunk: I, controller: TransformStreamDefaultController) => void | Promise; + flush?: (controller: TransformStreamDefaultController) => void | Promise; + cancel?: (reason: any) => void | Promise; + expectedLength?: number; +} +interface StreamPipeOptions { + preventAbort?: boolean; + preventCancel?: boolean; + /** + * Pipes this readable stream to a given writable stream destination. The way in which the piping process behaves under various error conditions can be customized with a number of passed options. It returns a promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered. + * + * Piping a stream will lock it for the duration of the pipe, preventing any other consumer from acquiring a reader. + * + * Errors and closures of the source and destination streams propagate as follows: + * + * An error in this source readable stream will abort destination, unless preventAbort is truthy. The returned promise will be rejected with the source's error, or with any error that occurs during aborting the destination. + * + * An error in destination will cancel this source readable stream, unless preventCancel is truthy. The returned promise will be rejected with the destination's error, or with any error that occurs during canceling the source. + * + * When this source readable stream closes, destination will be closed, unless preventClose is truthy. The returned promise will be fulfilled once this process completes, unless an error is encountered while closing the destination, in which case it will be rejected with that error. + * + * If destination starts out closed or closing, this source readable stream will be canceled, unless preventCancel is true. The returned promise will be rejected with an error indicating piping to a closed stream failed, or with any error that occurs during canceling the source. + * + * The signal option can be set to an AbortSignal to allow aborting an ongoing pipe operation via the corresponding AbortController. In this case, this source readable stream will be canceled, and destination aborted, unless the respective options preventCancel or preventAbort are set. + */ + preventClose?: boolean; + signal?: AbortSignal; +} +type ReadableStreamReadResult = { + done: false; + value: R; +} | { + done: true; + value?: undefined; +}; +/** + * The `ReadableStream` interface of the Streams API represents a readable stream of byte data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream) + */ +interface ReadableStream { + /** + * The **`locked`** read-only property of the ReadableStream interface returns whether or not the readable stream is locked to a reader. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/locked) + */ + get locked(): boolean; + /** + * The **`cancel()`** method of the ReadableStream interface returns a Promise that resolves when the stream is canceled. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/cancel) + */ + cancel(reason?: any): Promise; + /** + * The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader) + */ + getReader(): ReadableStreamDefaultReader; + /** + * The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader) + */ + getReader(options: ReadableStreamGetReaderOptions): ReadableStreamBYOBReader; + /** + * The **`pipeThrough()`** method of the ReadableStream interface provides a chainable way of piping the current stream through a transform stream or any other writable/readable pair. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/pipeThrough) + */ + pipeThrough(transform: ReadableWritablePair, options?: StreamPipeOptions): ReadableStream; + /** + * The **`pipeTo()`** method of the ReadableStream interface pipes the current `ReadableStream` to a given WritableStream and returns a Promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/pipeTo) + */ + pipeTo(destination: WritableStream, options?: StreamPipeOptions): Promise; + /** + * The **`tee()`** method of the two-element array containing the two resulting branches as new ReadableStream instances. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/tee) + */ + tee(): [ + ReadableStream, + ReadableStream + ]; + values(options?: ReadableStreamValuesOptions): AsyncIterableIterator; + [Symbol.asyncIterator](options?: ReadableStreamValuesOptions): AsyncIterableIterator; +} +/** + * The `ReadableStream` interface of the Streams API represents a readable stream of byte data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream) + */ +declare const ReadableStream: { + prototype: ReadableStream; + new (underlyingSource: UnderlyingByteSource, strategy?: QueuingStrategy): ReadableStream; + new (underlyingSource?: UnderlyingSource, strategy?: QueuingStrategy): ReadableStream; +}; +/** + * The **`ReadableStreamDefaultReader`** interface of the Streams API represents a default reader that can be used to read stream data supplied from a network (such as a fetch request). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultReader) + */ +declare class ReadableStreamDefaultReader { + constructor(stream: ReadableStream); + get closed(): Promise; + cancel(reason?: any): Promise; + /** + * The **`read()`** method of the ReadableStreamDefaultReader interface returns a Promise providing access to the next chunk in the stream's internal queue. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultReader/read) + */ + read(): Promise>; + /** + * The **`releaseLock()`** method of the ReadableStreamDefaultReader interface releases the reader's lock on the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultReader/releaseLock) + */ + releaseLock(): void; +} +/** + * The `ReadableStreamBYOBReader` interface of the Streams API defines a reader for a ReadableStream that supports zero-copy reading from an underlying byte source. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader) + */ +declare class ReadableStreamBYOBReader { + constructor(stream: ReadableStream); + get closed(): Promise; + cancel(reason?: any): Promise; + /** + * The **`read()`** method of the ReadableStreamBYOBReader interface is used to read data into a view on a user-supplied buffer from an associated readable byte stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/read) + */ + read(view: T): Promise>; + /** + * The **`releaseLock()`** method of the ReadableStreamBYOBReader interface releases the reader's lock on the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/releaseLock) + */ + releaseLock(): void; + readAtLeast(minElements: number, view: T): Promise>; +} +interface ReadableStreamBYOBReaderReadableStreamBYOBReaderReadOptions { + min?: number; +} +interface ReadableStreamGetReaderOptions { + /** + * Creates a ReadableStreamBYOBReader and locks the stream to the new reader. + * + * This call behaves the same way as the no-argument variant, except that it only works on readable byte streams, i.e. streams which were constructed specifically with the ability to handle "bring your own buffer" reading. The returned BYOB reader provides the ability to directly read individual chunks from the stream via its read() method, into developer-supplied buffers, allowing more precise control over allocation. + */ + mode: "byob"; +} +/** + * The **`ReadableStreamBYOBRequest`** interface of the Streams API represents a 'pull request' for data from an underlying source that will made as a zero-copy transfer to a consumer (bypassing the stream's internal queues). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest) + */ +declare abstract class ReadableStreamBYOBRequest { + /** + * The **`view`** getter property of the ReadableStreamBYOBRequest interface returns the current view. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest/view) + */ + get view(): Uint8Array | null; + /** + * The **`respond()`** method of the ReadableStreamBYOBRequest interface is used to signal to the associated readable byte stream that the specified number of bytes were written into the ReadableStreamBYOBRequest.view. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest/respond) + */ + respond(bytesWritten: number): void; + /** + * The **`respondWithNewView()`** method of the ReadableStreamBYOBRequest interface specifies a new view that the consumer of the associated readable byte stream should write to instead of ReadableStreamBYOBRequest.view. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest/respondWithNewView) + */ + respondWithNewView(view: ArrayBuffer | ArrayBufferView): void; + get atLeast(): number | null; +} +/** + * The **`ReadableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a ReadableStream's state and internal queue. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController) + */ +declare abstract class ReadableStreamDefaultController { + /** + * The **`desiredSize`** read-only property of the required to fill the stream's internal queue. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/desiredSize) + */ + get desiredSize(): number | null; + /** + * The **`close()`** method of the ReadableStreamDefaultController interface closes the associated stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/close) + */ + close(): void; + /** + * The **`enqueue()`** method of the ```js-nolint enqueue(chunk) ``` - `chunk` - : The chunk to enqueue. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/enqueue) + */ + enqueue(chunk?: R): void; + /** + * The **`error()`** method of the with the associated stream to error. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/error) + */ + error(reason: any): void; +} +/** + * The **`ReadableByteStreamController`** interface of the Streams API represents a controller for a readable byte stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController) + */ +declare abstract class ReadableByteStreamController { + /** + * The **`byobRequest`** read-only property of the ReadableByteStreamController interface returns the current BYOB request, or `null` if there are no pending requests. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/byobRequest) + */ + get byobRequest(): ReadableStreamBYOBRequest | null; + /** + * The **`desiredSize`** read-only property of the ReadableByteStreamController interface returns the number of bytes required to fill the stream's internal queue to its 'desired size'. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/desiredSize) + */ + get desiredSize(): number | null; + /** + * The **`close()`** method of the ReadableByteStreamController interface closes the associated stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/close) + */ + close(): void; + /** + * The **`enqueue()`** method of the ReadableByteStreamController interface enqueues a given chunk on the associated readable byte stream (the chunk is copied into the stream's internal queues). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/enqueue) + */ + enqueue(chunk: ArrayBuffer | ArrayBufferView): void; + /** + * The **`error()`** method of the ReadableByteStreamController interface causes any future interactions with the associated stream to error with the specified reason. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/error) + */ + error(reason: any): void; +} +/** + * The **`WritableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a WritableStream's state. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController) + */ +declare abstract class WritableStreamDefaultController { + /** + * The read-only **`signal`** property of the WritableStreamDefaultController interface returns the AbortSignal associated with the controller. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController/signal) + */ + get signal(): AbortSignal; + /** + * The **`error()`** method of the with the associated stream to error. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController/error) + */ + error(reason?: any): void; +} +/** + * The **`TransformStreamDefaultController`** interface of the Streams API provides methods to manipulate the associated ReadableStream and WritableStream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController) + */ +declare abstract class TransformStreamDefaultController { + /** + * The **`desiredSize`** read-only property of the TransformStreamDefaultController interface returns the desired size to fill the queue of the associated ReadableStream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/desiredSize) + */ + get desiredSize(): number | null; + /** + * The **`enqueue()`** method of the TransformStreamDefaultController interface enqueues the given chunk in the readable side of the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/enqueue) + */ + enqueue(chunk?: O): void; + /** + * The **`error()`** method of the TransformStreamDefaultController interface errors both sides of the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/error) + */ + error(reason: any): void; + /** + * The **`terminate()`** method of the TransformStreamDefaultController interface closes the readable side and errors the writable side of the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/terminate) + */ + terminate(): void; +} +interface ReadableWritablePair { + readable: ReadableStream; + /** + * Provides a convenient, chainable way of piping this readable stream through a transform stream (or any other { writable, readable } pair). It simply pipes the stream into the writable side of the supplied pair, and returns the readable side for further use. + * + * Piping a stream will lock it for the duration of the pipe, preventing any other consumer from acquiring a reader. + */ + writable: WritableStream; +} +/** + * The **`WritableStream`** interface of the Streams API provides a standard abstraction for writing streaming data to a destination, known as a sink. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream) + */ +declare class WritableStream { + constructor(underlyingSink?: UnderlyingSink, queuingStrategy?: QueuingStrategy); + /** + * The **`locked`** read-only property of the WritableStream interface returns a boolean indicating whether the `WritableStream` is locked to a writer. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/locked) + */ + get locked(): boolean; + /** + * The **`abort()`** method of the WritableStream interface aborts the stream, signaling that the producer can no longer successfully write to the stream and it is to be immediately moved to an error state, with any queued writes discarded. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/abort) + */ + abort(reason?: any): Promise; + /** + * The **`close()`** method of the WritableStream interface closes the associated stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/close) + */ + close(): Promise; + /** + * The **`getWriter()`** method of the WritableStream interface returns a new instance of WritableStreamDefaultWriter and locks the stream to that instance. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/getWriter) + */ + getWriter(): WritableStreamDefaultWriter; +} +/** + * The **`WritableStreamDefaultWriter`** interface of the Streams API is the object returned by WritableStream.getWriter() and once created locks the writer to the `WritableStream` ensuring that no other streams can write to the underlying sink. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter) + */ +declare class WritableStreamDefaultWriter { + constructor(stream: WritableStream); + /** + * The **`closed`** read-only property of the the stream errors or the writer's lock is released. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/closed) + */ + get closed(): Promise; + /** + * The **`ready`** read-only property of the that resolves when the desired size of the stream's internal queue transitions from non-positive to positive, signaling that it is no longer applying backpressure. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/ready) + */ + get ready(): Promise; + /** + * The **`desiredSize`** read-only property of the to fill the stream's internal queue. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/desiredSize) + */ + get desiredSize(): number | null; + /** + * The **`abort()`** method of the the producer can no longer successfully write to the stream and it is to be immediately moved to an error state, with any queued writes discarded. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/abort) + */ + abort(reason?: any): Promise; + /** + * The **`close()`** method of the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/close) + */ + close(): Promise; + /** + * The **`write()`** method of the operation. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/write) + */ + write(chunk?: W): Promise; + /** + * The **`releaseLock()`** method of the corresponding stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/releaseLock) + */ + releaseLock(): void; +} +/** + * The **`TransformStream`** interface of the Streams API represents a concrete implementation of the pipe chain _transform stream_ concept. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream) + */ +declare class TransformStream { + constructor(transformer?: Transformer, writableStrategy?: QueuingStrategy, readableStrategy?: QueuingStrategy); + /** + * The **`readable`** read-only property of the TransformStream interface returns the ReadableStream instance controlled by this `TransformStream`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/readable) + */ + get readable(): ReadableStream; + /** + * The **`writable`** read-only property of the TransformStream interface returns the WritableStream instance controlled by this `TransformStream`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/writable) + */ + get writable(): WritableStream; +} +declare class FixedLengthStream extends IdentityTransformStream { + constructor(expectedLength: number | bigint, queuingStrategy?: IdentityTransformStreamQueuingStrategy); +} +declare class IdentityTransformStream extends TransformStream { + constructor(queuingStrategy?: IdentityTransformStreamQueuingStrategy); +} +interface IdentityTransformStreamQueuingStrategy { + highWaterMark?: (number | bigint); +} +interface ReadableStreamValuesOptions { + preventCancel?: boolean; +} +/** + * The **`CompressionStream`** interface of the Compression Streams API is an API for compressing a stream of data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CompressionStream) + */ +declare class CompressionStream extends TransformStream { + constructor(format: "gzip" | "deflate" | "deflate-raw"); +} +/** + * The **`DecompressionStream`** interface of the Compression Streams API is an API for decompressing a stream of data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DecompressionStream) + */ +declare class DecompressionStream extends TransformStream { + constructor(format: "gzip" | "deflate" | "deflate-raw"); +} +/** + * The **`TextEncoderStream`** interface of the Encoding API converts a stream of strings into bytes in the UTF-8 encoding. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoderStream) + */ +declare class TextEncoderStream extends TransformStream { + constructor(); + get encoding(): string; +} +/** + * The **`TextDecoderStream`** interface of the Encoding API converts a stream of text in a binary encoding, such as UTF-8 etc., to a stream of strings. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoderStream) + */ +declare class TextDecoderStream extends TransformStream { + constructor(label?: string, options?: TextDecoderStreamTextDecoderStreamInit); + get encoding(): string; + get fatal(): boolean; + get ignoreBOM(): boolean; +} +interface TextDecoderStreamTextDecoderStreamInit { + fatal?: boolean; + ignoreBOM?: boolean; +} +/** + * The **`ByteLengthQueuingStrategy`** interface of the Streams API provides a built-in byte length queuing strategy that can be used when constructing streams. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy) + */ +declare class ByteLengthQueuingStrategy implements QueuingStrategy { + constructor(init: QueuingStrategyInit); + /** + * The read-only **`ByteLengthQueuingStrategy.highWaterMark`** property returns the total number of bytes that can be contained in the internal queue before backpressure is applied. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy/highWaterMark) + */ + get highWaterMark(): number; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy/size) */ + get size(): (chunk?: any) => number; +} +/** + * The **`CountQueuingStrategy`** interface of the Streams API provides a built-in chunk counting queuing strategy that can be used when constructing streams. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CountQueuingStrategy) + */ +declare class CountQueuingStrategy implements QueuingStrategy { + constructor(init: QueuingStrategyInit); + /** + * The read-only **`CountQueuingStrategy.highWaterMark`** property returns the total number of chunks that can be contained in the internal queue before backpressure is applied. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CountQueuingStrategy/highWaterMark) + */ + get highWaterMark(): number; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CountQueuingStrategy/size) */ + get size(): (chunk?: any) => number; +} +interface QueuingStrategyInit { + /** + * Creates a new ByteLengthQueuingStrategy with the provided high water mark. + * + * Note that the provided high water mark will not be validated ahead of time. Instead, if it is negative, NaN, or not a number, the resulting ByteLengthQueuingStrategy will cause the corresponding stream constructor to throw. + */ + highWaterMark: number; +} +interface TracePreviewInfo { + id: string; + slug: string; + name: string; +} +interface ScriptVersion { + id?: string; + tag?: string; + message?: string; +} +declare abstract class TailEvent extends ExtendableEvent { + readonly events: TraceItem[]; + readonly traces: TraceItem[]; +} +interface TraceItem { + readonly event: (TraceItemFetchEventInfo | TraceItemJsRpcEventInfo | TraceItemConnectEventInfo | TraceItemScheduledEventInfo | TraceItemAlarmEventInfo | TraceItemQueueEventInfo | TraceItemEmailEventInfo | TraceItemTailEventInfo | TraceItemCustomEventInfo | TraceItemHibernatableWebSocketEventInfo) | null; + readonly eventTimestamp: number | null; + readonly logs: TraceLog[]; + readonly exceptions: TraceException[]; + readonly diagnosticsChannelEvents: TraceDiagnosticChannelEvent[]; + readonly scriptName: string | null; + readonly entrypoint?: string; + readonly scriptVersion?: ScriptVersion; + readonly dispatchNamespace?: string; + readonly scriptTags?: string[]; + readonly tailAttributes?: Record; + readonly preview?: TracePreviewInfo; + readonly durableObjectId?: string; + readonly outcome: string; + readonly executionModel: string; + readonly truncated: boolean; + readonly cpuTime: number; + readonly wallTime: number; +} +interface TraceItemAlarmEventInfo { + readonly scheduledTime: Date; +} +interface TraceItemConnectEventInfo { +} +interface TraceItemCustomEventInfo { +} +interface TraceItemScheduledEventInfo { + readonly scheduledTime: number; + readonly cron: string; +} +interface TraceItemQueueEventInfo { + readonly queue: string; + readonly batchSize: number; +} +interface TraceItemEmailEventInfo { + readonly mailFrom: string; + readonly rcptTo: string; + readonly rawSize: number; +} +interface TraceItemTailEventInfo { + readonly consumedEvents: TraceItemTailEventInfoTailItem[]; +} +interface TraceItemTailEventInfoTailItem { + readonly scriptName: string | null; +} +interface TraceItemFetchEventInfo { + readonly response?: TraceItemFetchEventInfoResponse; + readonly request: TraceItemFetchEventInfoRequest; +} +interface TraceItemFetchEventInfoRequest { + readonly cf?: any; + readonly headers: Record; + readonly method: string; + readonly url: string; + getUnredacted(): TraceItemFetchEventInfoRequest; +} +interface TraceItemFetchEventInfoResponse { + readonly status: number; +} +interface TraceItemJsRpcEventInfo { + readonly rpcMethod: string; +} +interface TraceItemHibernatableWebSocketEventInfo { + readonly getWebSocketEvent: TraceItemHibernatableWebSocketEventInfoMessage | TraceItemHibernatableWebSocketEventInfoClose | TraceItemHibernatableWebSocketEventInfoError; +} +interface TraceItemHibernatableWebSocketEventInfoMessage { + readonly webSocketEventType: string; +} +interface TraceItemHibernatableWebSocketEventInfoClose { + readonly webSocketEventType: string; + readonly code: number; + readonly wasClean: boolean; +} +interface TraceItemHibernatableWebSocketEventInfoError { + readonly webSocketEventType: string; +} +interface TraceLog { + readonly timestamp: number; + readonly level: string; + readonly message: any; +} +interface TraceException { + readonly timestamp: number; + readonly message: string; + readonly name: string; + readonly stack?: string; +} +interface TraceDiagnosticChannelEvent { + readonly timestamp: number; + readonly channel: string; + readonly message: any; +} +interface TraceMetrics { + readonly cpuTime: number; + readonly wallTime: number; +} +interface UnsafeTraceMetrics { + fromTrace(item: TraceItem): TraceMetrics; +} +/** + * The **`URL`** interface is used to parse, construct, normalize, and encode URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL) + */ +declare class URL { + constructor(url: string | URL, base?: string | URL); + /** + * The **`origin`** read-only property of the URL interface returns a string containing the Unicode serialization of the origin of the represented URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/origin) + */ + get origin(): string; + /** + * The **`href`** property of the URL interface is a string containing the whole URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/href) + */ + get href(): string; + /** + * The **`href`** property of the URL interface is a string containing the whole URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/href) + */ + set href(value: string); + /** + * The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final `':'`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol) + */ + get protocol(): string; + /** + * The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final `':'`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol) + */ + set protocol(value: string); + /** + * The **`username`** property of the URL interface is a string containing the username component of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username) + */ + get username(): string; + /** + * The **`username`** property of the URL interface is a string containing the username component of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username) + */ + set username(value: string); + /** + * The **`password`** property of the URL interface is a string containing the password component of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password) + */ + get password(): string; + /** + * The **`password`** property of the URL interface is a string containing the password component of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password) + */ + set password(value: string); + /** + * The **`host`** property of the URL interface is a string containing the host, which is the URL.hostname, and then, if the port of the URL is nonempty, a `':'`, followed by the URL.port of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host) + */ + get host(): string; + /** + * The **`host`** property of the URL interface is a string containing the host, which is the URL.hostname, and then, if the port of the URL is nonempty, a `':'`, followed by the URL.port of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host) + */ + set host(value: string); + /** + * The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname) + */ + get hostname(): string; + /** + * The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname) + */ + set hostname(value: string); + /** + * The **`port`** property of the URL interface is a string containing the port number of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port) + */ + get port(): string; + /** + * The **`port`** property of the URL interface is a string containing the port number of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port) + */ + set port(value: string); + /** + * The **`pathname`** property of the URL interface represents a location in a hierarchical structure. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname) + */ + get pathname(): string; + /** + * The **`pathname`** property of the URL interface represents a location in a hierarchical structure. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname) + */ + set pathname(value: string); + /** + * The **`search`** property of the URL interface is a search string, also called a _query string_, that is a string containing a `'?'` followed by the parameters of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search) + */ + get search(): string; + /** + * The **`search`** property of the URL interface is a search string, also called a _query string_, that is a string containing a `'?'` followed by the parameters of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search) + */ + set search(value: string); + /** + * The **`hash`** property of the URL interface is a string containing a `'#'` followed by the fragment identifier of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash) + */ + get hash(): string; + /** + * The **`hash`** property of the URL interface is a string containing a `'#'` followed by the fragment identifier of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash) + */ + set hash(value: string); + /** + * The **`searchParams`** read-only property of the access to the [MISSING: httpmethod('GET')] decoded query arguments contained in the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/searchParams) + */ + get searchParams(): URLSearchParams; + /** + * The **`toJSON()`** method of the URL interface returns a string containing a serialized version of the URL, although in practice it seems to have the same effect as ```js-nolint toJSON() ``` None. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/toJSON) + */ + toJSON(): string; + /*function toString() { [native code] }*/ + toString(): string; + /** + * The **`URL.canParse()`** static method of the URL interface returns a boolean indicating whether or not an absolute URL, or a relative URL combined with a base URL, are parsable and valid. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/canParse_static) + */ + static canParse(url: string, base?: string): boolean; + /** + * The **`URL.parse()`** static method of the URL interface returns a newly created URL object representing the URL defined by the parameters. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/parse_static) + */ + static parse(url: string, base?: string): URL | null; + /** + * The **`createObjectURL()`** static method of the URL interface creates a string containing a URL representing the object given in the parameter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/createObjectURL_static) + */ + static createObjectURL(object: File | Blob): string; + /** + * The **`revokeObjectURL()`** static method of the URL interface releases an existing object URL which was previously created by calling Call this method when you've finished using an object URL to let the browser know not to keep the reference to the file any longer. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/revokeObjectURL_static) + */ + static revokeObjectURL(object_url: string): void; +} +/** + * The **`URLSearchParams`** interface defines utility methods to work with the query string of a URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams) + */ +declare class URLSearchParams { + constructor(init?: (Iterable> | Record | string)); + /** + * The **`size`** read-only property of the URLSearchParams interface indicates the total number of search parameter entries. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/size) + */ + get size(): number; + /** + * The **`append()`** method of the URLSearchParams interface appends a specified key/value pair as a new search parameter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/append) + */ + append(name: string, value: string): void; + /** + * The **`delete()`** method of the URLSearchParams interface deletes specified parameters and their associated value(s) from the list of all search parameters. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/delete) + */ + delete(name: string, value?: string): void; + /** + * The **`get()`** method of the URLSearchParams interface returns the first value associated to the given search parameter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/get) + */ + get(name: string): string | null; + /** + * The **`getAll()`** method of the URLSearchParams interface returns all the values associated with a given search parameter as an array. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/getAll) + */ + getAll(name: string): string[]; + /** + * The **`has()`** method of the URLSearchParams interface returns a boolean value that indicates whether the specified parameter is in the search parameters. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/has) + */ + has(name: string, value?: string): boolean; + /** + * The **`set()`** method of the URLSearchParams interface sets the value associated with a given search parameter to the given value. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/set) + */ + set(name: string, value: string): void; + /** + * The **`URLSearchParams.sort()`** method sorts all key/value pairs contained in this object in place and returns `undefined`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/sort) + */ + sort(): void; + /* Returns an array of key, value pairs for every entry in the search params. */ + entries(): IterableIterator<[ + key: string, + value: string + ]>; + /* Returns a list of keys in the search params. */ + keys(): IterableIterator; + /* Returns a list of values in the search params. */ + values(): IterableIterator; + forEach(callback: (this: This, value: string, key: string, parent: URLSearchParams) => void, thisArg?: This): void; + /*function toString() { [native code] }*/ + toString(): string; + [Symbol.iterator](): IterableIterator<[ + key: string, + value: string + ]>; +} +declare class URLPattern { + constructor(input?: (string | URLPatternInit), baseURL?: (string | URLPatternOptions), patternOptions?: URLPatternOptions); + get protocol(): string; + get username(): string; + get password(): string; + get hostname(): string; + get port(): string; + get pathname(): string; + get search(): string; + get hash(): string; + get hasRegExpGroups(): boolean; + test(input?: (string | URLPatternInit), baseURL?: string): boolean; + exec(input?: (string | URLPatternInit), baseURL?: string): URLPatternResult | null; +} +interface URLPatternInit { + protocol?: string; + username?: string; + password?: string; + hostname?: string; + port?: string; + pathname?: string; + search?: string; + hash?: string; + baseURL?: string; +} +interface URLPatternComponentResult { + input: string; + groups: Record; +} +interface URLPatternResult { + inputs: (string | URLPatternInit)[]; + protocol: URLPatternComponentResult; + username: URLPatternComponentResult; + password: URLPatternComponentResult; + hostname: URLPatternComponentResult; + port: URLPatternComponentResult; + pathname: URLPatternComponentResult; + search: URLPatternComponentResult; + hash: URLPatternComponentResult; +} +interface URLPatternOptions { + ignoreCase?: boolean; +} +/** + * A `CloseEvent` is sent to clients using WebSockets when the connection is closed. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent) + */ +declare class CloseEvent extends Event { + constructor(type: string, initializer?: CloseEventInit); + /** + * The **`code`** read-only property of the CloseEvent interface returns a WebSocket connection close code indicating the reason the connection was closed. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/code) + */ + readonly code: number; + /** + * The **`reason`** read-only property of the CloseEvent interface returns the WebSocket connection close reason the server gave for closing the connection; that is, a concise human-readable prose explanation for the closure. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/reason) + */ + readonly reason: string; + /** + * The **`wasClean`** read-only property of the CloseEvent interface returns `true` if the connection closed cleanly. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/wasClean) + */ + readonly wasClean: boolean; +} +interface CloseEventInit { + code?: number; + reason?: string; + wasClean?: boolean; +} +type WebSocketEventMap = { + close: CloseEvent; + message: MessageEvent; + open: Event; + error: ErrorEvent; +}; +/** + * The `WebSocket` object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket) + */ +declare var WebSocket: { + prototype: WebSocket; + new (url: string, protocols?: (string[] | string)): WebSocket; + readonly READY_STATE_CONNECTING: number; + readonly CONNECTING: number; + readonly READY_STATE_OPEN: number; + readonly OPEN: number; + readonly READY_STATE_CLOSING: number; + readonly CLOSING: number; + readonly READY_STATE_CLOSED: number; + readonly CLOSED: number; +}; +/** + * The `WebSocket` object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket) + */ +interface WebSocket extends EventTarget { + accept(options?: WebSocketAcceptOptions): void; + /** + * The **`WebSocket.send()`** method enqueues the specified data to be transmitted to the server over the WebSocket connection, increasing the value of `bufferedAmount` by the number of bytes needed to contain the data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/send) + */ + send(message: (ArrayBuffer | ArrayBufferView) | string): void; + /** + * The **`WebSocket.close()`** method closes the already `CLOSED`, this method does nothing. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/close) + */ + close(code?: number, reason?: string): void; + serializeAttachment(attachment: any): void; + deserializeAttachment(): any | null; + /** + * The **`WebSocket.readyState`** read-only property returns the current state of the WebSocket connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/readyState) + */ + readyState: number; + /** + * The **`WebSocket.url`** read-only property returns the absolute URL of the WebSocket as resolved by the constructor. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/url) + */ + url: string | null; + /** + * The **`WebSocket.protocol`** read-only property returns the name of the sub-protocol the server selected; this will be one of the strings specified in the `protocols` parameter when creating the WebSocket object, or the empty string if no connection is established. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/protocol) + */ + protocol: string | null; + /** + * The **`WebSocket.extensions`** read-only property returns the extensions selected by the server. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/extensions) + */ + extensions: string | null; + /** + * The **`WebSocket.binaryType`** property controls the type of binary data being received over the WebSocket connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/binaryType) + */ + binaryType: "blob" | "arraybuffer"; +} +interface WebSocketAcceptOptions { + /** + * When set to `true`, receiving a server-initiated WebSocket Close frame will not + * automatically send a reciprocal Close frame, leaving the connection in a half-open + * state. This is useful for proxying scenarios where you need to coordinate closing + * both sides independently. Defaults to `false` when the + * `no_web_socket_half_open_by_default` compatibility flag is enabled. + */ + allowHalfOpen?: boolean; +} +declare const WebSocketPair: { + new (): { + 0: WebSocket; + 1: WebSocket; + }; +}; +interface SqlStorage { + exec>(query: string, ...bindings: any[]): SqlStorageCursor; + get databaseSize(): number; + Cursor: typeof SqlStorageCursor; + Statement: typeof SqlStorageStatement; +} +declare abstract class SqlStorageStatement { +} +type SqlStorageValue = ArrayBuffer | string | number | null; +declare abstract class SqlStorageCursor> { + next(): { + done?: false; + value: T; + } | { + done: true; + value?: never; + }; + toArray(): T[]; + one(): T; + raw(): IterableIterator; + columnNames: string[]; + get rowsRead(): number; + get rowsWritten(): number; + [Symbol.iterator](): IterableIterator; +} +interface Socket { + get readable(): ReadableStream; + get writable(): WritableStream; + get closed(): Promise; + get opened(): Promise; + get upgraded(): boolean; + get secureTransport(): "on" | "off" | "starttls"; + close(): Promise; + startTls(options?: TlsOptions): Socket; +} +interface SocketOptions { + secureTransport?: string; + allowHalfOpen: boolean; + highWaterMark?: (number | bigint); +} +interface SocketAddress { + hostname: string; + port: number; +} +interface TlsOptions { + expectedServerHostname?: string; +} +interface SocketInfo { + remoteAddress?: string; + localAddress?: string; +} +/** + * The **`EventSource`** interface is web content's interface to server-sent events. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource) + */ +declare class EventSource extends EventTarget { + constructor(url: string, init?: EventSourceEventSourceInit); + /** + * The **`close()`** method of the EventSource interface closes the connection, if one is made, and sets the ```js-nolint close() ``` None. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/close) + */ + close(): void; + /** + * The **`url`** read-only property of the URL of the source. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/url) + */ + get url(): string; + /** + * The **`withCredentials`** read-only property of the the `EventSource` object was instantiated with CORS credentials set. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/withCredentials) + */ + get withCredentials(): boolean; + /** + * The **`readyState`** read-only property of the connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/readyState) + */ + get readyState(): number; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/open_event) */ + get onopen(): any | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/open_event) */ + set onopen(value: any | null); + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/message_event) */ + get onmessage(): any | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/message_event) */ + set onmessage(value: any | null); + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/error_event) */ + get onerror(): any | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/error_event) */ + set onerror(value: any | null); + static readonly CONNECTING: number; + static readonly OPEN: number; + static readonly CLOSED: number; + static from(stream: ReadableStream): EventSource; +} +interface EventSourceEventSourceInit { + withCredentials?: boolean; + fetcher?: Fetcher; +} +interface Container { + get running(): boolean; + start(options?: ContainerStartupOptions): void; + monitor(): Promise; + destroy(error?: any): Promise; + signal(signo: number): void; + getTcpPort(port: number): Fetcher; + setInactivityTimeout(durationMs: number | bigint): Promise; + interceptOutboundHttp(addr: string, binding: Fetcher): Promise; + interceptAllOutboundHttp(binding: Fetcher): Promise; + snapshotDirectory(options: ContainerDirectorySnapshotOptions): Promise; + snapshotContainer(options: ContainerSnapshotOptions): Promise; + interceptOutboundHttps(addr: string, binding: Fetcher): Promise; +} +interface ContainerDirectorySnapshot { + id: string; + size: number; + dir: string; + name?: string; +} +interface ContainerDirectorySnapshotOptions { + dir: string; + name?: string; +} +interface ContainerDirectorySnapshotRestoreParams { + snapshot: ContainerDirectorySnapshot; + mountPoint?: string; +} +interface ContainerSnapshot { + id: string; + size: number; + name?: string; +} +interface ContainerSnapshotOptions { + name?: string; +} +interface ContainerStartupOptions { + entrypoint?: string[]; + enableInternet: boolean; + env?: Record; + labels?: Record; + directorySnapshots?: ContainerDirectorySnapshotRestoreParams[]; + containerSnapshot?: ContainerSnapshot; +} +/** + * The **`MessagePort`** interface of the Channel Messaging API represents one of the two ports of a MessageChannel, allowing messages to be sent from one port and listening out for them arriving at the other. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort) + */ +declare abstract class MessagePort extends EventTarget { + /** + * The **`postMessage()`** method of the transfers ownership of objects to other browsing contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/postMessage) + */ + postMessage(data?: any, options?: (any[] | MessagePortPostMessageOptions)): void; + /** + * The **`close()`** method of the MessagePort interface disconnects the port, so it is no longer active. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/close) + */ + close(): void; + /** + * The **`start()`** method of the MessagePort interface starts the sending of messages queued on the port. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/start) + */ + start(): void; + get onmessage(): any | null; + set onmessage(value: any | null); +} +/** + * The **`MessageChannel`** interface of the Channel Messaging API allows us to create a new message channel and send data through it via its two MessagePort properties. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel) + */ +declare class MessageChannel { + constructor(); + /** + * The **`port1`** read-only property of the the port attached to the context that originated the channel. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port1) + */ + readonly port1: MessagePort; + /** + * The **`port2`** read-only property of the the port attached to the context at the other end of the channel, which the message is initially sent to. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port2) + */ + readonly port2: MessagePort; +} +interface MessagePortPostMessageOptions { + transfer?: any[]; +} +type LoopbackForExport Rpc.EntrypointBranded) | ExportedHandler | undefined = undefined> = T extends new (...args: any[]) => Rpc.WorkerEntrypointBranded ? LoopbackServiceStub> : T extends new (...args: any[]) => Rpc.DurableObjectBranded ? LoopbackDurableObjectClass> : T extends ExportedHandler ? LoopbackServiceStub : undefined; +type LoopbackServiceStub = Fetcher & (T extends CloudflareWorkersModule.WorkerEntrypoint ? (opts: { + props?: Props; +}) => Fetcher : (opts: { + props?: any; +}) => Fetcher); +type LoopbackDurableObjectClass = DurableObjectClass & (T extends CloudflareWorkersModule.DurableObject ? (opts: { + props?: Props; +}) => DurableObjectClass : (opts: { + props?: any; +}) => DurableObjectClass); +interface LoopbackDurableObjectNamespace extends DurableObjectNamespace { +} +interface LoopbackColoLocalActorNamespace extends ColoLocalActorNamespace { +} +interface SyncKvStorage { + get(key: string): T | undefined; + list(options?: SyncKvListOptions): Iterable<[ + string, + T + ]>; + put(key: string, value: T): void; + delete(key: string): boolean; +} +interface SyncKvListOptions { + start?: string; + startAfter?: string; + end?: string; + prefix?: string; + reverse?: boolean; + limit?: number; +} +interface WorkerStub { + getEntrypoint(name?: string, options?: WorkerStubEntrypointOptions): Fetcher; + getDurableObjectClass(name?: string, options?: WorkerStubEntrypointOptions): DurableObjectClass; +} +interface WorkerStubEntrypointOptions { + props?: any; + limits?: workerdResourceLimits; +} +interface WorkerLoader { + get(name: string | null, getCode: () => WorkerLoaderWorkerCode | Promise): WorkerStub; + load(code: WorkerLoaderWorkerCode): WorkerStub; +} +interface WorkerLoaderModule { + js?: string; + cjs?: string; + text?: string; + data?: ArrayBuffer; + json?: any; + py?: string; + wasm?: ArrayBuffer; +} +interface WorkerLoaderWorkerCode { + compatibilityDate: string; + compatibilityFlags?: string[]; + allowExperimental?: boolean; + limits?: workerdResourceLimits; + mainModule: string; + modules: Record; + env?: any; + globalOutbound?: (Fetcher | null); + tails?: Fetcher[]; + streamingTails?: Fetcher[]; +} +interface workerdResourceLimits { + cpuMs?: number; + subRequests?: number; +} +/** +* The Workers runtime supports a subset of the Performance API, used to measure timing and performance, +* as well as timing of subrequests and other operations. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/) +*/ +declare abstract class Performance { + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancetimeorigin) */ + get timeOrigin(): number; + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancenow) */ + now(): number; + /** + * The **`toJSON()`** method of the Performance interface is a Serialization; it returns a JSON representation of the Performance object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Performance/toJSON) + */ + toJSON(): object; +} +interface Tracing { + enterSpan(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T; + Span: typeof Span; +} +declare abstract class Span { + get isTraced(): boolean; + setAttribute(key: string, value?: (boolean | number | string)): void; +} +// ============================================================================ +// Agent Memory +// +// Public type surface for user Workers binding to an Agent Memory namespace. +// ============================================================================ +/** Memory type — every memory is classified into exactly one. */ +type AgentMemoryMemoryType = "fact" | "event" | "instruction" | "task"; +/** Search intensity for recall. */ +type AgentMemoryThinkingLevel = "low" | "medium" | "high"; +/** Response verbosity for recall. */ +type AgentMemoryResponseLength = "short" | "medium" | "long"; +/** A conversation message passed to ingest(). */ +interface AgentMemoryMessage { + role: "system" | "user" | "assistant"; + content: string; + /** Optional message timestamp. */ + timestamp?: Date; +} +/** Raw memory content passed to remember(). */ +interface AgentMemoryIncomingMemory { + /** Raw memory content. The service classifies and summarizes automatically. */ + content: string; + /** Optional session identifier to associate with this memory. */ + sessionId?: string | null | undefined; +} +/** A stored memory returned from remember(), get(), and delete(). */ +interface AgentMemoryMemory { + /** Memory ID. */ + id: string; + /** Memory type. */ + type: AgentMemoryMemoryType; + /** Text summary. */ + summary: string; + /** Memory text. */ + content: string; + /** Session that created this memory. */ + sessionId: string | null; + /** Memory creation time. */ + createdAt: Date; + /** Memory last-update time. */ + updatedAt: Date; +} +/** Single entry in a list() response. Same shape as Memory minus full content. */ +type AgentMemoryMemoryListEntry = Omit; +/** A scored memory candidate in a recall result. */ +interface AgentMemoryScoredCandidate { + /** Candidate ID. */ + id: string; + /** Text summary. */ + summary: string; + /** Session that created this candidate, when known. */ + sessionId: string | null; + /** Relevance score (higher is better). Comparable only within a single query. */ + score: number; +} +/** Options for the ingest() method. */ +interface AgentMemoryIngestOptions { + /** Session identifier to associate with memories created during ingestion. */ + sessionId?: string | null | undefined; +} +/** Options for the getSummary() method. */ +interface AgentMemoryGetSummaryOptions { + /** Session identifier to retrieve session summary for. */ + sessionId?: string | null | undefined; +} +/** Response from the getSummary() method. */ +interface AgentMemoryGetSummaryResponse { + /** Markdown summary. */ + summary: string; +} +/** + * Options for the recall() method. + * + * `referenceDate` accepts a Date object, an ISO-8601 date string + * (YYYY-MM-DD), or a full ISO-8601 datetime string. When provided, this + * date is used as "today" for resolving relative time references + * ("how many days ago", "last week") instead of the server's wall-clock time. + */ +interface AgentMemoryRecallOptions { + /** Recall intensity: "low" (default), "medium", or "high". */ + thinkingLevel?: AgentMemoryThinkingLevel; + /** Response verbosity: "short", "medium" (default), or "long". */ + responseLength?: AgentMemoryResponseLength; + /** Temporal anchor for date arithmetic. */ + referenceDate?: Date | string; +} +/** Response from the recall() method. */ +interface AgentMemoryRecallResult { + /** Number of memories retrieved. */ + count: number; + /** LLM-generated answer synthesizing the matching memories. */ + answer: string; + /** Matching memories ranked by relevance. */ + candidates: AgentMemoryScoredCandidate[]; +} +/** + * Options for the list() method. + * + * `cursor` is the opaque continuation token returned by the previous page; + * pass it back unchanged to fetch the next page. `sessionId` and `type` + * are exact-match filters; combining them is allowed. + */ +interface AgentMemoryListMemoriesOptions { + /** Maximum number of memories to return. Default 20, max 500. */ + limit?: number; + /** Opaque cursor from a previous page. */ + cursor?: string; + /** Exact-match session filter. */ + sessionId?: string; + /** Exact-match memory-type filter. */ + type?: AgentMemoryMemoryType; +} +/** Response from the list() method. */ +interface AgentMemoryListMemoriesResult { + memories: AgentMemoryMemoryListEntry[]; + /** Continuation cursor; absent when this page exhausted the result set. */ + cursor?: string; +} +/** + * A single Agent Memory profile, scoped to a profile name. + * + * Returned by {@link AgentMemoryNamespace.getProfile}. + */ +declare abstract class AgentMemoryProfile { + /** + * Retrieve a memory by ID. + * + * @param memoryId - ULID of the memory to retrieve. + * @throws if the memory does not exist. + */ + get(memoryId: string): Promise; + /** + * Delete a memory by ID. + * + * Removes the memory and any source messages linked by the memory's + * source message IDs. + * + * @param memoryId - ULID of the memory to delete. + * @throws if the memory does not exist. + */ + delete(memoryId: string): Promise; + /** + * Store a memory in this profile. The content is automatically classified, + * summarized, and indexed. + * + * @param memory - Raw memory content to persist. + */ + remember(memory: AgentMemoryIncomingMemory): Promise; + /** + * Extract memories from a conversation. + * + * @param messages - Conversation messages to extract memories from. + * @param options - Optional ingest options. + */ + ingest(messages: Iterable, options?: AgentMemoryIngestOptions): Promise; + /** + * Get a profile summary. + * + * @param options - Optional getSummary options. + */ + getSummary(options?: AgentMemoryGetSummaryOptions): Promise; + /** + * Recall memories in this profile. + * + * @param query - Recall query matched against memory content and keywords. + * @param options - Optional recall parameters. + * @returns Matching memories with relevance scores and a synthesized answer. + */ + recall(query: string, options?: AgentMemoryRecallOptions): Promise; + /** + * List active memories in this profile. + * + * Returns a paginated, filterable view of stored memories. Superseded + * versions are excluded. Use the returned `cursor` (when present) to + * fetch the next page. + * + * @param options - Optional pagination and filter options. + */ + list(options?: AgentMemoryListMemoriesOptions): Promise; + /** + * Soft-delete every memory and message in this profile that is tagged + * with `sessionId`. + * + * Idempotent: deleting a sessionId that has no rows is a no-op. + * + * @param sessionId - Session to delete. + */ + deleteSession(sessionId: string): Promise; +} +/** + * Namespace-level Agent Memory binding. + * + * Used as the type of an `env.MEMORY`-style binding backed by the Agent + * Memory product. + * + * @example + * ```ts + * export default { + * async fetch(_request: Request, env: Env): Promise { + * const profile = await env.MEMORY.getProfile("wrangler-e2e"); + * const summary = await profile.getSummary(); + * return Response.json(summary); + * }, + * }; + * ``` + */ +declare abstract class AgentMemoryNamespace { + /** + * Get a memory profile by name. Profiles are isolated by namespace and + * addressed by a compound key (namespaceId:profileName). + * + * @param profileName - Profile name (validated against naming rules). + * @returns RPC target for interacting with the profile. + */ + getProfile(profileName: string): Promise; + /** + * Soft-delete a profile and schedule deferred purge. Marks all + * memories and messages as deleted. + * + * @param profileName - Name of the profile to delete. + */ + deleteProfile(profileName: string): Promise; +} +// ============ AI Search Error Interfaces ============ +interface AiSearchInternalError extends Error { +} +interface AiSearchNotFoundError extends Error { +} +// ============ AI Search Common Types ============ +/** A single message in a conversation-style search or chat request. */ +type AiSearchMessage = { + role: 'system' | 'developer' | 'user' | 'assistant' | 'tool'; + content: string | null; +}; +/** + * Common shape for `ai_search_options` used by both single-instance and multi-instance requests. + * Contains retrieval, query rewrite, reranking, and cache sub-options. + */ +type AiSearchOptions = { + retrieval?: { + /** Which retrieval backend to use. Defaults to the instance's configured index_method. */ + retrieval_type?: 'vector' | 'keyword' | 'hybrid'; + /** Fusion method for combining vector + keyword results. */ + fusion_method?: 'max' | 'rrf'; + /** How keyword terms are combined: "and" = all terms must match, "or" = any term matches. */ + keyword_match_mode?: 'and' | 'or'; + /** Minimum similarity score (0-1) for a result to be included. Default 0.4. */ + match_threshold?: number; + /** Maximum number of results to return (1-50). Default 10. */ + max_num_results?: number; + /** Vectorize metadata filters applied to the search. */ + filters?: VectorizeVectorMetadataFilter; + /** Number of surrounding chunks to include for context (0-3). Default 0. */ + context_expansion?: number; + /** If true, return only item metadata without chunk text. */ + metadata_only?: boolean; + /** If true (default), return empty results on retrieval failure instead of throwing. */ + return_on_failure?: boolean; + /** Boost results by metadata field values. Max 3 entries. */ + boost_by?: Array<{ + field: string; + direction?: 'asc' | 'desc' | 'exists' | 'not_exists'; + }>; + [key: string]: unknown; + }; + query_rewrite?: { + enabled?: boolean; + model?: string; + rewrite_prompt?: string; + [key: string]: unknown; + }; + reranking?: { + enabled?: boolean; + model?: string; + /** Match threshold (0-1, default 0.4) */ + match_threshold?: number; + [key: string]: unknown; + }; + cache?: { + enabled?: boolean; + cache_threshold?: 'super_strict_match' | 'close_enough' | 'flexible_friend' | 'anything_goes'; + }; + [key: string]: unknown; +}; +// ============ AI Search Request Types ============ +/** + * Request body for single-instance search. + * Exactly one of `query` or `messages` must be provided. + */ +type AiSearchSearchRequest = { + /** Simple query string. */ + query: string; + messages?: never; + ai_search_options?: AiSearchOptions; +} | { + query?: never; + /** Conversation-style input. At least one user message with non-empty content is required. */ + messages: AiSearchMessage[]; + ai_search_options?: AiSearchOptions; +}; +type AiSearchChatCompletionsRequest = { + messages: AiSearchMessage[]; + model?: string; + stream?: boolean; + ai_search_options?: AiSearchOptions; + [key: string]: unknown; +}; +// ============ AI Search Multi-Instance Types (Namespace-Scoped) ============ +/** `ai_search_options` shape for multi-instance requests — requires `instance_ids`. */ +type AiSearchMultiSearchOptions = AiSearchOptions & { + /** Instance IDs to search across (1-10). */ + instance_ids: string[]; +}; +/** + * Request for searching across multiple instances within a namespace. + * `ai_search_options` is required and must include `instance_ids`. + * Exactly one of `query` or `messages` must be provided. + */ +type AiSearchMultiSearchRequest = { + /** Simple query string. */ + query: string; + messages?: never; + ai_search_options: AiSearchMultiSearchOptions; +} | { + query?: never; + /** Conversation-style input. */ + messages: AiSearchMessage[]; + ai_search_options: AiSearchMultiSearchOptions; +}; +/** A search result chunk tagged with the instance it originated from. */ +type AiSearchMultiSearchChunk = AiSearchSearchResponse['chunks'][number] & { + instance_id: string; +}; +/** Describes a per-instance error during a multi-instance operation. */ +type AiSearchMultiSearchError = { + instance_id: string; + message: string; +}; +/** Response from a multi-instance search, with chunks tagged by instance and optional partial-failure errors. */ +type AiSearchMultiSearchResponse = { + search_query: string; + chunks: AiSearchMultiSearchChunk[]; + errors?: AiSearchMultiSearchError[]; +}; +/** Request for chat completions across multiple instances within a namespace. `ai_search_options` is required and must include `instance_ids`. */ +type AiSearchMultiChatCompletionsRequest = Omit & { + ai_search_options: AiSearchMultiSearchOptions; +}; +/** Response from multi-instance chat completions, with chunks tagged by instance and optional partial-failure errors. */ +type AiSearchMultiChatCompletionsResponse = Omit & { + chunks: AiSearchMultiSearchChunk[]; + errors?: AiSearchMultiSearchError[]; +}; +// ============ AI Search Response Types ============ +type AiSearchSearchResponse = { + search_query: string; + chunks: Array<{ + id: string; + type: string; + /** Match score (0-1) */ + score: number; + text: string; + item: { + timestamp?: number; + key: string; + metadata?: Record; + }; + scoring_details?: { + /** Keyword match score (0-1) */ + keyword_score?: number; + /** Vector similarity score (0-1) */ + vector_score?: number; + /** Keyword rank position */ + keyword_rank?: number; + /** Vector rank position */ + vector_rank?: number; + /** Reranking model score */ + reranking_score?: number; + /** Fusion method used to combine results */ + fusion_method?: 'rrf' | 'max'; + [key: string]: unknown; + }; + }>; +}; +type AiSearchChatCompletionsResponse = { + id?: string; + object?: string; + model?: string; + choices: Array<{ + index?: number; + message: { + role: 'system' | 'developer' | 'user' | 'assistant' | 'tool'; + content: string | null; + [key: string]: unknown; + }; + [key: string]: unknown; + }>; + chunks: AiSearchSearchResponse['chunks']; + [key: string]: unknown; +}; +type AiSearchStatsResponse = { + queued?: number; + running?: number; + completed?: number; + error?: number; + skipped?: number; + outdated?: number; + last_activity?: string; + /** Storage engine statistics. */ + engine?: { + vectorize?: { + vectorsCount: number; + dimensions: number; + }; + r2?: { + payloadSizeBytes: number; + metadataSizeBytes: number; + objectCount: number; + }; + }; +}; +// ============ AI Search Instance Info Types ============ +type AiSearchInstanceInfo = { + id: string; + type?: 'r2' | 'web-crawler' | string; + source?: string; + source_params?: unknown; + paused?: boolean; + status?: string; + namespace?: string; + created_at?: string; + modified_at?: string; + token_id?: string; + ai_gateway_id?: string; + rewrite_query?: boolean; + reranking?: boolean; + embedding_model?: string; + ai_search_model?: string; + rewrite_model?: string; + reranking_model?: string; + /** @deprecated Use index_method instead. */ + hybrid_search_enabled?: boolean; + /** Controls which storage backends are active. */ + index_method?: { + vector?: boolean; + keyword?: boolean; + }; + /** Fusion method for combining vector and keyword results. */ + fusion_method?: 'max' | 'rrf'; + indexing_options?: { + keyword_tokenizer?: 'porter' | 'trigram'; + } | null; + retrieval_options?: { + keyword_match_mode?: 'and' | 'or'; + boost_by?: Array<{ + field: string; + direction?: 'asc' | 'desc' | 'exists' | 'not_exists'; + }>; + } | null; + chunk?: boolean; + chunk_size?: number; + chunk_overlap?: number; + score_threshold?: number; + max_num_results?: number; + cache?: boolean; + cache_threshold?: 'super_strict_match' | 'close_enough' | 'flexible_friend' | 'anything_goes'; + custom_metadata?: Array<{ + field_name: string; + data_type: 'text' | 'number' | 'boolean' | 'datetime'; + }>; + /** Sync interval in seconds. */ + sync_interval?: 3600 | 7200 | 14400 | 21600 | 43200 | 86400; + metadata?: Record; + [key: string]: unknown; +}; +/** Pagination, search, and ordering parameters for listing instances within a namespace. */ +type AiSearchListInstancesParams = { + page?: number; + per_page?: number; + /** Search instances by ID. */ + search?: string; + /** Field to sort by. */ + order_by?: 'created_at'; + /** Sort direction. */ + order_by_direction?: 'asc' | 'desc'; +}; +type AiSearchListResponse = { + result: AiSearchInstanceInfo[]; + result_info?: { + count: number; + page: number; + per_page: number; + total_count: number; + }; +}; +// ============ AI Search Config Types ============ +type AiSearchConfig = { + /** Instance ID (1-32 chars, pattern: ^[a-z0-9_]+(?:-[a-z0-9_]+)*$) */ + id: string; + /** Instance type. Omit to create with built-in storage. */ + type?: 'r2' | 'web-crawler' | string; + /** Source URL (required for web-crawler type). */ + source?: string; + source_params?: unknown; + /** Token ID (UUID format) */ + token_id?: string; + ai_gateway_id?: string; + /** Enable query rewriting (default false) */ + rewrite_query?: boolean; + /** Enable reranking (default false) */ + reranking?: boolean; + embedding_model?: string; + ai_search_model?: string; + rewrite_model?: string; + reranking_model?: string; + /** @deprecated Use index_method instead. */ + hybrid_search_enabled?: boolean; + /** Controls which storage backends are used during indexing. Defaults to vector-only. */ + index_method?: { + vector?: boolean; + keyword?: boolean; + }; + /** Fusion method for combining vector and keyword results. "rrf" = reciprocal rank fusion (default), "max" = maximum score. */ + fusion_method?: 'max' | 'rrf'; + indexing_options?: { + keyword_tokenizer?: 'porter' | 'trigram'; + } | null; + retrieval_options?: { + keyword_match_mode?: 'and' | 'or'; + boost_by?: Array<{ + field: string; + direction?: 'asc' | 'desc' | 'exists' | 'not_exists'; + }>; + } | null; + chunk?: boolean; + chunk_size?: number; + chunk_overlap?: number; + /** Minimum similarity score (0-1) for a result to be included. */ + score_threshold?: number; + max_num_results?: number; + cache?: boolean; + /** Similarity threshold for cache hits. Stricter = fewer cache hits but higher relevance. */ + cache_threshold?: 'super_strict_match' | 'close_enough' | 'flexible_friend' | 'anything_goes'; + custom_metadata?: Array<{ + field_name: string; + data_type: 'text' | 'number' | 'boolean' | 'datetime'; + }>; + namespace?: string; + /** Sync interval in seconds. 3600=1h, 7200=2h, 14400=4h, 21600=6h, 43200=12h, 86400=24h. */ + sync_interval?: 3600 | 7200 | 14400 | 21600 | 43200 | 86400; + metadata?: Record; + [key: string]: unknown; +}; +// ============ AI Search Item Types ============ +type AiSearchItemInfo = { + id: string; + key: string; + status: 'completed' | 'error' | 'skipped' | 'queued' | 'running' | 'outdated'; + next_action?: 'INDEX' | 'DELETE' | null; + error?: string; + checksum?: string; + namespace?: string; + chunks_count?: number | null; + file_size?: number | null; + source_id?: string | null; + last_seen_at?: string; + created_at?: string; + metadata?: Record; + [key: string]: unknown; +}; +type AiSearchItemContentResult = { + body: ReadableStream; + contentType: string; + filename: string; + size: number; +}; +type AiSearchUploadItemOptions = { + metadata?: Record; +}; +type AiSearchListItemsParams = { + page?: number; + per_page?: number; + /** Search items by key name. */ + search?: string; + /** Sort order for results. */ + sort_by?: 'status' | 'modified_at'; + /** Filter items by processing status. */ + status?: 'queued' | 'running' | 'completed' | 'error' | 'skipped' | 'outdated'; + /** Filter items by source (e.g. "builtin" or "web-crawler:https://example.com"). */ + source?: string; + /** JSON-encoded Vectorize filter for metadata filtering. */ + metadata_filter?: string; +}; +type AiSearchListItemsResponse = { + result: AiSearchItemInfo[]; + result_info?: { + count: number; + page: number; + per_page: number; + total_count: number; + }; +}; +// ============ AI Search Item Logs Types ============ +type AiSearchItemLogsParams = { + /** Maximum number of log entries to return (1-100, default 50). */ + limit?: number; + /** Opaque cursor for pagination. Pass the `cursor` value from a previous response. */ + cursor?: string; +}; +type AiSearchItemLog = { + timestamp: string; + action: string; + message: string; + fileKey?: string; + chunkCount?: number; + processingTimeMs?: number; + errorType?: string; +}; +/** Paginated response for item processing logs (cursor-based). */ +type AiSearchItemLogsResponse = { + result: AiSearchItemLog[]; + result_info: { + count: number; + per_page: number; + cursor: string | null; + truncated: boolean; + }; +}; +// ============ AI Search Item Chunks Types ============ +type AiSearchItemChunksParams = { + /** Maximum number of chunks to return (1-100, default 20). */ + limit?: number; + /** Offset into the chunks list (default 0). */ + offset?: number; +}; +/** A single indexed chunk belonging to an item, including its text content and byte range. */ +type AiSearchItemChunk = { + id: string; + text: string; + start_byte: number; + end_byte: number; + item?: { + timestamp?: number; + key: string; + metadata?: Record; + }; +}; +/** Paginated response for item chunks (offset-based). */ +type AiSearchItemChunksResponse = { + result: AiSearchItemChunk[]; + result_info: { + count: number; + total: number; + limit: number; + offset: number; + }; +}; +// ============ AI Search Job Types ============ +type AiSearchJobInfo = { + id: string; + source: 'user' | 'schedule'; + description?: string; + last_seen_at?: string; + started_at?: string; + ended_at?: string; + end_reason?: string; +}; +type AiSearchJobLog = { + id: number; + message: string; + message_type: number; + created_at: number; +}; +type AiSearchCreateJobParams = { + description?: string; +}; +type AiSearchListJobsParams = { + page?: number; + per_page?: number; +}; +type AiSearchListJobsResponse = { + result: AiSearchJobInfo[]; + result_info?: { + count: number; + page: number; + per_page: number; + total_count: number; + }; +}; +type AiSearchJobLogsParams = { + page?: number; + per_page?: number; +}; +type AiSearchJobLogsResponse = { + result: AiSearchJobLog[]; + result_info?: { + count: number; + page: number; + per_page: number; + total_count: number; + }; +}; +// ============ AI Search Sub-Service Classes ============ +/** + * Single item service for an AI Search instance. + * Provides info, download, sync, logs, and chunks operations on a specific item. + */ +declare abstract class AiSearchItem { + /** Get metadata about this item. */ + info(): Promise; + /** + * Download the item's content. + * @returns Object with body stream, content type, filename, and size. + */ + download(): Promise; + /** + * Trigger re-indexing of this item. + * @returns The updated item info. + */ + sync(): Promise; + /** + * Retrieve processing logs for this item (cursor-based pagination). + * @param params Optional pagination parameters (limit, cursor). + * @returns Paginated log entries for this item. + */ + logs(params?: AiSearchItemLogsParams): Promise; + /** + * List indexed chunks for this item (offset-based pagination). + * @param params Optional pagination parameters (limit, offset). + * @returns Paginated chunk entries for this item. + */ + chunks(params?: AiSearchItemChunksParams): Promise; +} +/** + * Items collection service for an AI Search instance. + * Provides list, upload, and access to individual items. + */ +declare abstract class AiSearchItems { + /** List items in this instance. */ + list(params?: AiSearchListItemsParams): Promise; + /** + * Upload a file as an item. Behaves as an upsert: if an item with the same + * filename already exists, it is overwritten and re-indexed. + * @param name Filename for the uploaded item. + * @param content File content as a ReadableStream, Blob, or string. + * @param options Optional metadata to attach to the item. + * @returns The created item info. + */ + upload(name: string, content: ReadableStream | Blob | string, options?: AiSearchUploadItemOptions): Promise; + /** + * Upload a file and poll until processing completes. + * Behaves as an upsert: if an item with the same filename already exists, + * it is overwritten and re-indexed. + * @param name Filename for the uploaded item. + * @param content File content as a ReadableStream, Blob, or string. + * @param options Optional metadata and polling configuration. + * @returns The item info after processing completes (or timeout). + */ + uploadAndPoll(name: string, content: ReadableStream | Blob | string, options?: AiSearchUploadItemOptions & { + /** Polling interval in milliseconds (default 1000). */ + pollIntervalMs?: number; + /** Maximum time to wait in milliseconds (default 30000). */ + timeoutMs?: number; + }): Promise; + /** + * Get an item by ID. + * @param itemId The item identifier. + * @returns Item service for info, download, sync, logs, and chunks operations. + */ + get(itemId: string): AiSearchItem; + /** + * Delete an item from the instance. + * @param itemId The item identifier. + */ + delete(itemId: string): Promise; +} +/** + * Single job service for an AI Search instance. + * Provides info, logs, and cancel operations for a specific job. + */ +declare abstract class AiSearchJob { + /** Get metadata about this job. */ + info(): Promise; + /** Get logs for this job. */ + logs(params?: AiSearchJobLogsParams): Promise; + /** + * Cancel a running job. + * @returns The updated job info. + * @throws AiSearchNotFoundError if the job does not exist. + */ + cancel(): Promise; +} +/** + * Jobs collection service for an AI Search instance. + * Provides list, create, and access to individual jobs. + */ +declare abstract class AiSearchJobs { + /** List jobs for this instance. */ + list(params?: AiSearchListJobsParams): Promise; + /** + * Create a new indexing job. + * @param params Optional job parameters. + * @returns The created job info. + */ + create(params?: AiSearchCreateJobParams): Promise; + /** + * Get a job by ID. + * @param jobId The job identifier. + * @returns Job service for info, logs, and cancel operations. + */ + get(jobId: string): AiSearchJob; +} +// ============ AI Search Binding Classes ============ +/** + * Instance-level AI Search service. + * + * Used as: + * - The return type of `AiSearchNamespace.get(name)` (namespace binding) + * - The type of `env.BLOG_SEARCH` (single instance binding via `ai_search`) + * + * Provides search, chat, update, stats, items, and jobs operations. + * + * @example + * ```ts + * // Via namespace binding + * const instance = env.AI_SEARCH.get("blog"); + * const results = await instance.search({ + * query: "How does caching work?", + * }); + * + * // Via single instance binding + * const results = await env.BLOG_SEARCH.search({ + * messages: [{ role: "user", content: "How does caching work?" }], + * }); + * ``` + */ +declare abstract class AiSearchInstance { + /** + * Search the AI Search instance for relevant chunks. + * @param params Search request with query or messages and optional AI search options. + * @returns Search response with matching chunks and search query. + */ + search(params: AiSearchSearchRequest): Promise; + /** + * Generate chat completions with AI Search context (streaming). + * @param params Chat completions request with stream: true. + * @returns ReadableStream of server-sent events. + */ + chatCompletions(params: AiSearchChatCompletionsRequest & { + stream: true; + }): Promise; + /** + * Generate chat completions with AI Search context. + * @param params Chat completions request. + * @returns Chat completion response with choices and RAG chunks. + */ + chatCompletions(params: AiSearchChatCompletionsRequest): Promise; + /** + * Update the instance configuration. + * @param config Partial configuration to update. + * @returns Updated instance info. + */ + update(config: Partial): Promise; + /** Get metadata about this instance. */ + info(): Promise; + /** + * Get instance statistics (item count, indexing status, etc.). + * @returns Statistics with counts per status, last activity time, and engine details. + */ + stats(): Promise; + /** Items collection — list, upload, and manage items in this instance. */ + get items(): AiSearchItems; + /** Jobs collection — list, create, and inspect indexing jobs. */ + get jobs(): AiSearchJobs; +} +/** + * Namespace-level AI Search service. + * + * Used as the type of `env.AI_SEARCH` (namespace binding via `ai_search_namespaces`). + * Scoped to a single namespace. Provides dynamic instance access, creation, deletion, + * and multi-instance search/chat operations. + * + * @example + * ```ts + * // Access an instance within the namespace + * const blog = env.AI_SEARCH.get("blog"); + * const results = await blog.search({ query: "How does caching work?" }); + * + * // List all instances in the namespace + * const instances = await env.AI_SEARCH.list(); + * + * // Create a new instance with built-in storage + * const tenant = await env.AI_SEARCH.create({ id: "tenant-123" }); + * + * // Upload items into the instance + * await tenant.items.upload("doc.pdf", fileContent); + * + * // Search across multiple instances + * const multi = await env.AI_SEARCH.search({ + * query: "caching", + * ai_search_options: { instance_ids: ["blog", "docs"] }, + * }); + * + * // Delete an instance + * await env.AI_SEARCH.delete("tenant-123"); + * ``` + */ +declare abstract class AiSearchNamespace { + /** + * Get an instance by name within the bound namespace. + * @param name Instance name. + * @returns Instance service for search, chat, update, stats, items, and jobs. + */ + get(name: string): AiSearchInstance; + /** + * List instances in the bound namespace. + * @param params Optional pagination, search, and ordering parameters. + * @returns Array of instance metadata with pagination info. + */ + list(params?: AiSearchListInstancesParams): Promise; + /** + * Create a new instance within the bound namespace. + * @param config Instance configuration. Only `id` is required — omit `type` and `source` to create with built-in storage. + * @returns Instance service for the newly created instance. + * + * @example + * ```ts + * // Create with built-in storage (upload items manually) + * const instance = await env.AI_SEARCH.create({ id: "my-search" }); + * + * // Create with web crawler source + * const instance = await env.AI_SEARCH.create({ + * id: "docs-search", + * type: "web-crawler", + * source: "https://developers.cloudflare.com", + * }); + * ``` + */ + create(config: AiSearchConfig): Promise; + /** + * Delete an instance from the bound namespace. + * @param name Instance name to delete. + */ + delete(name: string): Promise; + /** + * Search across multiple instances within the bound namespace. + * Fans out to the specified instance_ids and merges results. + * @param params Search request with required `ai_search_options.instance_ids`. + * @returns Search response with chunks tagged by instance_id and optional partial-failure errors. + */ + search(params: AiSearchMultiSearchRequest): Promise; + /** + * Generate chat completions across multiple instances within the bound namespace (streaming). + * Fans out to the specified instance_ids, merges context, and generates a response. + * @param params Chat completions request with stream: true and required `ai_search_options.instance_ids`. + * @returns ReadableStream of server-sent events. + */ + chatCompletions(params: AiSearchMultiChatCompletionsRequest & { + stream: true; + }): Promise; + /** + * Generate chat completions across multiple instances within the bound namespace. + * Fans out to the specified instance_ids, merges context, and generates a response. + * @param params Chat completions request with required `ai_search_options.instance_ids`. + * @returns Chat completion response with choices, chunks tagged by instance_id, and optional partial-failure errors. + */ + chatCompletions(params: AiSearchMultiChatCompletionsRequest): Promise; +} +type AiImageClassificationInput = { + image: number[]; +}; +type AiImageClassificationOutput = { + score?: number; + label?: string; +}[]; +declare abstract class BaseAiImageClassification { + inputs: AiImageClassificationInput; + postProcessedOutputs: AiImageClassificationOutput; +} +type AiImageToTextInput = { + image: number[]; + prompt?: string; + max_tokens?: number; + temperature?: number; + top_p?: number; + top_k?: number; + seed?: number; + repetition_penalty?: number; + frequency_penalty?: number; + presence_penalty?: number; + raw?: boolean; + messages?: RoleScopedChatInput[]; +}; +type AiImageToTextOutput = { + description: string; +}; +declare abstract class BaseAiImageToText { + inputs: AiImageToTextInput; + postProcessedOutputs: AiImageToTextOutput; +} +type AiImageTextToTextInput = { + image: string; + prompt?: string; + max_tokens?: number; + temperature?: number; + ignore_eos?: boolean; + top_p?: number; + top_k?: number; + seed?: number; + repetition_penalty?: number; + frequency_penalty?: number; + presence_penalty?: number; + raw?: boolean; + messages?: RoleScopedChatInput[]; +}; +type AiImageTextToTextOutput = { + description: string; +}; +declare abstract class BaseAiImageTextToText { + inputs: AiImageTextToTextInput; + postProcessedOutputs: AiImageTextToTextOutput; +} +type AiMultimodalEmbeddingsInput = { + image: string; + text: string[]; +}; +type AiIMultimodalEmbeddingsOutput = { + data: number[][]; + shape: number[]; +}; +declare abstract class BaseAiMultimodalEmbeddings { + inputs: AiImageTextToTextInput; + postProcessedOutputs: AiImageTextToTextOutput; +} +type AiObjectDetectionInput = { + image: number[]; +}; +type AiObjectDetectionOutput = { + score?: number; + label?: string; +}[]; +declare abstract class BaseAiObjectDetection { + inputs: AiObjectDetectionInput; + postProcessedOutputs: AiObjectDetectionOutput; +} +type AiSentenceSimilarityInput = { + source: string; + sentences: string[]; +}; +type AiSentenceSimilarityOutput = number[]; +declare abstract class BaseAiSentenceSimilarity { + inputs: AiSentenceSimilarityInput; + postProcessedOutputs: AiSentenceSimilarityOutput; +} +type AiAutomaticSpeechRecognitionInput = { + audio: number[]; +}; +type AiAutomaticSpeechRecognitionOutput = { + text?: string; + words?: { + word: string; + start: number; + end: number; + }[]; + vtt?: string; +}; +declare abstract class BaseAiAutomaticSpeechRecognition { + inputs: AiAutomaticSpeechRecognitionInput; + postProcessedOutputs: AiAutomaticSpeechRecognitionOutput; +} +type AiSummarizationInput = { + input_text: string; + max_length?: number; +}; +type AiSummarizationOutput = { + summary: string; +}; +declare abstract class BaseAiSummarization { + inputs: AiSummarizationInput; + postProcessedOutputs: AiSummarizationOutput; +} +type AiTextClassificationInput = { + text: string; +}; +type AiTextClassificationOutput = { + score?: number; + label?: string; +}[]; +declare abstract class BaseAiTextClassification { + inputs: AiTextClassificationInput; + postProcessedOutputs: AiTextClassificationOutput; +} +type AiTextEmbeddingsInput = { + text: string | string[]; +}; +type AiTextEmbeddingsOutput = { + shape: number[]; + data: number[][]; +}; +declare abstract class BaseAiTextEmbeddings { + inputs: AiTextEmbeddingsInput; + postProcessedOutputs: AiTextEmbeddingsOutput; +} +type RoleScopedChatInput = { + role: "user" | "assistant" | "system" | "tool" | (string & NonNullable); + content: string; + name?: string; +}; +type AiTextGenerationToolLegacyInput = { + name: string; + description: string; + parameters?: { + type: "object" | (string & NonNullable); + properties: { + [key: string]: { + type: string; + description?: string; + }; + }; + required: string[]; + }; +}; +type AiTextGenerationToolInput = { + type: "function" | (string & NonNullable); + function: { + name: string; + description: string; + parameters?: { + type: "object" | (string & NonNullable); + properties: { + [key: string]: { + type: string; + description?: string; + }; + }; + required: string[]; + }; + }; +}; +type AiTextGenerationFunctionsInput = { + name: string; + code: string; +}; +type AiTextGenerationResponseFormat = { + type: string; + json_schema?: any; +}; +type AiTextGenerationInput = { + prompt?: string; + raw?: boolean; + stream?: boolean; + max_tokens?: number; + temperature?: number; + top_p?: number; + top_k?: number; + seed?: number; + repetition_penalty?: number; + frequency_penalty?: number; + presence_penalty?: number; + messages?: RoleScopedChatInput[]; + response_format?: AiTextGenerationResponseFormat; + tools?: AiTextGenerationToolInput[] | AiTextGenerationToolLegacyInput[] | (object & NonNullable); + functions?: AiTextGenerationFunctionsInput[]; +}; +type AiTextGenerationToolLegacyOutput = { + name: string; + arguments: unknown; +}; +type AiTextGenerationToolOutput = { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +}; +type UsageTags = { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; +}; +type AiTextGenerationOutput = { + response?: string; + tool_calls?: AiTextGenerationToolLegacyOutput[] & AiTextGenerationToolOutput[]; + usage?: UsageTags; +}; +declare abstract class BaseAiTextGeneration { + inputs: AiTextGenerationInput; + postProcessedOutputs: AiTextGenerationOutput; +} +type AiTextToSpeechInput = { + prompt: string; + lang?: string; +}; +type AiTextToSpeechOutput = Uint8Array | { + audio: string; +}; +declare abstract class BaseAiTextToSpeech { + inputs: AiTextToSpeechInput; + postProcessedOutputs: AiTextToSpeechOutput; +} +type AiTextToImageInput = { + prompt: string; + negative_prompt?: string; + height?: number; + width?: number; + image?: number[]; + image_b64?: string; + mask?: number[]; + num_steps?: number; + strength?: number; + guidance?: number; + seed?: number; +}; +type AiTextToImageOutput = ReadableStream; +declare abstract class BaseAiTextToImage { + inputs: AiTextToImageInput; + postProcessedOutputs: AiTextToImageOutput; +} +type AiTranslationInput = { + text: string; + target_lang: string; + source_lang?: string; +}; +type AiTranslationOutput = { + translated_text?: string; +}; +declare abstract class BaseAiTranslation { + inputs: AiTranslationInput; + postProcessedOutputs: AiTranslationOutput; +} +/** + * Workers AI support for OpenAI's Chat Completions API + */ +type ChatCompletionContentPartText = { + type: "text"; + text: string; +}; +type ChatCompletionContentPartImage = { + type: "image_url"; + image_url: { + url: string; + detail?: "auto" | "low" | "high"; + }; +}; +type ChatCompletionContentPartInputAudio = { + type: "input_audio"; + input_audio: { + /** Base64 encoded audio data. */ + data: string; + format: "wav" | "mp3"; + }; +}; +type ChatCompletionContentPartFile = { + type: "file"; + file: { + /** Base64 encoded file data. */ + file_data?: string; + /** The ID of an uploaded file. */ + file_id?: string; + filename?: string; + }; +}; +type ChatCompletionContentPartRefusal = { + type: "refusal"; + refusal: string; +}; +type ChatCompletionContentPart = ChatCompletionContentPartText | ChatCompletionContentPartImage | ChatCompletionContentPartInputAudio | ChatCompletionContentPartFile; +type FunctionDefinition = { + name: string; + description?: string; + parameters?: Record; + strict?: boolean | null; +}; +type ChatCompletionFunctionTool = { + type: "function"; + function: FunctionDefinition; +}; +type ChatCompletionCustomToolGrammarFormat = { + type: "grammar"; + grammar: { + definition: string; + syntax: "lark" | "regex"; + }; +}; +type ChatCompletionCustomToolTextFormat = { + type: "text"; +}; +type ChatCompletionCustomToolFormat = ChatCompletionCustomToolTextFormat | ChatCompletionCustomToolGrammarFormat; +type ChatCompletionCustomTool = { + type: "custom"; + custom: { + name: string; + description?: string; + format?: ChatCompletionCustomToolFormat; + }; +}; +type ChatCompletionTool = ChatCompletionFunctionTool | ChatCompletionCustomTool; +type ChatCompletionMessageFunctionToolCall = { + id: string; + type: "function"; + function: { + name: string; + /** JSON-encoded arguments string. */ + arguments: string; + }; +}; +type ChatCompletionMessageCustomToolCall = { + id: string; + type: "custom"; + custom: { + name: string; + input: string; + }; +}; +type ChatCompletionMessageToolCall = ChatCompletionMessageFunctionToolCall | ChatCompletionMessageCustomToolCall; +type ChatCompletionToolChoiceFunction = { + type: "function"; + function: { + name: string; + }; +}; +type ChatCompletionToolChoiceCustom = { + type: "custom"; + custom: { + name: string; + }; +}; +type ChatCompletionToolChoiceAllowedTools = { + type: "allowed_tools"; + allowed_tools: { + mode: "auto" | "required"; + tools: Array>; + }; +}; +type ChatCompletionToolChoiceOption = "none" | "auto" | "required" | ChatCompletionToolChoiceFunction | ChatCompletionToolChoiceCustom | ChatCompletionToolChoiceAllowedTools; +type DeveloperMessage = { + role: "developer"; + content: string | Array<{ + type: "text"; + text: string; + }>; + name?: string; +}; +type SystemMessage = { + role: "system"; + content: string | Array<{ + type: "text"; + text: string; + }>; + name?: string; +}; +/** + * Permissive merged content part used inside UserMessage arrays. + * + * Cabidela has a limitation where anyOf/oneOf with enum-based discrimination + * inside nested array items does not correctly match different branches for + * different array elements, so the schema uses a single merged object. + */ +type UserMessageContentPart = { + type: "text" | "image_url" | "input_audio" | "file"; + text?: string; + image_url?: { + url?: string; + detail?: "auto" | "low" | "high"; + }; + input_audio?: { + data?: string; + format?: "wav" | "mp3"; + }; + file?: { + file_data?: string; + file_id?: string; + filename?: string; + }; +}; +type UserMessage = { + role: "user"; + content: string | Array; + name?: string; +}; +type AssistantMessageContentPart = { + type: "text" | "refusal"; + text?: string; + refusal?: string; +}; +type AssistantMessage = { + role: "assistant"; + content?: string | null | Array; + refusal?: string | null; + name?: string; + audio?: { + id: string; + }; + tool_calls?: Array; + function_call?: { + name: string; + arguments: string; + }; +}; +type ToolMessage = { + role: "tool"; + content: string | Array<{ + type: "text"; + text: string; + }>; + tool_call_id: string; +}; +type FunctionMessage = { + role: "function"; + content: string; + name: string; +}; +type ChatCompletionMessageParam = DeveloperMessage | SystemMessage | UserMessage | AssistantMessage | ToolMessage | FunctionMessage; +type ChatCompletionsResponseFormatText = { + type: "text"; +}; +type ChatCompletionsResponseFormatJSONObject = { + type: "json_object"; +}; +type ResponseFormatJSONSchema = { + type: "json_schema"; + json_schema: { + name: string; + description?: string; + schema?: Record; + strict?: boolean | null; + }; +}; +type ResponseFormat = ChatCompletionsResponseFormatText | ChatCompletionsResponseFormatJSONObject | ResponseFormatJSONSchema; +type ChatCompletionsStreamOptions = { + include_usage?: boolean; + include_obfuscation?: boolean; +}; +type PredictionContent = { + type: "content"; + content: string | Array<{ + type: "text"; + text: string; + }>; +}; +type AudioParams = { + voice: string | { + id: string; + }; + format: "wav" | "aac" | "mp3" | "flac" | "opus" | "pcm16"; +}; +type WebSearchUserLocation = { + type: "approximate"; + approximate: { + city?: string; + country?: string; + region?: string; + timezone?: string; + }; +}; +type WebSearchOptions = { + search_context_size?: "low" | "medium" | "high"; + user_location?: WebSearchUserLocation; +}; +type ChatTemplateKwargs = { + /** Whether to enable reasoning, enabled by default. */ + enable_thinking?: boolean; + /** If false, preserves reasoning context between turns. */ + clear_thinking?: boolean; +}; +/** Shared optional properties used by both Prompt and Messages input branches. */ +type ChatCompletionsCommonOptions = { + model?: string; + audio?: AudioParams; + frequency_penalty?: number | null; + logit_bias?: Record | null; + logprobs?: boolean | null; + top_logprobs?: number | null; + max_tokens?: number | null; + max_completion_tokens?: number | null; + metadata?: Record | null; + modalities?: Array<"text" | "audio"> | null; + n?: number | null; + parallel_tool_calls?: boolean; + prediction?: PredictionContent; + presence_penalty?: number | null; + reasoning_effort?: "low" | "medium" | "high" | null; + chat_template_kwargs?: ChatTemplateKwargs; + response_format?: ResponseFormat; + seed?: number | null; + service_tier?: "auto" | "default" | "flex" | "scale" | "priority" | null; + stop?: string | Array | null; + store?: boolean | null; + stream?: boolean | null; + stream_options?: ChatCompletionsStreamOptions; + temperature?: number | null; + tool_choice?: ChatCompletionToolChoiceOption; + tools?: Array; + top_p?: number | null; + user?: string; + web_search_options?: WebSearchOptions; + function_call?: "none" | "auto" | { + name: string; + }; + functions?: Array; +}; +type PromptTokensDetails = { + cached_tokens?: number; + audio_tokens?: number; +}; +type CompletionTokensDetails = { + reasoning_tokens?: number; + audio_tokens?: number; + accepted_prediction_tokens?: number; + rejected_prediction_tokens?: number; +}; +type CompletionUsage = { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + prompt_tokens_details?: PromptTokensDetails; + completion_tokens_details?: CompletionTokensDetails; +}; +type ChatCompletionTopLogprob = { + token: string; + logprob: number; + bytes: Array | null; +}; +type ChatCompletionTokenLogprob = { + token: string; + logprob: number; + bytes: Array | null; + top_logprobs: Array; +}; +type ChatCompletionAudio = { + id: string; + /** Base64 encoded audio bytes. */ + data: string; + expires_at: number; + transcript: string; +}; +type ChatCompletionUrlCitation = { + type: "url_citation"; + url_citation: { + url: string; + title: string; + start_index: number; + end_index: number; + }; +}; +type ChatCompletionResponseMessage = { + role: "assistant"; + content: string | null; + refusal: string | null; + annotations?: Array; + audio?: ChatCompletionAudio; + tool_calls?: Array; + function_call?: { + name: string; + arguments: string; + } | null; +}; +type ChatCompletionLogprobs = { + content: Array | null; + refusal?: Array | null; +}; +type ChatCompletionChoice = { + index: number; + message: ChatCompletionResponseMessage; + finish_reason: "stop" | "length" | "tool_calls" | "content_filter" | "function_call"; + logprobs: ChatCompletionLogprobs | null; +}; +type ChatCompletionsMessagesInput = { + messages: Array; +} & ChatCompletionsCommonOptions; +type ChatCompletionsOutput = { + id: string; + object: string; + created: number; + model: string; + choices: Array; + usage?: CompletionUsage; + system_fingerprint?: string | null; + service_tier?: "auto" | "default" | "flex" | "scale" | "priority" | null; +}; +/** + * Workers AI support for OpenAI's Responses API + * Reference: https://github.com/openai/openai-node/blob/master/src/resources/responses/responses.ts + * + * It's a stripped down version from its source. + * It currently supports basic function calling, json mode and accepts images as input. + * + * It does not include types for WebSearch, CodeInterpreter, FileInputs, MCP, CustomTools. + * We plan to add those incrementally as model + platform capabilities evolve. + */ +type ResponsesInput = { + background?: boolean | null; + conversation?: string | ResponseConversationParam | null; + include?: Array | null; + input?: string | ResponseInput; + instructions?: string | null; + max_output_tokens?: number | null; + parallel_tool_calls?: boolean | null; + previous_response_id?: string | null; + prompt_cache_key?: string; + reasoning?: Reasoning | null; + safety_identifier?: string; + service_tier?: "auto" | "default" | "flex" | "scale" | "priority" | null; + stream?: boolean | null; + stream_options?: StreamOptions | null; + temperature?: number | null; + text?: ResponseTextConfig; + tool_choice?: ToolChoiceOptions | ToolChoiceFunction; + tools?: Array; + top_p?: number | null; + truncation?: "auto" | "disabled" | null; +}; +type ResponsesOutput = { + id?: string; + created_at?: number; + output_text?: string; + error?: ResponseError | null; + incomplete_details?: ResponseIncompleteDetails | null; + instructions?: string | Array | null; + object?: "response"; + output?: Array; + parallel_tool_calls?: boolean; + temperature?: number | null; + tool_choice?: ToolChoiceOptions | ToolChoiceFunction; + tools?: Array; + top_p?: number | null; + max_output_tokens?: number | null; + previous_response_id?: string | null; + prompt?: ResponsePrompt | null; + reasoning?: Reasoning | null; + safety_identifier?: string; + service_tier?: "auto" | "default" | "flex" | "scale" | "priority" | null; + status?: ResponseStatus; + text?: ResponseTextConfig; + truncation?: "auto" | "disabled" | null; + usage?: ResponseUsage; +}; +type EasyInputMessage = { + content: string | ResponseInputMessageContentList; + role: "user" | "assistant" | "system" | "developer"; + type?: "message"; +}; +type ResponsesFunctionTool = { + name: string; + parameters: { + [key: string]: unknown; + } | null; + strict: boolean | null; + type: "function"; + description?: string | null; +}; +type ResponseIncompleteDetails = { + reason?: "max_output_tokens" | "content_filter"; +}; +type ResponsePrompt = { + id: string; + variables?: { + [key: string]: string | ResponseInputText | ResponseInputImage; + } | null; + version?: string | null; +}; +type Reasoning = { + effort?: ReasoningEffort | null; + generate_summary?: "auto" | "concise" | "detailed" | null; + summary?: "auto" | "concise" | "detailed" | null; +}; +type ResponseContent = ResponseInputText | ResponseInputImage | ResponseOutputText | ResponseOutputRefusal | ResponseContentReasoningText; +type ResponseContentReasoningText = { + text: string; + type: "reasoning_text"; +}; +type ResponseConversationParam = { + id: string; +}; +type ResponseCreatedEvent = { + response: Response; + sequence_number: number; + type: "response.created"; +}; +type ResponseCustomToolCallOutput = { + call_id: string; + output: string | Array; + type: "custom_tool_call_output"; + id?: string; +}; +type ResponseError = { + code: "server_error" | "rate_limit_exceeded" | "invalid_prompt" | "vector_store_timeout" | "invalid_image" | "invalid_image_format" | "invalid_base64_image" | "invalid_image_url" | "image_too_large" | "image_too_small" | "image_parse_error" | "image_content_policy_violation" | "invalid_image_mode" | "image_file_too_large" | "unsupported_image_media_type" | "empty_image_file" | "failed_to_download_image" | "image_file_not_found"; + message: string; +}; +type ResponseErrorEvent = { + code: string | null; + message: string; + param: string | null; + sequence_number: number; + type: "error"; +}; +type ResponseFailedEvent = { + response: Response; + sequence_number: number; + type: "response.failed"; +}; +type ResponseFormatText = { + type: "text"; +}; +type ResponseFormatJSONObject = { + type: "json_object"; +}; +type ResponseFormatTextConfig = ResponseFormatText | ResponseFormatTextJSONSchemaConfig | ResponseFormatJSONObject; +type ResponseFormatTextJSONSchemaConfig = { + name: string; + schema: { + [key: string]: unknown; + }; + type: "json_schema"; + description?: string; + strict?: boolean | null; +}; +type ResponseFunctionCallArgumentsDeltaEvent = { + delta: string; + item_id: string; + output_index: number; + sequence_number: number; + type: "response.function_call_arguments.delta"; +}; +type ResponseFunctionCallArgumentsDoneEvent = { + arguments: string; + item_id: string; + name: string; + output_index: number; + sequence_number: number; + type: "response.function_call_arguments.done"; +}; +type ResponseFunctionCallOutputItem = ResponseInputTextContent | ResponseInputImageContent; +type ResponseFunctionCallOutputItemList = Array; +type ResponseFunctionToolCall = { + arguments: string; + call_id: string; + name: string; + type: "function_call"; + id?: string; + status?: "in_progress" | "completed" | "incomplete"; +}; +interface ResponseFunctionToolCallItem extends ResponseFunctionToolCall { + id: string; +} +type ResponseFunctionToolCallOutputItem = { + id: string; + call_id: string; + output: string | Array; + type: "function_call_output"; + status?: "in_progress" | "completed" | "incomplete"; +}; +type ResponseIncludable = "message.input_image.image_url" | "message.output_text.logprobs"; +type ResponseIncompleteEvent = { + response: Response; + sequence_number: number; + type: "response.incomplete"; +}; +type ResponseInput = Array; +type ResponseInputContent = ResponseInputText | ResponseInputImage; +type ResponseInputImage = { + detail: "low" | "high" | "auto"; + type: "input_image"; + /** + * Base64 encoded image + */ + image_url?: string | null; +}; +type ResponseInputImageContent = { + type: "input_image"; + detail?: "low" | "high" | "auto" | null; + /** + * Base64 encoded image + */ + image_url?: string | null; +}; +type ResponseInputItem = EasyInputMessage | ResponseInputItemMessage | ResponseOutputMessage | ResponseFunctionToolCall | ResponseInputItemFunctionCallOutput | ResponseReasoningItem; +type ResponseInputItemFunctionCallOutput = { + call_id: string; + output: string | ResponseFunctionCallOutputItemList; + type: "function_call_output"; + id?: string | null; + status?: "in_progress" | "completed" | "incomplete" | null; +}; +type ResponseInputItemMessage = { + content: ResponseInputMessageContentList; + role: "user" | "system" | "developer"; + status?: "in_progress" | "completed" | "incomplete"; + type?: "message"; +}; +type ResponseInputMessageContentList = Array; +type ResponseInputMessageItem = { + id: string; + content: ResponseInputMessageContentList; + role: "user" | "system" | "developer"; + status?: "in_progress" | "completed" | "incomplete"; + type?: "message"; +}; +type ResponseInputText = { + text: string; + type: "input_text"; +}; +type ResponseInputTextContent = { + text: string; + type: "input_text"; +}; +type ResponseItem = ResponseInputMessageItem | ResponseOutputMessage | ResponseFunctionToolCallItem | ResponseFunctionToolCallOutputItem; +type ResponseOutputItem = ResponseOutputMessage | ResponseFunctionToolCall | ResponseReasoningItem; +type ResponseOutputItemAddedEvent = { + item: ResponseOutputItem; + output_index: number; + sequence_number: number; + type: "response.output_item.added"; +}; +type ResponseOutputItemDoneEvent = { + item: ResponseOutputItem; + output_index: number; + sequence_number: number; + type: "response.output_item.done"; +}; +type ResponseOutputMessage = { + id: string; + content: Array; + role: "assistant"; + status: "in_progress" | "completed" | "incomplete"; + type: "message"; +}; +type ResponseOutputRefusal = { + refusal: string; + type: "refusal"; +}; +type ResponseOutputText = { + text: string; + type: "output_text"; + logprobs?: Array; +}; +type ResponseReasoningItem = { + id: string; + summary: Array; + type: "reasoning"; + content?: Array; + encrypted_content?: string | null; + status?: "in_progress" | "completed" | "incomplete"; +}; +type ResponseReasoningSummaryItem = { + text: string; + type: "summary_text"; +}; +type ResponseReasoningContentItem = { + text: string; + type: "reasoning_text"; +}; +type ResponseReasoningTextDeltaEvent = { + content_index: number; + delta: string; + item_id: string; + output_index: number; + sequence_number: number; + type: "response.reasoning_text.delta"; +}; +type ResponseReasoningTextDoneEvent = { + content_index: number; + item_id: string; + output_index: number; + sequence_number: number; + text: string; + type: "response.reasoning_text.done"; +}; +type ResponseRefusalDeltaEvent = { + content_index: number; + delta: string; + item_id: string; + output_index: number; + sequence_number: number; + type: "response.refusal.delta"; +}; +type ResponseRefusalDoneEvent = { + content_index: number; + item_id: string; + output_index: number; + refusal: string; + sequence_number: number; + type: "response.refusal.done"; +}; +type ResponseStatus = "completed" | "failed" | "in_progress" | "cancelled" | "queued" | "incomplete"; +type ResponseStreamEvent = ResponseCompletedEvent | ResponseCreatedEvent | ResponseErrorEvent | ResponseFunctionCallArgumentsDeltaEvent | ResponseFunctionCallArgumentsDoneEvent | ResponseFailedEvent | ResponseIncompleteEvent | ResponseOutputItemAddedEvent | ResponseOutputItemDoneEvent | ResponseReasoningTextDeltaEvent | ResponseReasoningTextDoneEvent | ResponseRefusalDeltaEvent | ResponseRefusalDoneEvent | ResponseTextDeltaEvent | ResponseTextDoneEvent; +type ResponseCompletedEvent = { + response: Response; + sequence_number: number; + type: "response.completed"; +}; +type ResponseTextConfig = { + format?: ResponseFormatTextConfig; + verbosity?: "low" | "medium" | "high" | null; +}; +type ResponseTextDeltaEvent = { + content_index: number; + delta: string; + item_id: string; + logprobs: Array; + output_index: number; + sequence_number: number; + type: "response.output_text.delta"; +}; +type ResponseTextDoneEvent = { + content_index: number; + item_id: string; + logprobs: Array; + output_index: number; + sequence_number: number; + text: string; + type: "response.output_text.done"; +}; +type Logprob = { + token: string; + logprob: number; + top_logprobs?: Array; +}; +type TopLogprob = { + token?: string; + logprob?: number; +}; +type ResponseUsage = { + input_tokens: number; + output_tokens: number; + total_tokens: number; +}; +type Tool = ResponsesFunctionTool; +type ToolChoiceFunction = { + name: string; + type: "function"; +}; +type ToolChoiceOptions = "none"; +type ReasoningEffort = "minimal" | "low" | "medium" | "high" | null; +type StreamOptions = { + include_obfuscation?: boolean; +}; +/** Marks keys from T that aren't in U as optional never */ +type Without = { + [P in Exclude]?: never; +}; +/** Either T or U, but not both (mutually exclusive) */ +type XOR = (T & Without) | (U & Without); +type Ai_Cf_Baai_Bge_Base_En_V1_5_Input = { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: "mean" | "cls"; +} | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: "mean" | "cls"; + }[]; +}; +type Ai_Cf_Baai_Bge_Base_En_V1_5_Output = { + shape?: number[]; + /** + * Embeddings of the requested text values + */ + data?: number[][]; + /** + * The pooling method used in the embedding process. + */ + pooling?: "mean" | "cls"; +} | Ai_Cf_Baai_Bge_Base_En_V1_5_AsyncResponse; +interface Ai_Cf_Baai_Bge_Base_En_V1_5_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Baai_Bge_Base_En_V1_5 { + inputs: Ai_Cf_Baai_Bge_Base_En_V1_5_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_Base_En_V1_5_Output; +} +type Ai_Cf_Openai_Whisper_Input = string | { + /** + * An array of integers that represent the audio data constrained to 8-bit unsigned integer values + */ + audio: number[]; +}; +interface Ai_Cf_Openai_Whisper_Output { + /** + * The transcription + */ + text: string; + word_count?: number; + words?: { + word?: string; + /** + * The second this word begins in the recording + */ + start?: number; + /** + * The ending second when the word completes + */ + end?: number; + }[]; + vtt?: string; +} +declare abstract class Base_Ai_Cf_Openai_Whisper { + inputs: Ai_Cf_Openai_Whisper_Input; + postProcessedOutputs: Ai_Cf_Openai_Whisper_Output; +} +type Ai_Cf_Meta_M2M100_1_2B_Input = { + /** + * The text to be translated + */ + text: string; + /** + * The language code of the source text (e.g., 'en' for English). Defaults to 'en' if not specified + */ + source_lang?: string; + /** + * The language code to translate the text into (e.g., 'es' for Spanish) + */ + target_lang: string; +} | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: { + /** + * The text to be translated + */ + text: string; + /** + * The language code of the source text (e.g., 'en' for English). Defaults to 'en' if not specified + */ + source_lang?: string; + /** + * The language code to translate the text into (e.g., 'es' for Spanish) + */ + target_lang: string; + }[]; +}; +type Ai_Cf_Meta_M2M100_1_2B_Output = { + /** + * The translated text in the target language + */ + translated_text?: string; +} | Ai_Cf_Meta_M2M100_1_2B_AsyncResponse; +interface Ai_Cf_Meta_M2M100_1_2B_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Meta_M2M100_1_2B { + inputs: Ai_Cf_Meta_M2M100_1_2B_Input; + postProcessedOutputs: Ai_Cf_Meta_M2M100_1_2B_Output; +} +type Ai_Cf_Baai_Bge_Small_En_V1_5_Input = { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: "mean" | "cls"; +} | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: "mean" | "cls"; + }[]; +}; +type Ai_Cf_Baai_Bge_Small_En_V1_5_Output = { + shape?: number[]; + /** + * Embeddings of the requested text values + */ + data?: number[][]; + /** + * The pooling method used in the embedding process. + */ + pooling?: "mean" | "cls"; +} | Ai_Cf_Baai_Bge_Small_En_V1_5_AsyncResponse; +interface Ai_Cf_Baai_Bge_Small_En_V1_5_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Baai_Bge_Small_En_V1_5 { + inputs: Ai_Cf_Baai_Bge_Small_En_V1_5_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_Small_En_V1_5_Output; +} +type Ai_Cf_Baai_Bge_Large_En_V1_5_Input = { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: "mean" | "cls"; +} | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: "mean" | "cls"; + }[]; +}; +type Ai_Cf_Baai_Bge_Large_En_V1_5_Output = { + shape?: number[]; + /** + * Embeddings of the requested text values + */ + data?: number[][]; + /** + * The pooling method used in the embedding process. + */ + pooling?: "mean" | "cls"; +} | Ai_Cf_Baai_Bge_Large_En_V1_5_AsyncResponse; +interface Ai_Cf_Baai_Bge_Large_En_V1_5_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Baai_Bge_Large_En_V1_5 { + inputs: Ai_Cf_Baai_Bge_Large_En_V1_5_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_Large_En_V1_5_Output; +} +type Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Input = string | { + /** + * The input text prompt for the model to generate a response. + */ + prompt?: string; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * Controls the creativity of the AI's responses by adjusting how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; + image: number[] | (string & NonNullable); + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; +}; +interface Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Output { + description?: string; +} +declare abstract class Base_Ai_Cf_Unum_Uform_Gen2_Qwen_500M { + inputs: Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Input; + postProcessedOutputs: Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Output; +} +type Ai_Cf_Openai_Whisper_Tiny_En_Input = string | { + /** + * An array of integers that represent the audio data constrained to 8-bit unsigned integer values + */ + audio: number[]; +}; +interface Ai_Cf_Openai_Whisper_Tiny_En_Output { + /** + * The transcription + */ + text: string; + word_count?: number; + words?: { + word?: string; + /** + * The second this word begins in the recording + */ + start?: number; + /** + * The ending second when the word completes + */ + end?: number; + }[]; + vtt?: string; +} +declare abstract class Base_Ai_Cf_Openai_Whisper_Tiny_En { + inputs: Ai_Cf_Openai_Whisper_Tiny_En_Input; + postProcessedOutputs: Ai_Cf_Openai_Whisper_Tiny_En_Output; +} +interface Ai_Cf_Openai_Whisper_Large_V3_Turbo_Input { + audio: string | { + body?: object; + contentType?: string; + }; + /** + * Supported tasks are 'translate' or 'transcribe'. + */ + task?: string; + /** + * The language of the audio being transcribed or translated. + */ + language?: string; + /** + * Preprocess the audio with a voice activity detection model. + */ + vad_filter?: boolean; + /** + * A text prompt to help provide context to the model on the contents of the audio. + */ + initial_prompt?: string; + /** + * The prefix appended to the beginning of the output of the transcription and can guide the transcription result. + */ + prefix?: string; + /** + * The number of beams to use in beam search decoding. Higher values may improve accuracy at the cost of speed. + */ + beam_size?: number; + /** + * Whether to condition on previous text during transcription. Setting to false may help prevent hallucination loops. + */ + condition_on_previous_text?: boolean; + /** + * Threshold for detecting no-speech segments. Segments with no-speech probability above this value are skipped. + */ + no_speech_threshold?: number; + /** + * Threshold for filtering out segments with high compression ratio, which often indicate repetitive or hallucinated text. + */ + compression_ratio_threshold?: number; + /** + * Threshold for filtering out segments with low average log probability, indicating low confidence. + */ + log_prob_threshold?: number; + /** + * Optional threshold (in seconds) to skip silent periods that may cause hallucinations. + */ + hallucination_silence_threshold?: number; +} +interface Ai_Cf_Openai_Whisper_Large_V3_Turbo_Output { + transcription_info?: { + /** + * The language of the audio being transcribed or translated. + */ + language?: string; + /** + * The confidence level or probability of the detected language being accurate, represented as a decimal between 0 and 1. + */ + language_probability?: number; + /** + * The total duration of the original audio file, in seconds. + */ + duration?: number; + /** + * The duration of the audio after applying Voice Activity Detection (VAD) to remove silent or irrelevant sections, in seconds. + */ + duration_after_vad?: number; + }; + /** + * The complete transcription of the audio. + */ + text: string; + /** + * The total number of words in the transcription. + */ + word_count?: number; + segments?: { + /** + * The starting time of the segment within the audio, in seconds. + */ + start?: number; + /** + * The ending time of the segment within the audio, in seconds. + */ + end?: number; + /** + * The transcription of the segment. + */ + text?: string; + /** + * The temperature used in the decoding process, controlling randomness in predictions. Lower values result in more deterministic outputs. + */ + temperature?: number; + /** + * The average log probability of the predictions for the words in this segment, indicating overall confidence. + */ + avg_logprob?: number; + /** + * The compression ratio of the input to the output, measuring how much the text was compressed during the transcription process. + */ + compression_ratio?: number; + /** + * The probability that the segment contains no speech, represented as a decimal between 0 and 1. + */ + no_speech_prob?: number; + words?: { + /** + * The individual word transcribed from the audio. + */ + word?: string; + /** + * The starting time of the word within the audio, in seconds. + */ + start?: number; + /** + * The ending time of the word within the audio, in seconds. + */ + end?: number; + }[]; + }[]; + /** + * The transcription in WebVTT format, which includes timing and text information for use in subtitles. + */ + vtt?: string; +} +declare abstract class Base_Ai_Cf_Openai_Whisper_Large_V3_Turbo { + inputs: Ai_Cf_Openai_Whisper_Large_V3_Turbo_Input; + postProcessedOutputs: Ai_Cf_Openai_Whisper_Large_V3_Turbo_Output; +} +type Ai_Cf_Baai_Bge_M3_Input = Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts | Ai_Cf_Baai_Bge_M3_Input_Embedding | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: (Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts_1 | Ai_Cf_Baai_Bge_M3_Input_Embedding_1)[]; +}; +interface Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts { + /** + * A query you wish to perform against the provided contexts. If no query is provided the model with respond with embeddings for contexts + */ + query?: string; + /** + * List of provided contexts. Note that the index in this array is important, as the response will refer to it. + */ + contexts: { + /** + * One of the provided context content + */ + text?: string; + }[]; + /** + * When provided with too long context should the model error out or truncate the context to fit? + */ + truncate_inputs?: boolean; +} +interface Ai_Cf_Baai_Bge_M3_Input_Embedding { + text: string | string[]; + /** + * When provided with too long context should the model error out or truncate the context to fit? + */ + truncate_inputs?: boolean; +} +interface Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts_1 { + /** + * A query you wish to perform against the provided contexts. If no query is provided the model with respond with embeddings for contexts + */ + query?: string; + /** + * List of provided contexts. Note that the index in this array is important, as the response will refer to it. + */ + contexts: { + /** + * One of the provided context content + */ + text?: string; + }[]; + /** + * When provided with too long context should the model error out or truncate the context to fit? + */ + truncate_inputs?: boolean; +} +interface Ai_Cf_Baai_Bge_M3_Input_Embedding_1 { + text: string | string[]; + /** + * When provided with too long context should the model error out or truncate the context to fit? + */ + truncate_inputs?: boolean; +} +type Ai_Cf_Baai_Bge_M3_Output = Ai_Cf_Baai_Bge_M3_Output_Query | Ai_Cf_Baai_Bge_M3_Output_EmbeddingFor_Contexts | Ai_Cf_Baai_Bge_M3_Output_Embedding | Ai_Cf_Baai_Bge_M3_AsyncResponse; +interface Ai_Cf_Baai_Bge_M3_Output_Query { + response?: { + /** + * Index of the context in the request + */ + id?: number; + /** + * Score of the context under the index. + */ + score?: number; + }[]; +} +interface Ai_Cf_Baai_Bge_M3_Output_EmbeddingFor_Contexts { + response?: number[][]; + shape?: number[]; + /** + * The pooling method used in the embedding process. + */ + pooling?: "mean" | "cls"; +} +interface Ai_Cf_Baai_Bge_M3_Output_Embedding { + shape?: number[]; + /** + * Embeddings of the requested text values + */ + data?: number[][]; + /** + * The pooling method used in the embedding process. + */ + pooling?: "mean" | "cls"; +} +interface Ai_Cf_Baai_Bge_M3_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Baai_Bge_M3 { + inputs: Ai_Cf_Baai_Bge_M3_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_M3_Output; +} +interface Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Input { + /** + * A text description of the image you want to generate. + */ + prompt: string; + /** + * The number of diffusion steps; higher values can improve quality but take longer. + */ + steps?: number; +} +interface Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Output { + /** + * The generated image in Base64 format. + */ + image?: string; +} +declare abstract class Base_Ai_Cf_Black_Forest_Labs_Flux_1_Schnell { + inputs: Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Input; + postProcessedOutputs: Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Output; +} +type Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Input = Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Prompt | Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Messages; +interface Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + image?: number[] | (string & NonNullable); + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; +} +interface Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: string | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + image?: number[] | (string & NonNullable); + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + /** + * If true, the response will be streamed back incrementally. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Controls the creativity of the AI's responses by adjusting how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Output = { + /** + * The generated text response from the model + */ + response?: string; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct { + inputs: Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Input; + postProcessedOutputs: Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Output; +} +type Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Input = Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Prompt | Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Messages | Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Async_Batch; +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + content: string | { + /** + * Type of the content (text) + */ + type?: string; + /** + * Text content + */ + text?: string; + }[]; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_1; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_1 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Async_Batch { + requests?: { + /** + * User-supplied reference. This field will be present in the response as well it can be used to reference the request and response. It's NOT validated to be unique. + */ + external_reference?: string; + /** + * Prompt for the text generation model + */ + prompt?: string; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; + response_format?: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_2; + }[]; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_2 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +type Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +} | string | Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_AsyncResponse; +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast { + inputs: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Input; + postProcessedOutputs: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Output; +} +interface Ai_Cf_Meta_Llama_Guard_3_8B_Input { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender must alternate between 'user' and 'assistant'. + */ + role: "user" | "assistant"; + /** + * The content of the message as a string. + */ + content: string; + }[]; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Dictate the output format of the generated response. + */ + response_format?: { + /** + * Set to json_object to process and output generated text as JSON. + */ + type?: string; + }; +} +interface Ai_Cf_Meta_Llama_Guard_3_8B_Output { + response?: string | { + /** + * Whether the conversation is safe or not. + */ + safe?: boolean; + /** + * A list of what hazard categories predicted for the conversation, if the conversation is deemed unsafe. + */ + categories?: string[]; + }; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; +} +declare abstract class Base_Ai_Cf_Meta_Llama_Guard_3_8B { + inputs: Ai_Cf_Meta_Llama_Guard_3_8B_Input; + postProcessedOutputs: Ai_Cf_Meta_Llama_Guard_3_8B_Output; +} +interface Ai_Cf_Baai_Bge_Reranker_Base_Input { + /** + * A query you wish to perform against the provided contexts. + */ + /** + * Number of returned results starting with the best score. + */ + top_k?: number; + /** + * List of provided contexts. Note that the index in this array is important, as the response will refer to it. + */ + contexts: { + /** + * One of the provided context content + */ + text?: string; + }[]; +} +interface Ai_Cf_Baai_Bge_Reranker_Base_Output { + response?: { + /** + * Index of the context in the request + */ + id?: number; + /** + * Score of the context under the index. + */ + score?: number; + }[]; +} +declare abstract class Base_Ai_Cf_Baai_Bge_Reranker_Base { + inputs: Ai_Cf_Baai_Bge_Reranker_Base_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_Reranker_Base_Output; +} +type Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Input = Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Prompt | Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Messages; +interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + /** + * The content of the message as a string. + */ + content: string; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode_1; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode_1 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +type Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct { + inputs: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Input; + postProcessedOutputs: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Output; +} +type Ai_Cf_Qwen_Qwq_32B_Input = Ai_Cf_Qwen_Qwq_32B_Prompt | Ai_Cf_Qwen_Qwq_32B_Messages; +interface Ai_Cf_Qwen_Qwq_32B_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwq_32B_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: string | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + /** + * JSON schema that should be fufilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Qwen_Qwq_32B_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Qwen_Qwq_32B { + inputs: Ai_Cf_Qwen_Qwq_32B_Input; + postProcessedOutputs: Ai_Cf_Qwen_Qwq_32B_Output; +} +type Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Input = Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Prompt | Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Messages; +interface Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. Must be supplied for tool calls for Mistral-3. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: string | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + /** + * JSON schema that should be fufilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct { + inputs: Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Input; + postProcessedOutputs: Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Output; +} +type Ai_Cf_Google_Gemma_3_12B_It_Input = Ai_Cf_Google_Gemma_3_12B_It_Prompt | Ai_Cf_Google_Gemma_3_12B_It_Messages; +interface Ai_Cf_Google_Gemma_3_12B_It_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fufilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Google_Gemma_3_12B_It_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + content?: string | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[]; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + /** + * JSON schema that should be fufilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Google_Gemma_3_12B_It_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Google_Gemma_3_12B_It { + inputs: Ai_Cf_Google_Gemma_3_12B_It_Input; + postProcessedOutputs: Ai_Cf_Google_Gemma_3_12B_It_Output; +} +type Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Input = Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt | Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages | Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Async_Batch; +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: string | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; + /** + * JSON schema that should be fufilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Async_Batch { + requests: (Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt_Inner | Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages_Inner)[]; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt_Inner { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages_Inner { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: string | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; + /** + * JSON schema that should be fufilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The tool call id. + */ + id?: string; + /** + * Specifies the type of tool (e.g., 'function'). + */ + type?: string; + /** + * Details of the function tool. + */ + function?: { + /** + * The name of the tool to be called + */ + name?: string; + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + }; + }[]; +}; +declare abstract class Base_Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct { + inputs: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Input; + postProcessedOutputs: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Output; +} +type Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Input = Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Async_Batch; +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + content: string | { + /** + * Type of the content (text) + */ + type?: string; + /** + * Text content + */ + text?: string; + }[]; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_1; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_1 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Async_Batch { + requests: (Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt_1 | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages_1)[]; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt_1 { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_2; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_2 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages_1 { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + content: string | { + /** + * Type of the content (text) + */ + type?: string; + /** + * Text content + */ + text?: string; + }[]; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_3; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_3 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +type Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Output = Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Chat_Completion_Response | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Text_Completion_Response | string | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_AsyncResponse; +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Chat_Completion_Response { + /** + * Unique identifier for the completion + */ + id?: string; + /** + * Object type identifier + */ + object?: "chat.completion"; + /** + * Unix timestamp of when the completion was created + */ + created?: number; + /** + * Model used for the completion + */ + model?: string; + /** + * List of completion choices + */ + choices?: { + /** + * Index of the choice in the list + */ + index?: number; + /** + * The message generated by the model + */ + message?: { + /** + * Role of the message author + */ + role: string; + /** + * The content of the message + */ + content: string; + /** + * Internal reasoning content (if available) + */ + reasoning_content?: string; + /** + * Tool calls made by the assistant + */ + tool_calls?: { + /** + * Unique identifier for the tool call + */ + id: string; + /** + * Type of tool call + */ + type: "function"; + function: { + /** + * Name of the function to call + */ + name: string; + /** + * JSON string of arguments for the function + */ + arguments: string; + }; + }[]; + }; + /** + * Reason why the model stopped generating + */ + finish_reason?: string; + /** + * Stop reason (may be null) + */ + stop_reason?: string | null; + /** + * Log probabilities (if requested) + */ + logprobs?: {} | null; + }[]; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * Log probabilities for the prompt (if requested) + */ + prompt_logprobs?: {} | null; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Text_Completion_Response { + /** + * Unique identifier for the completion + */ + id?: string; + /** + * Object type identifier + */ + object?: "text_completion"; + /** + * Unix timestamp of when the completion was created + */ + created?: number; + /** + * Model used for the completion + */ + model?: string; + /** + * List of completion choices + */ + choices?: { + /** + * Index of the choice in the list + */ + index: number; + /** + * The generated text completion + */ + text: string; + /** + * Reason why the model stopped generating + */ + finish_reason: string; + /** + * Stop reason (may be null) + */ + stop_reason?: string | null; + /** + * Log probabilities (if requested) + */ + logprobs?: {} | null; + /** + * Log probabilities for the prompt (if requested) + */ + prompt_logprobs?: {} | null; + }[]; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8 { + inputs: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Input; + postProcessedOutputs: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Output; +} +interface Ai_Cf_Deepgram_Nova_3_Input { + audio: { + body: object; + contentType: string; + }; + /** + * Sets how the model will interpret strings submitted to the custom_topic param. When strict, the model will only return topics submitted using the custom_topic param. When extended, the model will return its own detected topics in addition to those submitted using the custom_topic param. + */ + custom_topic_mode?: "extended" | "strict"; + /** + * Custom topics you want the model to detect within your input audio or text if present Submit up to 100 + */ + custom_topic?: string; + /** + * Sets how the model will interpret intents submitted to the custom_intent param. When strict, the model will only return intents submitted using the custom_intent param. When extended, the model will return its own detected intents in addition those submitted using the custom_intents param + */ + custom_intent_mode?: "extended" | "strict"; + /** + * Custom intents you want the model to detect within your input audio if present + */ + custom_intent?: string; + /** + * Identifies and extracts key entities from content in submitted audio + */ + detect_entities?: boolean; + /** + * Identifies the dominant language spoken in submitted audio + */ + detect_language?: boolean; + /** + * Recognize speaker changes. Each word in the transcript will be assigned a speaker number starting at 0 + */ + diarize?: boolean; + /** + * Identify and extract key entities from content in submitted audio + */ + dictation?: boolean; + /** + * Specify the expected encoding of your submitted audio + */ + encoding?: "linear16" | "flac" | "mulaw" | "amr-nb" | "amr-wb" | "opus" | "speex" | "g729"; + /** + * Arbitrary key-value pairs that are attached to the API response for usage in downstream processing + */ + extra?: string; + /** + * Filler Words can help transcribe interruptions in your audio, like 'uh' and 'um' + */ + filler_words?: boolean; + /** + * Key term prompting can boost or suppress specialized terminology and brands. + */ + keyterm?: string; + /** + * Keywords can boost or suppress specialized terminology and brands. + */ + keywords?: string; + /** + * The BCP-47 language tag that hints at the primary spoken language. Depending on the Model and API endpoint you choose only certain languages are available. + */ + language?: string; + /** + * Spoken measurements will be converted to their corresponding abbreviations. + */ + measurements?: boolean; + /** + * Opts out requests from the Deepgram Model Improvement Program. Refer to our Docs for pricing impacts before setting this to true. https://dpgr.am/deepgram-mip. + */ + mip_opt_out?: boolean; + /** + * Mode of operation for the model representing broad area of topic that will be talked about in the supplied audio + */ + mode?: "general" | "medical" | "finance"; + /** + * Transcribe each audio channel independently. + */ + multichannel?: boolean; + /** + * Numerals converts numbers from written format to numerical format. + */ + numerals?: boolean; + /** + * Splits audio into paragraphs to improve transcript readability. + */ + paragraphs?: boolean; + /** + * Profanity Filter looks for recognized profanity and converts it to the nearest recognized non-profane word or removes it from the transcript completely. + */ + profanity_filter?: boolean; + /** + * Add punctuation and capitalization to the transcript. + */ + punctuate?: boolean; + /** + * Redaction removes sensitive information from your transcripts. + */ + redact?: string; + /** + * Search for terms or phrases in submitted audio and replaces them. + */ + replace?: string; + /** + * Search for terms or phrases in submitted audio. + */ + search?: string; + /** + * Recognizes the sentiment throughout a transcript or text. + */ + sentiment?: boolean; + /** + * Apply formatting to transcript output. When set to true, additional formatting will be applied to transcripts to improve readability. + */ + smart_format?: boolean; + /** + * Detect topics throughout a transcript or text. + */ + topics?: boolean; + /** + * Segments speech into meaningful semantic units. + */ + utterances?: boolean; + /** + * Seconds to wait before detecting a pause between words in submitted audio. + */ + utt_split?: number; + /** + * The number of channels in the submitted audio + */ + channels?: number; + /** + * Specifies whether the streaming endpoint should provide ongoing transcription updates as more audio is received. When set to true, the endpoint sends continuous updates, meaning transcription results may evolve over time. Note: Supported only for webosockets. + */ + interim_results?: boolean; + /** + * Indicates how long model will wait to detect whether a speaker has finished speaking or pauses for a significant period of time. When set to a value, the streaming endpoint immediately finalizes the transcription for the processed time range and returns the transcript with a speech_final parameter set to true. Can also be set to false to disable endpointing + */ + endpointing?: string; + /** + * Indicates that speech has started. You'll begin receiving Speech Started messages upon speech starting. Note: Supported only for webosockets. + */ + vad_events?: boolean; + /** + * Indicates how long model will wait to send an UtteranceEnd message after a word has been transcribed. Use with interim_results. Note: Supported only for webosockets. + */ + utterance_end_ms?: boolean; +} +interface Ai_Cf_Deepgram_Nova_3_Output { + results?: { + channels?: { + alternatives?: { + confidence?: number; + transcript?: string; + words?: { + confidence?: number; + end?: number; + start?: number; + word?: string; + }[]; + }[]; + }[]; + summary?: { + result?: string; + short?: string; + }; + sentiments?: { + segments?: { + text?: string; + start_word?: number; + end_word?: number; + sentiment?: string; + sentiment_score?: number; + }[]; + average?: { + sentiment?: string; + sentiment_score?: number; + }; + }; + }; +} +declare abstract class Base_Ai_Cf_Deepgram_Nova_3 { + inputs: Ai_Cf_Deepgram_Nova_3_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Nova_3_Output; +} +interface Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Input { + queries?: string | string[]; + /** + * Optional instruction for the task + */ + instruction?: string; + documents?: string | string[]; + text?: string | string[]; +} +interface Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Output { + data?: number[][]; + shape?: number[]; +} +declare abstract class Base_Ai_Cf_Qwen_Qwen3_Embedding_0_6B { + inputs: Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Input; + postProcessedOutputs: Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Output; +} +type Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Input = { + /** + * readable stream with audio data and content-type specified for that data + */ + audio: { + body: object; + contentType: string; + }; + /** + * type of data PCM data that's sent to the inference server as raw array + */ + dtype?: "uint8" | "float32" | "float64"; +} | { + /** + * base64 encoded audio data + */ + audio: string; + /** + * type of data PCM data that's sent to the inference server as raw array + */ + dtype?: "uint8" | "float32" | "float64"; +}; +interface Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Output { + /** + * if true, end-of-turn was detected + */ + is_complete?: boolean; + /** + * probability of the end-of-turn detection + */ + probability?: number; +} +declare abstract class Base_Ai_Cf_Pipecat_Ai_Smart_Turn_V2 { + inputs: Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Input; + postProcessedOutputs: Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Output; +} +declare abstract class Base_Ai_Cf_Openai_Gpt_Oss_120B { + inputs: XOR; + postProcessedOutputs: XOR; +} +declare abstract class Base_Ai_Cf_Openai_Gpt_Oss_20B { + inputs: XOR; + postProcessedOutputs: XOR; +} +interface Ai_Cf_Leonardo_Phoenix_1_0_Input { + /** + * A text description of the image you want to generate. + */ + prompt: string; + /** + * Controls how closely the generated image should adhere to the prompt; higher values make the image more aligned with the prompt + */ + guidance?: number; + /** + * Random seed for reproducibility of the image generation + */ + seed?: number; + /** + * The height of the generated image in pixels + */ + height?: number; + /** + * The width of the generated image in pixels + */ + width?: number; + /** + * The number of diffusion steps; higher values can improve quality but take longer + */ + num_steps?: number; + /** + * Specify what to exclude from the generated images + */ + negative_prompt?: string; +} +/** + * The generated image in JPEG format + */ +type Ai_Cf_Leonardo_Phoenix_1_0_Output = string; +declare abstract class Base_Ai_Cf_Leonardo_Phoenix_1_0 { + inputs: Ai_Cf_Leonardo_Phoenix_1_0_Input; + postProcessedOutputs: Ai_Cf_Leonardo_Phoenix_1_0_Output; +} +interface Ai_Cf_Leonardo_Lucid_Origin_Input { + /** + * A text description of the image you want to generate. + */ + prompt: string; + /** + * Controls how closely the generated image should adhere to the prompt; higher values make the image more aligned with the prompt + */ + guidance?: number; + /** + * Random seed for reproducibility of the image generation + */ + seed?: number; + /** + * The height of the generated image in pixels + */ + height?: number; + /** + * The width of the generated image in pixels + */ + width?: number; + /** + * The number of diffusion steps; higher values can improve quality but take longer + */ + num_steps?: number; + /** + * The number of diffusion steps; higher values can improve quality but take longer + */ + steps?: number; +} +interface Ai_Cf_Leonardo_Lucid_Origin_Output { + /** + * The generated image in Base64 format. + */ + image?: string; +} +declare abstract class Base_Ai_Cf_Leonardo_Lucid_Origin { + inputs: Ai_Cf_Leonardo_Lucid_Origin_Input; + postProcessedOutputs: Ai_Cf_Leonardo_Lucid_Origin_Output; +} +interface Ai_Cf_Deepgram_Aura_1_Input { + /** + * Speaker used to produce the audio. + */ + speaker?: "angus" | "asteria" | "arcas" | "orion" | "orpheus" | "athena" | "luna" | "zeus" | "perseus" | "helios" | "hera" | "stella"; + /** + * Encoding of the output audio. + */ + encoding?: "linear16" | "flac" | "mulaw" | "alaw" | "mp3" | "opus" | "aac"; + /** + * Container specifies the file format wrapper for the output audio. The available options depend on the encoding type.. + */ + container?: "none" | "wav" | "ogg"; + /** + * The text content to be converted to speech + */ + text: string; + /** + * Sample Rate specifies the sample rate for the output audio. Based on the encoding, different sample rates are supported. For some encodings, the sample rate is not configurable + */ + sample_rate?: number; + /** + * The bitrate of the audio in bits per second. Choose from predefined ranges or specific values based on the encoding type. + */ + bit_rate?: number; +} +/** + * The generated audio in MP3 format + */ +type Ai_Cf_Deepgram_Aura_1_Output = string; +declare abstract class Base_Ai_Cf_Deepgram_Aura_1 { + inputs: Ai_Cf_Deepgram_Aura_1_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Aura_1_Output; +} +interface Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Input { + /** + * Input text to translate. Can be a single string or a list of strings. + */ + text: string | string[]; + /** + * Target langauge to translate to + */ + target_language: "asm_Beng" | "awa_Deva" | "ben_Beng" | "bho_Deva" | "brx_Deva" | "doi_Deva" | "eng_Latn" | "gom_Deva" | "gon_Deva" | "guj_Gujr" | "hin_Deva" | "hne_Deva" | "kan_Knda" | "kas_Arab" | "kas_Deva" | "kha_Latn" | "lus_Latn" | "mag_Deva" | "mai_Deva" | "mal_Mlym" | "mar_Deva" | "mni_Beng" | "mni_Mtei" | "npi_Deva" | "ory_Orya" | "pan_Guru" | "san_Deva" | "sat_Olck" | "snd_Arab" | "snd_Deva" | "tam_Taml" | "tel_Telu" | "urd_Arab" | "unr_Deva"; +} +interface Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Output { + /** + * Translated texts + */ + translations: string[]; +} +declare abstract class Base_Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B { + inputs: Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Input; + postProcessedOutputs: Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Output; +} +type Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Input = Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Async_Batch; +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + content: string | { + /** + * Type of the content (text) + */ + type?: string; + /** + * Text content + */ + text?: string; + }[]; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_1; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_1 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Async_Batch { + requests: (Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt_1 | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages_1)[]; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt_1 { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_2; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_2 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages_1 { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + content: string | { + /** + * Type of the content (text) + */ + type?: string; + /** + * Text content + */ + text?: string; + }[]; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_3; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_3 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +type Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Output = Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Chat_Completion_Response | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Text_Completion_Response | string | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_AsyncResponse; +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Chat_Completion_Response { + /** + * Unique identifier for the completion + */ + id?: string; + /** + * Object type identifier + */ + object?: "chat.completion"; + /** + * Unix timestamp of when the completion was created + */ + created?: number; + /** + * Model used for the completion + */ + model?: string; + /** + * List of completion choices + */ + choices?: { + /** + * Index of the choice in the list + */ + index?: number; + /** + * The message generated by the model + */ + message?: { + /** + * Role of the message author + */ + role: string; + /** + * The content of the message + */ + content: string; + /** + * Internal reasoning content (if available) + */ + reasoning_content?: string; + /** + * Tool calls made by the assistant + */ + tool_calls?: { + /** + * Unique identifier for the tool call + */ + id: string; + /** + * Type of tool call + */ + type: "function"; + function: { + /** + * Name of the function to call + */ + name: string; + /** + * JSON string of arguments for the function + */ + arguments: string; + }; + }[]; + }; + /** + * Reason why the model stopped generating + */ + finish_reason?: string; + /** + * Stop reason (may be null) + */ + stop_reason?: string | null; + /** + * Log probabilities (if requested) + */ + logprobs?: {} | null; + }[]; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * Log probabilities for the prompt (if requested) + */ + prompt_logprobs?: {} | null; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Text_Completion_Response { + /** + * Unique identifier for the completion + */ + id?: string; + /** + * Object type identifier + */ + object?: "text_completion"; + /** + * Unix timestamp of when the completion was created + */ + created?: number; + /** + * Model used for the completion + */ + model?: string; + /** + * List of completion choices + */ + choices?: { + /** + * Index of the choice in the list + */ + index: number; + /** + * The generated text completion + */ + text: string; + /** + * Reason why the model stopped generating + */ + finish_reason: string; + /** + * Stop reason (may be null) + */ + stop_reason?: string | null; + /** + * Log probabilities (if requested) + */ + logprobs?: {} | null; + /** + * Log probabilities for the prompt (if requested) + */ + prompt_logprobs?: {} | null; + }[]; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It { + inputs: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Input; + postProcessedOutputs: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Output; +} +interface Ai_Cf_Pfnet_Plamo_Embedding_1B_Input { + /** + * Input text to embed. Can be a single string or a list of strings. + */ + text: string | string[]; +} +interface Ai_Cf_Pfnet_Plamo_Embedding_1B_Output { + /** + * Embedding vectors, where each vector is a list of floats. + */ + data: number[][]; + /** + * Shape of the embedding data as [number_of_embeddings, embedding_dimension]. + * + * @minItems 2 + * @maxItems 2 + */ + shape: [ + number, + number + ]; +} +declare abstract class Base_Ai_Cf_Pfnet_Plamo_Embedding_1B { + inputs: Ai_Cf_Pfnet_Plamo_Embedding_1B_Input; + postProcessedOutputs: Ai_Cf_Pfnet_Plamo_Embedding_1B_Output; +} +interface Ai_Cf_Deepgram_Flux_Input { + /** + * Encoding of the audio stream. Currently only supports raw signed little-endian 16-bit PCM. + */ + encoding: "linear16"; + /** + * Sample rate of the audio stream in Hz. + */ + sample_rate: string; + /** + * End-of-turn confidence required to fire an eager end-of-turn event. When set, enables EagerEndOfTurn and TurnResumed events. Valid Values 0.3 - 0.9. + */ + eager_eot_threshold?: string; + /** + * End-of-turn confidence required to finish a turn. Valid Values 0.5 - 0.9. + */ + eot_threshold?: string; + /** + * A turn will be finished when this much time has passed after speech, regardless of EOT confidence. + */ + eot_timeout_ms?: string; + /** + * Keyterm prompting can improve recognition of specialized terminology. Pass multiple keyterm query parameters to boost multiple keyterms. + */ + keyterm?: string; + /** + * Opts out requests from the Deepgram Model Improvement Program. Refer to Deepgram Docs for pricing impacts before setting this to true. https://dpgr.am/deepgram-mip + */ + mip_opt_out?: "true" | "false"; + /** + * Label your requests for the purpose of identification during usage reporting + */ + tag?: string; +} +/** + * Output will be returned as websocket messages. + */ +interface Ai_Cf_Deepgram_Flux_Output { + /** + * The unique identifier of the request (uuid) + */ + request_id?: string; + /** + * Starts at 0 and increments for each message the server sends to the client. + */ + sequence_id?: number; + /** + * The type of event being reported. + */ + event?: "Update" | "StartOfTurn" | "EagerEndOfTurn" | "TurnResumed" | "EndOfTurn"; + /** + * The index of the current turn + */ + turn_index?: number; + /** + * Start time in seconds of the audio range that was transcribed + */ + audio_window_start?: number; + /** + * End time in seconds of the audio range that was transcribed + */ + audio_window_end?: number; + /** + * Text that was said over the course of the current turn + */ + transcript?: string; + /** + * The words in the transcript + */ + words?: { + /** + * The individual punctuated, properly-cased word from the transcript + */ + word: string; + /** + * Confidence that this word was transcribed correctly + */ + confidence: number; + }[]; + /** + * Confidence that no more speech is coming in this turn + */ + end_of_turn_confidence?: number; +} +declare abstract class Base_Ai_Cf_Deepgram_Flux { + inputs: Ai_Cf_Deepgram_Flux_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Flux_Output; +} +interface Ai_Cf_Deepgram_Aura_2_En_Input { + /** + * Speaker used to produce the audio. + */ + speaker?: "amalthea" | "andromeda" | "apollo" | "arcas" | "aries" | "asteria" | "athena" | "atlas" | "aurora" | "callista" | "cora" | "cordelia" | "delia" | "draco" | "electra" | "harmonia" | "helena" | "hera" | "hermes" | "hyperion" | "iris" | "janus" | "juno" | "jupiter" | "luna" | "mars" | "minerva" | "neptune" | "odysseus" | "ophelia" | "orion" | "orpheus" | "pandora" | "phoebe" | "pluto" | "saturn" | "thalia" | "theia" | "vesta" | "zeus"; + /** + * Encoding of the output audio. + */ + encoding?: "linear16" | "flac" | "mulaw" | "alaw" | "mp3" | "opus" | "aac"; + /** + * Container specifies the file format wrapper for the output audio. The available options depend on the encoding type.. + */ + container?: "none" | "wav" | "ogg"; + /** + * The text content to be converted to speech + */ + text: string; + /** + * Sample Rate specifies the sample rate for the output audio. Based on the encoding, different sample rates are supported. For some encodings, the sample rate is not configurable + */ + sample_rate?: number; + /** + * The bitrate of the audio in bits per second. Choose from predefined ranges or specific values based on the encoding type. + */ + bit_rate?: number; +} +/** + * The generated audio in MP3 format + */ +type Ai_Cf_Deepgram_Aura_2_En_Output = string; +declare abstract class Base_Ai_Cf_Deepgram_Aura_2_En { + inputs: Ai_Cf_Deepgram_Aura_2_En_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Aura_2_En_Output; +} +interface Ai_Cf_Deepgram_Aura_2_Es_Input { + /** + * Speaker used to produce the audio. + */ + speaker?: "sirio" | "nestor" | "carina" | "celeste" | "alvaro" | "diana" | "aquila" | "selena" | "estrella" | "javier"; + /** + * Encoding of the output audio. + */ + encoding?: "linear16" | "flac" | "mulaw" | "alaw" | "mp3" | "opus" | "aac"; + /** + * Container specifies the file format wrapper for the output audio. The available options depend on the encoding type.. + */ + container?: "none" | "wav" | "ogg"; + /** + * The text content to be converted to speech + */ + text: string; + /** + * Sample Rate specifies the sample rate for the output audio. Based on the encoding, different sample rates are supported. For some encodings, the sample rate is not configurable + */ + sample_rate?: number; + /** + * The bitrate of the audio in bits per second. Choose from predefined ranges or specific values based on the encoding type. + */ + bit_rate?: number; +} +/** + * The generated audio in MP3 format + */ +type Ai_Cf_Deepgram_Aura_2_Es_Output = string; +declare abstract class Base_Ai_Cf_Deepgram_Aura_2_Es { + inputs: Ai_Cf_Deepgram_Aura_2_Es_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Aura_2_Es_Output; +} +interface Ai_Cf_Black_Forest_Labs_Flux_2_Dev_Input { + multipart: { + body?: object; + contentType?: string; + }; +} +interface Ai_Cf_Black_Forest_Labs_Flux_2_Dev_Output { + /** + * Generated image as Base64 string. + */ + image?: string; +} +declare abstract class Base_Ai_Cf_Black_Forest_Labs_Flux_2_Dev { + inputs: Ai_Cf_Black_Forest_Labs_Flux_2_Dev_Input; + postProcessedOutputs: Ai_Cf_Black_Forest_Labs_Flux_2_Dev_Output; +} +interface Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B_Input { + multipart: { + body?: object; + contentType?: string; + }; +} +interface Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B_Output { + /** + * Generated image as Base64 string. + */ + image?: string; +} +declare abstract class Base_Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B { + inputs: Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B_Input; + postProcessedOutputs: Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B_Output; +} +interface Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B_Input { + multipart: { + body?: object; + contentType?: string; + }; +} +interface Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B_Output { + /** + * Generated image as Base64 string. + */ + image?: string; +} +declare abstract class Base_Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B { + inputs: Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B_Input; + postProcessedOutputs: Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B_Output; +} +declare abstract class Base_Ai_Cf_Zai_Org_Glm_4_7_Flash { + inputs: ChatCompletionsInput; + postProcessedOutputs: ChatCompletionsOutput; +} +declare abstract class Base_Ai_Cf_Moonshotai_Kimi_K2_5 { + inputs: ChatCompletionsInput; + postProcessedOutputs: ChatCompletionsOutput; +} +declare abstract class Base_Ai_Cf_Moonshotai_Kimi_K2_6 { + inputs: ChatCompletionsInput; + postProcessedOutputs: ChatCompletionsOutput; +} +declare abstract class Base_Ai_Cf_Nvidia_Nemotron_3_120B_A12B { + inputs: ChatCompletionsInput; + postProcessedOutputs: ChatCompletionsOutput; +} +declare abstract class Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT { + inputs: ChatCompletionsInput; + postProcessedOutputs: ChatCompletionsOutput; +} +interface AiModels { + "@cf/huggingface/distilbert-sst-2-int8": BaseAiTextClassification; + "@cf/stabilityai/stable-diffusion-xl-base-1.0": BaseAiTextToImage; + "@cf/runwayml/stable-diffusion-v1-5-inpainting": BaseAiTextToImage; + "@cf/runwayml/stable-diffusion-v1-5-img2img": BaseAiTextToImage; + "@cf/lykon/dreamshaper-8-lcm": BaseAiTextToImage; + "@cf/bytedance/stable-diffusion-xl-lightning": BaseAiTextToImage; + "@cf/myshell-ai/melotts": BaseAiTextToSpeech; + "@cf/google/embeddinggemma-300m": BaseAiTextEmbeddings; + "@cf/microsoft/resnet-50": BaseAiImageClassification; + "@cf/meta/llama-2-7b-chat-int8": BaseAiTextGeneration; + "@cf/mistral/mistral-7b-instruct-v0.1": BaseAiTextGeneration; + "@cf/meta/llama-2-7b-chat-fp16": BaseAiTextGeneration; + "@hf/thebloke/llama-2-13b-chat-awq": BaseAiTextGeneration; + "@hf/thebloke/mistral-7b-instruct-v0.1-awq": BaseAiTextGeneration; + "@hf/thebloke/zephyr-7b-beta-awq": BaseAiTextGeneration; + "@hf/thebloke/openhermes-2.5-mistral-7b-awq": BaseAiTextGeneration; + "@hf/thebloke/neural-chat-7b-v3-1-awq": BaseAiTextGeneration; + "@hf/thebloke/deepseek-coder-6.7b-base-awq": BaseAiTextGeneration; + "@hf/thebloke/deepseek-coder-6.7b-instruct-awq": BaseAiTextGeneration; + "@cf/deepseek-ai/deepseek-math-7b-instruct": BaseAiTextGeneration; + "@cf/defog/sqlcoder-7b-2": BaseAiTextGeneration; + "@cf/openchat/openchat-3.5-0106": BaseAiTextGeneration; + "@cf/tiiuae/falcon-7b-instruct": BaseAiTextGeneration; + "@cf/thebloke/discolm-german-7b-v1-awq": BaseAiTextGeneration; + "@cf/qwen/qwen1.5-0.5b-chat": BaseAiTextGeneration; + "@cf/qwen/qwen1.5-7b-chat-awq": BaseAiTextGeneration; + "@cf/qwen/qwen1.5-14b-chat-awq": BaseAiTextGeneration; + "@cf/tinyllama/tinyllama-1.1b-chat-v1.0": BaseAiTextGeneration; + "@cf/microsoft/phi-2": BaseAiTextGeneration; + "@cf/qwen/qwen1.5-1.8b-chat": BaseAiTextGeneration; + "@cf/mistral/mistral-7b-instruct-v0.2-lora": BaseAiTextGeneration; + "@hf/nousresearch/hermes-2-pro-mistral-7b": BaseAiTextGeneration; + "@hf/nexusflow/starling-lm-7b-beta": BaseAiTextGeneration; + "@hf/google/gemma-7b-it": BaseAiTextGeneration; + "@cf/meta-llama/llama-2-7b-chat-hf-lora": BaseAiTextGeneration; + "@cf/google/gemma-2b-it-lora": BaseAiTextGeneration; + "@cf/google/gemma-7b-it-lora": BaseAiTextGeneration; + "@hf/mistral/mistral-7b-instruct-v0.2": BaseAiTextGeneration; + "@cf/meta/llama-3-8b-instruct": BaseAiTextGeneration; + "@cf/fblgit/una-cybertron-7b-v2-bf16": BaseAiTextGeneration; + "@cf/meta/llama-3-8b-instruct-awq": BaseAiTextGeneration; + "@cf/meta/llama-3.1-8b-instruct-fp8": BaseAiTextGeneration; + "@cf/meta/llama-3.1-8b-instruct-awq": BaseAiTextGeneration; + "@cf/meta/llama-3.2-3b-instruct": BaseAiTextGeneration; + "@cf/meta/llama-3.2-1b-instruct": BaseAiTextGeneration; + "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b": BaseAiTextGeneration; + "@cf/ibm-granite/granite-4.0-h-micro": BaseAiTextGeneration; + "@cf/facebook/bart-large-cnn": BaseAiSummarization; + "@cf/llava-hf/llava-1.5-7b-hf": BaseAiImageToText; + "@cf/baai/bge-base-en-v1.5": Base_Ai_Cf_Baai_Bge_Base_En_V1_5; + "@cf/openai/whisper": Base_Ai_Cf_Openai_Whisper; + "@cf/meta/m2m100-1.2b": Base_Ai_Cf_Meta_M2M100_1_2B; + "@cf/baai/bge-small-en-v1.5": Base_Ai_Cf_Baai_Bge_Small_En_V1_5; + "@cf/baai/bge-large-en-v1.5": Base_Ai_Cf_Baai_Bge_Large_En_V1_5; + "@cf/unum/uform-gen2-qwen-500m": Base_Ai_Cf_Unum_Uform_Gen2_Qwen_500M; + "@cf/openai/whisper-tiny-en": Base_Ai_Cf_Openai_Whisper_Tiny_En; + "@cf/openai/whisper-large-v3-turbo": Base_Ai_Cf_Openai_Whisper_Large_V3_Turbo; + "@cf/baai/bge-m3": Base_Ai_Cf_Baai_Bge_M3; + "@cf/black-forest-labs/flux-1-schnell": Base_Ai_Cf_Black_Forest_Labs_Flux_1_Schnell; + "@cf/meta/llama-3.2-11b-vision-instruct": Base_Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct; + "@cf/meta/llama-3.3-70b-instruct-fp8-fast": Base_Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast; + "@cf/meta/llama-guard-3-8b": Base_Ai_Cf_Meta_Llama_Guard_3_8B; + "@cf/baai/bge-reranker-base": Base_Ai_Cf_Baai_Bge_Reranker_Base; + "@cf/qwen/qwen2.5-coder-32b-instruct": Base_Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct; + "@cf/qwen/qwq-32b": Base_Ai_Cf_Qwen_Qwq_32B; + "@cf/mistralai/mistral-small-3.1-24b-instruct": Base_Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct; + "@cf/google/gemma-3-12b-it": Base_Ai_Cf_Google_Gemma_3_12B_It; + "@cf/meta/llama-4-scout-17b-16e-instruct": Base_Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct; + "@cf/qwen/qwen3-30b-a3b-fp8": Base_Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8; + "@cf/deepgram/nova-3": Base_Ai_Cf_Deepgram_Nova_3; + "@cf/qwen/qwen3-embedding-0.6b": Base_Ai_Cf_Qwen_Qwen3_Embedding_0_6B; + "@cf/pipecat-ai/smart-turn-v2": Base_Ai_Cf_Pipecat_Ai_Smart_Turn_V2; + "@cf/openai/gpt-oss-120b": Base_Ai_Cf_Openai_Gpt_Oss_120B; + "@cf/openai/gpt-oss-20b": Base_Ai_Cf_Openai_Gpt_Oss_20B; + "@cf/leonardo/phoenix-1.0": Base_Ai_Cf_Leonardo_Phoenix_1_0; + "@cf/leonardo/lucid-origin": Base_Ai_Cf_Leonardo_Lucid_Origin; + "@cf/deepgram/aura-1": Base_Ai_Cf_Deepgram_Aura_1; + "@cf/ai4bharat/indictrans2-en-indic-1B": Base_Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B; + "@cf/aisingapore/gemma-sea-lion-v4-27b-it": Base_Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It; + "@cf/pfnet/plamo-embedding-1b": Base_Ai_Cf_Pfnet_Plamo_Embedding_1B; + "@cf/deepgram/flux": Base_Ai_Cf_Deepgram_Flux; + "@cf/deepgram/aura-2-en": Base_Ai_Cf_Deepgram_Aura_2_En; + "@cf/deepgram/aura-2-es": Base_Ai_Cf_Deepgram_Aura_2_Es; + "@cf/black-forest-labs/flux-2-dev": Base_Ai_Cf_Black_Forest_Labs_Flux_2_Dev; + "@cf/black-forest-labs/flux-2-klein-4b": Base_Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B; + "@cf/black-forest-labs/flux-2-klein-9b": Base_Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B; + "@cf/zai-org/glm-4.7-flash": Base_Ai_Cf_Zai_Org_Glm_4_7_Flash; + "@cf/moonshotai/kimi-k2.5": Base_Ai_Cf_Moonshotai_Kimi_K2_5; + "@cf/moonshotai/kimi-k2.6": Base_Ai_Cf_Moonshotai_Kimi_K2_6; + "@cf/nvidia/nemotron-3-120b-a12b": Base_Ai_Cf_Nvidia_Nemotron_3_120B_A12B; + "@cf/google/gemma-4-26b-a4b-it": Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT; +} +type AiOptions = { + /** + * Send requests as an asynchronous batch job, only works for supported models + * https://developers.cloudflare.com/workers-ai/features/batch-api + */ + queueRequest?: boolean; + /** + * Establish websocket connections, only works for supported models + */ + websocket?: boolean; + /** + * Tag your requests to group and view them in Cloudflare dashboard. + * + * Rules: + * Tags must only contain letters, numbers, and the symbols: : - . / @ + * Each tag can have maximum 50 characters. + * Maximum 5 tags are allowed each request. + * Duplicate tags will removed. + */ + tags?: string[]; + gateway?: GatewayOptions; + returnRawResponse?: boolean; + prefix?: string; + extraHeaders?: object; + signal?: AbortSignal; +}; +type AiModelsSearchParams = { + author?: string; + hide_experimental?: boolean; + page?: number; + per_page?: number; + search?: string; + source?: number; + task?: string; +}; +type AiModelsSearchObject = { + id: string; + source: number; + name: string; + description: string; + task: { + id: string; + name: string; + description: string; + }; + tags: string[]; + properties: { + property_id: string; + value: string; + }[]; +}; +type ChatCompletionsBase = ChatCompletionsMessagesInput; +type ChatCompletionsInput = ChatCompletionsMessagesInput; +interface InferenceUpstreamError extends Error { +} +interface AiInternalError extends Error { +} +type AiModelListType = Record; +type AiAsyncBatchResponse = { + request_id: string; +}; +declare abstract class Ai { + aiGatewayLogId: string | null; + gateway(gatewayId: string): AiGateway; + /** + * @deprecated Use the standalone `ai_search_namespaces` or `ai_search` Workers bindings instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ + aiSearch(): AiSearchNamespace; + /** + * @deprecated AutoRAG has been replaced by AI Search. + * Use the standalone `ai_search_namespaces` or `ai_search` Workers bindings instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + * + * @param autoragId Instance ID + */ + autorag(autoragId: string): AutoRAG; + // Batch request + run(model: Name, inputs: { + requests: AiModelList[Name]['inputs'][]; + }, options: AiOptions & { + queueRequest: true; + }): Promise; + // Raw response + run(model: Name, inputs: AiModelList[Name]['inputs'], options: AiOptions & { + returnRawResponse: true; + }): Promise; + // WebSocket + run(model: Name, inputs: AiModelList[Name]['inputs'], options: AiOptions & { + websocket: true; + }): Promise; + // Streaming + run(model: Name, inputs: AiModelList[Name]['inputs'] & { + stream: true; + }, options?: AiOptions): Promise; + // Normal (default) - known model + run(model: Name, inputs: AiModelList[Name]['inputs'], options?: AiOptions): Promise; + // Unknown model (fallback). + // + // The `Exclude<..., keyof AiModelList>` constraint forces TypeScript to + // route any model name that is a literal key of `AiModelList` to one of + // the known-model overloads above (so input/output mismatches surface as + // type errors rather than silently falling back to `Record`). + // Names that aren't in `AiModelList` — e.g. third-party gateway models + // like `"google/nano-banana"` — still hit this overload. + run(model: Model extends keyof AiModelList ? never : Model, inputs: Record, options?: AiOptions): Promise>; + models(params?: AiModelsSearchParams): Promise; + toMarkdown(): ToMarkdownService; + toMarkdown(files: MarkdownDocument[], options?: ConversionRequestOptions): Promise; + toMarkdown(files: MarkdownDocument, options?: ConversionRequestOptions): Promise; +} +type GatewayRetries = { + maxAttempts?: 1 | 2 | 3 | 4 | 5; + retryDelayMs?: number; + backoff?: 'constant' | 'linear' | 'exponential'; +}; +type GatewayOptions = { + id: string; + cacheKey?: string; + cacheTtl?: number; + skipCache?: boolean; + metadata?: Record; + collectLog?: boolean; + eventId?: string; + requestTimeoutMs?: number; + retries?: GatewayRetries; +}; +type UniversalGatewayOptions = Exclude & { + /** + ** @deprecated + */ + id?: string; +}; +type AiGatewayPatchLog = { + score?: number | null; + feedback?: -1 | 1 | null; + metadata?: Record | null; +}; +type AiGatewayLog = { + id: string; + provider: string; + model: string; + model_type?: string; + path: string; + duration: number; + request_type?: string; + request_content_type?: string; + status_code: number; + response_content_type?: string; + success: boolean; + cached: boolean; + tokens_in?: number; + tokens_out?: number; + metadata?: Record; + step?: number; + cost?: number; + custom_cost?: boolean; + request_size: number; + request_head?: string; + request_head_complete: boolean; + response_size: number; + response_head?: string; + response_head_complete: boolean; + created_at: Date; +}; +type AIGatewayProviders = 'workers-ai' | 'anthropic' | 'aws-bedrock' | 'azure-openai' | 'google-vertex-ai' | 'huggingface' | 'openai' | 'perplexity-ai' | 'replicate' | 'groq' | 'cohere' | 'google-ai-studio' | 'mistral' | 'grok' | 'openrouter' | 'deepseek' | 'cerebras' | 'cartesia' | 'elevenlabs' | 'adobe-firefly'; +type AIGatewayHeaders = { + 'cf-aig-metadata': Record | string; + 'cf-aig-custom-cost': { + per_token_in?: number; + per_token_out?: number; + } | { + total_cost?: number; + } | string; + 'cf-aig-cache-ttl': number | string; + 'cf-aig-skip-cache': boolean | string; + 'cf-aig-cache-key': string; + 'cf-aig-event-id': string; + 'cf-aig-request-timeout': number | string; + 'cf-aig-max-attempts': number | string; + 'cf-aig-retry-delay': number | string; + 'cf-aig-backoff': string; + 'cf-aig-collect-log': boolean | string; + Authorization: string; + 'Content-Type': string; + [key: string]: string | number | boolean | object; +}; +type AIGatewayUniversalRequest = { + provider: AIGatewayProviders | string; // eslint-disable-line + endpoint: string; + headers: Partial; + query: unknown; +}; +interface AiGatewayInternalError extends Error { +} +interface AiGatewayLogNotFound extends Error { +} +declare abstract class AiGateway { + patchLog(logId: string, data: AiGatewayPatchLog): Promise; + getLog(logId: string): Promise; + run(data: AIGatewayUniversalRequest | AIGatewayUniversalRequest[], options?: { + gateway?: UniversalGatewayOptions; + extraHeaders?: object; + signal?: AbortSignal; + }): Promise; + getUrl(provider?: AIGatewayProviders | string): Promise; // eslint-disable-line +} +// Copyright (c) 2022-2025 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +/** + * Artifacts — Git-compatible file storage on Cloudflare Workers. + * + * Provides programmatic access to create, manage, and fork repositories, + * and to issue and revoke scoped access tokens. + */ +/** Information about a repository. */ +interface ArtifactsRepoInfo { + /** Unique repository ID. */ + id: string; + /** Repository name. */ + name: string; + /** Repository description, or null if not set. */ + description: string | null; + /** Default branch name (e.g. "main"). */ + defaultBranch: string; + /** ISO 8601 creation timestamp. */ + createdAt: string; + /** ISO 8601 last-updated timestamp. */ + updatedAt: string; + /** ISO 8601 timestamp of the last push, or null if never pushed. */ + lastPushAt: string | null; + /** Fork source (e.g. "github:owner/repo", "artifacts:namespace/repo"), or null if not a fork. */ + source: string | null; + /** Whether the repository is read-only. */ + readOnly: boolean; + /** HTTPS git remote URL. */ + remote: string; +} +/** Result of creating a repository — includes the initial access token. */ +interface ArtifactsCreateRepoResult { + /** Unique repository ID. */ + id: string; + /** Repository name. */ + name: string; + /** Repository description, or null if not set. */ + description: string | null; + /** Default branch name. */ + defaultBranch: string; + /** HTTPS git remote URL. */ + remote: string; + /** Plaintext access token (only returned at creation time). */ + token: string; + /** ISO 8601 token expiry timestamp. */ + tokenExpiresAt: string; +} +/** Paginated list of repositories. */ +interface ArtifactsRepoListResult { + /** Repositories in this page (without the `remote` field). */ + repos: Omit[]; + /** Total number of repositories in the namespace. */ + total: number; + /** Cursor for the next page, if there are more results. */ + cursor?: string; +} +/** Result of creating an access token. */ +interface ArtifactsCreateTokenResult { + /** Unique token ID. */ + id: string; + /** Plaintext token (only returned at creation time). */ + plaintext: string; + /** Token scope: "read" or "write". */ + scope: 'read' | 'write'; + /** ISO 8601 token expiry timestamp. */ + expiresAt: string; +} +/** Token metadata (no plaintext). */ +interface ArtifactsTokenInfo { + /** Unique token ID. */ + id: string; + /** Token scope: "read" or "write". */ + scope: 'read' | 'write'; + /** Token state: "active", "expired", or "revoked". */ + state: 'active' | 'expired' | 'revoked'; + /** ISO 8601 creation timestamp. */ + createdAt: string; + /** ISO 8601 expiry timestamp. */ + expiresAt: string; +} +/** Paginated list of tokens for a repository. */ +interface ArtifactsTokenListResult { + /** Tokens in this page. */ + tokens: ArtifactsTokenInfo[]; + /** Total number of tokens for the repository. */ + total: number; +} +/** + * Handle for a single repository. Returned by Artifacts.get(). + * + * Methods may throw `ArtifactsError` with code `INTERNAL_ERROR` if an unexpected service error occurs. + */ +interface ArtifactsRepo extends ArtifactsRepoInfo { + /** + * Create an access token for this repo. + * @param scope Token scope: "write" (default) or "read". + * @param ttl Time-to-live in seconds (default 86400, min 60, max 31536000). + * @throws {ArtifactsError} with code `INVALID_TTL` if ttl is out of range. + */ + createToken(scope?: 'write' | 'read', ttl?: number): Promise; + /** List tokens for this repo (metadata only, no plaintext). */ + listTokens(): Promise; + /** + * Revoke a token by plaintext or ID. + * @param tokenOrId Plaintext token or token ID. + * @returns true if revoked, false if not found. + * @throws {ArtifactsError} with code `INVALID_INPUT` if tokenOrId is empty. + */ + revokeToken(tokenOrId: string): Promise; + // ── Fork ── + /** + * Fork this repo to a new repo. + * @param name Target repository name. + * @param opts Optional: description, readOnly flag, defaultBranchOnly (default true). + * @throws {ArtifactsError} with code `INVALID_REPO_NAME` if name is invalid. + * @throws {ArtifactsError} with code `ALREADY_EXISTS` if the target repo already exists. + * @throws {ArtifactsError} with code `FORK_IN_PROGRESS` if a fork is already running. + */ + fork(name: string, opts?: { + description?: string; + readOnly?: boolean; + defaultBranchOnly?: boolean; + }): Promise; +} +// ── Error types ────────────────────────────────────────────────────────────── +/** + * Error codes returned by Artifacts binding operations. + * + * Each code maps to a numeric code available on `ArtifactsError.numericCode`. + */ +type ArtifactsErrorCode = 'ALREADY_EXISTS' | 'NOT_FOUND' | 'IMPORT_IN_PROGRESS' | 'FORK_IN_PROGRESS' | 'INVALID_INPUT' | 'INVALID_REPO_NAME' | 'INVALID_TTL' | 'INVALID_URL' | 'REMOTE_AUTH_REQUIRED' | 'UPSTREAM_UNAVAILABLE' | 'MEMORY_LIMIT' | 'INTERNAL_ERROR'; +/** + * Error thrown by Artifacts binding operations. + * + * Uses a string `.code` discriminator following the Cloudflare platform + * convention (StreamError, ImagesError, etc.). The `.numericCode` matches + * the REST API `errors[].code` values. + */ +interface ArtifactsError extends Error { + readonly name: 'ArtifactsError'; + /** String error code for programmatic matching. */ + readonly code: ArtifactsErrorCode; + /** Numeric error code matching the REST API. */ + readonly numericCode: number; +} +// ── Binding ────────────────────────────────────────────────────────────────── +/** + * Artifacts binding — namespace-level operations. + * + * Methods may throw `ArtifactsError` with code `INTERNAL_ERROR` if an unexpected service error occurs. + */ +interface Artifacts { + /** + * Create a new repository with an initial access token. + * @param name Repository name (alphanumeric, dots, hyphens, underscores). + * @param opts Optional: readOnly flag, description, default branch name. + * @returns Repo metadata with initial token. + * @throws {ArtifactsError} with code `INVALID_REPO_NAME` if name is invalid. + * @throws {ArtifactsError} with code `ALREADY_EXISTS` if the repo already exists. + */ + create(name: string, opts?: { + readOnly?: boolean; + description?: string; + setDefaultBranch?: string; + }): Promise; + /** + * Get a handle to an existing repository. + * @param name Repository name. + * @returns Repo handle. + * @throws {ArtifactsError} with code `NOT_FOUND` if the repo does not exist. + * @throws {ArtifactsError} with code `IMPORT_IN_PROGRESS` if the repo is still importing. + * @throws {ArtifactsError} with code `FORK_IN_PROGRESS` if the repo is still forking. + */ + get(name: string): Promise; + /** + * Import a repository from an external git remote. + * @param params Source URL and optional branch/depth, plus target name and options. + * @returns Repo metadata with initial token. + * @throws {ArtifactsError} with code `INVALID_REPO_NAME` if the target name is invalid. + * @throws {ArtifactsError} with code `INVALID_INPUT` if the source URL is not valid HTTPS. + * @throws {ArtifactsError} with code `INVALID_URL` if the source URL does not point to a git repository. + * @throws {ArtifactsError} with code `REMOTE_AUTH_REQUIRED` if the remote requires authentication. + * @throws {ArtifactsError} with code `NOT_FOUND` if the remote repository does not exist. + * @throws {ArtifactsError} with code `UPSTREAM_UNAVAILABLE` if the remote cannot be reached. + * @throws {ArtifactsError} with code `MEMORY_LIMIT` if the import exceeds service memory limits. + * @throws {ArtifactsError} with code `ALREADY_EXISTS` if the target repo already exists. + */ + import(params: { + source: { + url: string; + branch?: string; + depth?: number; + }; + target: { + name: string; + opts?: { + description?: string; + readOnly?: boolean; + }; + }; + }): Promise; + /** + * List repositories with cursor-based pagination. + * @param opts Optional: limit (1–200, default 50), cursor for next page. + */ + list(opts?: { + limit?: number; + cursor?: string; + }): Promise; + /** + * Delete a repository and all associated tokens. + * @param name Repository name. + * @returns true if deleted, false if not found. + * @throws {ArtifactsError} with code `INVALID_REPO_NAME` if name is invalid. + */ + delete(name: string): Promise; +} +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +interface AutoRAGInternalError extends Error { +} +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +interface AutoRAGNotFoundError extends Error { +} +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +interface AutoRAGUnauthorizedError extends Error { +} +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +interface AutoRAGNameNotSetError extends Error { +} +type ComparisonFilter = { + key: string; + type: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte'; + value: string | number | boolean; +}; +type CompoundFilter = { + type: 'and' | 'or'; + filters: ComparisonFilter[]; +}; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +type AutoRagSearchRequest = { + query: string; + filters?: CompoundFilter | ComparisonFilter; + max_num_results?: number; + ranking_options?: { + ranker?: string; + score_threshold?: number; + }; + reranking?: { + enabled?: boolean; + model?: string; + }; + rewrite_query?: boolean; +}; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +type AutoRagAiSearchRequest = AutoRagSearchRequest & { + stream?: boolean; + system_prompt?: string; +}; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +type AutoRagAiSearchRequestStreaming = Omit & { + stream: true; +}; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +type AutoRagSearchResponse = { + object: 'vector_store.search_results.page'; + search_query: string; + data: { + file_id: string; + filename: string; + score: number; + attributes: Record; + content: { + type: 'text'; + text: string; + }[]; + }[]; + has_more: boolean; + next_page: string | null; +}; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +type AutoRagListResponse = { + id: string; + enable: boolean; + type: string; + source: string; + vectorize_name: string; + paused: boolean; + status: string; +}[]; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +type AutoRagAiSearchResponse = AutoRagSearchResponse & { + response: string; +}; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +declare abstract class AutoRAG { + /** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ + list(): Promise; + /** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ + search(params: AutoRagSearchRequest): Promise; + /** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ + aiSearch(params: AutoRagAiSearchRequestStreaming): Promise; + /** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ + aiSearch(params: AutoRagAiSearchRequest): Promise; + /** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ + aiSearch(params: AutoRagAiSearchRequest): Promise; +} +type BrowserRunLifecycleEvent = 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2'; +type BrowserRunResourceType = 'document' | 'stylesheet' | 'image' | 'media' | 'font' | 'script' | 'texttrack' | 'xhr' | 'fetch' | 'prefetch' | 'eventsource' | 'websocket' | 'manifest' | 'signedexchange' | 'ping' | 'cspviolationreport' | 'preflight' | 'other'; +/** Options fields shared by all quick actions. */ +interface BrowserRunBaseOptions { + /** Adds `