From 940cd6a6528e73587cee595c388c6b5dee6dedfd Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 11:29:48 +0100 Subject: [PATCH 001/137] docs: plan plugin registry labelling service --- .gitignore | 4 + .../implementation-plan.md | 1635 +++++++++++++++++ .../plugin-registry-labelling-service/spec.md | 1375 ++++++++++++++ 3 files changed, 3014 insertions(+) create mode 100644 .opencode/plans/plugin-registry-labelling-service/implementation-plan.md create mode 100644 .opencode/plans/plugin-registry-labelling-service/spec.md diff --git a/.gitignore b/.gitignore index 5e4e771b8b..c63bb485a6 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/plugin-registry-labelling-service/ +!.opencode/plans/plugin-registry-labelling-service/** # .claude is local-only EXCEPT for the symlinks pointing at AGENTS.md and skills/ .claude/* diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md new file mode 100644 index 0000000000..d2bb08281f --- /dev/null +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -0,0 +1,1635 @@ +# Plugin Registry Labelling Service Implementation Plan + +Companion: [Implementation spec](./spec.md) + +Status: execution plan pending Gate 0 contract and platform decisions + +This plan turns the plugin registry labelling-service spec into independently deliverable workstreams. It defines dependencies, integration gates, file ownership, merge boundaries, and completion criteria. It intentionally contains no time estimates. + +## Outcomes + +The implementation is complete when: + +1. Every valid new registry release is independently observed and receives a CID-bound assessment state. +2. The EmDash labeller issues standard DRISL-signed labels through public `queryLabels` and replayable `subscribeLabels` endpoints. +3. A fresh aggregator verifies, ingests, hydrates, and enforces labels from configured labellers. +4. Official clients require an active positive assessment and consistently block pending, errored, yanked, taken-down, or high-risk releases. +5. Deterministic validation, dependency intelligence, code/metadata AI, image analysis, and publisher-history context run under one versioned policy. +6. AI can hard-block only critical security or impersonation findings; quality findings warn or downrank. +7. Reviewers can inspect evidence, rerun assessments, and override false positives; admins alone control emergency `!takedown` and publisher-wide compromise actions. +8. Public assessment summaries explain decisions without exposing private evidence or exploit details. +9. Publishers receive actionable notices and have a documented manual reconsideration route. +10. Signing-key rotation, full label replay, assessment outages, artifact failures, backups, and disaster recovery are exercised. +11. The hosted service and a fresh third-party aggregator pass the same public protocol conformance suite. + +## Execution Rules + +- The [spec](./spec.md) is the source of truth for policy and behavior. This plan may sequence work but must not weaken its invariants. +- Label vocabulary, subject/CID rules, and official-client effects are frozen before production labels are issued. +- Standard label serialization/signature logic and consumer policy logic have one shared implementation each. Do not duplicate them across the labeller, aggregator, registry client, core handlers, and admin. +- Every asynchronous producer/consumer is idempotent before it is connected to a real Queue, Workflow, Durable Object, or Jetstream source. +- A Jetstream event is discovery only. No public label, including `assessment-pending`, is signed before source-record verification. +- Model output is normalized evidence. It never calls signing primitives or selects arbitrary label values. +- Automated labels target an exact release URI + CID. Package and publisher labels remain manual in v1. +- No positive assessment requirement is enabled in production until first-party releases have been assessed and rollback is tested. +- Incomplete integration remains unreachable by default. Feature flags may expose staging paths, but production clients must not depend on half-built label state. +- Tests land with each workstream. Protocol, security, and failure tests are not deferred to the final gate. +- The operator console uses Kumo, Lingui, and RTL-safe logical layout from its first UI change. +- Existing unrelated worktree changes are left untouched. +- Published-package changes receive changesets when their workstream lands. + +## Dependency Model + +### Workstream IDs + +| ID | Workstream | Primary output | +| --- | --- | --- | +| `W0` | Decisions and feasibility | Ratified contracts and proved platform assumptions | +| `W1` | Shared protocol and policy | Lexicons, label crypto, policy schema, and moderation semantics | +| `W2` | Service foundation and persistence | `apps/labeller`, D1 state, bindings, queues, and workflow skeleton | +| `W3` | Label issuance and distribution | Typed signer, label history, query API, and replayable subscription | +| `W4` | Aggregator label consumption | Verified subscription, current state, hydration, redaction, and cascades | +| `W5` | Client eligibility enforcement | One moderation helper used by registry client, core, CLI, and admin | +| `W6` | Discovery and assessment orchestration | Independent Jetstream ingest, verified subjects, run lifecycle, reconciliation | +| `W7` | Artifact and deterministic analysis | Safe artifact acquisition, bundle checks, dependency/SBOM scanners | +| `W8` | AI, image, and policy resolution | Versioned model pipeline, normalized findings, automated decisions | +| `W9` | Operator authentication and console | Access-protected review UI and manual label actions | +| `W10` | Transparency and notifications | Public assessment API, policy document, email, reconsideration flow | +| `W11` | Operations and self-hosting | Deployment, secrets, observability, backups, rotation, runbooks | +| `W12` | Conformance and security | Cross-component, adversarial, browser, and production-launch verification | + +### High-Level Graph + +```mermaid +flowchart TD + W0[W0 Decisions and feasibility] + W1[W1 Shared protocol and policy] + W2[W2 Service foundation] + W3[W3 Issuance and distribution] + W4[W4 Aggregator consumption] + W5[W5 Client enforcement] + W6[W6 Discovery and orchestration] + W7[W7 Artifact and deterministic analysis] + W8[W8 AI and policy resolution] + W9[W9 Operator console] + W10[W10 Transparency and notifications] + W11[W11 Operations] + W12[W12 Conformance and security] + + W0 --> W1 + W0 --> W2 + W0 --> W7 + W0 --> W11 + W1 --> W2 + W1 --> W3 + W1 --> W4 + W1 --> W5 + W1 --> W8 + W2 --> W3 + W2 --> W6 + W2 --> W8 + W2 --> W9 + W2 --> W10 + W2 --> W11 + W3 --> W4 + W3 --> W6 + W3 --> W9 + W3 --> W10 + W4 --> W5 + W4 --> W12 + W5 --> W9 + W5 --> W12 + W7 --> W6 + W7 --> W8 + W8 --> W9 + W8 --> W10 + W8 --> W12 + W9 --> W12 + W10 --> W12 + W11 --> W12 +``` + +### Critical Paths + +Protocol and enforcement path: + +```text +W0 contract decisions +-> W1 shared labels and policy +-> W2 service state foundation +-> W3 signed distribution +-> W4 aggregator consumption +-> W5 install/update enforcement +-> W12 conformance +``` + +Automated assessment path: + +```text +W0 scanner/model/artifact feasibility +-> W7 deterministic analysis +-> W8 AI and policy resolution + \ +W2 service foundation -> W6 verified discovery/orchestration + / + -> W12 conformance +``` + +Operational launch path: + +```text +W2 service foundation +-> W3 signer +-> W9 operator controls +-> W11 key/backup/incident operations +-> W12 production drill +``` + +`W4` can begin against signed fixtures before the hosted labeller is deployed. `W7` can build shared artifact primitives in parallel with `W2` after Gate 0. `W9` can build read-only assessment views after the state/API contracts freeze, but mutation UI waits for `W3` and `W5`. + +## Integration Gates + +### Gate 0: Contract and Platform Decisions + +Required before production implementation is treated as stable: + +- Experimental labeller NSIDs and public API shapes are ratified. +- Label vocabulary, subject/CID rules, blocking matrix, and override precedence are represented as executable policy fixtures. +- The `security:yanked` to `security-yanked` production preflight is complete. +- The labeler-declaration strategy is decided: minimal `app.bsky.labeler.service/self` or base label service plus EmDash policy document. +- Signing-key format, Secrets Store binding, generation ceremony, and recovery custody are selected. +- DRISL labels signed in workerd verify in an independent ATProto implementation, and vice versa. +- The aggregator mirror contract can supply artifact bytes and metadata required by the assessor. +- Initial dependency/advisory sources and critical-applicability rules are selected. +- The initial Workers AI model and structured-output path can process representative plugin inputs through AI Gateway. +- Cloudflare Access JWT group/email claims for reviewer/admin mapping are confirmed with the production identity provider. + +Gate owner: `W0`. + +### Gate 1: Shared Contract Foundation + +- Labeller lexicons generate and round-trip through checked-in types. +- Shared policy fixtures produce identical outcomes in Node and workerd. +- DRISL sign/verify vectors pass in the shared package. +- Every current registry consumer recognizes `security-yanked` and the complete blocking vocabulary. +- No production label has been issued under the legacy colon value. + +Gate owners: `W1`. + +### Gate 2: Signed Label Distribution + +- An authorized test action produces one signed label and one immutable audit action. +- `queryLabels` returns it with valid signature and pagination. +- `subscribeLabels` replays it from cursor `0`, resumes after disconnect, and streams a later negation. +- A fresh aggregator verifies the source DID/key, ingests history, and projects correct current state. +- Key rotation test vectors and replay behavior pass. + +Gate owners: `W2`, `W3`, minimum `W4`. + +### Gate 3: Consumer Eligibility Contract + +- Aggregator request-header parsing and `atproto-content-labelers` behavior pass contract tests. +- Search, package, release, and latest-release endpoints expand publisher/package/release subjects correctly. +- One registry-client helper returns the same eligibility result for browser, server install, update, and CLI paths. +- Missing positive assessment, pending, error, blocking labels, package/publisher cascades, yanks, and takedowns cannot be installed through official paths. +- Manual override precedence works for an exact release CID and cannot override broader manual blocks. + +Gate owners: `W4`, `W5`. + +### Gate 4: Automated Assessment + +- A verified release event creates one idempotent run and no label is signed before record verification. +- Verified mirror bytes are checksum-checked and safely extracted; declared-URL fallback passes SSRF/adversarial tests. +- Permanent validation failures become deterministic findings; transient failures retry and become `assessment-error` only after exhaustion. +- Dependency, code/metadata, image, and publisher-history stages produce normalized findings. +- Policy emits blocking labels only for critical security/impersonation findings and warning labels for quality findings. +- A model outage never creates a malicious-content label. + +Gate owners: `W6`, `W7`, `W8`. + +### Gate 5: Operator and Transparency Readiness + +- Reviewers/admins authenticate through verified Access JWTs and see only role-allowed actions. +- False-positive unblock atomically negates selected automated labels and issues `assessment-passed` plus `assessment-overridden`. +- Only admins can issue/retract `!takedown` and `publisher-compromised`. +- Public current/historical assessment APIs distinguish automated runs, pending runs, and manual actions. +- Notification outbox delivers block/warn/retraction notices without exposing private evidence. +- Arabic RTL, keyboard, localization, and confirmation-ceremony tests pass. + +Gate owners: `W9`, `W10`. + +### Gate 6: Production Launch + +- Every first-party release has a current assessment under the production policy. +- Positive-assessment enforcement is enabled only after the assessed catalog and rollback path are verified. +- Full label replay into a clean aggregator reproduces current state. +- Signing-key rotation, compromised-key response, D1 restore, Jetstream cursor recovery, Queue/DLQ recovery, and notification recovery drills pass. +- External security review has no unresolved critical or high findings. +- Production smoke succeeds from release publication through assessment, label ingest, discovery, and clean-site install decision. + +Gate owners: `W11`, `W12`, with all preceding gates complete. + +## Workstream W0: Decisions and Feasibility + +This workstream converts the remaining ratification points into executable decisions and platform proofs. + +### `W0.1` Freeze public identifiers and API shapes + +Decide: + +- Experimental labeller query/procedure NSIDs. +- Whether a decision-notice repository record ships in v1. +- Stable path for `/.well-known/emdash-labeler-policy.json`. +- Public assessment ID format and current-assessment lookup parameters. +- Error codes and pagination/cursor shapes. + +Output: approved lexicon examples and endpoint table used by `W1.2`. + +Dependencies: none. + +### `W0.2` Freeze label policy fixtures + +Encode table-driven fixtures for: + +- Eligibility labels and precedence. +- Blocking security labels. +- Warning/quality labels. +- Manual release/package/publisher labels. +- CID-bound versus URI-wide applicability. +- Manual override behavior. +- Negation and expiration. +- Accepted-labeller and `redact` policy. + +Output: machine-readable fixture set with expected `eligible | pending | error | blocked` outcomes. + +Dependencies: none. + +### `W0.3` Complete the vocabulary cutover audit + +Inspect and enumerate every current use of `security:yanked`, including: + +- `apps/aggregator` migrations and SQL filters. +- Aggregator read endpoint descriptions/tests. +- `packages/registry-client` docs/types/tests. +- `packages/core/src/api/handlers/registry.ts` install/update checks. +- Admin rendering/tests. +- Seed/deployment data, if any. + +Define the preflight query for persisted legacy labels and the temporary dual-read procedure only if production data exists. + +Output: exact patch list and migration decision for `W1.1`. + +Dependencies: none. + +### `W0.4` Prove label crypto interoperability + +Build a focused workerd prototype using `@atcute/cbor` and `@atcute/crypto`: + +- Construct only allowed v1 fields. +- DRISL encode, hash, and sign with selected curve/key format. +- Verify through an independent implementation or reference vectors. +- Verify an independently signed label in the prototype. +- Reject extra fields, wrong keys, malformed bytes, and invalid signatures. +- Define the minimal routine-rotation and compromise procedure: issuance pause boundary, DID update ordering, old-key identification, historical re-signing, and subscriber recovery. + +Output: committed vectors, selected crypto API, and key-lifecycle contract consumed by `W3.7` and documented by `W11.4`. + +Dependencies: signing-key format draft. + +### `W0.5` Decide identity and declaration hosting + +Prove one of: + +- `did:web` document plus a small PDS-hosted `app.bsky.labeler.service/self` record that does not imply unsupported report handling. +- Base `#atproto_labeler` service plus EmDash policy document without an `app.bsky` declaration. + +Verify how clients obtain localized label definitions in either path. + +Output: DID document fixture, declaration/policy fixture, and deployment ownership. + +Dependencies: `W0.1` draft vocabulary. + +### `W0.6` Prove artifact mirror consumption + +Against the current/planned aggregator mirror: + +- Resolve release coordinates to mirror URL/object metadata. +- Fetch the exact bundle without privileged database coupling. +- Verify release record, CID, artifact ID, and checksum. +- Distinguish mirror miss, transient failure, and permanent mismatch. +- Confirm declared-URL fallback inputs. + +Output: artifact acquisition contract consumed by `W7`. + +Dependencies: none. + +### `W0.7` Select scanners and AI model + +Evaluate representative fixtures through: + +- Candidate Workers AI model via AI Gateway with strict JSON output. +- Initial vulnerability/advisory sources. +- Malware/hash/signature source. +- SBOM parser/generator candidates. +- Image-capable model path. + +Record model input/output limits, supported file/image types, scanner licensing, update identifiers, and deterministic critical rules. + +Output: versioned adapter choices and calibration baseline. + +Dependencies: policy fixtures from `W0.2`. + +### `W0.8` Prove Access role claims + +Create staging Access policies and verify: + +- JWT issuer/audience validation. +- Reviewer/admin group or email claim shape. +- User and service-token distinction. +- Expiry, revoked membership, missing group, and spoofed header behavior. + +Output: exact role-mapping configuration and JWT fixtures. + +Dependencies: none. + +### `W0.9` Decide retention and communications + +Ratify: + +- Private evidence and incident evidence retention. +- AI Gateway log retention/training controls. +- Email provider and sending domain. +- Monitored reconsideration address. +- Security contact fallback behavior. + +Output: deployable constants and privacy/communications checklist. + +Dependencies: none. + +### W0 Completion + +Gate 0 passes. Failed crypto, identity, mirror, model, scanner, or Access assumptions change the spec before downstream production work continues. + +## Workstream W1: Shared Protocol and Policy + +Create or extend shared packages so the labeler and every consumer use the same contracts. + +### `W1.1` Apply the label vocabulary cutover + +Update all current references from `security:yanked` to `security-yanked` and centralize the complete vocabulary. + +Files include: + +- `apps/aggregator/migrations/0001_init.sql` +- `apps/aggregator/src/routes/xrpc/searchPackages.ts` +- `packages/registry-client/src/discovery/index.ts` +- `packages/core/src/api/handlers/registry.ts` +- Registry admin components and tests. + +If `W0.3` finds persisted legacy values, add the bounded compatibility read and explicit removal condition. Do not emit new colon-valued labels. + +Dependencies: `W0.2`, `W0.3`. + +### `W1.2` Add labeller lexicons + +Under `packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/`, define: + +- Assessment/public finding definitions. +- `getAssessment`. +- `getCurrentAssessment`. +- `listAssessments`. +- `getPolicy`. +- Optional decision-notice record if ratified. + +Use standard `com.atproto.label.*` lexicons for label distribution rather than redefining labels. + +Dependencies: `W0.1`. + +### `W1.3` Generate and export typed contracts + +- Regenerate atcute types. +- Export values and types from `packages/registry-lexicons/src/index.ts`. +- Extend `NSID`, `QUERY_NSIDS`, and procedure/record constants as appropriate. +- Add valid/invalid/current/manual-override/superseded fixtures. +- Add a changeset for the published lexicon package. + +Dependencies: `W1.2`. + +### `W1.4` Create shared label crypto primitives + +Create `packages/registry-moderation` (or a ratified equivalent) with Workers/Node-compatible exports: + +- Strict `LabelV1` construction. +- `signLabel` and `verifyLabel`. +- Public-key extraction for `#atproto_label`. +- Signature and key-rotation error codes. +- External test vectors from `W0.4`. + +No generic `signObject` API is exported. + +Dependencies: `W0.4`. + +### `W1.5` Implement policy and subject applicability + +In the shared moderation package: + +- Export label vocabulary and definitions. +- Parse versioned policy fixtures. +- Classify labels as eligibility, automated blocking, warning, or manual system labels. +- Evaluate source DID, negation, expiration, CID binding, and subject scope. +- Expand publisher/package/release subject sets from typed inputs. +- Enforce manual override precedence without bypassing broader manual blocks. +- Export the single pure `evaluateReleaseModeration` function and `ReleaseModeration` result type used by every consumer. +- Drive evaluator behavior from the `W0.2` fixture corpus. + +Dependencies: `W0.2`. + +### `W1.6` Implement accepted-labeler header parsing + +Add a shared RFC-8941-compatible parser/serializer for: + +- `atproto-accept-labelers`. +- `atproto-content-labelers`. +- DID deduplication. +- Boolean `redact` merge behavior. +- Missing versus empty distinction. +- Invalid syntax errors. + +Dependencies: none after Gate 0. + +### `W1.7` Publish policy schema and fixtures + +Define the machine-readable policy document schema, versioning rules, localized label definitions, official-client effects, contact fields, and assessment schema version. + +Fixtures cover: + +- Current production policy. +- Unknown future labels. +- Invalid block mappings. +- AI critical quality normalization to warning. +- Manual action restrictions. + +Dependencies: `W1.3`, `W1.5`. + +### W1 Completion + +Gate 1 passes. The signer, aggregator, registry client, core handlers, CLI, and admin can consume one typed policy and fixture corpus. + +## Workstream W2: Service Foundation and Persistence + +### `W2.1` Scaffold `apps/labeller` + +Follow `apps/aggregator` deployment conventions: + +- Cloudflare Vite plugin. +- Wrangler config and generated Worker types. +- D1 migrations. +- Queues and DLQs. +- Workflows binding. +- Durable Object migrations. +- R2 evidence binding. +- Workers AI binding with AI Gateway config. +- Static assets for the later console. +- Real workerd/D1 Vitest configuration. + +Add root/CI scripts so `apps/labeller` build, typecheck, tests, and migrations are not skipped by package-only filters. + +Dependencies: Gate 0, `W1.3` package shape. + +### `W2.2` Implement D1 migrations + +Create tables from the spec for: + +- `subjects`. +- `assessments` and `current_assessments`. +- `findings` and `evidence_objects`. +- `issued_labels` and `label_sequence`. +- `actions` and `notifications`. +- `ingest_state`. +- Assessment stage attempts and dead-letter metadata where needed. + +Add constraints/indexes for run-key uniqueness, subject lookup, current assessment, label sequence, and outbox delivery. + +Dependencies: `W1.5`, schema ratification. + +### `W2.3` Implement typed repositories + +Create narrow repositories for: + +- Subject observation/deletion. +- Assessment run creation and state transition. +- Current-assessment projection. +- Findings/evidence metadata. +- Action audit. +- Notification outbox. +- Ingest cursors. + +Repositories expose transaction-scoped operations needed by finalization. Do not spread raw SQL across handlers and workflow steps. + +Dependencies: `W2.2`. + +### `W2.4` Implement assessment lifecycle state machine + +Define legal transitions for: + +```text +observed -> verifying -> pending -> running -> passed | warned | blocked | error + -> stale | cancelled +``` + +Include: + +- Deterministic `runKey` and stable trigger IDs. +- Redelivery idempotency. +- Workflow lease/ownership. +- Immutable operator-trigger/action reference shape used by rerun backends and later UI. +- Typed assessment-stage adapter interfaces and policy-proposal/finalization boundary, with deterministic stub implementations for orchestration tests. +- Supersession links. +- Preserve immutable completed row state; derive public `superseded` when a newer completed current assessment links to it and replaces the current pointer. +- Current pointer update only on successful completed finalization. +- Pending/error labels represented as effects, not database state shortcuts. + +Dependencies: `W2.3`. + +### `W2.5` Implement evidence storage boundary + +- Hash every evidence object. +- Store large private evidence in R2 and references in D1. +- Separate public summaries from private detail at the type boundary. +- Apply retention class metadata. +- Prevent public serializers from accepting private evidence types. + +Dependencies: `W0.9`, `W2.3`. + +### `W2.6` Add health and internal diagnostics + +Provide unauthenticated liveness and protected readiness/detail endpoints covering D1, Queue, Workflow, DO, AI, R2, signing configuration, and policy version without exposing secrets. + +Dependencies: `W2.1`, `W2.3`. + +### W2 Completion + +The service can persist and recover every lifecycle state without model/scanner or label-distribution code. All migrations and repositories pass real workerd/D1 tests. + +## Workstream W3: Label Issuance and Distribution + +### `W3.1` Provision service identity and key material + +- Add the minimal staging custom-domain, D1, Secrets Store, and static DID/policy bindings required to exercise the signer. Full production environment hardening remains in `W11.1`. +- Deploy staging `did:web` document. +- Publish `#atproto_label` and `#atproto_labeler` entries. +- Configure Secrets Store binding for the private key. +- Publish the selected declaration/policy discovery path. +- Add startup validation that public/private key IDs agree. + +Dependencies: `W0.5`, `W2.1` app scaffold. + +### `W3.2` Implement typed issuance proposals + +Define separate proposal types for: + +- Automated assessment labels. +- Reviewer release actions, including `security-yanked`. +- Reviewer package actions limited to `package-disputed`. +- Admin release/package/publisher/system actions, including `!takedown` and `publisher-compromised`. +- Negations. + +The issuer validates vocabulary, actor role, subject type, CID rules, finding category/severity, reason, action ID, and idempotency key before signing. + +Dependencies: `W1.4`, `W1.5`, `W2.3`. + +### `W3.3` Implement atomic sequence and issuance + +In one D1 transaction: + +- Allocate the next sequence without `MAX(seq)`. +- Store immutable action/assessment linkage. +- Store the signed label. +- Update any effective local projection. +- Commit outbox/broadcast work. + +Concurrent issuance tests prove monotonic unique sequence and idempotent retries. + +Dependencies: `W3.2`. + +### `W3.4` Implement `queryLabels` + +Support: + +- URI patterns. +- Sources. +- Limit and cursor. +- Full signatures. +- Negation/history semantics. +- Stable ordering and pagination. +- Public rate limits. + +Dependencies: `W3.3`, standard lexicon types. + +### `W3.5` Implement Subscriber Durable Object + +One named DO: + +- Accepts inbound WebSockets with Hibernation API. +- Replays D1 rows after cursor. +- Joins live broadcast only after replay boundary is safe. +- Handles cursor `0` full history. +- Isolates slow/broken subscribers. +- Recovers entirely from D1 after eviction/restart. + +Dependencies: `W3.3`. + +### `W3.6` Implement `subscribeLabels` + +Wire the standard XRPC subscription endpoint to the Subscriber DO and cover framing, info/error messages, cursor validation, disconnect/reconnect, replay/live race, and backpressure. + +Dependencies: `W3.5`. + +### `W3.7` Implement routine key rotation + +- Pause issuance. +- Publish new DID key. +- Resume with new key ID. +- Lazily re-sign queried historical labels without changing `cts`. +- Expose rotation status and alerts. + +Dependencies: `W0.4` key-lifecycle contract, `W3.4`. + +### `W3.8` Reject unsupported standard reports + +Implement `com.atproto.moderation.createReport` as an explicit `NotSupported` XRPC response while v1 report intake is deferred. The handler never writes a report row or enqueues work. Add a conformance test and keep the identity/declaration metadata consistent with this behavior. + +Dependencies: `W0.5`, XRPC router from `W3.4`. + +### W3 Completion + +The labeller passes Gate 2's signer/query/subscription checks independently of automated assessment. + +## Workstream W4: Aggregator Label Consumption + +### `W4.1` Implement labeller configuration and DID resolution + +Use the existing `labellers` table for bounded trusted/discoverable sources. Resolve endpoint/key, cache with expiry, refresh periodically, and retry once on signature failure. + +Dependencies: `W1.4`, `W3.1` fixtures. + +### `W4.2` Implement outbound subscription Durable Object + +One DO per configured labeller: + +- Connect to `subscribeLabels`. +- Resume from `ingest_state` cursor. +- Verify signatures before Queue acceptance. +- Refresh DID key on first verification failure. +- Persist cursor only after durable acceptance. +- Reconnect with jittered backoff. +- Support cursor `0` rebuild. + +Dependencies: `W4.1`, `W3.6` or protocol fixtures. + +### `W4.3` Implement Labels Queue consumer + +In a D1 transaction: + +- Insert append-only label history. +- Upsert `label_state` only for newer `cts`. +- Preserve negation, expiry, CID, trusted source, signature, and received metadata. +- Ignore duplicate and out-of-order state changes correctly. + +Dependencies: `W4.2`. + +### `W4.4` Parse request labeler policy + +At the aggregator request boundary: + +- Parse `atproto-accept-labelers` once using `W1.6`. +- Apply deployment defaults only when absent. +- Treat empty as no labelers. +- Reject malformed syntax. +- Resolve unavailable sources. +- Pass typed policy to all handlers. +- Set and CORS-expose `atproto-content-labelers`. + +Dependencies: `W1.6`. + +### `W4.5` Implement batched subject expansion and hydration + +Refactor `apps/aggregator/src/routes/xrpc/views.ts` and handlers to batch labels for: + +- Publisher DID. +- Package profile URI/current CID. +- Release URI/current CID. + +Apply accepted source, negation, expiry, URI-wide, and CID-bound rules. Avoid mapper-level N+1 queries. + +Dependencies: `W1.5`, `W4.3`, `W4.4`. + +### `W4.6` Implement endpoint filtering/redaction + +Update: + +- `searchPackages`. +- `getPackage`. +- `resolvePackage`. +- `listReleases`. +- `getLatestRelease`. + +Requirements: + +- `redact` handles `!takedown`/`!suspend`. +- Direct reads return explainable tombstone/moderation state. +- Latest release is selected dynamically from eligible releases, not blindly from `packages.latest_version`. +- Package/publisher labels cascade at policy evaluation, not by copying labels. + +Dependencies: `W1.5` shared evaluator, `W4.5`. + +### `W4.7` Add replay and reconciliation + +- Rebuild one labeller from cursor `0` into an empty projection. +- Compare local sequence/high-water state with subscription progress. +- Re-resolve signing keys on cron. +- Emit component metrics/events for lag and verification failure; `W11.3` turns them into deployment dashboards and alerts. + +Dependencies: `W4.3`. + +### W4 Completion + +The aggregator consumes any conforming configured labeller and produces deterministic hydrated/filtering behavior from the shared policy. + +## Workstream W5: Client Eligibility Enforcement + +### `W5.1` Integrate the shared moderation evaluator + +Re-export or wrap `W1.5` from `packages/registry-client` without reimplementing policy. The shared evaluator accepts: + +- Accepted labeller policy. +- Publisher labels. +- Package labels and current package CID. +- Release labels and current release CID. +- Optional public assessment reference. + +Return: + +```ts +interface ReleaseModeration { + eligibility: "eligible" | "pending" | "error" | "blocked"; + blockingLabels: Label[]; + warningLabels: Label[]; + assessment?: PublicAssessmentRef; + override?: PublicManualActionRef; +} +``` + +Run the `W0.2` fixture corpus against the shared package and the registry-client export to prove they are identical. + +Dependencies: `W1.5`. + +### `W5.2` Extend discovery-client response handling + +- Interpret `atproto-content-labelers`. +- Preserve source/CID/expiry needed by the evaluator. +- Fetch `getCurrentAssessment` on demand. +- Allow per-request accepted-labeller overrides if needed. +- Keep signature verification available through a full-label fetch path. + +Dependencies: `W1.3`, `W4.4`, `W5.1`. + +### `W5.3` Enforce in core install and update handlers + +Replace direct `security:yanked` comparisons in `packages/core/src/api/handlers/registry.ts` with `W5.1` immediately before artifact download. + +Both install and update must reject: + +- Missing positive assessment. +- Pending/error. +- Automated blocking labels. +- `security-yanked`. +- `!takedown`/redacted state. +- Package/publisher blocking cascades. + +Warning-only releases require explicit consent data already validated by the server. + +Dependencies: `W4.6`, `W5.1`. + +### `W5.4` Surface eligibility in existing CLI discovery paths + +Use the same evaluator for the current CLI registry search/info surfaces. Do not invent install/update commands in this workstream. If CLI install/update is added later, it must consume the same evaluator; pinned installs may override warnings only, never missing assessment or blocking/manual system labels under official policy. + +Dependencies: `W5.1`, existing CLI registry discovery path. + +### `W5.5` Update admin registry UI + +Update registry browse/detail/install surfaces to show: + +- Eligibility state. +- Localized label definitions. +- Issuing labeller. +- Public summary/coverage. +- Automated versus manual override. +- Warning confirmation. +- Block/reconsideration details. + +Include accepted-labeller config in every React Query key. + +Dependencies: `W5.2`, `W10.2` public API contract; static fixtures may unblock UI earlier. + +### `W5.6` Add installed-release alerts + +When an installed release becomes `security-yanked`, taken down, or newly blocked, surface it in the admin and prevent updating to another ineligible version. Define periodic/on-admin-load refresh without adding unsafe background assumptions. + +Dependencies: `W5.1`, existing plugin-state storage. + +### W5 Completion + +Gate 3 passes. No official install/update path contains its own label-string policy. + +## Workstream W6: Discovery and Assessment Orchestration + +### `W6.1` Reuse/extract Jetstream client primitives + +Share or adapt the tested patterns from: + +- `apps/aggregator/src/jetstream-client.ts`. +- `apps/aggregator/src/jetstream-ingestor.ts`. +- `apps/aggregator/src/records-do.ts`. + +Preserve close semantics, structural validation, cursor-after-acceptance, jittered reconnect, and test injection. + +Dependencies: `W2.1`. + +### `W6.2` Implement Discovery Durable Object + +- Subscribe to stable and experimental release collections. +- Create deterministic verification job keys. +- Enqueue before persisting cursor. +- Handle create/update/delete. +- Expose health/cursor state. +- Never issue labels directly. + +Dependencies: `W6.1`, `W2.3`. + +### `W6.3` Verify source records + +For each job: + +- Fetch exact URI/CID through aggregator `sync.getRecord` cache or publisher PDS fallback. +- Verify MST proof/signature and collection/rkey. +- Reject mismatch/forgery/deletion. +- Persist subject only after verification. +- Issue `assessment-pending` only after verification through `W3`. + +Dependencies: `W3.3`, existing aggregator/PDS verification helpers, `W2.4`. + +### `W6.4` Implement Workflow stage orchestration + +Workflow stages: + +1. Verify subject. +2. Acquire artifact inputs. +3. Deterministic bundle validation. +4. Dependency/SBOM analysis. +5. Code/metadata AI. +6. Image AI where applicable. +7. Policy resolution. +8. Atomic finalization/label issuance. +9. Notification outbox enqueue. + +Each stage stores bounded serializable output and has explicit retry/permanent-failure classification. + +Dependencies: `W2.4` stage interfaces, `W6.3`. `W7` and `W8` later supply production adapters without changing orchestration. + +### `W6.5` Implement supersession and current projection + +- Serialize finalization per URI + CID. +- Under the same subject lock used for finalization, re-read deletion and current-CID state immediately before label issuance. +- If the subject was deleted or its CID changed, retain the completed evidence as stale/forensic-only, issue no new positive/current labels, and do not move the current pointer. +- Keep exactly one completed automated current assessment. +- Leave current pointer unchanged while a newer run is pending. +- Let pending/error labels affect eligibility as specified. +- Never let stale/cancelled runs become current. +- Preserve active manual overrides across automated runs. +- Set `supersedes_assessment_id` on a successful replacement; public serializers derive the old run's `superseded` state from that link/current pointer without mutating its original completed result. + +Dependencies: `W2.4` policy-proposal contract, `W3.3`. + +### `W6.6` Implement initial and operator rerun triggers + +- `initial:` trigger. +- Immutable operator action ID trigger. +- Deterministic run key including policy/model/prompt/scanner set. + +Dependencies: `W2.4` operator-trigger contract. + +### `W6.7` Implement scanner-intelligence triggers + +- Accept a scanner/advisory corpus revision as an immutable trigger ID. +- Select only affected releases from dependency/hash indexes. +- Reuse the same deterministic run creation path as initial/operator triggers. +- Deduplicate repeated intelligence notifications. + +Dependencies: `W6.6`, `W7.5`. + +### `W6.8` Implement reconciliation + +Scheduled reconciliation finds: + +- Aggregator releases absent from subjects. +- Verified subjects without assessment state. +- Stuck pending/running runs. +- Completed current assessments with inconsistent effective labels. +- Deleted subjects with queued/running work. + +Dependencies: `W6.5`, aggregator read API. + +### W6 Completion + +Every verified event creates the correct idempotent lifecycle, and replay/reconciliation repair missed work without duplicate public decisions. + +## Workstream W7: Artifact and Deterministic Analysis + +Coordinate this work with the delegated release service plan's proposed `packages/registry-verification`; do not create competing fetch/bundle implementations. + +### `W7.1` Establish shared verification ownership + +Decide whether to: + +- Land the common `packages/registry-verification` foundation first. +- Extend it if already landed. +- Extract only the minimum shared primitives in a precursor PR used by both plans. + +Required shared surface: checksum, safe fetch, tar validation, manifest parsing, and declared-access canonicalization. + +Dependencies: `W0.6`. + +### `W7.2` Implement verified artifact acquisition + +- Prefer validated aggregator mirrors. +- Verify release URI/CID/artifact ID/checksum. +- Fall back to declared URL with HTTPS, DNS/redirect SSRF controls, byte/time budgets, and no ambient credentials. +- Distinguish mirror miss, transient fetch failure, permanent checksum mismatch, and policy rejection. + +Dependencies: `W7.1`. + +### `W7.3` Implement canonical bundle validation + +Reject: + +- Compressed/decompressed/per-file/file-count excess. +- Traversal and duplicate normalized paths. +- Symlinks, devices, and special files. +- Malformed gzip/tar. +- Missing/duplicate manifest or entrypoint. +- Unknown access categories/operations. +- Manifest/release declared-access mismatch. + +Produce validated file inventory and bounded source/image inputs for later stages. + +Dependencies: `W7.1`. + +### `W7.4` Implement deterministic security checks + +Adapters/findings for: + +- Known malicious artifact/file hashes. +- Forbidden executable/payload classes. +- Unambiguous forbidden runtime patterns. +- Metadata/identity/URL consistency. +- Image dimensions/types. + +Map permanent conditions to `artifact-integrity-failure`, `invalid-bundle`, `undeclared-access`, or other ratified deterministic findings. Never map transport errors to malicious findings. + +Dependencies: `W0.2`, `W7.3`. + +### `W7.5` Implement SBOM and dependency analysis + +- Parse publisher-supplied SBOM bound to the release. +- Derive dependency evidence from supported lockfiles/bundle metadata. +- Query selected advisory sources through adapters. +- Record advisory source/version, exploit status, applicability, reachability evidence, and dependency path. +- Maintain affected artifact/dependency indexes for intelligence-triggered reassessment. +- Apply the ratified critical rule rather than raw CVSS alone. + +Dependencies: `W0.7`, `W7.3`. + +### `W7.6` Port and expand deterministic fixtures + +Reuse legacy marketplace fixtures and add archive, SSRF, dependency applicability, checksum, declaration mismatch, and identity cases. Tests run without live external scanners. + +Dependencies: `W7.2` through `W7.5`. + +### W7 Completion + +The service can produce complete deterministic/dependency findings and safely prepare bounded AI inputs without calling a model. + +## Workstream W8: AI, Image, and Policy Resolution + +### `W8.1` Define normalized finding contracts + +Implement strict schemas for deterministic, dependency, model, image, and history findings with: + +- Allowed category. +- Severity. +- Confidence where applicable. +- Public summary. +- Private detail. +- Evidence references. +- Affected files/images/dependencies. +- Source/version metadata. + +Unknown categories and unresolved evidence references fail validation. + +Dependencies: `W1.5`, `W2.5`. + +### `W8.2` Implement code/metadata AI adapter + +- Invoke the selected Workers AI model through AI Gateway. +- Disable decision caching. +- Attach assessment/policy/model metadata without secrets. +- Delimit all plugin-controlled text as untrusted input. +- Use strict structured output. +- Select bounded source using deterministic entrypoint/import analysis. +- Store model/prompt hashes and Gateway request ID. +- Treat model/parse/transport failures as retryable operational failures. + +Dependencies: `W0.7`, `W7.3`, `W8.1`. + +### `W8.3` Implement image AI adapter + +- Preserve true MIME/hash/dimensions/source path. +- Analyze supported icons/screenshots for impersonation, phishing UI, misleading content, and policy imagery. +- Bound image count/size and model concurrency. +- Keep image quality findings non-blocking unless they establish critical impersonation/credential harvesting. + +Dependencies: `W0.7`, `W7.3`, `W8.1`. + +### `W8.4` Implement publisher-history context + +Provide bounded factual context: + +- Prior releases from the DID. +- Recent handle/profile changes. +- Existing active manual labels. +- Repeated exact hashes/policy findings. +- Verification state as display context only. + +History may recommend operator review but cannot automatically produce package/publisher labels. + +Dependencies: `W2.3`, registry publisher/profile data. + +### `W8.5` Implement versioned policy resolver + +Resolve normalized findings under an immutable policy version: + +- Permanent deterministic critical findings -> blocking descriptive labels. +- Critical scanner applicability -> blocking descriptive labels. +- AI critical security/impersonation -> blocking descriptive labels. +- Quality findings of any severity -> warning/downranking labels. +- No blocking findings -> `assessment-passed`. +- Transient exhaustion -> `assessment-error`. +- Negate stale automated labels, but never action-backed manual labels. + +Return a typed proposal accepted by `W3.2`, not a signed label. + +Dependencies: `W1.7`, `W8.1` through `W8.4`. + +### `W8.6` Build calibration harness + +- Port legacy code/image audit fixtures. +- Add false-positive-sensitive legitimate security/network plugins. +- Record model outputs outside normal deterministic CI. +- Compare policy outcomes between model/prompt versions. +- Require explicit review of newly blocked/newly allowed fixtures. +- Produce a calibration report artifact for policy/model changes. + +Dependencies: `W8.2`, `W8.3`, `W8.5`. + +### W8 Completion + +Gate 4 passes. A complete automated run can propose and issue the expected labels while preserving the signer boundary. + +## Workstream W9: Operator Authentication and Console + +### `W9.1` Implement Access JWT verification + +- Verify `Cf-Access-Jwt-Assertion` using remote JWKS, exact issuer/audience, expiry, and not-before. +- Cache JWKS safely. +- Map ratified reviewer/admin claims. +- Distinguish human and service-token identities. +- Reject spoofed raw identity headers. + +Dependencies: `W0.8`, `W2.1`. + +### `W9.2` Implement mutation protections + +- Same-origin validation. +- CSRF token/double-submit protection. +- JSON content type. +- Idempotency key. +- Fresh role evaluation. +- Required reason. +- Immutable action audit. + +Dependencies: `W9.1`, `W2.3`. + +### `W9.3` Build console shell and read-only views + +Routes: + +- Dashboard. +- Assessment list/detail. +- Subject history. +- Audit log. +- System status. + +Use Kumo, Lingui, RTL-safe classes, accessible tables/dialogs, and server APIs that never serialize private evidence to public routes. + +Dependencies: `W2.3`, `W9.1`, static `W8.1` fixtures. + +### `W9.4` Implement reviewer label actions + +- Issue/retract descriptive release labels. +- Issue/retract `security-yanked`. +- Issue/retract `package-disputed`. +- Require exact CID/URI-wide confirmation as appropriate. +- Show resulting official-client effect before submit. + +Dependencies: `W3.3`, `W5.1`, `W9.2`. + +### `W9.5` Implement rerun and false-positive override + +- Rerun creates an immutable operator trigger ID. +- Unblock is one transaction/action that negates selected automated blocking labels and issues `assessment-passed` plus `assessment-overridden` for exact URI + CID. +- New automated findings remain visible but cannot remove the override. +- Reviewer/admin can explicitly retract the override. + +Dependencies: `W6.6`, `W9.4`. + +### `W9.6` Implement admin-only emergency actions + +- `!takedown` on release/package/publisher. +- `publisher-compromised` on DID. +- High-friction typed confirmation. +- Emit an immediate high-severity operational event and notification outbox; `W11.3` configures deployment alerts from that event. +- Pause/resume automated issuance. +- Dead-letter retry/quarantine controls. + +Dependencies: `W9.2`, `W3.3`. + +### `W9.7` Add browser/accessibility/localization tests + +- Reviewer versus admin controls. +- Expired/revoked Access session. +- CSRF/idempotency. +- Confirmation ceremony. +- Keyboard navigation. +- Arabic RTL. +- Localized labels/operator strings. +- Public/private evidence separation. + +Dependencies: `W9.3` through `W9.6`. + +### W9 Completion + +Operators can handle the expected low-volume queue without direct database edits or unsafe generic signing APIs. + +## Workstream W10: Transparency and Notifications + +### `W10.1` Publish machine-readable policy + +Serve the versioned policy document with: + +- Labeller DID. +- Policy/schema versions. +- Supported subjects. +- Localized label definitions. +- Official effects. +- Public API/contact/reconsideration links. +- Model/scanner transparency statement. + +Dependencies: `W1.7`, `W3.1` identity. + +### `W10.2` Implement public assessment APIs + +- Immutable `getAssessment(id)`. +- Materialized `getCurrentAssessment(uri, cid, src?)`. +- Paginated `listAssessments` with bounded public filters. +- `getPolicy`. + +Current response includes completed automated assessment, newer pending run, active labels, and active manual override as separate concepts. + +Historical `getAssessment(id)` derives `state: "superseded"` when a newer completed assessment names it as `supersedesAssessmentId` and owns the current pointer. Pending/stale/cancelled runs never supersede the previous completed assessment. + +Dependencies: `W1.3`, `W2.3`, `W6.5`. + +### `W10.3` Implement public/private serializers + +Explicitly exclude source excerpts, raw prompts/responses, contacts, Gateway payloads, operator notes, and dangerous exploit detail. Add compile-time types and snapshot tests proving no private fields leak. + +Dependencies: `W2.5`, `W10.2`. + +### `W10.4` Implement contact resolution + +Resolve in order: + +1. Package security contact. +2. Package author/contact metadata. +3. Publisher profile contact. + +Do not access private PDS account email. Store delivery addresses only as long as needed and retain keyed hashes for audit/deduplication. + +Dependencies: `W0.9`, registry profile/package reads. + +### `W10.5` Implement notification outbox and email adapter + +Notify on block, warning, override, retraction, prolonged error, and emergency takedown. Include public summary/effect/reconsideration URL without private details. Delivery retries independently and never rolls back a label. + +Dependencies: `W2.3`, `W10.3`, `W10.4`. + +### `W10.6` Implement reconsideration workflow + +- Publish monitored contact instructions. +- Accept opaque assessment ID in communication. +- Let operator attach private reconsideration notes. +- Record rerun/override/outcome as auditable actions. +- Send outcome notice. + +No authenticated appeals portal or SLA is introduced. + +Dependencies: `W9.5`, `W10.5`. + +### `W10.7` Decide/implement optional ATProto decision notice + +Only if `W0.1` ratifies it: + +- Publish a repository record referencing subject and public assessment. +- Make clear it is subscribable transparency, not guaranteed delivery. +- Keep email as the active notification channel. + +Dependencies: `W1.2`, `W3.1`, `W10.2`. + +### W10 Completion + +Gate 5 transparency/notification requirements pass without exposing private evidence. + +## Workstream W11: Operations and Self-Hosting + +### `W11.1` Harden production deployment environments + +Extend the minimal staging signer environment from `W3.1` into complete staging/production Wrangler configuration for: + +- Custom domain and routes. +- D1. +- R2. +- Queues/DLQs. +- Workflow. +- Durable Objects and migrations. +- Workers AI/AI Gateway. +- Secrets Store. +- Access application/audience. +- Email adapter secrets. +- Cron triggers. + +Generate Worker types; do not hand-maintain `Env`. + +Dependencies: `W0` platform choices, `W2.1`. + +### `W11.2` Add CI and deployment safety + +- Build/typecheck/test `apps/labeller` explicitly. +- Run real workerd/D1 migrations in CI. +- Validate generated lexicons/types are current. +- Block deploy on missing required bindings/secrets/policy version. +- Add staging smoke before production promotion. +- Keep signing-key provisioning outside repository/workflow logs. + +Dependencies: `W2.1`, `W3.1`. + +### `W11.3` Implement metrics, logs, and alerts + +Each component workstream emits its own typed counters/events without depending on `W11`. Aggregate those signals into deployment logs, dashboards, and alerts covering every signal in spec section 22, including Jetstream cursor age, stage latency, model/scanner failures/cost, issuance sequence, subscriber lag, overrides, notification failures, and DLQ age. + +Alerts include emergency actions, signing/DID mismatch, block spikes, stale pending work, and reconciliation gaps. + +Dependencies: component metrics from `W2` through `W10`. + +### `W11.4` Write key-management runbooks + +- Begin from the ratified `W0.4` key-lifecycle contract and the implemented `W3.7` behavior. +- Initial generation and custody. +- Routine rotation. +- Compromise response. +- Issuance pause/resume. +- Historical re-signing. +- Aggregator replay instructions. +- Audit evidence and incident communications. + +Dependencies: `W3.7`. + +### `W11.5` Implement backup, restore, and retention jobs + +- D1 backup/export. +- Evidence metadata and retained R2 objects. +- Restore validation. +- Retention deletion with active-block exceptions. +- Contact-data minimization. +- AI Gateway retention configuration. + +Dependencies: `W0.9`, `W2.5`. + +### `W11.6` Write operational runbooks + +- Jetstream outage/cursor loss. +- Queue/DLQ recovery. +- Workflow stuck runs. +- Model/scanner outage. +- Mirror/fallback outage. +- Subscriber lag. +- Publisher reconsideration. +- Emergency takedown/retraction. +- Full aggregator replay. + +Dependencies: all owning workstreams provide failure semantics. + +### `W11.7` Document self-hosting and third-party consumption + +Document: + +- One-deployment-per-DID model. +- Required bindings and costs. +- DID/key/domain setup. +- Policy/model/scanner configuration boundaries. +- How a third-party aggregator subscribes and verifies. +- How clients choose accepted labellers. +- Which defaults are EmDash policy rather than protocol rules. + +Dependencies: public contracts stable through Gate 5. + +### W11 Completion + +The service can be deployed, observed, rotated, restored, and consumed without undocumented operator knowledge. + +## Workstream W12: Conformance and Security + +### `W12.1` Build protocol conformance suite + +Black-box tests for: + +- DID/key discovery. +- `queryLabels` pagination/filtering/signatures. +- `subscribeLabels` replay/live/negation. +- Cursor recovery. +- Key rotation. +- Policy and assessment APIs. +- Fresh third-party aggregator subscription. + +Dependencies: `W3`, `W4`, `W10`. + +### `W12.2` Build full registry integration suite + +Exercise: + +```text +publish release +-> Jetstream discovery +-> source verification +-> artifact acquisition +-> assessment +-> signed labels +-> aggregator ingest/hydration +-> admin/CLI eligibility +-> install or block +``` + +Cover pass, warning, block, pending, error, override, yank, takedown, new CID, deletion, and package/publisher cascade. + +Dependencies: `W4` through `W10`. + +### `W12.3` Build adversarial suite + +Cover every spec section 24.4 case plus: + +- Forged pending-label attempt from unverified Jetstream event. +- Model output selecting arbitrary labels. +- Manual pass without `assessment-overridden`. +- Override attempting to bypass publisher/package manual block. +- Concurrent assessment finalization/current-pointer races. +- Reassessment under changed scanner version. +- Legacy `security:yanked` data preflight behavior. +- Malformed RFC-8941 accepted-labeler headers. + +Dependencies: relevant implementation workstreams. + +### `W12.4` Add query-count and performance checks + +- Prove label hydration is batched. +- Snapshot aggregator query counts for search/package/release/latest paths. +- Measure full-history replay and current-state rebuild. +- Exercise Queue bursts above expected launch volume. +- Confirm model/source limits avoid Worker resource failures. + +Dependencies: `W4.5`, `W6`, `W8`. + +### `W12.5` Run calibration and false-positive review + +- Run the production model/policy on historical/fixture corpus. +- Manually inspect every automated blocking result. +- Record false-positive/false-negative and override expectations. +- Freeze production model, prompt hash, and policy version. + +Dependencies: `W8.6`, `W9` review UI optional but preferred. + +### `W12.6` Run browser and accessibility conformance + +- Desktop/mobile operator console. +- Arabic RTL. +- Keyboard-only actions. +- Screen-reader labels/dialogs. +- Access role changes during session. +- Registry consumer warning/block/override displays. + +Dependencies: `W5.5`, `W9.7`. + +### `W12.7` Run operational drills + +- Routine key rotation. +- Compromised key. +- D1 restore. +- Lost Jetstream cursor. +- Full subscriber replay. +- Queue/DLQ recovery. +- AI outage. +- Mirror outage and safe fallback. +- Emergency takedown and retraction. +- Notification provider outage. + +Dependencies: `W11` runbooks and complete staging deployment. + +### `W12.8` External security review and closure + +Review focus: + +- Signing-key boundary. +- Label signing/verification. +- Artifact fetch/extraction. +- Prompt injection/model authority. +- Access authentication/authorization. +- Operator action and override semantics. +- Public/private evidence separation. +- Aggregator/client enforcement consistency. + +All critical/high findings are fixed and retested before Gate 6. + +Dependencies: feature-complete staging system. + +### W12 Completion + +Gate 6 passes and production positive-assessment enforcement may be enabled. + +## Parallelization Guide + +After Gate 0: + +| Parallel lane | Can proceed | Must wait for | +| --- | --- | --- | +| Protocol | `W1.2` lexicons, `W1.4` crypto, `W1.6` headers | Ratified identifiers/vectors | +| Service core | `W2.1` scaffold, migration drafting | Stable schema types for final migration | +| Artifact security | `W7.1` ownership, `W7.2`/`W7.3` primitives | Mirror contract and shared-package decision | +| Operations | Minimal staging signer bindings in `W3.1`, CI skeleton in `W2.1` | Full production hardening waits for final secrets/key format | +| Aggregator | Subscription/hydration design and fixture tests | `W1` contracts; live connection waits for `W3` | +| Client/UI | Static eligibility fixtures and UI states | Runtime integration waits for `W4`/`W10` | + +After Gate 2: + +- `W4` aggregator ingestion, `W6` verified discovery, `W9` auth/shell, and `W10` policy/public API can proceed concurrently. +- `W5` registry-client/core/CLI adapters can integrate the `W1.5` evaluator before live hydration; server enforcement waits for `W4.6`. +- `W8` adapters can develop against recorded validated-bundle fixtures while `W6` orchestration is built. + +After Gate 4: + +- `W9` mutation flows, `W10` notifications, `W11` complete operations, and `W12` integration/adversarial suites proceed concurrently. + +## Recommended Merge Sequence + +Each item should remain independently reviewable and leave production behavior safe by default. + +1. Gate 0 fixtures and decision records. +2. Vocabulary cutover, shared policy fixture corpus, and the single shared moderation evaluator. +3. Labeller lexicons/generated types. +4. Shared label crypto and header parsing. +5. `apps/labeller` scaffold, migrations, repositories, and CI coverage. +6. Typed signer, `queryLabels`, and signing vectors. +7. Subscriber DO and `subscribeLabels`. +8. Aggregator subscription, verification, and current-state projection. +9. Aggregator header parsing, batched hydration, and endpoint filtering using the shared evaluator. +10. Registry-client evaluator adapter and server install/update enforcement behind a disabled production flag. +11. Admin/CLI consumer UI states against fixtures. +12. Jetstream discovery and source-record verification. +13. Shared artifact acquisition and canonical bundle validation. +14. Deterministic and dependency analysis. +15. AI/image adapters, policy resolver, and calibration harness. +16. Automated assessment finalization and current-assessment projection. +17. Access authentication and read-only operator console. +18. Reviewer/admin mutations, overrides, and emergency actions. +19. Public assessment API, policy document, email, and reconsideration flow. +20. Observability, backups, rotation, self-hosting docs, and runbooks. +21. Conformance/adversarial/security closure. +22. First-party catalog assessment and staged production enforcement enablement. + +## Cross-Workstream Ownership Boundaries + +| Concern | Owner | Consumers | +| --- | --- | --- | +| Label vocabulary/effects | `W1` | All workstreams | +| DRISL signing/verification | `W1` | `W3`, `W4`, conformance | +| D1 state transitions | `W2` | `W3`, `W6`, `W9`, `W10` | +| Public label history/sequence | `W3` | `W4`, third parties | +| Aggregator current label state | `W4` | `W5`, directory/admin | +| Release eligibility semantics/evaluator | `W1` | `W4`, `W5`, core, CLI, admin | +| Consumer eligibility adapters | `W5` | Core, CLI, admin | +| Run lifecycle/supersession | `W6` | `W8`, `W9`, `W10` | +| Artifact safety | `W7` | Labeller, installer, delegated release service | +| Finding-to-label mapping | `W8` | `W3`, `W9`, transparency | +| Human authorization/actions | `W9` | `W3`, audit, notifications | +| Public explanation/contact | `W10` | Registry client/admin, publishers | +| Production configuration/runbooks | `W11` | Operators, self-hosters | +| Cross-component acceptance | `W12` | Gate 6 | + +If a change crosses an ownership boundary, the owning shared contract changes first and all consumers update in the same integration gate. Do not fix policy divergence with local string checks. + +## Launch Sequencing + +### Staging-only protocol phase + +- Deploy DID, signer, query, and subscription endpoints. +- Issue only test labels against test subjects. +- Connect staging aggregator. +- Keep official production client policy unchanged. + +Exit: Gate 2. + +### Consumer shadow phase + +- Production aggregator may ingest/hydrate labels without enforcing positive assessment. +- Admin may display shadow moderation state to operators only. +- Compare shared evaluator decisions against current install behavior. +- Assess first-party catalog and resolve unexpected blocks. + +Exit: Gates 3 and 4 plus complete first-party assessment. + +### Warning enforcement phase + +- Surface warnings and assessment state. +- Continue allowing current production behavior where the positive gate is not yet enabled. +- Exercise operator override, notification, and incident paths on staging/controlled subjects. + +Exit: Gate 5 and operational drills. + +### Positive-assessment enforcement phase + +- Enable the shared eligibility gate for official admin/core/CLI paths. +- Monitor pending/error rates, blocks, overrides, subscriber lag, and install failures. +- Keep deployment-level rollback capable of restoring prior client policy without deleting labels or corrupting assessment history. + +Exit: Gate 6 production smoke and sustained healthy operation. + +## Final Acceptance Checklist + +### Contracts + +- [ ] Stable policy/vocabulary fixtures are ratified. +- [ ] Labeller lexicons and policy schema are published. +- [ ] Shared crypto/header/moderation packages are the only implementations. +- [ ] Legacy colon vocabulary is absent or bounded by an explicit migration. + +### Service + +- [ ] D1 migrations and real workerd tests pass. +- [ ] Signer, query, subscription, replay, negation, and rotation pass. +- [ ] Discovery never labels unverified subjects. +- [ ] Assessment reruns/supersession/current pointer are deterministic. +- [ ] Artifact, dependency, AI, image, and history stages pass their fixtures. + +### Consumers + +- [ ] Aggregator verifies, hydrates, filters, redacts, and cascades without N+1 queries. +- [ ] `atproto-content-labelers` contract is correct. +- [ ] Core install/update and CLI/admin use the same evaluator. +- [ ] Manual override cannot bypass package/publisher/takedown actions. +- [ ] Installed blocked/yanked release alerts work. + +### Operators and Publishers + +- [ ] Access JWT and roles are verified server-side. +- [ ] Reviewer/admin action boundaries pass tests. +- [ ] Public/private evidence separation is proven. +- [ ] Notifications and reconsideration flow work without DB edits. +- [ ] Kumo/Lingui/RTL/accessibility requirements pass. + +### Operations + +- [ ] Metrics and alerts cover every critical dependency. +- [ ] Backup/restore and retention jobs pass. +- [ ] Key rotation and compromise drills pass. +- [ ] Full replay into a fresh aggregator passes. +- [ ] Self-hosting/third-party consumption docs are complete. +- [ ] External security review critical/high findings are closed. diff --git a/.opencode/plans/plugin-registry-labelling-service/spec.md b/.opencode/plans/plugin-registry-labelling-service/spec.md new file mode 100644 index 0000000000..289464de7e --- /dev/null +++ b/.opencode/plans/plugin-registry-labelling-service/spec.md @@ -0,0 +1,1375 @@ +# EmDash Plugin Registry Labelling Service + +Companion: [Implementation plan](./implementation-plan.md) + +Status: draft implementation spec for the follow-on trust and moderation work described by RFC #694. + +This document specifies the EmDash-operated reference labeller for the decentralized plugin registry. It covers automated release assessment, signed ATProto labels, operator actions, the public explanation API, aggregator and client integration, and the small operator console. + +It does not amend RFC #694. The RFC establishes the registry and identifies a labeller as the trust and moderation layer; this document defines that service in enough detail to implement and review it. + +## 1. Decisions + +The following decisions were made before drafting this spec. + +| Area | Decision | +| --- | --- | +| Launch role | Safety and quality labeller, with narrow takedown authority | +| Assessment coverage | Assess every release, including verified and first-party publishers | +| Automated inputs | Bundle code, manifest and metadata, images, dependencies/SBOM, and publisher history | +| Automated authority | Deterministic critical findings, critical scanner matches, and a single AI critical finding may hard-block | +| AI hard-block scope | Security and impersonation findings only; quality findings never hard-block | +| Hard-block representation | Descriptive labels, not `!takedown` | +| Official client policy | Require a positive assessment label; hard-block configured high-risk labels | +| Manual scope | Review/override automated decisions and issue emergency takedowns | +| Broad labels | Package and publisher labels require manual action | +| Emergency takedown | Admin-only `!takedown` issuance and retraction | +| Operator auth | Cloudflare Access | +| Operator roles | Reviewer and admin | +| Deployable shape | One Worker application for assessment, issuance, distribution, and console | +| Discovery | Independent Jetstream consumer | +| Artifact source | Verified aggregator mirror, with a declared-URL fallback | +| AI | Workers AI through AI Gateway | +| Public evidence | Public assessment summary; private detailed evidence | +| Publisher notice | Package security contact, then publisher profile fallback; email plus an ATProto-visible notice where practical | +| Appeals | Email/manual reconsideration, not a first-class appeals workflow in v1 | +| Policy changes | Versioned in code and deployed through normal review | +| Reassessment | New releases and relevant scanner-intelligence changes | +| Assessment failure | Release remains in a temporary pending/unknown state and official clients block it | +| Reports | Defer `com.atproto.moderation.createReport` and report triage in v1 | + +## 2. Goals + +1. Assess every newly observed EmDash plugin release without making publication depend on a central upload service. +2. Produce standard, signed ATProto labels that any aggregator or client can consume and verify. +3. Let the official aggregator and EmDash admin make conservative install decisions without treating the aggregator as the source of truth. +4. Automate the common path at the expected initial volume of a handful of releases per day. +5. Give operators enough interface to understand a decision, override it, re-run an assessment, or issue an emergency takedown. +6. Give publishers a useful public explanation and a direct notification when a release is blocked or warned. +7. Preserve the distinction between publication and reach: publishers control their records; labellers publish opinions; consumers choose policy. +8. Keep policy, model, scanner, and operator decisions auditable and reproducible. + +## 3. Non-goals + +- Preventing a publisher from writing a release record to their own repository. +- Acting as the only valid labeller for the ecosystem. +- Replacing install-time provenance, checksum, manifest consistency, or sandbox enforcement. +- Guaranteeing that an AI assessment proves code is safe. +- Accepting user reports through `com.atproto.moderation.createReport` in v1. +- A full case-management, appeals, or customer-support system. +- Automated package-wide or publisher-wide punishment. +- Publisher verification issuance. Verification remains a separate trust process even if a future console combines the views. +- Ratings, reviews, install counts, popularity ranking, or recommendation scoring. +- General ATProto social-content moderation. +- Native npm plugin assessment. V1 covers sandboxed registry releases only. +- Multi-tenant hosted labelling. One deployment represents one labeller DID. + +## 4. Trust Model + +There are four distinct authorities. The implementation must not collapse them. + +1. **Publisher repository:** authoritative for package and release records. +2. **Artifact checksum in the signed release:** authoritative for the bytes the publisher released. +3. **Labeller:** authoritative only for labels issued by its DID. +4. **Consumer policy:** authoritative for whether a label warns, hides, blocks, or is ignored. + +The official EmDash deployment trusts `did:web:labels.emdashcms.com` by default. Other aggregators and clients may ignore it, consume it without enforcing it, or combine it with other labellers. + +An automated hard block is therefore not deletion and is not a network takedown. It is a signed descriptive claim, such as `malware`, which the official EmDash policy treats as ineligible for installation. + +`!takedown` is stronger. It requests protocol-level redaction when the labeller is accepted with the `redact` flag. V1 reserves it for an admin's emergency manual action. + +## 5. Architecture + +The reference service is a single Cloudflare Worker application in `apps/labeller`. It contains separate internal capabilities even though they deploy together. + +```mermaid +flowchart LR + JS[Jetstream] --> JD[Discovery Durable Object] + JD --> Q[Assessment Queue] + Q --> WF[Assessment Workflow] + AGG[Aggregator mirror and sync.getRecord] --> WF + SRC[Publisher artifact URL fallback] --> WF + WF --> DET[Deterministic checks] + WF --> DEP[Dependency and SBOM scanners] + WF --> AI[Workers AI via AI Gateway] + DET --> PE[Versioned policy engine] + DEP --> PE + AI --> PE + PE --> D1[(Labeller D1)] + PE --> ISSUER[Label issuer] + UI[Access-protected operator console] --> D1 + UI --> ISSUER + ISSUER --> D1 + ISSUER --> SD[Subscriber Durable Object] + SD --> SUB[com.atproto.label.subscribeLabels] + D1 --> QUERY[com.atproto.label.queryLabels] + D1 --> PUBLIC[Public assessment API] + SUB --> AGGI[Aggregator label ingest] + QUERY --> AGGI +``` + +### 5.1 Components + +| Component | Responsibility | +| --- | --- | +| Discovery Durable Object | Maintains the outbound Jetstream subscription and persists the cursor | +| Assessment Queue | Buffers and deduplicates immutable release assessment jobs | +| Assessment Workflow | Fetches verified inputs, runs checks, stores evidence, and invokes policy | +| Policy engine | Maps normalized findings to labels and client effects under a versioned policy | +| Label issuer | Allocates sequence numbers, signs labels, stores history, and broadcasts them | +| Subscriber Durable Object | Serves replayable `subscribeLabels` WebSocket connections using hibernation | +| Query API | Implements standard `queryLabels` | +| Public assessment API | Exposes case-specific summaries without private evidence | +| Operator console | Provides queue, assessment detail, override, re-run, and emergency takedown actions | +| Notification worker step | Sends publisher notices after externally visible decisions | + +### 5.2 Why one deployable + +At a handful of releases per day, separate services would add deployment, authentication, and failure modes without useful scale isolation. Internal modules must still preserve least privilege: + +- Only the issuer module can read the label-signing key. +- Assessment model output is data, never a direct call to signing primitives. +- The policy engine constructs a bounded decision proposal. +- The issuer validates every proposal against the policy's allowed label vocabulary and subject rules. +- Operator routes are protected by Access JWT verification and role checks. +- Public label and assessment routes cannot reach operator mutations. + +If model execution later becomes high-volume or accepts broader untrusted inputs, the assessor can split into another Worker without changing the public label protocol. + +## 6. Identity and Declaration + +The service identity is `did:web:labels.emdashcms.com`. + +Its DID document must include: + +- `#atproto_label`, the public key used to verify labels. +- `#atproto_labeler`, an `AtprotoLabeler` service pointing to `https://labels.emdashcms.com`. +- An ATProto repository/PDS service if the service publishes a standard labeler declaration record. + +The service may publish an `app.bsky.labeler.service/self` declaration for compatibility with existing ATProto tooling and localized label definitions. Its `subjectTypes`, `subjectCollections`, and `reasonTypes` fields describe report scope in the Bluesky application model; they do not constrain which labels this service may issue. Subject restrictions are enforced by the issuer and documented in the EmDash policy document. + +Because v1 does not accept standard moderation reports, the declaration must not advertise report handling. Phase 0 must verify whether a minimal declaration can omit all report-scope fields without misleading existing clients. If not, v1 publishes no `app.bsky` declaration and uses the base label service plus the EmDash policy document. A call to `com.atproto.moderation.createReport` returns `NotSupported`; it is never silently accepted into an unmonitored queue. + +The versioned, machine-readable policy document is available at: + +```text +GET /.well-known/emdash-labeler-policy.json +``` + +It includes: + +- Labeller DID. +- Policy version and effective timestamp. +- Supported subject collections. +- Label definitions and default official-client effects. +- Current assessment schema version. +- Public explanation API base URL. +- Contact and reconsideration URL/email. +- A statement that model output is advisory evidence interpreted by versioned policy. + +This document is informational. Signed labels remain the interoperable source of claims. + +## 7. Label Vocabulary + +Label values use lowercase kebab case as recommended by the ATProto label specification. Confidence, scores, model names, and structured evidence do not go in `val`. + +### 7.1 Eligibility labels + +| Label | Subject | Meaning | Official policy | +| --- | --- | --- | --- | +| `assessment-passed` | Release URI + CID | Required checks completed and either policy or an authorized manual override found no blocking condition | Eligible, subject to all other labels and install checks | +| `assessment-overridden` | Release URI + CID | A reviewer/admin explicitly superseded the automated outcome for this exact release CID | Use the accompanying manual pass until explicitly retracted | +| `assessment-pending` | Release URI + CID | Assessment is queued, running, retrying, or awaiting an artifact | Block temporarily | +| `assessment-error` | Release URI + CID | Assessment could not complete after bounded retries | Block and surface operational failure | + +The official client requires an active `assessment-passed` label from an accepted labeller. Absence of that label means unknown, not safe. + +`assessment-pending` improves UX but is not the security gate. If the pending label is delayed, missing, expired, or retracted incorrectly, the missing positive label still blocks. + +When assessment completes, the issuer negates `assessment-pending` and issues exactly one of: + +- `assessment-passed`, possibly alongside warning labels. +- One or more blocking descriptive labels, without `assessment-passed`. +- `assessment-error` if the pipeline exhausted retries without a judgment. + +A reviewer/admin may manually unblock a false positive by issuing negations for the automated blocking labels plus action-backed `assessment-passed` and `assessment-overridden` labels for the exact URI + CID. The public assessment response links the public action summary and retains the superseded automated assessment. Merely retracting a blocking label is not enough to make a release eligible. + +### 7.2 Blocking security labels + +| Label | Meaning | Automatic issuance | +| --- | --- | --- | +| `malware` | Code or payload intentionally compromises, persists, mines, destroys, or executes an unauthorized workload | Deterministic/scanner critical or AI critical | +| `data-exfiltration` | Plugin intentionally sends protected site/user data outside its declared and expected purpose | Deterministic/scanner critical or AI critical | +| `credential-harvesting` | Plugin solicits, captures, or transmits credentials deceptively | Deterministic/scanner critical or AI critical | +| `supply-chain-compromise` | Artifact or dependency evidence indicates a known malicious or substituted component | Deterministic/scanner critical or AI critical | +| `critical-vulnerability` | A scanner identifies a critical, applicable vulnerability under the policy's blocking rule | Critical scanner rule only unless AI identifies an independently blocking exploit path | +| `artifact-integrity-failure` | Available artifact bytes do not match the checksum in the signed release | Deterministic critical | +| `invalid-bundle` | The archive is malformed, unsafe, or missing required installable content | Deterministic critical | +| `undeclared-access` | Bundle behavior or manifest inconsistency attempts access outside the signed declaration | Deterministic critical; AI may propose but not establish manifest inequality | +| `impersonation` | Package or publisher materially impersonates another identity, product, or trusted project | Deterministic identity evidence or AI critical | + +Official EmDash clients block these labels. Other consumers choose their own behavior. + +The policy must distinguish a vulnerable dependency from an actually blocking critical vulnerability. A raw CVSS score alone is insufficient. The versioned rule should require applicable package/version evidence and one of: known exploitation, reachable critical behavior, a policy-maintained deny-list, or another explicit rule. + +### 7.3 Warning labels + +| Label | Meaning | Official policy | +| --- | --- | --- | +| `suspicious-code` | Concerning behavior lacks enough evidence for a blocking security label | Warn before install | +| `obfuscated-code` | Material code is intentionally difficult to inspect | Warn before install | +| `privacy-risk` | Collection, tracking, or transmission creates a non-blocking privacy concern | Warn before install | +| `misleading-metadata` | Description, screenshots, identity claims, or declared purpose materially differ from behavior | Warn and downrank | +| `low-quality` | Release is placeholder, generated without substance, or lacks meaningful functionality | Warn and downrank | +| `broken-release` | Bundle cannot perform its stated function despite passing structural registry validation | Warn and downrank | + +Quality labels never prevent direct installation on their own. The official admin presents the reason and requires confirmation when warning labels are active. + +### 7.4 Manual system labels + +| Label | Subject | Authority | Effect | +| --- | --- | --- | --- | +| `!takedown` | Release, package, or publisher | Admin only | Redact for consumers accepting this labeller with `redact` | +| `security-yanked` | Release | Reviewer or admin | Block install/update; retain visible tombstone and explanation | +| `publisher-compromised` | Publisher DID | Admin only | Block all publisher packages/releases until retracted | +| `package-disputed` | Package URI | Reviewer or admin | Warn and prevent recommendation; direct install policy is configurable | + +`security-yanked` replaces the colon-containing `security:yanked` placeholder currently present in parts of the aggregator and admin. Colon-structured values conflict with ATProto's recommended label syntax. + +The vocabulary cutover is required before the labeller issues production labels. Aggregator SQL, registry-client helpers, admin rendering, and install/update handlers must use `security-yanked` and the complete blocking-label set. A production preflight checks for persisted legacy `security:yanked` labels. If any exist, consumers temporarily recognize both values while operators reissue/negate them; otherwise no compatibility path is added. + +### 7.5 Severity is assessment data + +Labels describe conditions, not model severity. Each assessment finding has an internal severity of `critical`, `high`, `medium`, `low`, or `info`. + +Policy maps findings to labels. Only a `critical` security or impersonation finding may produce an automated blocking label. A `critical` quality finding is normalized to a warning because quality labels are non-blocking by policy. + +## 8. Subject and CID Rules + +### 8.1 Automated labels + +Automation labels only an individual release record and always supplies both: + +- `uri`: immutable release AT URI. +- `cid`: the exact release record CID assessed. + +The assessment also binds the artifact checksum, artifact ID, and observed bytes. This prevents an assessment of one release-record version or artifact from being replayed as an assessment of another. + +### 8.2 Package labels + +Package/profile labels are manual in v1. + +- A correction-specific label such as `misleading-metadata` should be CID-bound so editing the profile removes its applicability pending reassessment. +- A policy action such as `package-disputed` or `!takedown` is URI-wide and remains active across profile edits until explicitly retracted. + +### 8.3 Publisher labels + +Publisher DID labels are manual in v1 and apply to all packages from that identity according to consumer policy. A release finding never automatically escalates to a publisher label. + +### 8.4 Deletion + +Deleting a release record does not erase assessment history or labels. Aggregators may stop listing the release because of the registry tombstone, while the public assessment remains available for transparency and incident response. + +## 9. Release Assessment Workflow + +### 9.1 Discovery + +The Discovery Durable Object maintains an independent Jetstream subscription for experimental and stable EmDash release collections. + +For each create or update event: + +1. Validate the event shape. +2. Derive a verification job key from `(release URI, release CID)`. +3. Enqueue the verification/assessment job durably. +4. Persist the Jetstream cursor only after queue acceptance. +5. In the workflow, fetch and cryptographically verify the source record for the exact URI + CID. +6. Only after verification, insert the subject and issue `assessment-pending`. +7. Continue with artifact acquisition and assessment. + +Deletes record a tombstone and cancel queued work where possible. A running assessment may finish for forensic purposes but must not issue a new positive label for a deleted subject. + +Jetstream is discovery only. Raw event JSON is never trusted as an assessment input or as sufficient authority to sign even a pending label. A forged or unverifiable event is retained as an operational dead letter and produces no public label. + +### 9.2 Idempotency + +Every assessment run has a deterministic `runKey`: + +```text +sha256( + release-uri + "\n" + + release-cid + "\n" + + policy-version + "\n" + + model-id + "\n" + + prompt-hash + "\n" + + scanner-set-version + "\n" + + trigger-id +) +``` + +`trigger-id` is stable for a logical trigger: `initial:` for first observation, an advisory/signature corpus revision for scanner intelligence, or the immutable operator action ID for a manual rerun. At most one active workflow exists for a `runKey`. Redelivery wakes or observes the same run; a new scanner revision or operator action creates a new immutable run that may supersede the previous effective assessment. + +A new policy version does not automatically reassess every release. Scanner-intelligence updates select affected releases and enqueue a run with the new scanner-set version and trigger ID. + +### 9.3 Acquire verified inputs + +The workflow obtains: + +1. Signed release record and CID. +2. Associated package profile and publisher profile. +3. EmDash release extension and declared access. +4. Bundle from a validated aggregator mirror. +5. Bundle from the publisher-declared URL only if the mirror is unavailable. +6. Images referenced by the package/release where needed. +7. SBOM from the release record or bundle, if present. +8. Relevant publisher history already observed by the labeller. + +Every artifact source is checksum-verified against the signed release before extraction. The mirror is a transport optimization, not a trust root. + +Fallback URL fetching must use the same class of controls as the registry installer: + +- HTTPS by default. +- DNS-aware SSRF checks. +- Redirect revalidation and bounded redirect count. +- Compressed, per-file, decompressed, and file-count caps. +- Streaming abort at limits. +- Safe tar path handling and rejection of links/special files. +- Global time and byte budgets. +- Content-type sniffing rather than trusting headers. +- No ambient credentials or cookies. + +### 9.4 Deterministic validation + +Run deterministic checks before any AI call: + +- Release and extension lexicon validity. +- Release rkey/package/version consistency. +- Artifact checksum and archive validity. +- Required bundle files and manifest validity. +- Canonical manifest `declaredAccess` equality with the signed release extension. +- Unknown access category/operation rejection. +- Forbidden archive entries, executable payload classes, and known malicious hashes. +- Static forbidden-runtime patterns where they are unambiguous. +- Metadata URL and identity consistency. +- Image dimensions/types and archive policy. + +A registry-invalid release may already have been rejected by the aggregator. The labeller still stores the observation if independently discovered, but only issues labels for a resolvable, valid subject. Invalid/unresolvable records go to operational dead letters rather than creating unverifiable public labels. + +Permanent failures after the source record is verified are findings, not operational errors. Checksum mismatch maps to `artifact-integrity-failure`; malformed/unsafe/missing bundle content maps to `invalid-bundle`; manifest declaration mismatch maps to `undeclared-access`. Transient mirror/network/model/scanner unavailability retries and may become `assessment-error`. + +### 9.5 Dependency and SBOM analysis + +The service prefers a publisher-supplied SBOM when it is bound to the release. It may also derive dependency evidence from lockfiles and bundled metadata. + +The normalized result records: + +- Ecosystem, package, version, and dependency path. +- Advisory identifier and source. +- Severity and exploit status. +- Whether the vulnerable code is bundled/reachable where determinable. +- Scanner database/version and observation time. +- Confidence and any suppression rule. + +Missing SBOM data is not itself a blocking condition in v1. It may lower assessment coverage and be shown in the public summary. + +Scanner-intelligence updates enqueue only releases whose dependency/hash index intersects the changed advisories or signatures. + +### 9.6 Code and metadata AI assessment + +Workers AI is invoked through AI Gateway with caching disabled for moderation decisions. Gateway request IDs are retained with private evidence. + +The model receives bounded, explicitly delimited inputs: + +- Manifest and signed declared access. +- Package/release metadata. +- File inventory and deterministic scanner summary. +- Relevant source files, selected by deterministic entrypoint/import analysis. +- Publisher-history facts, not an unbounded reputation narrative. +- A policy-generated task and output schema. + +All plugin-controlled text and code is untrusted data. The prompt states that instructions inside those inputs must never alter the audit task. Model output is parsed through a strict schema and cannot name arbitrary labels. + +The model returns findings, not labels: + +```ts +interface ModelFinding { + category: AllowedFindingCategory; + severity: "critical" | "high" | "medium" | "low" | "info"; + confidence: number; + title: string; + summary: string; + evidenceRefs: string[]; + affectedFiles: string[]; +} +``` + +`AllowedFindingCategory` is generated from the versioned policy. Unknown categories fail validation. + +The model may produce a critical security or impersonation finding that the policy maps directly to a hard-blocking descriptive label. This is an explicit product decision. Guardrails are therefore mandatory: + +- Structured output with strict category allowlist. +- Evidence references must resolve to supplied input spans or deterministic facts. +- No hard block from free-form summary text. +- No hard block from quality categories. +- Fixture-based calibration before model/prompt changes deploy. +- Model ID, prompt hash, policy version, and input manifest retained. +- Immediate operator retraction path. + +### 9.7 Image assessment + +Images are assessed for: + +- Brand impersonation. +- Misleading screenshots. +- Credential phishing or fake system UI. +- Hateful or explicit imagery that violates the published directory policy. +- Mismatch between screenshots and actual plugin behavior where code/metadata evidence supports it. + +Image-only quality concerns generate warning labels. Critical impersonation or credential-harvesting evidence may hard-block. + +Every image retains its original MIME type, hash, dimensions, and source path in private evidence. The implementation must not coerce arbitrary image bytes to `image/png` as the legacy marketplace auditor did. + +### 9.8 Publisher history + +History is context, not guilt by association. The model/policy may use: + +- Prior releases from the same DID. +- Recent identity/handle/profile changes. +- Existing active manual labels. +- Repeated exact malicious hashes or repeated policy violations. +- Verification state as display context, never as an assessment exemption. + +Automation cannot issue package- or publisher-wide labels from history. It can create an operator review recommendation. + +### 9.9 Policy resolution + +The policy engine consumes normalized deterministic, scanner, image, and model findings. + +Resolution order: + +1. If the subject disappeared or CID changed, store the result as stale and issue no current label. +2. Map permanent artifact, archive, manifest, and other validation failures to deterministic findings. Do not retry them as infrastructure failures. +3. If transient infrastructure failure prevented complete required inputs, retry; after exhaustion issue `assessment-error` and negate `assessment-pending`. +4. Map blocking deterministic/scanner findings to descriptive labels. +5. Map AI critical security/impersonation findings to descriptive blocking labels. +6. Map non-blocking findings to warning labels. +7. If no blocking label exists, issue `assessment-passed`. +8. Negate prior active automated labels for the same URI + CID that are no longer supported by this assessment. +9. Negate `assessment-pending`. +10. Store the public summary and private evidence. +11. Notify the publisher for newly issued/retracted blocking or warning labels. + +An assessment may pass and warn at the same time. `assessment-passed` means no hard-blocking condition, not "the labeller found nothing concerning." + +## 10. Reassessment and Supersession + +V1 reassesses when: + +- A new release CID is observed. +- A known-malware or vulnerability intelligence update identifies an affected artifact/dependency. +- An operator requests a re-run. + +Policy/model changes do not silently rewrite historical outcomes. A deployment may mark old assessments as using an older policy, and an admin may enqueue a bounded migration if a change requires it. + +When a new assessment supersedes an old assessment for the same URI + CID: + +- The old assessment remains immutable and public as historical metadata. +- Current labels are changed only through newly signed positive or negation labels. +- The new assessment records `supersedesAssessmentId`. +- Notifications explain whether the effective install policy changed. + +Manual overrides remain in force across automated reassessment until an operator explicitly removes them. Automation cannot negate action-backed `assessment-passed`/`assessment-overridden` labels or undo a human `!takedown`, `security-yanked`, package label, or publisher label. + +Exactly one completed automated assessment is current for each `(labeller DID, release URI, release CID)`. Starting a newer run does not move that pointer, but its active `assessment-pending` label temporarily makes the release ineligible even if the prior run passed, unless `assessment-overridden` remains active. When the newer run completes, label changes and the current-assessment pointer update in the same transaction. A stale/cancelled run never becomes current. A manual pass is a separate active override layered over the current automated assessment, not a fabricated automated assessment row. + +## 11. Manual Operator Workflow + +### 11.1 Console pages + +The purpose-built Kumo console is served by the labeller Worker under `/admin` and protected by Cloudflare Access. + +V1 pages: + +| Route | Purpose | +| --- | --- | +| `/admin` | Queue health, pending/error counts, recent blocking decisions, subscription health | +| `/admin/assessments` | Filterable assessment list by state, label, publisher, package, model, and policy version | +| `/admin/assessments/:id` | Public summary, private findings, evidence excerpts, model/scanner provenance, labels, and timeline | +| `/admin/subjects/:encodedUri` | All assessments and active/historical labels for a release/package/publisher | +| `/admin/audit-log` | Immutable operator and system action log | +| `/admin/system` | Read-only policy/model versions, signing key ID, Jetstream cursor, subscriber health, queue/DLQ health | + +All user-facing strings use Lingui and all layout uses RTL-safe logical classes. The console uses Kumo components rather than hand-rolled controls. + +### 11.2 Reviewer actions + +A reviewer may: + +- Issue or retract a descriptive release warning/block label. +- Override an automated decision with a required reason. +- Re-run an assessment. +- Issue/retract `security-yanked` on a release. +- Issue/retract `package-disputed`. +- View private evidence and notification status. + +An unblock override is one atomic action: negate the selected automated blocking labels and issue manual `assessment-passed` plus `assessment-overridden` labels for the exact URI + CID. The console does not offer a standalone "retract and allow" shortcut. New automated findings remain visible and alert reviewers, but cannot negate the override; a reviewer/admin must retract it explicitly. + +A reviewer may not: + +- Issue or retract `!takedown`. +- Issue or retract `publisher-compromised`. +- Change policy, model, signing keys, Access role mappings, or system configuration. + +### 11.3 Admin actions + +An admin may perform reviewer actions and: + +- Issue/retract `!takedown` on a release, package, or publisher. +- Issue/retract `publisher-compromised`. +- Retry or quarantine dead-letter work. +- Pause automated issuance while leaving public query/subscription endpoints available. +- Start and complete signing-key rotation. + +Policy remains versioned in code. Admin status pages show configuration but do not provide live prompt/threshold editing. + +### 11.4 Action ceremony + +Every mutation uses a confirmation dialog showing: + +- Subject URI/DID and current resolved display identity. +- Label value and resulting official-client effect. +- Whether the action is CID-bound or subject-wide. +- Existing active labels that will be superseded. +- Required operator reason. + +`!takedown` additionally requires typing the package slug, publisher handle, or final URI segment. At current scale, two-person approval is not required, but the audit event and alert are immediate. + +## 12. Operator Authentication and Authorization + +Cloudflare Access protects `/admin/*` and internal mutation APIs. + +The Worker must validate `Cf-Access-Jwt-Assertion` itself using: + +- The Access team domain JWKS. +- Expected issuer. +- Exact Access application audience. +- Signature, expiry, and not-before claims. + +Presence of an Access header is not authentication. + +Role mapping is deployment configuration: + +```json +{ + "admins": ["emdash-labeller-admins"], + "reviewers": ["emdash-labeller-reviewers"] +} +``` + +The exact claim used for groups must be verified against the configured Access identity provider. Email allowlists may be used initially, but roles must not be inferred from an unverified request header. + +Mutation requests also require: + +- Same-origin checks. +- CSRF token or signed double-submit token. +- JSON content type. +- Idempotency key. +- Freshly verified Access identity. +- Role check in the handler. + +Service-to-service automation does not reuse human Access sessions. Internal queue/workflow calls use bindings. Any external administrative automation uses an Access service token plus a separate scoped service credential checked by the Worker. + +## 13. Public and Internal Interfaces + +### 13.1 Standard ATProto endpoints + +The service implements: + +- `GET /xrpc/com.atproto.label.queryLabels` +- `GET /xrpc/com.atproto.label.subscribeLabels` with WebSocket upgrade + +Semantics follow the ATProto label specification, including cursors, signatures, negation, expiration, source filtering, URI patterns, and replay from cursor `0` for retained history. + +The service does not require authentication for public label distribution. + +### 13.2 Public assessment API + +The public API supplies the case-specific context labels cannot carry. + +Proposed experimental XRPC endpoints: + +```text +com.emdashcms.experimental.labeller.getAssessment +com.emdashcms.experimental.labeller.getCurrentAssessment +com.emdashcms.experimental.labeller.listAssessments +com.emdashcms.experimental.labeller.getPolicy +``` + +`getAssessment` accepts `id` and returns: + +```ts +interface PublicAssessment { + id: string; + subject: { uri: string; cid: string }; + artifact: { id: string; checksum: string }; + state: "pending" | "passed" | "warned" | "blocked" | "error" | "superseded"; + summary: string; + coverage: { + code: "complete" | "partial" | "unavailable"; + metadata: "complete" | "partial" | "unavailable"; + images: "complete" | "not-present" | "partial" | "unavailable"; + dependencies: "complete" | "partial" | "unavailable"; + }; + labels: Array<{ val: string; active: boolean; issuedAt: string }>; + policyVersion: string; + model: { provider: "workers-ai"; modelId: string; promptVersion: string }; + scannerVersions: Record; + createdAt: string; + completedAt?: string; + supersedesAssessmentId?: string; + reconsiderationUrl: string; +} +``` + +Public output excludes: + +- Source excerpts not already public through the artifact. +- Full model prompts/responses. +- Operator-only notes. +- Publisher contact data. +- Gateway logs/tokens/request payloads. +- Exploit details that would increase harm before remediation. + +Standard label objects cannot carry an assessment ID, so clients do not infer one from timestamps. `getCurrentAssessment` accepts exact `uri`, `cid`, and optional `src`, then reads the materialized current pointer and returns the current completed automated assessment, any newer pending run, active public labels, and any active manual override. Manual labels/actions have public action summaries and no fabricated assessment association. `getAssessment(id)` remains the immutable historical lookup. + +### 13.3 Operator API + +Purpose-built JSON endpoints under `/admin/api` are acceptable because they are not ecosystem protocol. They include: + +- `GET /assessments` +- `GET /assessments/:id` +- `POST /assessments/:id/retry` +- `POST /labels/issue` +- `POST /labels/retract` +- `GET /subjects` +- `GET /audit-log` +- `GET /system/health` + +Mutation bodies identify a subject, label, optional CID/expiry, reason, and idempotency key. The server derives `src`, `cts`, sequence, operator identity, and signing key ID. + +There is no generic "sign arbitrary label object" endpoint. + +## 14. Storage Model + +The labeller uses an independent D1 database. + +### 14.1 Subjects and assessments + +```sql +CREATE TABLE subjects ( + uri TEXT NOT NULL, + cid TEXT NOT NULL, + did TEXT NOT NULL, + collection TEXT NOT NULL, + rkey TEXT NOT NULL, + observed_at TEXT NOT NULL, + deleted_at TEXT, + PRIMARY KEY (uri, cid) +); + +CREATE TABLE assessments ( + id TEXT PRIMARY KEY, + run_key TEXT NOT NULL UNIQUE, + uri TEXT NOT NULL, + cid TEXT NOT NULL, + artifact_id TEXT, + artifact_checksum TEXT, + state TEXT NOT NULL, + trigger TEXT NOT NULL, + trigger_id TEXT NOT NULL, + policy_version TEXT NOT NULL, + model_id TEXT, + prompt_hash TEXT, + scanner_versions_json TEXT NOT NULL, + public_summary TEXT, + coverage_json TEXT NOT NULL, + supersedes_assessment_id TEXT, + started_at TEXT, + completed_at TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (uri, cid) REFERENCES subjects(uri, cid) +); + +CREATE INDEX idx_assessments_state_created +ON assessments(state, created_at DESC); + +CREATE INDEX idx_assessments_subject +ON assessments(uri, cid, created_at DESC); + +CREATE TABLE current_assessments ( + src TEXT NOT NULL, + uri TEXT NOT NULL, + cid TEXT NOT NULL, + assessment_id TEXT NOT NULL REFERENCES assessments(id), + updated_at TEXT NOT NULL, + PRIMARY KEY (src, uri, cid) +); +``` + +The policy-finalization transaction updates `current_assessments` only after storing the completed result and issuing its label changes. It replaces the pointer only for the run being finalized under the subject's workflow lock. Pending, stale, cancelled, and failed-before-decision runs do not replace it; an `assessment-error` remains visible through labels and run history while the last completed pointer is retained for explanation. + +### 14.2 Findings and evidence + +```sql +CREATE TABLE findings ( + id TEXT PRIMARY KEY, + assessment_id TEXT NOT NULL REFERENCES assessments(id), + source TEXT NOT NULL, + category TEXT NOT NULL, + severity TEXT NOT NULL, + confidence REAL, + title TEXT NOT NULL, + public_summary TEXT NOT NULL, + private_detail TEXT NOT NULL, + evidence_refs_json TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_findings_assessment +ON findings(assessment_id); + +CREATE TABLE evidence_objects ( + id TEXT PRIMARY KEY, + assessment_id TEXT NOT NULL REFERENCES assessments(id), + kind TEXT NOT NULL, + sha256 TEXT NOT NULL, + r2_key TEXT, + metadata_json TEXT NOT NULL, + created_at TEXT NOT NULL +); +``` + +Large private evidence lives encrypted at rest in R2; D1 stores references and hashes. Raw bundles need not be retained by the labeller when the aggregator mirror is durable. Selected evidence required to explain a decision is retained according to the policy below. + +### 14.3 Issued labels + +```sql +CREATE TABLE issued_labels ( + seq INTEGER NOT NULL UNIQUE, + src TEXT NOT NULL, + uri TEXT NOT NULL, + cid TEXT, + val TEXT NOT NULL, + neg INTEGER NOT NULL DEFAULT 0, + cts TEXT NOT NULL, + exp TEXT, + sig BLOB NOT NULL, + ver INTEGER NOT NULL DEFAULT 1, + signing_key_id TEXT NOT NULL, + assessment_id TEXT, + action_id TEXT, + PRIMARY KEY (src, uri, val, cts) +); + +CREATE INDEX idx_issued_labels_subject +ON issued_labels(uri, cid, seq); + +CREATE TABLE label_sequence ( + id INTEGER PRIMARY KEY CHECK (id = 1), + next_seq INTEGER NOT NULL +); +``` + +Sequence allocation and label insertion occur atomically. The service never derives sequence from `MAX(seq)`. + +### 14.4 Actions, notifications, and cursors + +```sql +CREATE TABLE actions ( + id TEXT PRIMARY KEY, + actor_type TEXT NOT NULL, + actor_id TEXT NOT NULL, + role TEXT, + action TEXT NOT NULL, + subject_uri TEXT NOT NULL, + subject_cid TEXT, + label_value TEXT, + reason TEXT NOT NULL, + idempotency_key TEXT NOT NULL UNIQUE, + metadata_json TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE notifications ( + id TEXT PRIMARY KEY, + action_id TEXT NOT NULL REFERENCES actions(id), + channel TEXT NOT NULL, + recipient_hash TEXT NOT NULL, + state TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + provider_id TEXT, + last_error TEXT, + created_at TEXT NOT NULL, + sent_at TEXT +); + +CREATE TABLE ingest_state ( + source TEXT PRIMARY KEY, + cursor TEXT NOT NULL, + updated_at TEXT NOT NULL +); +``` + +## 15. Label Signing and Distribution + +Signing follows the ATProto label specification exactly: + +1. Construct only the allowed v1 label fields, excluding `sig` and `$type`. +2. Encode with deterministic DRISL CBOR. +3. SHA-256 the canonical bytes. +4. Sign the raw hash using the private key corresponding to `#atproto_label`. +5. Store signature bytes and signing key ID. + +No generic object-signing helper accepts arbitrary fields. The issuer takes a typed internal proposal and reconstructs the complete label object itself. + +The private key is stored in Cloudflare Secrets Store. A plain Worker secret is acceptable only for initial local/staging deployment, not production launch. + +### 15.1 Subscriber Durable Object + +One named instance, `main`, owns inbound subscribers. + +- Uses WebSocket Hibernation. +- Replays D1 labels with `seq > cursor` before joining live delivery. +- Persists no correctness-critical state only in memory. +- Broadcast failures affect one subscriber and do not roll back an issued label. +- Supports replay from `cursor=0` for retained history. + +### 15.2 Key rotation + +1. Pause new issuance while assessments continue to queue decisions. +2. Generate and store a new private key. +3. Update the DID document's `#atproto_label` verification method. +4. Resume issuance with the new key ID. +5. Re-sign old labels lazily when queried, preserving `cts`. +6. Monitor downstream signature failures. + +The operator console exposes rotation state but never private key material. + +### 15.3 Key compromise + +Key compromise is different from routine rotation. The incident procedure must: + +- Remove/replace the compromised key in the DID document. +- Pause issuance. +- Publish an incident notice in the policy endpoint. +- Establish the last trusted sequence/time where possible. +- Reissue current effective labels with the new key. +- Tell aggregators to replay from a declared safe cursor or full history. +- Preserve compromised history for forensics without treating signatures as currently valid. + +## 16. Aggregator Integration + +The existing aggregator schema already has `labels`, `label_state`, `labellers`, and `ingest_state`, but no working subscription or hydration path. + +### 16.1 Ingest + +For each configured labeller: + +1. Resolve `#atproto_labeler` and `#atproto_label` from its DID document. +2. Maintain an outbound `subscribeLabels` connection in a per-labeller Durable Object. +3. Resume from the persisted cursor. +4. Verify each signature, refreshing the DID document once on failure. +5. Enqueue verified labels. +6. Insert append-only history and update current state only when the incoming `cts` is newer. +7. Persist cursor after durable acceptance. + +Negated and expired labels remain in history but are not hydrated as active labels. + +### 16.2 Request policy + +The aggregator parses `atproto-accept-labelers` once at the request boundary and passes a typed policy to every handler. + +- Missing header: apply deployment defaults. +- Empty header: hydrate/enforce no labellers. +- Invalid syntax: return a client error. +- Repeated DIDs: merge flags. +- Unknown/unavailable DID: omit it from `atproto-content-labelers`. +- `redact`: apply `!takedown` and `!suspend` redaction semantics. + +The response sets `atproto-content-labelers` to the sources actually considered. + +The current router's response-header contract must be corrected as part of label ingest: allow and parse `atproto-accept-labelers`, set `atproto-content-labelers`, and expose `atproto-content-labelers` through CORS. It must not echo/expose the request header as though it were the interpreted response policy. Contract tests cover missing, empty, malformed, repeated, unavailable, and `redact` forms. + +### 16.3 Hydration + +Hydrate labels in batches for all package/release URIs in a response. Do not query once per item. + +The package and release views include active labels from requested/accepted sources. The aggregator may omit signatures in hydrated views as allowed by the label spec; clients can retrieve full signed labels from the labeller. + +### 16.4 Eligibility and filtering + +For the official default policy: + +- Search excludes package/publisher `!takedown`, `publisher-compromised`, and package-wide blocking labels. +- Release lists omit redacted releases and expose blocked releases as tombstones where useful. +- `getLatestRelease` selects the highest semver with active `assessment-passed` and no active blocking labels. +- Direct release resolution returns enough status to explain pending/error/blocked outcomes rather than pretending the release does not exist. +- Package/publisher labels cascade at query-policy time; they are not copied onto every release. + +Subject expansion is explicit: + +- Package search/detail loads labels for the publisher DID and package profile URI. A CID-bound package label applies only when its CID matches the current profile CID; a URI-wide label has no CID. +- Release list/detail/latest loads labels for the publisher DID, package profile URI, and release URI. A CID-bound package or release label applies only to the corresponding current record CID. +- Publisher DID labels are URI-wide because an account label has no record CID. +- Every lookup also filters accepted source DID, `neg = 0`, and unexpired state before policy evaluation. + +Queries batch these subject sets and use indexes covering `(uri, src, val)`/current-state access. Tests exercise all three subject scopes and both CID-bound and URI-wide labels. + +`packages.latest_version` cannot be trusted for label-aware selection. `getLatestRelease` must query eligible releases dynamically or maintain a separate policy-specific projection updated by label ingestion. At current scale, the dynamic query is simpler and safer. + +### 16.5 Cache behavior + +Label-dependent responses remain `private, no-store` and read D1 primary state so a newly ingested takedown affects the next response globally. Static policy and DID documents may be cached. + +## 17. Registry Client and EmDash Admin Behavior + +The registry client exposes one policy helper rather than making every UI or handler compare raw strings. + +```ts +interface ReleaseModeration { + eligibility: "eligible" | "pending" | "error" | "blocked"; + blockingLabels: Label[]; + warningLabels: Label[]; + assessment?: PublicAssessmentRef; +} +``` + +The helper takes release labels, package labels, publisher labels, accepted-labeller policy, and the current package/release CIDs. It is the only supported server-side eligibility decision path. Both install and update handlers call it immediately before artifact download; they must not retain an independent `security-yanked` string check. + +Official behavior: + +- Manual `!takedown`, `security-yanked`, package, or publisher blocking labels always block and cannot be bypassed by a release override. +- Active `assessment-overridden` plus `assessment-passed`: treat the exact release CID as eligible despite automated pending/error/blocking labels, while displaying the newer findings to the operator. +- Otherwise, active `assessment-pending` or `assessment-error`: do not recommend or install, even if an older automated `assessment-passed` remains active. +- No active `assessment-passed`: do not recommend or install. +- Active blocking label: do not install. +- Warning labels only: show a warning and require explicit confirmation. +- `!takedown`: redact according to accepted labeller flags; no install override. +- `security-yanked`: block fresh installs and updates; alert sites with the installed version. +- Package/publisher blocking label: apply to all releases. + +The server-side install/update handler repeats the eligibility check immediately before downloading. Browser UI state is not an authorization or safety boundary. + +The admin displays: + +- Label name and localized definition. +- Issuing labeller. +- Public assessment summary and coverage. +- Policy/model version. +- Whether the decision is automated or manually overridden. +- Reconsideration contact for blocked publishers. + +Query/cache keys must include accepted labeller configuration. A policy change must not leave stale search/detail data in React Query caches. + +Pinned CLI installs may offer a deliberate override for warning labels, but never for missing assessment, blocking labels, or `!takedown` under the official policy unless a site operator explicitly changes trusted-labeller policy at deployment level. + +## 18. Publisher Notifications and Reconsideration + +### 18.1 Contact resolution + +Resolve contacts in order: + +1. Package `security[]` contact. +2. Package author/contact metadata. +3. Publisher profile contact. + +The service does not attempt to read private PDS account email. + +### 18.2 Notification events + +Notify on: + +- New blocking label. +- New warning label. +- Manual override. +- Retraction of a blocking/warning label. +- Assessment error that remains unresolved past the operational target. +- Emergency takedown. + +Do not send a separate email for every internal retry or unchanged reassessment. + +Email includes subject, label, public summary, public assessment URL, effect in official clients, and reconsideration instructions. It does not include private exploit detail. + +### 18.3 ATProto-visible notice + +There is no generic guaranteed ATProto inbox for arbitrary plugin publishers. V1 should publish an EmDash notification/decision record in the labeller's repository referencing the subject and public assessment, if a small interoperable record shape is justified. Publishers and tools may subscribe to those records. + +This is supplementary. Email is the active notification channel. The spec must not claim that publishing a record guarantees delivery. + +### 18.4 Reconsideration + +V1 documents a monitored email address and includes an opaque assessment ID. An operator locates the assessment, records the publisher's reason in private notes, and may re-run or override. + +There is no formal SLA, reporter portal, or authenticated appeals state machine in v1. Every resulting label action is still audited and the publisher receives the outcome. + +## 19. Evidence, Privacy, and Retention + +### 19.1 Public transparency + +Public summaries explain: + +- What class of issue was found. +- Whether it came from automation or manual action. +- Which assessment coverage completed. +- Which policy/model/scanner versions were used. +- Which labels are active. +- How to request reconsideration. + +They do not expose secrets, contact addresses, exploit recipes, or irrelevant source excerpts. + +### 19.2 Private evidence + +Private evidence may include: + +- Model request/response after secret redaction. +- Code excerpts and file hashes. +- Scanner raw results. +- Image-analysis details. +- Operator notes. +- Notification delivery failures. + +Access is limited to reviewer/admin roles and every view of highly sensitive evidence may be audit-logged if practical. + +### 19.3 Retention + +Initial policy: + +- Signed labels and public assessment summaries: indefinite while the service operates. +- Operator audit log: indefinite. +- Normal private assessment evidence: 180 days. +- Evidence supporting an active block/takedown: retain while active plus 365 days. +- Raw fetched bundles: do not retain after assessment unless quarantined as incident evidence. +- Contact addresses: store only for delivery duration; retain a keyed hash for deduplication/audit. +- AI Gateway logs: configure a retention period consistent with private evidence policy and disable provider training where applicable. + +Retention values are policy constants versioned in code and may change after legal/privacy review. + +## 20. Security Requirements + +### 20.1 Threats and mitigations + +| Threat | Mitigation | +| --- | --- | +| Prompt injection in source/metadata | Delimit untrusted input, strict system task, structured schema, category allowlist, evidence-reference validation | +| Model false positive blocks legitimate release | Narrow blockable categories, fixture calibration, public explanation, immediate reviewer retraction, immutable audit trail | +| Model false negative | Defense in depth: deterministic checks, scanners, sandbox enforcement, reports in future, reassessment on intelligence updates | +| Malicious artifact attacks fetcher/extractor | Mirror preference, checksum verification, SSRF controls, streaming caps, safe archive parsing, time budgets | +| Aggregator serves wrong artifact | Verify signed release record, CID, and artifact checksum independently | +| Publisher mutates artifact URL bytes | Checksum binds accepted bytes; mismatch blocks assessment and installation | +| Forged Jetstream event | Re-fetch and verify signed source record before assessment | +| Forged label | DRISL signature verification against `#atproto_label` | +| Signing-key exfiltration through assessor | Typed policy proposal boundary; key accessible only to issuer module; Secrets Store | +| Access header spoofing | Verify Access JWT signature, issuer, audience, expiry; do not trust raw identity headers | +| Reviewer account compromise | Role limits, required reasons, confirmation ceremony, alerts, immutable action log | +| Admin incorrectly issues `!takedown` | Explicit high-friction confirmation, immediate alert, signed negation rollback | +| Replay/out-of-order labels | Monotonic sequence for stream and latest-`cts` current-state projection | +| Stale labels after CID update | Automated labels are CID-bound; clients check CID applicability | +| Package/publisher overreach | Broad labels are manual only; subject-wide action shown explicitly | +| Assessment absence mistaken for safety | Official clients require positive `assessment-passed` | +| Labeller outage blocks ecosystem indefinitely | Clear pending/error state, alerts, retry/DLQ, site operators can choose a different trust policy/labeller | +| Policy/model drift changes outcomes silently | Version all policy/model/prompt/scanner inputs; no automatic whole-catalog rewrite | +| Dependency scanner noise | Applicability/blocking rule stricter than raw severity score | +| Evidence leaks vulnerability details | Public/private split and redacted summaries | + +### 20.2 Signing proposal validation + +Before signing, the issuer verifies: + +- `val` is in the deployed policy vocabulary or a protocol-reserved value allowed for the actor role. +- Automated actions target release records only and include CID. +- Automated blocking labels correspond to a critical finding in an allowed security/impersonation category. +- Quality findings cannot map to blocking labels. +- `!takedown` and publisher-wide labels come from an admin action. +- Manual `assessment-passed` and `assessment-overridden` labels come from the same reviewer/admin unblock action, target an exact release URI + CID, and atomically negate the blocking labels named by that action. +- Manual action has a verified actor, role, reason, and idempotency key. +- Subject URI/DID parses and belongs to an allowed collection/type. + +This enforcement is code, not prompt text. + +## 21. Reliability and Recovery + +### 21.1 Targets + +Initial operational targets, not protocol guarantees: + +- New release observed from Jetstream: under 30 seconds p95. +- Pending label issued: under 60 seconds p95. +- Normal assessment completed: under 10 minutes p95. +- Emergency manual label visible to connected subscribers: under 10 seconds p95. +- Public label/query availability: 99.9% monthly target. + +At a handful of releases per day, correctness and debuggability matter more than throughput. + +### 21.2 Retry policy + +- Record/artifact fetch: bounded exponential retry; distinguish permanent checksum/validation failure from transient network failure. +- Scanners/models: bounded retry on transport/rate/availability errors; never reinterpret an execution error as a malicious finding. +- Notifications: independent retry and DLQ; notification failure does not roll back a label. +- Label broadcast: D1 commit is authoritative; disconnected subscribers replay by cursor. + +### 21.3 Reconciliation + +A scheduled reconciliation job: + +- Compares recently observed aggregator releases with labeller subjects. +- Finds releases lacking any assessment state. +- Finds pending assessments older than the target. +- Validates that each completed effective assessment has the expected current labels. +- Verifies subscriber high-water sequence against D1. +- Re-resolves the labeller DID document and signing key publication. + +The independent Jetstream consumer remains the primary discovery path. Reconciliation may use the aggregator as a completeness cross-check without making it the authority for source records. + +### 21.4 Disaster recovery + +Back up D1 and private evidence metadata regularly. Recovery order: + +1. Restore D1 and signing key version mapping. +2. Restore public query endpoints. +3. Restore subscriber stream and verify sequence continuity. +4. Resume Jetstream discovery from persisted cursor. +5. Reconcile missing releases. +6. Resume assessment workflows and notifications. + +If sequence continuity cannot be preserved, publish an operational incident and require subscribers to replay from cursor `0` after the restored history is available. + +## 22. Observability + +Emit metrics for: + +- Jetstream connection state and cursor age. +- Releases observed, deduplicated, queued, running, completed, blocked, warned, errored. +- Assessment duration by stage. +- Artifact mirror hit/fallback/failure and checksum mismatch. +- Scanner/model error rate, latency, token use, and cost. +- Finding and label counts by category and policy/model version. +- Operator overrides and automated-label retraction rate. +- Issued label sequence and Subscriber DO connection count. +- Aggregator subscriber lag where observable. +- Notification delivery state. +- DLQ depth and oldest age. + +Alert on: + +- Jetstream disconnected or cursor stale for more than five minutes. +- Pending assessment older than 20 minutes. +- Any sustained model/scanner failure causing assessment errors. +- Signing failure or DID/key mismatch. +- Subscriber endpoint failure. +- Emergency `!takedown` issuance. +- Spike in automated blocks or operator override rate. +- Queue/DLQ growth. +- Reconciliation finds an unassessed release. + +AI Gateway metadata should include assessment ID, policy version, stage, and model route, but not publisher contact or secrets. + +## 23. Policy and Model Change Process + +Policy, prompts, schemas, label definitions, model routes, and thresholds are versioned source files reviewed like code. + +A change requires: + +1. New immutable policy version. +2. Fixture corpus evaluation. +3. Comparison against the prior production version. +4. Explicit review of newly blocked and newly allowed fixtures. +5. Staging run against a representative historical sample. +6. Deployment notes stating whether historical reassessment is required. +7. Monitoring of block and override rates after rollout. + +The fixture corpus includes the legacy marketplace cases plus new cases for: + +- Legitimate network use. +- Data exfiltration. +- Credential harvesting. +- Crypto mining/resource abuse. +- Obfuscation. +- Prompt injection. +- Dynamic URL construction. +- Misleading screenshots. +- Brand impersonation. +- Vulnerable but unreachable dependency. +- Known exploited dependency. +- Generated but functional plugin. +- Low-effort placeholder. +- False-positive-sensitive security tooling. + +Tests cannot call a nondeterministic live model in normal CI. CI validates parsers, policy mapping, fixtures with recorded model outputs, and optional scheduled calibration runs. Model upgrades require a recorded calibration report. + +## 24. Testing Strategy + +### 24.1 Unit tests + +- Label DRISL signing and verification against external vectors. +- Strict signing-field allowlist. +- Label vocabulary and role/subject restrictions. +- Finding normalization and policy mapping. +- Security/impersonation critical block behavior. +- Quality critical warning-only behavior. +- CID applicability, negation, expiry, and supersession. +- Access JWT verification and role mapping. +- CSRF/idempotency enforcement. +- Public/private assessment serialization. +- Contact resolution and redaction. + +### 24.2 Real D1/workerd tests + +- Migrations and constraints. +- Atomic sequence allocation under concurrency. +- Idempotent discovery/assessment enqueue. +- Assessment state transitions. +- Issuance plus action audit transaction. +- Subscriber replay and reconnect from cursor. +- Key rotation query re-signing. +- Notification outbox retries. + +### 24.3 Integration tests + +- Publish release record -> Jetstream event -> verified record fetch -> mirrored bundle -> assessment -> labels -> aggregator ingest -> admin eligibility. +- No positive assessment -> install blocked. +- Pass plus warning -> install confirmation required. +- Blocking descriptive label -> search/detail visible with explanation, install blocked. +- Admin `!takedown` -> redacted response for `redact` consumer. +- Negation -> release becomes eligible on next primary read where `assessment-passed` is active. +- New release CID -> old CID labels do not apply. +- Package/publisher manual label cascades through aggregator policy. +- Artifact mirror unavailable -> safe declared-URL fallback. +- Checksum mismatch -> no AI call and blocking deterministic result. +- Model outage -> retries then `assessment-error`, never a malicious label. + +### 24.4 Adversarial tests + +- Source code instructs the model to output pass/critical/arbitrary labels. +- Findings reference nonexistent evidence spans. +- Archive bomb, path traversal, symlink, huge sparse file, malformed gzip/tar. +- Redirect chain to private/link-local/metadata addresses. +- DNS rebinding between validation and fetch. +- Image parser bombs and MIME confusion. +- Forged release event and forged record proof. +- Forged label, stale signing key, and malicious extra signed fields. +- Out-of-order positive/negation delivery. +- Compromised reviewer attempts admin-only action. +- Spoofed Access identity header without valid JWT. +- Duplicate mutation/idempotency replay. +- Publisher edits profile during assessment. +- Release deletion during assessment. + +### 24.5 UI tests + +- Reviewer and admin action availability. +- Confirmation ceremony and required reason. +- Public/private evidence separation. +- Pending/error/blocked/warned states. +- Arabic RTL layout and keyboard navigation. +- Localized label definitions and operator strings. + +## 25. Delivery Plan + +### Phase 0: Contract freeze + +- Ratify label vocabulary, subject rules, and official-client effects. +- Cut current aggregator/admin/core constants and SQL from `security:yanked` to `security-yanked`, with a production preflight for persisted legacy labels. +- Define the complete blocking-label set and the single registry-client moderation helper contract. +- Add experimental labeller lexicons and policy schema. +- Publish signing/verification test vectors. +- Decide the standard labeler declaration/PDS hosting detail. + +### Phase 1: Issuer and distribution + +- Create `apps/labeller` Worker, D1, DID document, signing key, query endpoint, and Subscriber DO. +- Implement typed internal issuance, negation, sequence allocation, key IDs, and audit actions. +- Add a minimal admin-only CLI/API for test issuance before the console. +- Implement aggregator subscription, signature verification, label state, subject expansion, and RFC-8941 request-header parsing. +- Set and CORS-expose `atproto-content-labelers`; add missing/empty/malformed/repeated/unavailable/redact contract tests. +- Prove a manual label reaches aggregator and client end to end. + +### Phase 2: Eligibility contract + +- Add `assessment-pending`, `assessment-passed`, and `assessment-error` handling. +- Make aggregator latest-release selection label-aware. +- Add registry-client moderation helpers. +- Make the shared helper the only install/update eligibility gate and enforce positive assessment in both handlers. +- Update admin search/detail/install UI. + +This phase may initially use deterministic test assessments. It freezes the safety contract before AI is involved. + +### Phase 3: Automated assessment + +- Add independent Jetstream discovery and reconciliation. +- Add verified mirror artifact fetch and safe fallback. +- Port/harden deterministic, code, metadata, and image audit inputs. +- Add SBOM/dependency scanning. +- Add Workers AI through AI Gateway and versioned policy resolution. +- Port and extend the fixture corpus. + +### Phase 4: Operator product + +- Add Access JWT validation and reviewer/admin mapping. +- Build Kumo/Lingui console. +- Add override, re-run, `security-yanked`, package action, and admin emergency takedown flows. +- Add notifications and reconsideration handling. +- Add operational dashboards and alerts. + +### Phase 5: Production launch + +- Assess all first-party releases with no exemption. +- Run historical staging calibration. +- Complete key backup/rotation and disaster-recovery drill. +- Verify full replay from cursor `0` into a fresh aggregator. +- Verify a fresh self-hosted aggregator can subscribe and enforce labels. +- Publish policy, contact, privacy, and reconsideration documentation. +- Enable positive-assessment requirement in official production clients. + +## 26. Definition of Done + +The v1 labelling service is complete when: + +- Every new release is independently observed and receives a CID-bound assessment state. +- Official clients require a signed positive assessment and block configured descriptive security labels. +- Deterministic, dependency, code/metadata AI, image, and publisher-history inputs run under a versioned policy. +- AI critical findings can hard-block only security/impersonation categories. +- Quality findings remain warning/downranking signals. +- Labels are spec-compliant, signed, queryable, replayable, negatable, and verified by the aggregator. +- The aggregator correctly parses accepted labellers, hydrates labels, handles redaction, and selects eligible latest releases. +- Reviewers can inspect and override assessments; admins can issue emergency takedowns. +- Cloudflare Access JWTs are verified in the Worker and roles are enforced server-side. +- Public assessment summaries explain decisions without exposing private evidence. +- Publishers receive notices through security/profile contacts and have a documented reconsideration route. +- Signing-key rotation, subscriber replay, model outage, artifact failure, and disaster recovery have been exercised. +- A third-party fresh aggregator can consume the public labeller without private EmDash infrastructure. + +## 27. Deferred Follow-ups + +- Standard `com.atproto.moderation.createReport` intake and a report-review queue. +- Authenticated publisher appeals with status tracking. +- Two-person approval or expiring emergency holds for high-risk manual actions. +- Automated package/publisher escalation for repeated offenses. +- Additional independent/community labellers and site-level trust-policy UI. +- Publisher self-requested security yanks through authenticated repository records. +- Formal ATProto notification record if the ecosystem converges on one. +- Automated whole-catalog reassessment on major policy/model versions. +- Native plugin/npm assessment. +- Multi-tenant labelling SaaS. +- Hardware-backed or remote signing service if Secrets Store custody is insufficient. + +## 28. Remaining Ratification Points + +These are implementation-specific decisions that should be settled before Phase 0 closes, not product questions requiring another broad design round. + +1. Exact stable/experimental NSIDs for labeller public APIs and any decision-notice record. +2. Whether the labeler publishes `app.bsky.labeler.service/self` from a small hosted repository or exposes only the base label service plus EmDash policy document. +3. Initial Workers AI model ID and the measured calibration threshold used to assign model severity/confidence. +4. Initial dependency scanner/advisory sources and the exact critical applicability rule. +5. Production Secrets Store key format and operational key-generation ceremony. +6. Email delivery provider and monitored reconsideration address. +7. Final retention values after legal/privacy review. From dbf641351837b79701d75a40f0cabc92b446ea25 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Fri, 10 Jul 2026 10:35:14 +0000 Subject: [PATCH 002/137] 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 3e7f830d4d1ffba9e3c4e24d8b88d255b73df672 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 11:43:59 +0100 Subject: [PATCH 003/137] style: format --- .../implementation-plan.md | 88 +++--- .../plugin-registry-labelling-service/spec.md | 260 +++++++++--------- 2 files changed, 174 insertions(+), 174 deletions(-) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index d2bb08281f..b660c6360f 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -42,21 +42,21 @@ The implementation is complete when: ### Workstream IDs -| ID | Workstream | Primary output | -| --- | --- | --- | -| `W0` | Decisions and feasibility | Ratified contracts and proved platform assumptions | -| `W1` | Shared protocol and policy | Lexicons, label crypto, policy schema, and moderation semantics | -| `W2` | Service foundation and persistence | `apps/labeller`, D1 state, bindings, queues, and workflow skeleton | -| `W3` | Label issuance and distribution | Typed signer, label history, query API, and replayable subscription | -| `W4` | Aggregator label consumption | Verified subscription, current state, hydration, redaction, and cascades | -| `W5` | Client eligibility enforcement | One moderation helper used by registry client, core, CLI, and admin | -| `W6` | Discovery and assessment orchestration | Independent Jetstream ingest, verified subjects, run lifecycle, reconciliation | -| `W7` | Artifact and deterministic analysis | Safe artifact acquisition, bundle checks, dependency/SBOM scanners | -| `W8` | AI, image, and policy resolution | Versioned model pipeline, normalized findings, automated decisions | -| `W9` | Operator authentication and console | Access-protected review UI and manual label actions | -| `W10` | Transparency and notifications | Public assessment API, policy document, email, reconsideration flow | -| `W11` | Operations and self-hosting | Deployment, secrets, observability, backups, rotation, runbooks | -| `W12` | Conformance and security | Cross-component, adversarial, browser, and production-launch verification | +| ID | Workstream | Primary output | +| ----- | -------------------------------------- | ------------------------------------------------------------------------------ | +| `W0` | Decisions and feasibility | Ratified contracts and proved platform assumptions | +| `W1` | Shared protocol and policy | Lexicons, label crypto, policy schema, and moderation semantics | +| `W2` | Service foundation and persistence | `apps/labeller`, D1 state, bindings, queues, and workflow skeleton | +| `W3` | Label issuance and distribution | Typed signer, label history, query API, and replayable subscription | +| `W4` | Aggregator label consumption | Verified subscription, current state, hydration, redaction, and cascades | +| `W5` | Client eligibility enforcement | One moderation helper used by registry client, core, CLI, and admin | +| `W6` | Discovery and assessment orchestration | Independent Jetstream ingest, verified subjects, run lifecycle, reconciliation | +| `W7` | Artifact and deterministic analysis | Safe artifact acquisition, bundle checks, dependency/SBOM scanners | +| `W8` | AI, image, and policy resolution | Versioned model pipeline, normalized findings, automated decisions | +| `W9` | Operator authentication and console | Access-protected review UI and manual label actions | +| `W10` | Transparency and notifications | Public assessment API, policy document, email, reconsideration flow | +| `W11` | Operations and self-hosting | Deployment, secrets, observability, backups, rotation, runbooks | +| `W12` | Conformance and security | Cross-component, adversarial, browser, and production-launch verification | ### High-Level Graph @@ -772,11 +772,11 @@ Return: ```ts interface ReleaseModeration { - eligibility: "eligible" | "pending" | "error" | "blocked"; - blockingLabels: Label[]; - warningLabels: Label[]; - assessment?: PublicAssessmentRef; - override?: PublicManualActionRef; + eligibility: "eligible" | "pending" | "error" | "blocked"; + blockingLabels: Label[]; + warningLabels: Label[]; + assessment?: PublicAssessmentRef; + override?: PublicManualActionRef; } ``` @@ -1489,14 +1489,14 @@ Gate 6 passes and production positive-assessment enforcement may be enabled. After Gate 0: -| Parallel lane | Can proceed | Must wait for | -| --- | --- | --- | -| Protocol | `W1.2` lexicons, `W1.4` crypto, `W1.6` headers | Ratified identifiers/vectors | -| Service core | `W2.1` scaffold, migration drafting | Stable schema types for final migration | -| Artifact security | `W7.1` ownership, `W7.2`/`W7.3` primitives | Mirror contract and shared-package decision | -| Operations | Minimal staging signer bindings in `W3.1`, CI skeleton in `W2.1` | Full production hardening waits for final secrets/key format | -| Aggregator | Subscription/hydration design and fixture tests | `W1` contracts; live connection waits for `W3` | -| Client/UI | Static eligibility fixtures and UI states | Runtime integration waits for `W4`/`W10` | +| Parallel lane | Can proceed | Must wait for | +| ----------------- | ---------------------------------------------------------------- | ------------------------------------------------------------ | +| Protocol | `W1.2` lexicons, `W1.4` crypto, `W1.6` headers | Ratified identifiers/vectors | +| Service core | `W2.1` scaffold, migration drafting | Stable schema types for final migration | +| Artifact security | `W7.1` ownership, `W7.2`/`W7.3` primitives | Mirror contract and shared-package decision | +| Operations | Minimal staging signer bindings in `W3.1`, CI skeleton in `W2.1` | Full production hardening waits for final secrets/key format | +| Aggregator | Subscription/hydration design and fixture tests | `W1` contracts; live connection waits for `W3` | +| Client/UI | Static eligibility fixtures and UI states | Runtime integration waits for `W4`/`W10` | After Gate 2: @@ -1537,22 +1537,22 @@ Each item should remain independently reviewable and leave production behavior s ## Cross-Workstream Ownership Boundaries -| Concern | Owner | Consumers | -| --- | --- | --- | -| Label vocabulary/effects | `W1` | All workstreams | -| DRISL signing/verification | `W1` | `W3`, `W4`, conformance | -| D1 state transitions | `W2` | `W3`, `W6`, `W9`, `W10` | -| Public label history/sequence | `W3` | `W4`, third parties | -| Aggregator current label state | `W4` | `W5`, directory/admin | -| Release eligibility semantics/evaluator | `W1` | `W4`, `W5`, core, CLI, admin | -| Consumer eligibility adapters | `W5` | Core, CLI, admin | -| Run lifecycle/supersession | `W6` | `W8`, `W9`, `W10` | -| Artifact safety | `W7` | Labeller, installer, delegated release service | -| Finding-to-label mapping | `W8` | `W3`, `W9`, transparency | -| Human authorization/actions | `W9` | `W3`, audit, notifications | -| Public explanation/contact | `W10` | Registry client/admin, publishers | -| Production configuration/runbooks | `W11` | Operators, self-hosters | -| Cross-component acceptance | `W12` | Gate 6 | +| Concern | Owner | Consumers | +| --------------------------------------- | ----- | ---------------------------------------------- | +| Label vocabulary/effects | `W1` | All workstreams | +| DRISL signing/verification | `W1` | `W3`, `W4`, conformance | +| D1 state transitions | `W2` | `W3`, `W6`, `W9`, `W10` | +| Public label history/sequence | `W3` | `W4`, third parties | +| Aggregator current label state | `W4` | `W5`, directory/admin | +| Release eligibility semantics/evaluator | `W1` | `W4`, `W5`, core, CLI, admin | +| Consumer eligibility adapters | `W5` | Core, CLI, admin | +| Run lifecycle/supersession | `W6` | `W8`, `W9`, `W10` | +| Artifact safety | `W7` | Labeller, installer, delegated release service | +| Finding-to-label mapping | `W8` | `W3`, `W9`, transparency | +| Human authorization/actions | `W9` | `W3`, audit, notifications | +| Public explanation/contact | `W10` | Registry client/admin, publishers | +| Production configuration/runbooks | `W11` | Operators, self-hosters | +| Cross-component acceptance | `W12` | Gate 6 | If a change crosses an ownership boundary, the owning shared contract changes first and all consumers update in the same integration gate. Do not fix policy divergence with local string checks. diff --git a/.opencode/plans/plugin-registry-labelling-service/spec.md b/.opencode/plans/plugin-registry-labelling-service/spec.md index 289464de7e..23b17893ec 100644 --- a/.opencode/plans/plugin-registry-labelling-service/spec.md +++ b/.opencode/plans/plugin-registry-labelling-service/spec.md @@ -12,31 +12,31 @@ It does not amend RFC #694. The RFC establishes the registry and identifies a la The following decisions were made before drafting this spec. -| Area | Decision | -| --- | --- | -| Launch role | Safety and quality labeller, with narrow takedown authority | -| Assessment coverage | Assess every release, including verified and first-party publishers | -| Automated inputs | Bundle code, manifest and metadata, images, dependencies/SBOM, and publisher history | -| Automated authority | Deterministic critical findings, critical scanner matches, and a single AI critical finding may hard-block | -| AI hard-block scope | Security and impersonation findings only; quality findings never hard-block | -| Hard-block representation | Descriptive labels, not `!takedown` | -| Official client policy | Require a positive assessment label; hard-block configured high-risk labels | -| Manual scope | Review/override automated decisions and issue emergency takedowns | -| Broad labels | Package and publisher labels require manual action | -| Emergency takedown | Admin-only `!takedown` issuance and retraction | -| Operator auth | Cloudflare Access | -| Operator roles | Reviewer and admin | -| Deployable shape | One Worker application for assessment, issuance, distribution, and console | -| Discovery | Independent Jetstream consumer | -| Artifact source | Verified aggregator mirror, with a declared-URL fallback | -| AI | Workers AI through AI Gateway | -| Public evidence | Public assessment summary; private detailed evidence | -| Publisher notice | Package security contact, then publisher profile fallback; email plus an ATProto-visible notice where practical | -| Appeals | Email/manual reconsideration, not a first-class appeals workflow in v1 | -| Policy changes | Versioned in code and deployed through normal review | -| Reassessment | New releases and relevant scanner-intelligence changes | -| Assessment failure | Release remains in a temporary pending/unknown state and official clients block it | -| Reports | Defer `com.atproto.moderation.createReport` and report triage in v1 | +| Area | Decision | +| ------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Launch role | Safety and quality labeller, with narrow takedown authority | +| Assessment coverage | Assess every release, including verified and first-party publishers | +| Automated inputs | Bundle code, manifest and metadata, images, dependencies/SBOM, and publisher history | +| Automated authority | Deterministic critical findings, critical scanner matches, and a single AI critical finding may hard-block | +| AI hard-block scope | Security and impersonation findings only; quality findings never hard-block | +| Hard-block representation | Descriptive labels, not `!takedown` | +| Official client policy | Require a positive assessment label; hard-block configured high-risk labels | +| Manual scope | Review/override automated decisions and issue emergency takedowns | +| Broad labels | Package and publisher labels require manual action | +| Emergency takedown | Admin-only `!takedown` issuance and retraction | +| Operator auth | Cloudflare Access | +| Operator roles | Reviewer and admin | +| Deployable shape | One Worker application for assessment, issuance, distribution, and console | +| Discovery | Independent Jetstream consumer | +| Artifact source | Verified aggregator mirror, with a declared-URL fallback | +| AI | Workers AI through AI Gateway | +| Public evidence | Public assessment summary; private detailed evidence | +| Publisher notice | Package security contact, then publisher profile fallback; email plus an ATProto-visible notice where practical | +| Appeals | Email/manual reconsideration, not a first-class appeals workflow in v1 | +| Policy changes | Versioned in code and deployed through normal review | +| Reassessment | New releases and relevant scanner-intelligence changes | +| Assessment failure | Release remains in a temporary pending/unknown state and official clients block it | +| Reports | Defer `com.atproto.moderation.createReport` and report triage in v1 | ## 2. Goals @@ -111,18 +111,18 @@ flowchart LR ### 5.1 Components -| Component | Responsibility | -| --- | --- | -| Discovery Durable Object | Maintains the outbound Jetstream subscription and persists the cursor | -| Assessment Queue | Buffers and deduplicates immutable release assessment jobs | -| Assessment Workflow | Fetches verified inputs, runs checks, stores evidence, and invokes policy | -| Policy engine | Maps normalized findings to labels and client effects under a versioned policy | -| Label issuer | Allocates sequence numbers, signs labels, stores history, and broadcasts them | -| Subscriber Durable Object | Serves replayable `subscribeLabels` WebSocket connections using hibernation | -| Query API | Implements standard `queryLabels` | -| Public assessment API | Exposes case-specific summaries without private evidence | -| Operator console | Provides queue, assessment detail, override, re-run, and emergency takedown actions | -| Notification worker step | Sends publisher notices after externally visible decisions | +| Component | Responsibility | +| ------------------------- | ----------------------------------------------------------------------------------- | +| Discovery Durable Object | Maintains the outbound Jetstream subscription and persists the cursor | +| Assessment Queue | Buffers and deduplicates immutable release assessment jobs | +| Assessment Workflow | Fetches verified inputs, runs checks, stores evidence, and invokes policy | +| Policy engine | Maps normalized findings to labels and client effects under a versioned policy | +| Label issuer | Allocates sequence numbers, signs labels, stores history, and broadcasts them | +| Subscriber Durable Object | Serves replayable `subscribeLabels` WebSocket connections using hibernation | +| Query API | Implements standard `queryLabels` | +| Public assessment API | Exposes case-specific summaries without private evidence | +| Operator console | Provides queue, assessment detail, override, re-run, and emergency takedown actions | +| Notification worker step | Sends publisher notices after externally visible decisions | ### 5.2 Why one deployable @@ -176,12 +176,12 @@ Label values use lowercase kebab case as recommended by the ATProto label specif ### 7.1 Eligibility labels -| Label | Subject | Meaning | Official policy | -| --- | --- | --- | --- | -| `assessment-passed` | Release URI + CID | Required checks completed and either policy or an authorized manual override found no blocking condition | Eligible, subject to all other labels and install checks | -| `assessment-overridden` | Release URI + CID | A reviewer/admin explicitly superseded the automated outcome for this exact release CID | Use the accompanying manual pass until explicitly retracted | -| `assessment-pending` | Release URI + CID | Assessment is queued, running, retrying, or awaiting an artifact | Block temporarily | -| `assessment-error` | Release URI + CID | Assessment could not complete after bounded retries | Block and surface operational failure | +| Label | Subject | Meaning | Official policy | +| ----------------------- | ----------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `assessment-passed` | Release URI + CID | Required checks completed and either policy or an authorized manual override found no blocking condition | Eligible, subject to all other labels and install checks | +| `assessment-overridden` | Release URI + CID | A reviewer/admin explicitly superseded the automated outcome for this exact release CID | Use the accompanying manual pass until explicitly retracted | +| `assessment-pending` | Release URI + CID | Assessment is queued, running, retrying, or awaiting an artifact | Block temporarily | +| `assessment-error` | Release URI + CID | Assessment could not complete after bounded retries | Block and surface operational failure | The official client requires an active `assessment-passed` label from an accepted labeller. Absence of that label means unknown, not safe. @@ -197,17 +197,17 @@ A reviewer/admin may manually unblock a false positive by issuing negations for ### 7.2 Blocking security labels -| Label | Meaning | Automatic issuance | -| --- | --- | --- | -| `malware` | Code or payload intentionally compromises, persists, mines, destroys, or executes an unauthorized workload | Deterministic/scanner critical or AI critical | -| `data-exfiltration` | Plugin intentionally sends protected site/user data outside its declared and expected purpose | Deterministic/scanner critical or AI critical | -| `credential-harvesting` | Plugin solicits, captures, or transmits credentials deceptively | Deterministic/scanner critical or AI critical | -| `supply-chain-compromise` | Artifact or dependency evidence indicates a known malicious or substituted component | Deterministic/scanner critical or AI critical | -| `critical-vulnerability` | A scanner identifies a critical, applicable vulnerability under the policy's blocking rule | Critical scanner rule only unless AI identifies an independently blocking exploit path | -| `artifact-integrity-failure` | Available artifact bytes do not match the checksum in the signed release | Deterministic critical | -| `invalid-bundle` | The archive is malformed, unsafe, or missing required installable content | Deterministic critical | -| `undeclared-access` | Bundle behavior or manifest inconsistency attempts access outside the signed declaration | Deterministic critical; AI may propose but not establish manifest inequality | -| `impersonation` | Package or publisher materially impersonates another identity, product, or trusted project | Deterministic identity evidence or AI critical | +| Label | Meaning | Automatic issuance | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `malware` | Code or payload intentionally compromises, persists, mines, destroys, or executes an unauthorized workload | Deterministic/scanner critical or AI critical | +| `data-exfiltration` | Plugin intentionally sends protected site/user data outside its declared and expected purpose | Deterministic/scanner critical or AI critical | +| `credential-harvesting` | Plugin solicits, captures, or transmits credentials deceptively | Deterministic/scanner critical or AI critical | +| `supply-chain-compromise` | Artifact or dependency evidence indicates a known malicious or substituted component | Deterministic/scanner critical or AI critical | +| `critical-vulnerability` | A scanner identifies a critical, applicable vulnerability under the policy's blocking rule | Critical scanner rule only unless AI identifies an independently blocking exploit path | +| `artifact-integrity-failure` | Available artifact bytes do not match the checksum in the signed release | Deterministic critical | +| `invalid-bundle` | The archive is malformed, unsafe, or missing required installable content | Deterministic critical | +| `undeclared-access` | Bundle behavior or manifest inconsistency attempts access outside the signed declaration | Deterministic critical; AI may propose but not establish manifest inequality | +| `impersonation` | Package or publisher materially impersonates another identity, product, or trusted project | Deterministic identity evidence or AI critical | Official EmDash clients block these labels. Other consumers choose their own behavior. @@ -215,25 +215,25 @@ The policy must distinguish a vulnerable dependency from an actually blocking cr ### 7.3 Warning labels -| Label | Meaning | Official policy | -| --- | --- | --- | -| `suspicious-code` | Concerning behavior lacks enough evidence for a blocking security label | Warn before install | -| `obfuscated-code` | Material code is intentionally difficult to inspect | Warn before install | -| `privacy-risk` | Collection, tracking, or transmission creates a non-blocking privacy concern | Warn before install | -| `misleading-metadata` | Description, screenshots, identity claims, or declared purpose materially differ from behavior | Warn and downrank | -| `low-quality` | Release is placeholder, generated without substance, or lacks meaningful functionality | Warn and downrank | -| `broken-release` | Bundle cannot perform its stated function despite passing structural registry validation | Warn and downrank | +| Label | Meaning | Official policy | +| --------------------- | ---------------------------------------------------------------------------------------------- | ------------------- | +| `suspicious-code` | Concerning behavior lacks enough evidence for a blocking security label | Warn before install | +| `obfuscated-code` | Material code is intentionally difficult to inspect | Warn before install | +| `privacy-risk` | Collection, tracking, or transmission creates a non-blocking privacy concern | Warn before install | +| `misleading-metadata` | Description, screenshots, identity claims, or declared purpose materially differ from behavior | Warn and downrank | +| `low-quality` | Release is placeholder, generated without substance, or lacks meaningful functionality | Warn and downrank | +| `broken-release` | Bundle cannot perform its stated function despite passing structural registry validation | Warn and downrank | Quality labels never prevent direct installation on their own. The official admin presents the reason and requires confirmation when warning labels are active. ### 7.4 Manual system labels -| Label | Subject | Authority | Effect | -| --- | --- | --- | --- | -| `!takedown` | Release, package, or publisher | Admin only | Redact for consumers accepting this labeller with `redact` | -| `security-yanked` | Release | Reviewer or admin | Block install/update; retain visible tombstone and explanation | -| `publisher-compromised` | Publisher DID | Admin only | Block all publisher packages/releases until retracted | -| `package-disputed` | Package URI | Reviewer or admin | Warn and prevent recommendation; direct install policy is configurable | +| Label | Subject | Authority | Effect | +| ----------------------- | ------------------------------ | ----------------- | ---------------------------------------------------------------------- | +| `!takedown` | Release, package, or publisher | Admin only | Redact for consumers accepting this labeller with `redact` | +| `security-yanked` | Release | Reviewer or admin | Block install/update; retain visible tombstone and explanation | +| `publisher-compromised` | Publisher DID | Admin only | Block all publisher packages/releases until retracted | +| `package-disputed` | Package URI | Reviewer or admin | Warn and prevent recommendation; direct install policy is configurable | `security-yanked` replaces the colon-containing `security:yanked` placeholder currently present in parts of the aggregator and admin. Colon-structured values conflict with ATProto's recommended label syntax. @@ -393,13 +393,13 @@ The model returns findings, not labels: ```ts interface ModelFinding { - category: AllowedFindingCategory; - severity: "critical" | "high" | "medium" | "low" | "info"; - confidence: number; - title: string; - summary: string; - evidenceRefs: string[]; - affectedFiles: string[]; + category: AllowedFindingCategory; + severity: "critical" | "high" | "medium" | "low" | "info"; + confidence: number; + title: string; + summary: string; + evidenceRefs: string[]; + affectedFiles: string[]; } ``` @@ -490,14 +490,14 @@ The purpose-built Kumo console is served by the labeller Worker under `/admin` a V1 pages: -| Route | Purpose | -| --- | --- | -| `/admin` | Queue health, pending/error counts, recent blocking decisions, subscription health | -| `/admin/assessments` | Filterable assessment list by state, label, publisher, package, model, and policy version | -| `/admin/assessments/:id` | Public summary, private findings, evidence excerpts, model/scanner provenance, labels, and timeline | -| `/admin/subjects/:encodedUri` | All assessments and active/historical labels for a release/package/publisher | -| `/admin/audit-log` | Immutable operator and system action log | -| `/admin/system` | Read-only policy/model versions, signing key ID, Jetstream cursor, subscriber health, queue/DLQ health | +| Route | Purpose | +| ----------------------------- | ------------------------------------------------------------------------------------------------------ | +| `/admin` | Queue health, pending/error counts, recent blocking decisions, subscription health | +| `/admin/assessments` | Filterable assessment list by state, label, publisher, package, model, and policy version | +| `/admin/assessments/:id` | Public summary, private findings, evidence excerpts, model/scanner provenance, labels, and timeline | +| `/admin/subjects/:encodedUri` | All assessments and active/historical labels for a release/package/publisher | +| `/admin/audit-log` | Immutable operator and system action log | +| `/admin/system` | Read-only policy/model versions, signing key ID, Jetstream cursor, subscriber health, queue/DLQ health | All user-facing strings use Lingui and all layout uses RTL-safe logical classes. The console uses Kumo components rather than hand-rolled controls. @@ -561,8 +561,8 @@ Role mapping is deployment configuration: ```json { - "admins": ["emdash-labeller-admins"], - "reviewers": ["emdash-labeller-reviewers"] + "admins": ["emdash-labeller-admins"], + "reviewers": ["emdash-labeller-reviewers"] } ``` @@ -609,25 +609,25 @@ com.emdashcms.experimental.labeller.getPolicy ```ts interface PublicAssessment { - id: string; - subject: { uri: string; cid: string }; - artifact: { id: string; checksum: string }; - state: "pending" | "passed" | "warned" | "blocked" | "error" | "superseded"; - summary: string; - coverage: { - code: "complete" | "partial" | "unavailable"; - metadata: "complete" | "partial" | "unavailable"; - images: "complete" | "not-present" | "partial" | "unavailable"; - dependencies: "complete" | "partial" | "unavailable"; - }; - labels: Array<{ val: string; active: boolean; issuedAt: string }>; - policyVersion: string; - model: { provider: "workers-ai"; modelId: string; promptVersion: string }; - scannerVersions: Record; - createdAt: string; - completedAt?: string; - supersedesAssessmentId?: string; - reconsiderationUrl: string; + id: string; + subject: { uri: string; cid: string }; + artifact: { id: string; checksum: string }; + state: "pending" | "passed" | "warned" | "blocked" | "error" | "superseded"; + summary: string; + coverage: { + code: "complete" | "partial" | "unavailable"; + metadata: "complete" | "partial" | "unavailable"; + images: "complete" | "not-present" | "partial" | "unavailable"; + dependencies: "complete" | "partial" | "unavailable"; + }; + labels: Array<{ val: string; active: boolean; issuedAt: string }>; + policyVersion: string; + model: { provider: "workers-ai"; modelId: string; promptVersion: string }; + scannerVersions: Record; + createdAt: string; + completedAt?: string; + supersedesAssessmentId?: string; + reconsiderationUrl: string; } ``` @@ -937,10 +937,10 @@ The registry client exposes one policy helper rather than making every UI or han ```ts interface ReleaseModeration { - eligibility: "eligible" | "pending" | "error" | "blocked"; - blockingLabels: Label[]; - warningLabels: Label[]; - assessment?: PublicAssessmentRef; + eligibility: "eligible" | "pending" | "error" | "blocked"; + blockingLabels: Label[]; + warningLabels: Label[]; + assessment?: PublicAssessmentRef; } ``` @@ -1058,28 +1058,28 @@ Retention values are policy constants versioned in code and may change after leg ### 20.1 Threats and mitigations -| Threat | Mitigation | -| --- | --- | -| Prompt injection in source/metadata | Delimit untrusted input, strict system task, structured schema, category allowlist, evidence-reference validation | -| Model false positive blocks legitimate release | Narrow blockable categories, fixture calibration, public explanation, immediate reviewer retraction, immutable audit trail | -| Model false negative | Defense in depth: deterministic checks, scanners, sandbox enforcement, reports in future, reassessment on intelligence updates | -| Malicious artifact attacks fetcher/extractor | Mirror preference, checksum verification, SSRF controls, streaming caps, safe archive parsing, time budgets | -| Aggregator serves wrong artifact | Verify signed release record, CID, and artifact checksum independently | -| Publisher mutates artifact URL bytes | Checksum binds accepted bytes; mismatch blocks assessment and installation | -| Forged Jetstream event | Re-fetch and verify signed source record before assessment | -| Forged label | DRISL signature verification against `#atproto_label` | -| Signing-key exfiltration through assessor | Typed policy proposal boundary; key accessible only to issuer module; Secrets Store | -| Access header spoofing | Verify Access JWT signature, issuer, audience, expiry; do not trust raw identity headers | -| Reviewer account compromise | Role limits, required reasons, confirmation ceremony, alerts, immutable action log | -| Admin incorrectly issues `!takedown` | Explicit high-friction confirmation, immediate alert, signed negation rollback | -| Replay/out-of-order labels | Monotonic sequence for stream and latest-`cts` current-state projection | -| Stale labels after CID update | Automated labels are CID-bound; clients check CID applicability | -| Package/publisher overreach | Broad labels are manual only; subject-wide action shown explicitly | -| Assessment absence mistaken for safety | Official clients require positive `assessment-passed` | -| Labeller outage blocks ecosystem indefinitely | Clear pending/error state, alerts, retry/DLQ, site operators can choose a different trust policy/labeller | -| Policy/model drift changes outcomes silently | Version all policy/model/prompt/scanner inputs; no automatic whole-catalog rewrite | -| Dependency scanner noise | Applicability/blocking rule stricter than raw severity score | -| Evidence leaks vulnerability details | Public/private split and redacted summaries | +| Threat | Mitigation | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Prompt injection in source/metadata | Delimit untrusted input, strict system task, structured schema, category allowlist, evidence-reference validation | +| Model false positive blocks legitimate release | Narrow blockable categories, fixture calibration, public explanation, immediate reviewer retraction, immutable audit trail | +| Model false negative | Defense in depth: deterministic checks, scanners, sandbox enforcement, reports in future, reassessment on intelligence updates | +| Malicious artifact attacks fetcher/extractor | Mirror preference, checksum verification, SSRF controls, streaming caps, safe archive parsing, time budgets | +| Aggregator serves wrong artifact | Verify signed release record, CID, and artifact checksum independently | +| Publisher mutates artifact URL bytes | Checksum binds accepted bytes; mismatch blocks assessment and installation | +| Forged Jetstream event | Re-fetch and verify signed source record before assessment | +| Forged label | DRISL signature verification against `#atproto_label` | +| Signing-key exfiltration through assessor | Typed policy proposal boundary; key accessible only to issuer module; Secrets Store | +| Access header spoofing | Verify Access JWT signature, issuer, audience, expiry; do not trust raw identity headers | +| Reviewer account compromise | Role limits, required reasons, confirmation ceremony, alerts, immutable action log | +| Admin incorrectly issues `!takedown` | Explicit high-friction confirmation, immediate alert, signed negation rollback | +| Replay/out-of-order labels | Monotonic sequence for stream and latest-`cts` current-state projection | +| Stale labels after CID update | Automated labels are CID-bound; clients check CID applicability | +| Package/publisher overreach | Broad labels are manual only; subject-wide action shown explicitly | +| Assessment absence mistaken for safety | Official clients require positive `assessment-passed` | +| Labeller outage blocks ecosystem indefinitely | Clear pending/error state, alerts, retry/DLQ, site operators can choose a different trust policy/labeller | +| Policy/model drift changes outcomes silently | Version all policy/model/prompt/scanner inputs; no automatic whole-catalog rewrite | +| Dependency scanner noise | Applicability/blocking rule stricter than raw severity score | +| Evidence leaks vulnerability details | Public/private split and redacted summaries | ### 20.2 Signing proposal validation From 4a07a48820b847b8007d75799d3cd40c3702a6a4 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 13:17:22 +0100 Subject: [PATCH 004/137] docs: keep implementation plans ephemeral --- .../plugin-registry-labelling-service/implementation-plan.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index b660c6360f..ef41c24faa 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -34,6 +34,7 @@ The implementation is complete when: - No positive assessment requirement is enabled in production until first-party releases have been assessed and rollback is tested. - Incomplete integration remains unreachable by default. Feature flags may expose staging paths, but production clients must not depend on half-built label state. - Tests land with each workstream. Protocol, security, and failure tests are not deferred to the final gate. +- `.opencode/plans` is ephemeral coordination material. Retained runtime code and tests must never import it; ratified contracts, fixtures, and vectors move into normal package/test directories before consumers use them. - The operator console uses Kumo, Lingui, and RTL-safe logical layout from its first UI change. - Existing unrelated worktree changes are left untouched. - Published-package changes receive changesets when their workstream lands. @@ -226,6 +227,7 @@ Gate owners: `W9`, `W10`. - Signing-key rotation, compromised-key response, D1 restore, Jetstream cursor recovery, Queue/DLQ recovery, and notification recovery drills pass. - External security review has no unresolved critical or high findings. - Production smoke succeeds from release publication through assessment, label ingest, discovery, and clean-site install decision. +- Every retained contract/fixture/vector lives under a normal package or test directory, no source/test imports `.opencode/plans`, and this planning folder is removed before the umbrella PR merges. Gate owners: `W11`, `W12`, with all preceding gates complete. @@ -1600,6 +1602,7 @@ Exit: Gate 6 production smoke and sustained healthy operation. - [ ] Labeller lexicons and policy schema are published. - [ ] Shared crypto/header/moderation packages are the only implementations. - [ ] Legacy colon vocabulary is absent or bounded by an explicit migration. +- [ ] Retained source/tests have no `.opencode/plans` dependency, all durable fixtures have moved to normal package/test paths, and the ephemeral planning folder is removed. ### Service From 532fef3354a16237929276ad67983e56ed2b8423 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 13:25:49 +0100 Subject: [PATCH 005/137] docs: define labeller Gate 0 contracts (#1910) * docs: define labeller gate 0 contracts * docs: record contract ratification --- .../gate-0/contracts.md | 353 +++++++ .../gate-0/fixtures/moderation-cases.json | 946 ++++++++++++++++++ .../gate-0/fixtures/moderation-policy.json | 432 ++++++++ 3 files changed, 1731 insertions(+) create mode 100644 .opencode/plans/plugin-registry-labelling-service/gate-0/contracts.md create mode 100644 .opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/moderation-cases.json create mode 100644 .opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/moderation-policy.json diff --git a/.opencode/plans/plugin-registry-labelling-service/gate-0/contracts.md b/.opencode/plans/plugin-registry-labelling-service/gate-0/contracts.md new file mode 100644 index 0000000000..85a97b8a28 --- /dev/null +++ b/.opencode/plans/plugin-registry-labelling-service/gate-0/contracts.md @@ -0,0 +1,353 @@ +# Gate 0 Contract Ratification Proposal + +Status: ratified contracts for `W0.1` and `W0.2` as of 2026-07-10. This is not a production Lexicon or protocol implementation. + +Companions: [implementation spec](../spec.md), [implementation plan](../implementation-plan.md), [moderation policy fixture](./fixtures/moderation-policy.json), and [moderation cases](./fixtures/moderation-cases.json). + +## Scope + +This proposal freezes public identifiers, public API shapes, label issuance authority, current-label reduction, subject applicability, and official-client moderation precedence. Standard label distribution continues to use `com.atproto.label.*`; no EmDash label-distribution Lexicon is proposed. + +Subjects use the current experimental registry identifiers: + +- Package: `com.emdashcms.experimental.package.profile` +- Release: `com.emdashcms.experimental.package.release` +- Publisher: a DID, optionally described by `com.emdashcms.experimental.publisher.profile` +- Reference labeller: `did:web:labels.emdashcms.com` + +## Proposed Public Identifiers + +| NSID | Kind | Purpose | +| ---------------------------------------------------------- | ----------- | -------------------------------------------------------------------------- | +| `com.emdashcms.experimental.labeller.defs` | definitions | Shared public assessment, policy, coverage, label, and manual-action views | +| `com.emdashcms.experimental.labeller.getAssessment` | query | Fetch one immutable historical assessment by public ID | +| `com.emdashcms.experimental.labeller.getCurrentAssessment` | query | Fetch effective assessment state for an exact release URI and CID | +| `com.emdashcms.experimental.labeller.listAssessments` | query | Page through public assessments using bounded filters | +| `com.emdashcms.experimental.labeller.getPolicy` | query | Fetch the current machine-readable policy document | + +The service also implements `com.atproto.label.queryLabels` and `com.atproto.label.subscribeLabels` without redefining them. + +### Decision notice + +No decision-notice repository record ships in v1. Labels provide the subscribable decision stream, public assessment queries provide explanations, and email remains the active publisher-notification channel. A future notice record can use a new experimental NSID without changing these queries. + +### Well-known policy path + +The permanent discovery URL is: + +```text +https://labels.emdashcms.com/.well-known/emdash-labeler-policy.json +``` + +It returns the same policy object as `com.emdashcms.experimental.labeller.getPolicy`, with `Content-Type: application/json`. Responses use a representation-derived ETag and may be publicly cached for at most five minutes. The policy document is informational; stale policy data never changes whether a signed label is current. + +## Shared Public Shapes + +These shapes are inputs to `W1.2`. They intentionally use only fixed object properties, arrays, and other Lexicon-expressible structures. All datetimes are validated RFC 3339 `datetime` values. + +### Assessment ID + +Assessment IDs are opaque, case-sensitive `asmt_<26-character canonical uppercase ULID>` strings. Example: `asmt_01J2Q5Y7V8N9M0K1H2G3F4E5D6`. The ULID timestamp is allocation metadata only; clients use response timestamps for ordering and display. + +### `publicAssessment` + +```ts +interface PublicAssessment { + id: string; + src: string; + subject: { uri: string; cid: string }; + artifact?: { id?: string; checksum: string }; + state: "pending" | "passed" | "warned" | "blocked" | "error" | "superseded"; + summary: string; + coverage: { + code: "complete" | "partial" | "unavailable"; + metadata: "complete" | "partial" | "unavailable"; + images: "complete" | "not-present" | "partial" | "unavailable"; + dependencies: "complete" | "partial" | "unavailable"; + }; + labels: Array<{ + val: string; + active: boolean; + issuedAt: string; + expiresAt?: string; + }>; + policyVersion: string; + assessmentSchemaVersion: number; + model?: { + provider: "workers-ai"; + modelId: string; + promptVersion: string; + }; + scannerVersions: Array<{ scanner: string; version: string }>; + createdAt: string; + completedAt?: string; + supersedesAssessmentId?: string; + reconsiderationUrl: string; +} +``` + +`artifact` and `model` are absent when unavailable. `scannerVersions` is sorted by `scanner` and contains at most one entry per scanner. `state: "superseded"` is derived only when a newer completed assessment names this assessment and owns the current pointer. + +### `publicManualAction` + +```ts +interface PublicManualAction { + id: string; + src: string; + subject: { uri: string; cid?: string }; + type: "override" | "label-issue" | "label-retraction" | "emergency-takedown"; + summary: string; + labels: Array<{ val: string; active: boolean; issuedAt: string; expiresAt?: string }>; + createdAt: string; +} +``` + +This is a public summary, not the private operator reason or evidence record. + +### Policy document + +`fixtures/moderation-policy.json` is the canonical example. Its Lexicon-expressible shape is: + +```ts +interface LabelerPolicyDocument { + schemaVersion: 1; + policyVersion: string; + effectiveAt: string; + labellerDid: string; + assessmentSchemaVersion: number; + supportedSubjects: { + publisher: { kind: "did" }; + packageCollections: string[]; + releaseCollections: string[]; + }; + reasonCodes: Array<{ code: string; description: string }>; + labels: Array<{ + value: string; + category: "eligibility" | "automated-block" | "warning" | "manual-system"; + officialEffect: "pass" | "pending" | "error" | "block" | "warn" | "redact"; + subjectRules: Array<{ + subject: "release" | "package" | "publisher"; + cidRule: "required" | "optional" | "forbidden"; + issuanceModes: Array<"automated" | "reviewer" | "admin">; + }>; + locales: Array<{ lang: string; name: string; description: string }>; + }>; + overrideRule: { + subject: "release"; + cidRule: "required"; + reviewerLabels: string[]; + requireSameSource: true; + requireAtomicIssuance: true; + }; + precedence: string[]; + publicApi: { + baseUrl: string; + policyUrl: string; + getAssessmentNsid: string; + getCurrentAssessmentNsid: string; + listAssessmentsNsid: string; + getPolicyNsid: string; + }; + contact: { reconsiderationUrl: string; reconsiderationEmail: string }; + transparency: { modelOutputIsAdvisoryEvidence: true }; +} +``` + +`issuanceModes` are subject-specific. An admin inherits reviewer capability, but the policy still lists `admin` where an action is admin-only. V1 advertises only deployed collections; clients do not infer wildcards. + +## Endpoint Contracts + +All four custom methods are unauthenticated `query` Lexicons served under `/xrpc/{NSID}`. + +### `getAssessment` + +Parameter: required `id`, maximum 64 bytes. Output: one `publicAssessment`. + +Errors: `InvalidRequest`, `NotFound`, `RateLimitExceeded`. + +`NotFound` does not distinguish never-existing, non-public, or retention-removed private data. A public summary referenced by an issued label remains resolvable. + +### `getCurrentAssessment` + +| Name | Required | Constraint | Meaning | +| ----- | -------- | --------------------------- | ----------------------------------------------- | +| `uri` | yes | exact `at-uri`, no wildcard | Release record URI | +| `cid` | yes | CID | Exact release record version being evaluated | +| `src` | no | DID | Labeller source; defaults to the endpoint's DID | + +```ts +interface CurrentAssessmentView { + src: string; + subject: { uri: string; cid: string }; + current?: PublicAssessment; + pending?: PublicAssessment; + activeLabels: Array<{ + src: string; + uri: string; + cid?: string; + val: string; + cts: string; + exp?: string; + }>; + override?: PublicManualAction; +} +``` + +`current`, `pending`, active labels, and override remain separate concepts. Active labels exclude a stream whose winner is negated, expired, or CID-inapplicable. Unknown URI/CID returns `NotFound`; a different `src` returns `UnsupportedSource` rather than an ambiguous empty response. + +Errors: `InvalidRequest`, `NotFound`, `UnsupportedSource`, `RateLimitExceeded`. + +### `listAssessments` + +| Name | Required | Constraint | +| -------- | -------- | --------------------------------- | +| `src` | no | endpoint DID only | +| `uri` | no | exact `at-uri`, no wildcard | +| `cid` | no | CID; requires `uri` | +| `state` | no | one public assessment state | +| `limit` | no | integer 1-100, default 50 | +| `cursor` | no | opaque string, maximum 1024 bytes | + +Output: `{ assessments: PublicAssessment[], cursor?: string }`. + +Ordering is `createdAt DESC, id DESC` with exclusive keyset pagination. The opaque base64url cursor encodes cursor version, final parsed `createdAt`, final `id`, and a hash of effective filters. A cursor is emitted only when another row exists. Changed filters, malformed data, or an unknown cursor version returns `InvalidCursor`, never page one. + +Errors: `InvalidRequest`, `InvalidCursor`, `UnsupportedSource`, `RateLimitExceeded`. + +### `getPolicy` + +No parameters. Output: current `LabelerPolicyDocument`. Historical policies remain operational artifacts in v1 rather than a public query surface. + +Error: `RateLimitExceeded`. + +### Error codes + +| Code | HTTP status | Meaning | +| --------------------- | ----------- | ----------------------------------------------------------------------- | +| `InvalidRequest` | 400 | Lexicon or cross-field validation failed | +| `InvalidCursor` | 400 | Cursor is malformed, unsupported, or bound to different filters | +| `UnsupportedSource` | 400 | `src` differs from this deployment's labeller DID | +| `NotFound` | 404 | Requested public assessment or exact subject is unavailable | +| `RateLimitExceeded` | 429 | Public rate limit exceeded; response should include `Retry-After` | +| `InternalServerError` | 500 | Generic XRPC error for an unhandled server failure, not method-declared | + +`com.atproto.moderation.createReport` returns `NotSupported` and persists nothing in v1. + +## Moderation Evaluation Contract + +`fixtures/moderation-cases.json` is executable input for `W1.5`. + +### Inputs and accepted sources + +The evaluator receives a parsed evaluation instant, exact publisher/package/release subjects and current CIDs, accepted labeller DIDs with `redact` flags, and label history. Missing `atproto-accept-labelers` is resolved to deployment defaults before evaluation; explicitly empty means no accepted sources and therefore no qualifying positive assessment. + +Unaccepted sources are ignored. Every accepted source is reduced and evaluated independently. A source's valid override suppresses only that source's automated states. It never suppresses another accepted source's pending, error, automated block, warning, or manual action. + +The `redact` flag controls presentation only. An accepted `!takedown` always blocks installation and marks its subject redacted only when that source has `redact: true`. + +### Current label state + +Current state has exactly the ATProto key `(src, uri, val)`. CID is not part of the stream key. + +For each stream: + +1. Parse every validated `cts` into an instant; never compare raw RFC 3339 text. +2. Select the event with the greatest parsed `cts`. +3. Treat semantically identical events at that instant as duplicate delivery. Semantic identity compares `ver`, `src`, `uri`, `cid`, `val`, `neg`, `cts`, and `exp`; signatures and transport metadata do not break a tie. +4. If non-identical events share the greatest parsed `cts`, mark that source's stream ambiguous. The official evaluator returns fail-closed `error` with `label-state-collision`; neither event wins. +5. If the winner has `neg: true`, the stream is inactive. A negation replaces the stream regardless of its CID. +6. Otherwise parse `exp`; the stream is inactive when `exp <= evaluatedAt`. +7. Apply the winner's CID metadata to the current subject only after current-state selection. + +An expired winner does not reveal or reactivate an older event. An expired negation therefore leaves the stream inactive. Invalid `cts`/`exp` values are rejected before evaluation by label validation. + +Subscription sequence and query pagination order are cursor/replay bookkeeping only. They never select current moderation state or resolve equal-`cts` collisions. + +Subject issuance rules constrain what the reference issuer may sign; they do not alter the ATProto stream key. A consumer does not discard an accepted signed event before stream reduction merely because its CID would violate the current issuer policy. This lets a later CID-bearing negation safely retract an older URI-wide event and prevents legacy or buggy metadata from splitting one stream into two. + +### Subject and CID applicability + +- Release labels apply only to the exact release URI. A CID-bound winner applies only when its CID equals the current release CID; a URI-wide winner omits CID. +- Package labels apply only to the package URI. A CID-bound winner applies only to the current package CID; a URI-wide winner cascades to all package releases. +- Publisher labels target the publisher DID, omit CID, and cascade to all packages/releases from that DID. +- Automated eligibility, descriptive security, and warning labels are release-only and require release CID. +- `security-yanked` and release/package/publisher `!takedown` are URI-wide. +- `package-disputed` may be CID-bound to the current profile or URI-wide; this is a ratification proposal needed to exercise package-CID applicability without allowing manual issuance of automated descriptive values. +- CID mismatch makes the selected winner inapplicable; it does not restore an older same-stream event. + +### Issuance authority and provenance + +Standard labels do not prove whether an event came from automation or an operator. V1 therefore does not assign different consumer effects to the same descriptive value based on external action metadata. + +- Automated descriptive security and warning values are release-only and automated-only. +- Reviewers cannot manually issue those values. They use `security-yanked` for a release or `package-disputed` for a package. +- Admins use `!takedown` and `publisher-compromised` for emergency/broad actions. +- `assessment-passed` permits automated and reviewer issuance. Its reviewer meaning is recognized only when the same source also has active exact-CID `assessment-overridden`. +- `assessment-overridden` is reviewer-issued and exact-CID. +- The issuer permits a reviewer `assessment-passed` only in one atomic action with `assessment-overridden` for the same release URI and CID. It never offers a standalone reviewer-pass operation. +- The issuer enforces allowed subject/mode combinations before signing. Public action metadata explains an action but never changes evaluator semantics. + +### Per-source evaluation and aggregation + +For each accepted source: + +1. Apply manual blocks from that source. +2. Recognize an override only from active exact-CID `assessment-passed` plus `assessment-overridden` from that source. +3. Suppress that source's automated pending, error, and descriptive block values when its override is valid. +4. Otherwise classify active `assessment-error`, `assessment-pending`, automated blocks, pass, and warnings. + +Aggregate unsuppressed source results in this order: + +1. Any manual block: `blocked`. +2. Any label-state collision: `error`. +3. Any unsuppressed assessment error: `error`. +4. Any unsuppressed pending state: `pending`. +5. Any unsuppressed automated block: `blocked`. +6. No accepted source has an active pass or valid override: `blocked` with `missing-assessment-pass`. +7. Otherwise: `eligible`, with warnings from all accepted sources. + +A pass or override from source A cannot bypass pending, error, or block from source B. An accepted source that has no relevant state does not veto a pass from another accepted source. + +### Result fields + +```ts +interface ReleaseModeration { + eligibility: "eligible" | "pending" | "error" | "blocked"; + reasonCodes: string[]; + blockingLabels: string[]; + stateLabels: string[]; + warningLabels: string[]; + suppressedLabels: string[]; + redacted: boolean; +} +``` + +- `blockingLabels`: only active, applicable manual or automated labels whose policy effect is block/redact. +- `stateLabels`: active, applicable `assessment-pending` and `assessment-error` values. They never appear in `blockingLabels`. +- `warningLabels`: active, applicable warning values. +- `suppressedLabels`: same-source automated state/block values hidden by a valid override but retained for display. +- Label references in fixtures are ordered by source alias, subject, then value. Reasons follow aggregate precedence and are deduplicated. + +## Ratified Decisions + +1. Approve the five proposed NSIDs and no v1 decision-notice record. +2. Approve `asmt_` public assessment IDs. +3. Approve exact URI+CID current lookup and `createdAt DESC, id DESC` keyset pagination. +4. Approve `UnsupportedSource` for a foreign `src` on this single-DID deployment. +5. Approve the Lexicon-expressible policy shape, fixed API properties, array reason/scanner entries, and permanent well-known path. +6. Approve `(src, uri, val)` current-state keys, parsed-instant `cts` ordering, and sequence-independent reduction. +7. Approve fail-closed `error` for a non-identical equal-`cts` collision. +8. Approve reducing accepted signed events before applying issuer-policy CID restrictions. +9. Approve expired winners never revealing older events, including expired negations. +10. Approve subject-specific issuance rules and automated release-only descriptive labels. +11. Approve that automated descriptive security/warning values cannot be manually issued in v1; reviewers/admins use the dedicated manual values instead. +12. Approve consumer override recognition solely from the same-source exact-CID pass/override pair, because standard labels cannot prove action provenance. +13. Approve that reviewer-issued pass is available only as one atomic pass/override pair, never as a standalone action. +14. Approve independent per-source evaluation and aggregate error > pending > automated block > positive-assessment resolution after manual blocks/collisions. +15. Approve accepted `!takedown` always blocking while `redact` controls presentation. +16. Approve `package-disputed` as warning/non-recommendation, not a direct-install block. +17. Approve optional CID binding for `package-disputed`; URI-wide disputes persist, while CID-bound disputes apply only to one package-profile version. +18. Approve manual release/package/publisher blocks taking precedence over overrides. +19. Approve `stateLabels` as the result field for pending/error labels, separate from `blockingLabels`. + +No existing spec, plan, or production Lexicon was changed. The policy document and fixtures were ratified together on 2026-07-10; `W1.2` and `W1.5` may treat them as frozen once this PR merges into the integration branch. diff --git a/.opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/moderation-cases.json b/.opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/moderation-cases.json new file mode 100644 index 0000000000..d85b7a102a --- /dev/null +++ b/.opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/moderation-cases.json @@ -0,0 +1,946 @@ +{ + "schemaVersion": 2, + "policyFixture": "./moderation-policy.json", + "evaluatedAt": "2026-07-10T12:00:00Z", + "fixtureEncoding": { + "sourceAliases": "Each label src is a key in sources; expand it to that DID before evaluation.", + "subjectAliases": "Each label subject selects the URI from caseDefaults.subject; publisher selects publisherDid.", + "labelReferences": "Expected labels use source:subject:value after alias expansion.", + "merge": "Recursively merge each case over caseDefaults; case arrays replace default arrays." + }, + "sources": { + "A": "did:web:labels.emdashcms.com", + "B": "did:web:other-labels.example", + "U": "did:web:unaccepted-labels.example" + }, + "caseDefaults": { + "acceptedLabellers": [{ "src": "A", "redact": true }], + "subject": { + "publisherDid": "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + "packageUri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/com.emdashcms.experimental.package.profile/gallery-plugin", + "packageCid": "bafyreiclpjmh2e5ug4oufdmfnz4r4a6o2lrfu5hzgopzjlb3u2v5il5z4a", + "releaseUri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/com.emdashcms.experimental.package.release/gallery-plugin:1.2.0", + "releaseCid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "oldReleaseCid": "bafyreigh2akiscaildc4mscz4uzpcbap5jxg26eecmrf6cmnvkzkjmoixa", + "oldPackageCid": "bafyreigh2akiscaildc4mscz4uzpcbap5jxg26eecmrf6cmnvkzkjmoixb" + }, + "labels": [] + }, + "cases": [ + { + "id": "eligible-exact-cid-pass", + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T10:00:00Z" + } + ], + "expected": { + "eligibility": "eligible", + "reasonCodes": ["eligible-assessment-pass"], + "blockingLabels": [], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "blocked-missing-pass", + "expected": { + "eligibility": "blocked", + "reasonCodes": ["missing-assessment-pass"], + "blockingLabels": [], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "pending-over-pass", + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T09:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-pending", + "cts": "2026-07-10T10:00:00Z" + } + ], + "expected": { + "eligibility": "pending", + "reasonCodes": ["assessment-pending"], + "blockingLabels": [], + "stateLabels": ["A:release:assessment-pending"], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "error-over-pending-and-pass", + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T08:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-pending", + "cts": "2026-07-10T09:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-error", + "cts": "2026-07-10T10:00:00Z" + } + ], + "expected": { + "eligibility": "error", + "reasonCodes": ["assessment-error"], + "blockingLabels": [], + "stateLabels": ["A:release:assessment-error", "A:release:assessment-pending"], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "blocked-automated-security-label", + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T09:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "malware", + "cts": "2026-07-10T10:00:00Z" + } + ], + "expected": { + "eligibility": "blocked", + "reasonCodes": ["automated-block"], + "blockingLabels": ["A:release:malware"], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "eligible-warning-only", + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T09:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "obfuscated-code", + "cts": "2026-07-10T10:00:00Z" + } + ], + "expected": { + "eligibility": "eligible", + "reasonCodes": ["eligible-assessment-pass", "warning-labels"], + "blockingLabels": [], + "stateLabels": [], + "warningLabels": ["A:release:obfuscated-code"], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "blocked-cid-mismatch-pass", + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreigh2akiscaildc4mscz4uzpcbap5jxg26eecmrf6cmnvkzkjmoixa", + "val": "assessment-passed", + "cts": "2026-07-10T10:00:00Z" + } + ], + "expected": { + "eligibility": "blocked", + "reasonCodes": ["missing-assessment-pass"], + "blockingLabels": [], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "blocked-expired-pass", + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T10:00:00Z", + "exp": "2026-07-10T12:00:00Z" + } + ], + "expected": { + "eligibility": "blocked", + "reasonCodes": ["missing-assessment-pass"], + "blockingLabels": [], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "eligible-negated-block", + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "malware", + "cts": "2026-07-10T08:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "malware", + "neg": true, + "cts": "2026-07-10T09:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T10:00:00Z" + } + ], + "expected": { + "eligibility": "eligible", + "reasonCodes": ["eligible-assessment-pass"], + "blockingLabels": [], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "blocked-package-cascade", + "labels": [ + { "src": "A", "subject": "package", "val": "!takedown", "cts": "2026-07-10T10:00:00Z" }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T09:00:00Z" + } + ], + "expected": { + "eligibility": "blocked", + "reasonCodes": ["manual-block"], + "blockingLabels": ["A:package:!takedown"], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": [], + "redacted": true + } + }, + { + "id": "blocked-publisher-cascade", + "labels": [ + { + "src": "A", + "subject": "publisher", + "val": "publisher-compromised", + "cts": "2026-07-10T10:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T09:00:00Z" + } + ], + "expected": { + "eligibility": "blocked", + "reasonCodes": ["manual-block"], + "blockingLabels": ["A:publisher:publisher-compromised"], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "eligible-same-source-override", + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "malware", + "cts": "2026-07-10T08:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-pending", + "cts": "2026-07-10T09:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-error", + "cts": "2026-07-10T09:30:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T10:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-overridden", + "cts": "2026-07-10T10:00:01Z" + } + ], + "expected": { + "eligibility": "eligible", + "reasonCodes": ["eligible-manual-override"], + "blockingLabels": [], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": [ + "A:release:assessment-error", + "A:release:assessment-pending", + "A:release:malware" + ], + "redacted": false + } + }, + { + "id": "blocked-broader-manual-block-over-override", + "labels": [ + { "src": "A", "subject": "package", "val": "!takedown", "cts": "2026-07-10T11:00:00Z" }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "malware", + "cts": "2026-07-10T08:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T10:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-overridden", + "cts": "2026-07-10T10:00:01Z" + } + ], + "expected": { + "eligibility": "blocked", + "reasonCodes": ["manual-block"], + "blockingLabels": ["A:package:!takedown"], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": ["A:release:malware"], + "redacted": true + } + }, + { + "id": "blocked-unaccepted-pass-ignored", + "labels": [ + { + "src": "U", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T10:00:00Z" + } + ], + "expected": { + "eligibility": "blocked", + "reasonCodes": ["missing-assessment-pass", "unaccepted-labels-ignored"], + "blockingLabels": [], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "blocked-takedown-without-redact", + "acceptedLabellers": [{ "src": "A", "redact": false }], + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T09:00:00Z" + }, + { "src": "A", "subject": "release", "val": "!takedown", "cts": "2026-07-10T10:00:00Z" } + ], + "expected": { + "eligibility": "blocked", + "reasonCodes": ["manual-block"], + "blockingLabels": ["A:release:!takedown"], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "eligible-uri-wide-package-dispute", + "labels": [ + { + "src": "A", + "subject": "package", + "val": "package-disputed", + "cts": "2026-07-10T10:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T09:00:00Z" + } + ], + "expected": { + "eligibility": "eligible", + "reasonCodes": ["eligible-assessment-pass", "warning-labels"], + "blockingLabels": [], + "stateLabels": [], + "warningLabels": ["A:package:package-disputed"], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "eligible-package-current-cid-label", + "labels": [ + { + "src": "A", + "subject": "package", + "cid": "bafyreiclpjmh2e5ug4oufdmfnz4r4a6o2lrfu5hzgopzjlb3u2v5il5z4a", + "val": "package-disputed", + "cts": "2026-07-10T10:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T09:00:00Z" + } + ], + "expected": { + "eligibility": "eligible", + "reasonCodes": ["eligible-assessment-pass", "warning-labels"], + "blockingLabels": [], + "stateLabels": [], + "warningLabels": ["A:package:package-disputed"], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "eligible-package-mismatched-cid-label-ignored", + "labels": [ + { + "src": "A", + "subject": "package", + "cid": "bafyreigh2akiscaildc4mscz4uzpcbap5jxg26eecmrf6cmnvkzkjmoixb", + "val": "package-disputed", + "cts": "2026-07-10T10:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T09:00:00Z" + } + ], + "expected": { + "eligibility": "eligible", + "reasonCodes": ["eligible-assessment-pass"], + "blockingLabels": [], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "eligible-uri-wide-release-label-negated-by-cid-event", + "labels": [ + { + "src": "A", + "subject": "release", + "val": "security-yanked", + "cts": "2026-07-10T08:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "security-yanked", + "neg": true, + "cts": "2026-07-10T09:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T10:00:00Z" + } + ], + "expected": { + "eligibility": "eligible", + "reasonCodes": ["eligible-assessment-pass"], + "blockingLabels": [], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "eligible-expired-block", + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "malware", + "cts": "2026-07-10T08:00:00Z", + "exp": "2026-07-10T12:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T10:00:00Z" + } + ], + "expected": { + "eligibility": "eligible", + "reasonCodes": ["eligible-assessment-pass"], + "blockingLabels": [], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "eligible-expired-negation-does-not-reactivate-block", + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "malware", + "cts": "2026-07-10T08:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "malware", + "neg": true, + "cts": "2026-07-10T09:00:00Z", + "exp": "2026-07-10T12:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T10:00:00Z" + } + ], + "expected": { + "eligibility": "eligible", + "reasonCodes": ["eligible-assessment-pass"], + "blockingLabels": [], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "blocked-newer-different-cid-replaces-pass-stream", + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T09:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreigh2akiscaildc4mscz4uzpcbap5jxg26eecmrf6cmnvkzkjmoixa", + "val": "assessment-passed", + "cts": "2026-07-10T10:00:00Z" + } + ], + "expected": { + "eligibility": "blocked", + "reasonCodes": ["missing-assessment-pass"], + "blockingLabels": [], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "blocked-parsed-cts-ordering-not-text-ordering", + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T10:30:00+02:00" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreigh2akiscaildc4mscz4uzpcbap5jxg26eecmrf6cmnvkzkjmoixa", + "val": "assessment-passed", + "cts": "2026-07-10T09:00:00Z" + } + ], + "expected": { + "eligibility": "blocked", + "reasonCodes": ["missing-assessment-pass"], + "blockingLabels": [], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "error-equal-cts-nonidentical-collision", + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T10:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreigh2akiscaildc4mscz4uzpcbap5jxg26eecmrf6cmnvkzkjmoixa", + "val": "assessment-passed", + "cts": "2026-07-10T10:00:00+00:00" + } + ], + "expected": { + "eligibility": "error", + "reasonCodes": ["label-state-collision"], + "blockingLabels": [], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "pending-source-b-not-bypassed-by-source-a-override", + "acceptedLabellers": [ + { "src": "A", "redact": true }, + { "src": "B", "redact": false } + ], + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "malware", + "cts": "2026-07-10T08:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T10:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-overridden", + "cts": "2026-07-10T10:00:01Z" + }, + { + "src": "B", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-pending", + "cts": "2026-07-10T11:00:00Z" + } + ], + "expected": { + "eligibility": "pending", + "reasonCodes": ["assessment-pending"], + "blockingLabels": [], + "stateLabels": ["B:release:assessment-pending"], + "warningLabels": [], + "suppressedLabels": ["A:release:malware"], + "redacted": false + } + }, + { + "id": "error-source-b-not-bypassed-by-source-a-override", + "acceptedLabellers": [ + { "src": "A", "redact": true }, + { "src": "B", "redact": false } + ], + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "malware", + "cts": "2026-07-10T08:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T10:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-overridden", + "cts": "2026-07-10T10:00:01Z" + }, + { + "src": "B", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-error", + "cts": "2026-07-10T11:00:00Z" + } + ], + "expected": { + "eligibility": "error", + "reasonCodes": ["assessment-error"], + "blockingLabels": [], + "stateLabels": ["B:release:assessment-error"], + "warningLabels": [], + "suppressedLabels": ["A:release:malware"], + "redacted": false + } + }, + { + "id": "block-source-b-not-bypassed-by-source-a-override", + "acceptedLabellers": [ + { "src": "A", "redact": true }, + { "src": "B", "redact": false } + ], + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "malware", + "cts": "2026-07-10T08:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T10:00:00Z" + }, + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-overridden", + "cts": "2026-07-10T10:00:01Z" + }, + { + "src": "B", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "malware", + "cts": "2026-07-10T11:00:00Z" + } + ], + "expected": { + "eligibility": "blocked", + "reasonCodes": ["automated-block"], + "blockingLabels": ["B:release:malware"], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": ["A:release:malware"], + "redacted": false + } + }, + { + "id": "pending-source-b-not-bypassed-by-source-a-pass", + "acceptedLabellers": [ + { "src": "A", "redact": true }, + { "src": "B", "redact": false } + ], + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T10:00:00Z" + }, + { + "src": "B", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-pending", + "cts": "2026-07-10T11:00:00Z" + } + ], + "expected": { + "eligibility": "pending", + "reasonCodes": ["assessment-pending"], + "blockingLabels": [], + "stateLabels": ["B:release:assessment-pending"], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "error-source-b-not-bypassed-by-source-a-pass", + "acceptedLabellers": [ + { "src": "A", "redact": true }, + { "src": "B", "redact": false } + ], + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T10:00:00Z" + }, + { + "src": "B", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-error", + "cts": "2026-07-10T11:00:00Z" + } + ], + "expected": { + "eligibility": "error", + "reasonCodes": ["assessment-error"], + "blockingLabels": [], + "stateLabels": ["B:release:assessment-error"], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + }, + { + "id": "block-source-b-not-bypassed-by-source-a-pass", + "acceptedLabellers": [ + { "src": "A", "redact": true }, + { "src": "B", "redact": false } + ], + "labels": [ + { + "src": "A", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "assessment-passed", + "cts": "2026-07-10T10:00:00Z" + }, + { + "src": "B", + "subject": "release", + "cid": "bafyreig5l2zfc7l5m4zq3r6v4s2wqkd3j7yq5x7x6n2j4h5r3p6s7t2w4e", + "val": "malware", + "cts": "2026-07-10T11:00:00Z" + } + ], + "expected": { + "eligibility": "blocked", + "reasonCodes": ["automated-block"], + "blockingLabels": ["B:release:malware"], + "stateLabels": [], + "warningLabels": [], + "suppressedLabels": [], + "redacted": false + } + } + ] +} diff --git a/.opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/moderation-policy.json b/.opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/moderation-policy.json new file mode 100644 index 0000000000..a86932e13b --- /dev/null +++ b/.opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/moderation-policy.json @@ -0,0 +1,432 @@ +{ + "schemaVersion": 1, + "policyVersion": "2026-07-10.experimental.2", + "effectiveAt": "2026-07-10T00:00:00Z", + "labellerDid": "did:web:labels.emdashcms.com", + "assessmentSchemaVersion": 1, + "supportedSubjects": { + "publisher": { "kind": "did" }, + "packageCollections": ["com.emdashcms.experimental.package.profile"], + "releaseCollections": ["com.emdashcms.experimental.package.release"] + }, + "reasonCodes": [ + { + "code": "eligible-assessment-pass", + "description": "At least one accepted source has an active pass for the exact release CID." + }, + { + "code": "eligible-manual-override", + "description": "At least one accepted source has an active exact-CID reviewer override pair." + }, + { + "code": "missing-assessment-pass", + "description": "No accepted source has an active exact-CID pass or override." + }, + { + "code": "assessment-pending", + "description": "An accepted source has an unsuppressed pending assessment." + }, + { + "code": "assessment-error", + "description": "An accepted source has an unsuppressed assessment error." + }, + { + "code": "label-state-collision", + "description": "An accepted source emitted non-identical latest events with the same parsed creation instant." + }, + { + "code": "automated-block", + "description": "An accepted source has an applicable unsuppressed automated security label." + }, + { + "code": "manual-block", + "description": "An accepted source has an applicable manual release, package, or publisher block." + }, + { + "code": "warning-labels", + "description": "The release is eligible but has applicable warning labels." + }, + { + "code": "unaccepted-labels-ignored", + "description": "Labels from sources outside the accepted set were ignored." + } + ], + "labels": [ + { + "value": "assessment-passed", + "category": "eligibility", + "officialEffect": "pass", + "subjectRules": [ + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated", "reviewer"] } + ], + "locales": [ + { + "lang": "en", + "name": "Assessment passed", + "description": "Required assessment completed without an active blocking condition, or a reviewer supplied the pass half of an override pair." + } + ] + }, + { + "value": "assessment-overridden", + "category": "eligibility", + "officialEffect": "pass", + "subjectRules": [ + { "subject": "release", "cidRule": "required", "issuanceModes": ["reviewer"] } + ], + "locales": [ + { + "lang": "en", + "name": "Assessment overridden", + "description": "A reviewer superseded the automated outcome for this exact release CID." + } + ] + }, + { + "value": "assessment-pending", + "category": "eligibility", + "officialEffect": "pending", + "subjectRules": [ + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + ], + "locales": [ + { + "lang": "en", + "name": "Assessment pending", + "description": "Required assessment work has not completed." + } + ] + }, + { + "value": "assessment-error", + "category": "eligibility", + "officialEffect": "error", + "subjectRules": [ + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + ], + "locales": [ + { + "lang": "en", + "name": "Assessment error", + "description": "Required assessment work failed after bounded retries." + } + ] + }, + { + "value": "malware", + "category": "automated-block", + "officialEffect": "block", + "subjectRules": [ + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + ], + "locales": [ + { + "lang": "en", + "name": "Malware", + "description": "The release contains intentionally harmful code or payloads." + } + ] + }, + { + "value": "data-exfiltration", + "category": "automated-block", + "officialEffect": "block", + "subjectRules": [ + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + ], + "locales": [ + { + "lang": "en", + "name": "Data exfiltration", + "description": "The release intentionally transmits protected data outside its declared purpose." + } + ] + }, + { + "value": "credential-harvesting", + "category": "automated-block", + "officialEffect": "block", + "subjectRules": [ + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + ], + "locales": [ + { + "lang": "en", + "name": "Credential harvesting", + "description": "The release deceptively captures or transmits credentials." + } + ] + }, + { + "value": "supply-chain-compromise", + "category": "automated-block", + "officialEffect": "block", + "subjectRules": [ + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + ], + "locales": [ + { + "lang": "en", + "name": "Supply-chain compromise", + "description": "Artifact or dependency evidence indicates a malicious or substituted component." + } + ] + }, + { + "value": "critical-vulnerability", + "category": "automated-block", + "officialEffect": "block", + "subjectRules": [ + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + ], + "locales": [ + { + "lang": "en", + "name": "Critical vulnerability", + "description": "An applicable critical vulnerability meets the policy's blocking rule." + } + ] + }, + { + "value": "artifact-integrity-failure", + "category": "automated-block", + "officialEffect": "block", + "subjectRules": [ + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + ], + "locales": [ + { + "lang": "en", + "name": "Artifact integrity failure", + "description": "Artifact bytes do not match the checksum in the signed release." + } + ] + }, + { + "value": "invalid-bundle", + "category": "automated-block", + "officialEffect": "block", + "subjectRules": [ + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + ], + "locales": [ + { + "lang": "en", + "name": "Invalid bundle", + "description": "The installable archive is malformed, unsafe, or incomplete." + } + ] + }, + { + "value": "undeclared-access", + "category": "automated-block", + "officialEffect": "block", + "subjectRules": [ + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + ], + "locales": [ + { + "lang": "en", + "name": "Undeclared access", + "description": "Bundle behavior or its manifest attempts access outside the signed declaration." + } + ] + }, + { + "value": "impersonation", + "category": "automated-block", + "officialEffect": "block", + "subjectRules": [ + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + ], + "locales": [ + { + "lang": "en", + "name": "Impersonation", + "description": "The release materially impersonates another identity, project, or product." + } + ] + }, + { + "value": "suspicious-code", + "category": "warning", + "officialEffect": "warn", + "subjectRules": [ + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + ], + "locales": [ + { + "lang": "en", + "name": "Suspicious code", + "description": "Concerning behavior lacks enough evidence for a blocking security label." + } + ] + }, + { + "value": "obfuscated-code", + "category": "warning", + "officialEffect": "warn", + "subjectRules": [ + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + ], + "locales": [ + { + "lang": "en", + "name": "Obfuscated code", + "description": "Material code is intentionally difficult to inspect." + } + ] + }, + { + "value": "privacy-risk", + "category": "warning", + "officialEffect": "warn", + "subjectRules": [ + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + ], + "locales": [ + { + "lang": "en", + "name": "Privacy risk", + "description": "The release creates a non-blocking privacy concern." + } + ] + }, + { + "value": "misleading-metadata", + "category": "warning", + "officialEffect": "warn", + "subjectRules": [ + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + ], + "locales": [ + { + "lang": "en", + "name": "Misleading metadata", + "description": "Release metadata or screenshots materially differ from identity claims or behavior." + } + ] + }, + { + "value": "low-quality", + "category": "warning", + "officialEffect": "warn", + "subjectRules": [ + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + ], + "locales": [ + { + "lang": "en", + "name": "Low quality", + "description": "The release lacks meaningful functionality or substance." + } + ] + }, + { + "value": "broken-release", + "category": "warning", + "officialEffect": "warn", + "subjectRules": [ + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + ], + "locales": [ + { + "lang": "en", + "name": "Broken release", + "description": "The structurally valid bundle cannot perform its stated function." + } + ] + }, + { + "value": "!takedown", + "category": "manual-system", + "officialEffect": "redact", + "subjectRules": [ + { "subject": "release", "cidRule": "forbidden", "issuanceModes": ["admin"] }, + { "subject": "package", "cidRule": "forbidden", "issuanceModes": ["admin"] }, + { "subject": "publisher", "cidRule": "forbidden", "issuanceModes": ["admin"] } + ], + "locales": [ + { + "lang": "en", + "name": "Taken down", + "description": "An administrator issued an emergency takedown action." + } + ] + }, + { + "value": "security-yanked", + "category": "manual-system", + "officialEffect": "block", + "subjectRules": [ + { "subject": "release", "cidRule": "forbidden", "issuanceModes": ["reviewer"] } + ], + "locales": [ + { + "lang": "en", + "name": "Security yanked", + "description": "A reviewer withdrew this release for security reasons." + } + ] + }, + { + "value": "publisher-compromised", + "category": "manual-system", + "officialEffect": "block", + "subjectRules": [ + { "subject": "publisher", "cidRule": "forbidden", "issuanceModes": ["admin"] } + ], + "locales": [ + { + "lang": "en", + "name": "Publisher compromised", + "description": "The publisher identity is believed to be compromised." + } + ] + }, + { + "value": "package-disputed", + "category": "manual-system", + "officialEffect": "warn", + "subjectRules": [ + { "subject": "package", "cidRule": "optional", "issuanceModes": ["reviewer"] } + ], + "locales": [ + { + "lang": "en", + "name": "Package disputed", + "description": "The package has an unresolved ownership or policy dispute." + } + ] + } + ], + "overrideRule": { + "subject": "release", + "cidRule": "required", + "reviewerLabels": ["assessment-passed", "assessment-overridden"], + "requireSameSource": true, + "requireAtomicIssuance": true + }, + "precedence": [ + "manual-block", + "label-state-collision", + "assessment-error", + "assessment-pending", + "automated-block", + "missing-assessment-pass", + "eligible" + ], + "publicApi": { + "baseUrl": "https://labels.emdashcms.com/xrpc/", + "policyUrl": "https://labels.emdashcms.com/.well-known/emdash-labeler-policy.json", + "getAssessmentNsid": "com.emdashcms.experimental.labeller.getAssessment", + "getCurrentAssessmentNsid": "com.emdashcms.experimental.labeller.getCurrentAssessment", + "listAssessmentsNsid": "com.emdashcms.experimental.labeller.listAssessments", + "getPolicyNsid": "com.emdashcms.experimental.labeller.getPolicy" + }, + "contact": { + "reconsiderationUrl": "https://emdashcms.com/plugin-moderation/reconsideration", + "reconsiderationEmail": "plugin-moderation@emdashcms.com" + }, + "transparency": { "modelOutputIsAdvisoryEvidence": true } +} From 035889b7bf8264de71a046ca480a55223bcf5635 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 13:27:02 +0100 Subject: [PATCH 006/137] docs: audit registry label vocabulary (#1912) * docs: audit registry label vocabulary * docs: record vocabulary ratification --- .../gate-0/vocabulary-audit.md | 508 ++++++++++++++++++ 1 file changed, 508 insertions(+) create mode 100644 .opencode/plans/plugin-registry-labelling-service/gate-0/vocabulary-audit.md diff --git a/.opencode/plans/plugin-registry-labelling-service/gate-0/vocabulary-audit.md b/.opencode/plans/plugin-registry-labelling-service/gate-0/vocabulary-audit.md new file mode 100644 index 0000000000..19bd38c36d --- /dev/null +++ b/.opencode/plans/plugin-registry-labelling-service/gate-0/vocabulary-audit.md @@ -0,0 +1,508 @@ +# Gate 0 Vocabulary Cutover Audit + +Status: W0.3 audit complete. Canonical `security-yanked`, no-new-legacy emission, collision-safe ingest identity, and conditional Branch A were ratified on 2026-07-10. The production preflight remains an operator action. + +Scope: tracked source, migrations, tests, documentation, generated contracts, and deployment configuration as of branch `feat/labeller-02-vocabulary-audit`. This audit is read-only. It does not establish production database state. + +## Recommendation + +Ratify `security-yanked` as the only value emitted by new code. Use the no-compatibility branch if the production preflight returns no legacy rows. Repository evidence makes that the likely result, but only the read-only production query below can establish it. + +If legacy rows exist, do not rewrite signed `labels` history. First classify every row from its subject URI. Deploy a bounded consumer-only alias, reissue only active release-record rows as canonical `security-yanked`, and signed-negate their legacy values only after the compatibility floor is enforced. Quarantine package-profile, publisher-DID, malformed, and unknown-scope rows until an operator explicitly maps each action; never emit release-only `security-yanked` on those subjects. + +The spelling cutover alone is insufficient. Current consumers do not implement the complete blocking policy, active-label semantics, accepted-source policy, subject cascading, or CID applicability. The history primary key is also not collision-safe for distinct valid labels sharing `(src, uri, val, cts)`. W1.1 should centralize vocabulary, while a forward schema migration plus W1.5, W4, and W5 must land before label ingest or issuance is enabled. + +## Occurrence Inventory + +### Legacy `security:yanked` + +| Location | Reference | Current role | Required future action | +| ------------------------------------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/aggregator/migrations/0001_init.sql:187-230` | `labels`, `label_state`, and example at line 191 | Schema accepts arbitrary `val`; the comment names the legacy value. | Change the fresh-install comment. Do not edit signed persisted history. Add a mandatory forward collision-safe history migration before ingest. | +| `apps/aggregator/src/routes/xrpc/searchPackages.ts:148-162` | `ENFORCEMENT_FILTER_SQL` | Raw SQL blocks package-profile URIs with `!takedown` or the legacy value. | Replace with shared policy-driven subject evaluation. A temporary `IN (..., 'security:yanked')` alias is permitted only on the persisted-row branch. | +| `packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/searchPackages.json:8` | Query description | Generated-contract source documents the legacy spelling. | Use `security-yanked` and describe policy rather than a partial hard-coded pair. Regenerate types. | +| `packages/registry-client/src/discovery/index.ts:1-13` | Module documentation | Says the aggregator enforces `!takedown` and the legacy value. | Correct spelling and document that the client evaluates typed moderation state rather than trusting endpoint filtering alone. | +| `packages/core/src/api/handlers/registry.ts:1-39` | Install-flow documentation | Describes only the legacy yank as the label gate. | Describe and call the shared release-moderation evaluator. | +| `packages/core/src/api/handlers/registry.ts:791-808` | `handleRegistryInstall` | Raw comparisons over package and release label arrays. | Replace both comparisons with one shared evaluation immediately before artifact download. | +| `packages/core/src/api/handlers/registry.ts:1462-1471` | `handleRegistryUpdate` | Raw comparison over release labels only. | Replace with the same shared evaluation used by install. | +| `packages/admin/src/components/RegistryPluginDetail.tsx:115-136` | Release filtering | Claims aggregator filtering plus local defense in depth. | Consume typed eligibility; do not locally classify strings. | +| `packages/admin/src/components/RegistryPluginDetail.tsx:833-850` | `YANKED_LABEL_VALUE`, `isYanked` | Defines and compares the legacy value and intentionally ignores `neg`. | Delete after the shared evaluator is wired into the UI. | + +No current runtime, migration, test, generated type, or public documentation occurrence of canonical `security-yanked` exists outside the labelling-service spec and implementation plan. The canonical value is therefore not currently recognized by shipped consumers. + +### `!takedown` and redaction + +| Location | Reference | Current behavior or gap | +| ------------------------------------------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/aggregator/migrations/0001_init.sql:191` | Schema comment | `val` can store `!takedown`; no issuer or ingest path exists. | +| `apps/aggregator/src/routes/xrpc/searchPackages.ts:153-162` | `ENFORCEMENT_FILTER_SQL` | Filters only a package-profile URI, only when `trusted = 1`, and regardless of the request's accepted labellers or `redact` flag. | +| `packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/searchPackages.json:8` | Query description | Describes takedown filtering but does not encode accepted-source or redaction semantics. | +| `packages/registry-client/src/discovery/index.ts:101-114` | `acceptLabelers` docs | Forwards an opaque header and documents `;redact`; it neither parses the header nor interprets the response policy. | +| `apps/aggregator/src/routes/xrpc/router.ts:43-64` | CORS contract | Allows `atproto-accept-labelers`, but incorrectly exposes that request header instead of `atproto-content-labelers`. The handler never parses either accepted DIDs or `redact`. | +| Core install/update handlers | No comparison | `!takedown` is not independently blocked. They rely on aggregator filtering that only exists on package search, not direct package/release reads. | +| Admin browse/detail | No rendering or comparison | No redacted/takedown state is shown or blocked locally. | + +### Verification and `verified` + +There are two unrelated mechanisms that current code partially conflates: + +1. `apps/aggregator/migrations/0001_init.sql:133-162` defines `publisher_verifications`, a table of signed `com.emdashcms.experimental.publisher.verification` records bound to subject handle and display name. +2. `apps/aggregator/src/records-consumer.ts:793-841` ingests those records. No read endpoint converts them into an ATProto label or hydrates a `verified` value. +3. `packages/admin/src/components/RegistryBrowse.tsx:168-188` and `RegistryPluginDetail.tsx:236-243` treat any raw label with `val === "verified"` as publisher verification. +4. `packages/admin/tests/components/RegistryPluginDetail.test.tsx:248-275` is the only focused verified-label fixture. It proves tooltip rendering, not source acceptance, negation, expiration, CID binding, or the verification-record validity rules. +5. `packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/defs.json:51-59,113-121` permits generic standard ATProto labels but does not define a `verified` vocabulary value. +6. `docs/src/content/docs/plugins/registry.mdx:20-29,75-85` and `docs/src/content/docs/reference/configuration.mdx:400-405` describe verification labels even though the current aggregator does not hydrate them. + +`verified` must not become an eligibility shortcut. Coordinator confirmation is needed on whether publisher verification remains a separately evaluated registry record, becomes a typed non-moderation label under a distinct policy, or is removed from the admin label path. In every case, the current raw `verified` checks must be replaced. + +### Generated contracts and client types + +- `packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/defs.json:6-123` is the source contract. Package and release views have optional arrays of standard `com.atproto.label.defs#label`; `labels` is not a required field. +- `packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/listReleases.json:4-63` promises hard-enforcement filtering and hydrated labels that the current handler does not implement. +- `packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/getLatestRelease.json:4-36` promises the highest non-yanked release and `NotFound` when no eligible release exists; current code excludes only publisher tombstones. +- `packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/defs.ts:5-145` preserves the complete standard label shape through generated validation, including fields such as `src`, `uri`, `cid`, `val`, `neg`, `cts`, and `exp`. It adds no vocabulary or applicability policy. +- Generated `listReleases.ts` and `getLatestRelease.ts` preserve method shapes but not source descriptions, so source-Lexicon behavior claims need direct contract tests in addition to generated-file freshness checks. +- `packages/registry-client/src/discovery/index.ts:36-55` preserves those arrays in `ValidatedPackageView` and `ValidatedReleaseView`, but provides no active-label or eligibility helper. +- `packages/registry-client/tests/discovery.test.ts:33-326` covers transport, header forwarding, envelope validation, and embedded-record validation. It has no moderation-state fixture. +- `packages/registry-client/README.md:27-32` documents only opaque server-side hard-takedown filtering. + +## Current Behavior and Gaps + +### Database state model + +`apps/aggregator/migrations/0001_init.sql:180-241` creates the following dormant label infrastructure: + +- `labels` is intended as append-only history and retains `cid`, `neg`, `exp`, signature, version, trust, and receive time. Its primary key `(src, uri, val, cts)` is not a safe event identity: two distinct valid signed labels can share those four fields while differing in CID, negation, expiry, or signature. The second insert would collide and history would be lost. +- `label_state` projects one latest row per `(src, uri, val)` and retains that row's `cid`, `neg`, `exp`, and trust bit. +- The schema comment defines active as `neg = 0` and unexpired. The only runtime query using this rule is package search. +- `trusted` is copied onto each row. It can record ingestion provenance or the deployment-default trust snapshot at receipt time, but it must never decide request applicability. Request evaluation uses the current parsed accepted-DID policy, so a trust configuration change takes effect without rewriting history. +- No tracked source contains `INSERT`, `UPDATE`, or `UPSERT` statements for `labels`, `label_state`, or `labellers`. No seed or deployment data populates them. + +Before any label ingest, add a forward migration to give each history event collision-safe identity. Preferred shape: a stable digest of the exact verified signed-label bytes as the primary/idempotency key, plus unique `(src, source_sequence, frame_index)` coordinates for subscription ingest. `subscribeLabels` sequence identifies a frame, and one frame may carry multiple labels, so `(src, source_sequence)` alone is not unique. A different event-coordinate primary key is acceptable only if every ingest and replay path proves the same per-label identity. Do not use `cts` as event identity. Projection ordering uses verified `(source_sequence, frame_index)` after `cts`; a replay lacking those coordinates must retain both same-`cts` events and quarantine an ambiguous state transition rather than silently choose by insertion order or digest. + +The state key chooses one current label for `(src, uri, val)` while retaining its CID. Consumers still have to reject a CID-bound state row when its CID does not equal the current subject CID. Current search does not perform that check. + +### Active, negated, expired, and CID-bound labels + +Current behavior is inconsistent: + +- Package search checks persisted `trusted = 1`, `neg = 0`, and a textual `exp > strftime(...)` comparison. +- Package search does not check `cid`, accepted source DID, or `redact`. +- RFC 3339 strings are not safely ordered as text, and SQLite date functions are permissive rather than RFC3339 validators. SQL inventories raw `exp` only. Application code must first validate strict RFC3339 syntax and then parse/compare the instant against one evaluation time. Equality is expired/inactive. New malformed-expiry labels are rejected before write. An already-persisted positive legacy row with malformed or ambiguous expiry is quarantined and held suppressive until disposition. +- Package search constructs only the package profile URI. Because future `security-yanked` is release-only, this filter cannot suppress a yanked release. A release label's URI does not equal the package profile URI. +- Core install/update raw-array checks use only `val`. A negated, expired, unaccepted, wrong-CID, or untrusted label would still block if hydrated. +- Admin `isYanked` deliberately mirrors that incorrect raw-value behavior. +- Admin `verified` checks likewise ignore source acceptance, negation, expiration, CID applicability, and the separate publisher-verification validity contract. +- Aggregator views currently return `labels: []` unconditionally, so the core/admin checks are normally inert. + +The future shared evaluator must define an applicable active label as one whose source DID is in the current request's accepted set, whose latest state is not negated, whose application-validated strict RFC3339 expiry is absent or strictly later than the evaluation instant, and whose CID is absent for a URI-wide action or exactly matches the current subject CID. Persisted `trusted` may explain ingestion/default provenance but cannot add or remove a source from that request. Unknown values remain visible as data but have no official effect unless policy assigns one. + +### Aggregator endpoint trace + +| Endpoint | Current implementation | Moderation consequence | +| ------------------ | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `searchPackages` | `searchPackages.ts:36-162` applies the partial package-URI SQL filter, then `packageView` emits `labels: []`. | Only dormant trusted package-level legacy yank/takedown state is filtered. Publisher scope, release scope, accepted sources, and redaction are absent. | +| `getPackage` | `getPackage.ts:17-38` reads `packages` and calls `packageView`. | Direct reads ignore all labels and takedowns. | +| `resolvePackage` | `resolvePackage.ts:50-84` resolves a handle, reads `packages`, and calls `packageView`. | Same as `getPackage`. | +| `listReleases` | `listReleases.ts:22-104` selects every non-tombstoned release and calls `releaseView`. | No moderation filter, tombstone, hydration, or explanation. | +| `getLatestRelease` | `getLatestRelease.ts:25-66` trusts `packages.latest_version`, then falls back to highest non-tombstoned semver. | "Eligible" currently means only non-tombstoned. Positive assessment and every blocking label are ignored. | +| View mapping | `views.ts:120-163` | Package and release labels are always empty arrays. | +| Request boundary | `router.ts:43-117` | The accepted-labeller header is allowed but not parsed; no `atproto-content-labelers` response is produced. | + +### Registry client trace + +`DiscoveryClient` forwards `acceptLabelers` on every request and validates response envelopes. It does not: + +- Parse accepted labellers or the aggregator's actual content-labeller response. +- Filter active state. +- Evaluate publisher, package, and release subjects together. +- Require `assessment-passed`. +- Classify pending, error, blocking, warning, manual override, or redacted states. +- Verify hydrated label signatures. + +Its `listReleases` and `getLatestRelease` comments promise yank-aware behavior that the aggregator does not implement. CLI `search` and `info` construct the client without accepted-labeller configuration (`packages/plugin-cli/src/commands/search.ts:48-58`, `info.ts:45-56`); `info` prints raw labels (`info.ts:98-102`) without explaining effect or active state. + +### Core install, update, and update-check trace + +- `handleRegistryInstall` resolves package and release views, then blocks only raw legacy yanks on package or release (`registry.ts:684-808`). It does not block `!takedown`, canonical yank, any descriptive security label, missing/pending/error assessment, or package/publisher cascades. +- Explicit-version installs page through `listReleases`, so they bypass any future latest-release filtering unless the handler performs the same shared evaluation. +- `handleRegistryUpdate` resolves a release and blocks only a raw legacy release yank (`registry.ts:1389-1471`). It does not load package/publisher state and has less defense than install. +- `handleRegistryUpdateCheck` trusts `getLatestRelease` and exposes an update without evaluating its moderation state (`registry.ts:1683-1744`). Failures are skipped, so an ineligible installed release also has no alert path. +- `packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts:293-353` independently resolves latest or explicit release media through `DiscoveryClient` and returns the declared URL without evaluating moderation. A stale or hand-built proxy URL could therefore retrieve media for a blocked/redacted explicit release unless this route shares the same policy or the aggregator returns a redacted tombstone. +- Focused core tests do not cover moderation. `packages/core/tests/unit/api/registry-handlers.test.ts:1-12` explicitly lacks update happy-path coverage; `registry-env-gate-handler.test.ts:47-67` supplies only `labels: []`. + +The shared evaluation must run after exact release selection and again immediately before artifact download for both install and update. Update-check and installed-release alert paths must consume the same result type rather than infer eligibility from endpoint selection. + +### Admin browse and detail trace + +- Browse trusts aggregator search filtering and shows only a raw `verified` shield (`RegistryBrowse.tsx:151-204`). Its React Query key at lines 51-62 omits `config.acceptLabelers`, so changing accepted policy can reuse stale results. +- Detail's package query key at lines 100-107 also omits `config.acceptLabelers`. The release-list key includes it at lines 126-130. +- Detail removes only raw legacy yanks and hides every release if all returned releases match that string (`RegistryPluginDetail.tsx:115-178,833-850`). It does not surface pending/error/block reasons, warnings, source, CID, expiry, overrides, or reconsideration. +- There is no `RegistryBrowse` component test and no yanked/moderation test in `RegistryPluginDetail.test.tsx`. +- Server errors document only `RELEASE_YANKED` (`packages/admin/src/lib/api/registry.ts:674-692`), not the future eligibility states. + +## Complete Official Blocking Policy + +W0.2 owns executable fixtures and is authoritative if it differs from this audit. The spec currently requires the following evaluation shape: + +1. Require an applicable active `assessment-passed` from an accepted labeller. Its absence is `pending`/unknown and blocks. +2. `assessment-pending` blocks temporarily. `assessment-error` blocks and explains an operational failure. +3. Block active automated security labels: `malware`, `data-exfiltration`, `credential-harvesting`, `supply-chain-compromise`, `critical-vulnerability`, `artifact-integrity-failure`, `invalid-bundle`, `undeclared-access`, and `impersonation`. +4. Block active manual `security-yanked` on a release. +5. Redact and block `!takedown` according to accepted-labeller `redact` policy. `!suspend` must follow the standard redaction contract where supported. +6. Block `publisher-compromised` across all packages and releases from the publisher. +7. Apply package and publisher labels at evaluation time without copying them onto release rows. `package-disputed` warns and prevents recommendation; direct-install blocking remains a ratification point. +8. Warning values (`suspicious-code`, `obfuscated-code`, `privacy-risk`, `misleading-metadata`, `low-quality`, `broken-release`) do not block alone, but official admin installation requires explicit warning consent. +9. An exact-CID action-backed `assessment-passed` plus `assessment-overridden` may override selected automated release blocks according to W0.2 fixtures. It never overrides `!takedown`, `security-yanked`, `publisher-compromised`, or broader manual package/publisher blocks. +10. A wrong-CID positive label never makes the current release eligible. A URI-wide manual action remains applicable across record edits until negated or expired. + +## Production Persistence Evidence + +Evidence that a database may exist: + +- `docs/src/content/docs/plugins/registry.mdx:12-18,37-49` says the reference aggregator runs at `registry.emdashcms.com`. +- `apps/aggregator/wrangler.jsonc:1-21` names Worker `emdash-aggregator` and D1 database `emdash-aggregator`; the database is auto-provisioned on first deploy. +- `apps/aggregator/package.json:7-16` provides production deploy and remote migration commands. +- Git history contains the aggregator scaffold, ingest, read API, install integration, and Workers Builds fixes, consistent with deployment work. + +Evidence against application-created label rows: + +- The only label tables are the initial migration's dormant infrastructure. +- `views.ts:15-18` and `searchPackages.ts:12-15,148-152` explicitly say label hydration/ingest has not landed and the table is empty in the current slice. +- Exhaustive tracked searches found no writer for `labels`, `label_state`, or `labellers` and no seed data containing any moderation value. +- There is no configured labeller subscription, label queue, or labeler DID in current deployment configuration. + +Evidence limitations: + +- `wrangler.jsonc` intentionally omits `database_id` and production routes are configured at deploy time, so this checkout cannot identify or inspect the deployed D1 instance. +- Operators could have inserted rows manually or through untracked deployment tooling. +- Documentation proving a deployed aggregator does not prove which migrations ran or what rows exist. + +Conclusion: persisted production label rows are unlikely, and application-produced legacy rows are especially unlikely, but production state is unknown until an authorized operator runs the read-only preflight. + +## Safe Production Preflight + +Run the following read-only SQL against each production/staging aggregator D1 database before W1.1 deploys. Do not run it against the labeller database. It returns counts first, followed by the matching rows without signature bytes. + +```sql +SELECT + 'labels_history' AS storage, + COUNT(*) AS total_rows, + COALESCE(SUM(CASE WHEN val = 'security:yanked' THEN 1 ELSE 0 END), 0) AS legacy_rows, + COALESCE(SUM(CASE WHEN val = 'security-yanked' THEN 1 ELSE 0 END), 0) AS canonical_rows +FROM labels +UNION ALL +SELECT + 'label_state' AS storage, + COUNT(*) AS total_rows, + COALESCE(SUM(CASE WHEN val = 'security:yanked' THEN 1 ELSE 0 END), 0) AS legacy_rows, + COALESCE(SUM(CASE WHEN val = 'security-yanked' THEN 1 ELSE 0 END), 0) AS canonical_rows +FROM label_state; + +WITH matching AS ( + SELECT + 'labels_history' AS storage, + src, + uri, + cid, + val, + neg, + cts, + exp, + trusted + FROM labels + WHERE val IN ('security:yanked', 'security-yanked') + UNION ALL + SELECT + 'label_state' AS storage, + src, + uri, + cid, + val, + neg, + cts, + exp, + trusted + FROM label_state + WHERE val IN ('security:yanked', 'security-yanked') +), uri_parts AS ( + SELECT + *, + CASE WHEN substr(uri, 1, 5) = 'at://' THEN 1 ELSE 0 END AS is_at_uri, + CASE + WHEN substr(uri, 1, 5) = 'at://' THEN instr(substr(uri, 6), '/') + ELSE 0 + END AS authority_slash + FROM matching +), authority_parts AS ( + SELECT + *, + CASE + WHEN is_at_uri = 1 AND authority_slash > 1 + THEN substr(uri, 6, authority_slash - 1) + WHEN is_at_uri = 0 + THEN uri + ELSE NULL + END AS candidate_did, + CASE + WHEN is_at_uri = 1 AND authority_slash > 1 + THEN substr(uri, 6 + authority_slash) + ELSE NULL + END AS path_after_authority + FROM uri_parts +), parsed_parts AS ( + SELECT + *, + substr(candidate_did, 5) AS did_body, + instr(substr(candidate_did, 5), ':') AS method_separator, + instr(path_after_authority, '/') AS collection_separator + FROM authority_parts +), fields AS ( + SELECT + *, + substr(did_body, 1, method_separator - 1) AS did_method, + substr(did_body, method_separator + 1) AS did_identifier, + substr(path_after_authority, 1, collection_separator - 1) AS subject_collection, + substr(path_after_authority, collection_separator + 1) AS subject_rkey + FROM parsed_parts +), screened AS ( + SELECT + *, + CASE + WHEN substr(candidate_did, 1, 4) = 'did:' + AND method_separator > 1 + AND did_method NOT GLOB '*[^a-z0-9]*' + AND length(did_identifier) > 0 + AND did_identifier NOT GLOB '*[^A-Za-z0-9._:-]*' + THEN 1 + ELSE 0 + END AS did_shape_candidate + FROM fields +), classified AS ( + SELECT + *, + CASE + WHEN is_at_uri = 0 AND did_shape_candidate = 1 THEN 'publisher_candidate' + WHEN is_at_uri = 1 + AND did_shape_candidate = 1 + AND collection_separator > 1 + AND length(subject_rkey) > 0 + AND subject_rkey NOT GLOB '*[/?#]*' + THEN CASE subject_collection + WHEN 'com.emdashcms.experimental.package.release' THEN 'release_candidate' + WHEN 'com.emdashcms.experimental.package.profile' THEN 'package_candidate' + ELSE 'unknown' + END + ELSE 'unknown' + END AS subject_scope + FROM screened +) +SELECT + storage, + subject_scope, + CASE WHEN is_at_uri = 1 THEN subject_collection ELSE NULL END AS subject_collection, + src, + uri, + cid, + val, + neg, + cts, + exp, + trusted +FROM classified +ORDER BY storage, subject_scope, src, uri, val, cts; +``` + +The SQL is only a conservative structural screen. It cannot validate full DID, AT URI, or ATProto record-key grammar. `release_candidate`, `package_candidate`, and `publisher_candidate` are not valid subjects yet. Before automatic canonical reissue, application code must use an ATProto parser/validator to require a fully valid DID authority, a plain publisher DID with no suffix, an AT URI with exactly authority/collection/rkey, the exact collection, and a record key satisfying the complete ATProto grammar, byte-length limit, and reserved `.`/`..` rejection. Any SQL candidate that application parsing does not strictly validate becomes `unknown` and remains quarantined/suppressive. + +The SQL intentionally rejects some potentially valid percent-encoded DID forms as `unknown` rather than risk a false positive. Conversely, strings such as a trailing-colon DID or invalid rkey may pass the SQL shape screen; candidate status never authorizes migration. + +Focused classifier fixtures: + +| Subject | SQL screen | Required application result | +| ------------------------------------------------------------------------- | --------------------- | -------------------------------------- | +| `did:plc:abc` | `publisher_candidate` | `publisher` | +| `did:plc:abc/path` or `did:plc:abc#fragment` | `unknown` | `unknown` | +| `did::abc` or `did:PLC:abc` | `unknown` | `unknown` | +| `did:web:example.com:` | `publisher_candidate` | `unknown` (invalid trailing-colon DID) | +| `at://did:plc:abc/com.emdashcms.experimental.package.release/pkg:1.0.0` | `release_candidate` | `release` | +| `at://did:plc:abc/com.emdashcms.experimental.package.profile/pkg` | `package_candidate` | `package` | +| Missing authority, empty collection/rkey, extra path, or wrong collection | `unknown` | `unknown` | +| Release rkey containing a space or `%` | `release_candidate` | `unknown` | +| Release rkey longer than the ATProto byte limit | `release_candidate` | `unknown` | +| Release rkey `.` or `..` | `release_candidate` | `unknown` (reserved) | + +Interpretation: + +- If both `legacy_rows` values are zero, choose Branch A. +- If either is nonzero, choose Branch B even when every projected legacy row is negated or expired. History replay and old projections still need an explicit compatibility decision. +- Only an application-validated `release_candidate` is eligible for automatic canonical reissue. Package/publisher candidates, failed application validation, and SQL `unknown` rows require explicit operator mapping and remain quarantined. +- SQL does not determine activity from `exp`. Application code treats a latest positive row as non-suppressive only when `exp` is strict RFC3339 and its parsed instant is at or before the single evaluation instant. Missing, malformed, or ambiguous expiry remains an active hold; a latest negation is inactive regardless of expiry. +- Any canonical row before the official signer exists is unexpected and requires provenance review before proceeding. + +## Migration Branches + +### Branch A: no persisted legacy rows + +Recommended branch, contingent on the preflight: + +1. Add no vocabulary compatibility alias and no legacy-row data migration. +2. Change all tracked examples and raw references to canonical `security-yanked` while centralizing the complete vocabulary. +3. Add the collision-safe forward history migration even on an empty database; it is a prerequisite for ingest, not legacy-data compatibility. +4. Add the shared evaluator and endpoint/client tests before issuing any production label. +5. Deploy the compatibility-floor component versions before enabling canonical label issuance. +6. Re-run the preflight immediately before issuance. A newly appeared legacy row switches the rollout to Branch B. + +This follows the spec's explicit instruction not to carry compatibility without persisted data. + +### Branch B: persisted legacy rows exist + +1. Export/backup the affected D1 database and retain the preflight output as rollout evidence. +2. Apply the collision-safe forward history migration before enabling any subscriber, query backfill, or replay writer. +3. Inventory SQL candidates, then application-parse each raw subject and expiry. Record strict subject validity/scope, issuer, CID, current projected state, strict RFC3339 result/instant, and signature provenance. +4. Mark only application-validated `at://.../com.emdashcms.experimental.package.release/` rows as release-scoped. Quarantine package-profile URIs, publisher DIDs, malformed/ambiguous URIs or rkeys, SQL-only candidates, unknown collections, and unexplained/manual rows. Do not reinterpret them as release yanks and do not negate them until an operator approves a policy-valid replacement or explicit retirement. +5. Before changing vocabulary state, materialize fail-closed compatibility holds for every active quarantined row. A package hold suppresses the package and all of its releases; a publisher hold suppresses every indexed package/release for that DID. For malformed/unknown rows, preserve every subject suppressed by the pre-cutover query and suppress any indexed subject resolved by exact stored URI; an unresolvable row blocks exact-URI access and alias removal but must not create an unrelated ecosystem-wide block. +6. Deploy consumers that normalize only release-scoped `security:yanked` to the canonical release-yank policy class on read. Non-release/malformed rows use compatibility holds, not canonical normalization. The alias is consumer-only; no producer may emit the legacy value. +7. Enforce the compatibility floor below across the reference aggregator and supported official clients. Publishing packages does not upgrade deployed EmDash sites, CLIs, third-party aggregators, or cached admin bundles. +8. For each active, verified release-scoped legacy action, issue canonical `security-yanked` with the same release URI and intended CID scope. Keep the legacy positive active during the compatibility interval so old consumers retain their existing signal. +9. Only after the compatibility floor is enforced, issue a signed negation of that release-scoped legacy value. Canonical blocking must already be effective. Never emit canonical `security-yanked` on a package URI or publisher DID. +10. Let normal verified ingestion update `label_state`; do not hand-edit its projection unless a separately reviewed repair runbook is required. +11. Replay into a clean collision-safe history/projection and confirm identical blocked release subjects under the dual-read evaluator, including same-`cts` events. +12. Keep the dual-read rollout as the minimum rollback version. Rolling back below it after any legacy negation can fail open. +13. Remove the alias and compatibility holds only after all configured aggregators report zero active legacy state, every quarantined scope has an explicit replacement/retirement, full replay reproduces canonical state, and monitoring confirms no producer emitted the legacy value for the agreed retention window. + +Quarantined-state behavior is explicit: + +- Positive rows with no expiry or a strictly parsed future RFC3339 instant are active holds. A positive row with malformed or ambiguous expiry is also an active hold because expiry cannot be proved. +- Latest negated rows and positive rows whose strict RFC3339 instant is at or before the single evaluation instant remain quarantined for audit/mapping but do not suppress subjects. Exact equality is non-suppressive. +- A later valid positive can reactivate a hold according to collision-safe sequence/frame ordering. +- Manual retirement removes a hold only through an audited disposition; it does not rewrite signed history or emit release-only canonical vocabulary on a broader/malformed subject. + +## Component Compatibility Floor + +The current versions below are evidence from tracked manifests, not claims about every deployed installation. The implementation PR must replace each `TBD` with the first released version or deployed commit satisfying that class. + +| Component class | Current/target version marker | Existing behavior | Required compatibility-floor behavior | Safe after legacy negation? | +| ------------------------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `A0` current/reference-old aggregator | Private `@emdash-cms/aggregator@0.0.0`; current branch commit | Legacy package-search string filter only; direct package/release reads, latest selection, explicit release lists, and labels are not policy-complete. | Upgrade to `A1` before canonical issuance is relied upon. | No. | +| `A1` reference aggregator | First deployed compatibility-floor commit: `TBD` | New class. | Verify before write; collision-safe history; current accepted-DID evaluation; canonical+legacy dual-read; application-validated subject/expiry; publisher/package/release expansion; block/redact search, package, resolve, list, latest, explicit-version, and media/artifact resolution consistently. For clients without typed tombstone support, omit blocked releases rather than returning an installable-looking view. | Yes for acquisition paths. | +| `S0` already-installed EmDash/core/admin | `emdash@0.28.1`, `@emdash-cms/admin@0.28.1`, client `0.3.2` | Embeds its registry-client and raw legacy checks at build/install time. Updating npm packages or the reference Worker does not upgrade it. No complete installed-release alert path. | Either upgrade the installation to `S1` or require it to use an `A1` reference endpoint that fail-closes every latest/explicit/artifact path. Publish the minimum supported EmDash version. | Acquisition can be safe through `A1`; installed-plugin alert/disable coverage remains absent. | +| `S1` policy-aware EmDash/core/admin | First compatible EmDash/admin/client releases: `TBD` | New class. | Shared evaluator immediately before install/update artifact fetch, explicit-version parity, update-check/installed-release alerts, policy-keyed admin queries, and blocked/redacted artifact proxy. | Yes. | +| `C0` installed CLI/registry-client | `@emdash-cms/plugin-cli@0.6.0`, registry client `0.3.2` | No accepted-labeller option in current search/info commands; displays unclassified raw labels; no install/update command. | Upgrade to `C1` for accurate moderation display. | No install bypass exists, but output is not trustworthy as policy explanation. | +| `C1` policy-aware CLI/client | First compatible CLI/client releases: `TBD` | New class. | Current accepted-DID policy, typed moderation result, canonical+bounded-legacy handling, and content-labeller reporting. | Yes for its supported read-only paths. | +| Third-party/self-hosted old aggregator or client | Deployment-specific; no repository-controlled version floor | Version and rollout are outside the reference deployment's control. | Operator attestation of `A1`/`S1` equivalent behavior or explicit exclusion from the supported compatibility set. | Unknown until attested. | + +Safe rollout and rollback protections: + +1. Land and deploy `A1` before using canonical labels for official enforcement. Exercise latest, explicit-version, list pagination, direct package/resolve, and artifact/media paths against both vocabulary values. +2. Release `S1` and `C1`, publish their exact minimum versions, and inventory managed installations. Self-hosted installations cannot be assumed upgraded; their operators must upgrade or accept unsupported status explicitly. +3. Under Branch B, issue canonical release labels while retaining active legacy positives. This overlap is the only interval in which both old legacy-aware and new canonical-aware consumers can remain blocked. +4. Do not signed-negate any legacy positive until the operator has enforced the declared floor: all reference endpoints are `A1`, supported managed EmDash installations are `S1` or pinned to `A1` fail-closed endpoints, and third-party compatibility has an explicit disposition. +5. Before negation, rollback to `A0`/`S0` remains possible only because legacy positives remain active, though their pre-existing policy gaps still exist. After the first negation, pin rollback to `A1`/`S1`; restore by replaying immutable history/projection, never by downgrading consumers. +6. Old installed sites have no automatic installed-plugin alert/disable upgrade path. Do not claim fleet-wide post-install protection until those sites run `S1`; `A1` only protects future acquisition requests that reach it. + +Removal inventory (application parsing decides whether each positive row is suppressive): + +```sql +SELECT + src, + uri, + cid, + neg, + cts, + exp, + trusted +FROM label_state +WHERE val = 'security:yanked' + AND neg = 0 +ORDER BY src, uri, cts; +``` + +The removal procedure passes every raw `exp` through the same strict application validator. A valid RFC3339 instant exactly equal to or before the evaluation instant is inactive; a future instant or absent expiry is active. Invalid, date-only, timezone-less, space-separated, or otherwise ambiguous values remain active fail-closed holds and produce diagnostics. Historical legacy rows remain valid audit evidence after the compatibility alias is removed. + +## Future PR Patch List + +### W1.1: vocabulary cutover + +- Add the ratified vocabulary to the shared moderation package selected by W1.5. No endpoint-local constants. +- `apps/aggregator/migrations/0001_init.sql`: correct the fresh-install example; preserve already-applied databases. +- Add a forward aggregator migration replacing history identity `(src, uri, val, cts)` with collision-safe event identity. Prefer signed-label digest/idempotency plus unique verified `(src, source_sequence, frame_index)` subscription coordinates; define deterministic duplicate, multi-label-frame, and same-`cts` conflict handling before ingest starts. +- `apps/aggregator/src/routes/xrpc/searchPackages.ts`: remove the legacy raw pair in favor of shared policy inputs, or add the bounded alias only under Branch B. +- `packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/defs.json`, `searchPackages.json`, `listReleases.json`, and `getLatestRelease.json`: correct source contract claims and describe typed moderation/redaction behavior. Regenerate `defs.ts`, `searchPackages.ts`, `listReleases.ts`, `getLatestRelease.ts`, generated indexes, and ambient registrations. +- `packages/registry-lexicons/tests/types.test.ts` plus aggregator read-contract tests: assert the regenerated package/release label shapes and the observable list/latest eligibility contract rather than relying on descriptions that generated TypeScript omits. +- `packages/registry-client/src/discovery/index.ts`, `README.md`, and `tests/discovery.test.ts`: correct vocabulary and add typed moderation-state fixtures. +- `packages/core/src/api/handlers/registry.ts`: remove all three legacy comparisons and route install/update through the shared evaluator. +- `packages/admin/src/components/RegistryPluginDetail.tsx`: remove `YANKED_LABEL_VALUE` and `isYanked`. +- Add changesets for affected published packages when implementation lands. + +### W4: aggregator policy and hydration + +- `apps/aggregator/src/routes/xrpc/router.ts`: parse accepted labellers once, set/CORS-expose `atproto-content-labelers`, and preserve missing versus empty policy. +- Add label ingest/projection code that validates payload shape and source, verifies signatures before any history/projection write, and preserves exact `src`, `uri`, `cid`, `val`, `neg`, `cts`, `exp`, signed bytes/digest, verified source sequence, and frame index. +- Treat persisted `trusted` only as receipt-time ingestion/default-policy metadata. Every request resolves current accepted DIDs independently; changing deployment trust must immediately affect old rows without rewriting them. +- Add one application-layer ATProto subject validator used by preflight migration and runtime policy. It must fully validate DID grammar, AT URI segmentation/collection, and record-key grammar, byte length, and reserved `.`/`..`; SQL candidates confer no validity. +- Add one strict application-layer RFC3339 syntax/instant parser. SQL stores/inventories raw expiry only. New malformed expiry is rejected; persisted malformed/ambiguous positive legacy expiry stays suppressive; strict instant equality is inactive. +- Under Branch B, add temporary compatibility holds for active quarantined package, publisher, malformed, and unknown-scope legacy rows. Hydration/filtering must apply their fail-closed suppression without converting them to canonical release vocabulary; negated/validly expired rows retain audit state without an active hold. +- `apps/aggregator/src/routes/xrpc/views.ts`: replace unconditional empty arrays with batched publisher/package/release hydration. +- `searchPackages.ts`, `getPackage.ts`, `resolvePackage.ts`, `listReleases.ts`, and `getLatestRelease.ts`: use shared subject expansion and evaluation. Search/redaction, direct tombstones, and latest eligible release must agree. +- `apps/aggregator/test/read-api.test.ts`: clear label tables between tests and add source, active/negated/expired, URI-wide/CID-bound, subject-cascade, redaction, and latest-selection coverage. +- Add request-header/CORS contract tests for absent, empty, malformed, repeated, unavailable, and `redact` accepted-labeller forms. +- Add workerd/D1 ingest security tests for invalid signatures, wrong DID/key, malformed/extra-field payloads, routine key rotation, one DID/key refresh on first verification failure, persistent failure after refresh, and unverifiable replay. Every rejection must prove zero history rows, zero projection writes, and no durable cursor advancement. +- Add collision/replay tests proving every label in one multi-label subscription frame receives distinct `(src, source_sequence, frame_index)` coordinates, exact redelivery deduplicates by digest, distinct valid same-`cts` labels both survive history, verified frame coordinates resolve projection order, and coordinate-less ambiguous replay is quarantined rather than overwritten. + +### W5: client, core, CLI, and admin enforcement + +- Add `evaluateReleaseModeration` and `ReleaseModeration` to the shared package and re-export/wrap it from registry-client without duplicating policy. +- Registry client must preserve response `atproto-content-labelers`, all label applicability fields, and assessment references; add full-label verification access separately. +- `handleRegistryInstall`, `handleRegistryUpdate`, and `handleRegistryUpdateCheck` must share evaluation over publisher, package, and exact release subjects. Explicit-version paths must not bypass it. +- `packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts` must fail closed for redacted/blocked exact releases so media retrieval cannot bypass direct-read policy. +- Add focused core handler tests for every eligibility outcome and prove rejection occurs before artifact fetch. +- `packages/admin/src/components/RegistryBrowse.tsx`: add accepted-labeller policy to the query key and render typed eligibility, not raw `verified`. +- `RegistryPluginDetail.tsx`: add accepted-labeller policy to the package query key; consume typed eligibility, warnings, issuer, summary, override, and reconsideration state. +- `packages/admin/tests/components/RegistryPluginDetail.test.tsx`: replace raw-yank assumptions with policy fixtures; add negation/expiry/CID/warning/override tests. Add browse tests. +- `packages/admin/src/lib/api/registry.ts`: expose localized server error mapping for pending, error, blocked, redacted, and warning-consent outcomes. +- `packages/plugin-cli/src/commands/search.ts` and `info.ts`: use configured official accepted-labeller policy and display the shared moderation result rather than unclassified raw labels. +- `docs/src/content/docs/plugins/registry.mdx`, `registry-client.mdx`, and `reference/configuration.mdx`: document canonical vocabulary, positive-assessment requirement, warning behavior, and actual accepted/content-labeler semantics after implementation. +- Add installed-release refresh/alert coverage for canonical yank, takedown, and newly blocking labels. + +## Test Matrix + +| Layer | Required cases | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Vocabulary | Canonical value accepted; legacy rejected for issuance; legacy read alias present only in Branch B. | +| History identity | Multi-label frame indices; exact-redelivery digest idempotency; distinct valid same-`cts` labels retained; verified `(sequence,index)` tie-break; coordinate-less conflict quarantine; replay. | +| State projection | Newer positive, newer negation, out-of-order older positive, duplicate delivery, collision-safe replay from history. | +| Expiry | SQL raw preservation; strict app parsing of `Z`/offset and fractional/no-fraction forms; reject date-only, timezone-less, space-separated, malformed; exact-instant equality inactive. | +| Source policy | Missing header defaults, empty accepts none, multiple/repeated DIDs, unavailable DID, `redact` merge, correct `atproto-content-labelers`, and trust changes after ingest without row rewrites. | +| Ingest security | Invalid signature, wrong key/DID, malformed or extra-field payload, rotated key after one refresh, failure after refresh, unverifiable replay, zero writes/cursor advance on rejection. | +| CID applicability | Exact current CID, wrong CID, URI-wide action, package profile CID change, release CID change. | +| Subject scope | SQL candidate only; app DID/AT/rkey validation; trailing colon, suffix, spaces, percent, overlength, `.`/`..`, empty/extra path; quarantine holds; no broad canonical conversion. | +| Eligibility | Missing pass, passed, pending, error, every blocking security label, canonical yank, takedown, publisher compromise, warning-only, unknown label. | +| Overrides | Exact-CID automated-block override, newer assessment while override remains active, override retraction, inability to bypass manual release/package/publisher blocks. | +| Aggregator reads | Search, package, resolve, release list, latest release, direct blocked tombstone, all-blocked package, cache `private, no-store`. | +| Registry client | Envelope preservation, content-labeller response, shared evaluator parity, signed full-label retrieval, malformed label fail-closed behavior. | +| Core | Latest and explicit install, latest and explicit update, update check, no artifact request before eligibility, stable localized error codes. | +| Admin | Browse/detail query-key isolation, pending/error/block/warn UI, warning consent, issuer display, reconsideration, installed alert, Arabic RTL. | +| Compatibility | Zero-row no-alias path; active/negated/expired legacy rows; `A0/A1`, `S0/S1`, and `C0/C1`; latest/explicit/artifact paths; canonical overlap before negation; rollback floor; alias removal. | +| Verification | Verification-record valid/expired/tombstoned/identity-drift states; raw `verified` label cannot grant eligibility or bypass moderation. | + +## Rollback and Compatibility Removal + +- Before legacy negation, canonical and legacy positives overlap. Rollback may retain legacy-aware consumers, but current `A0`/`S0` policy gaps remain and must not be described as complete enforcement. +- After the first legacy negation, rollback must not go below recorded `A1`/`S1` compatibility-floor versions. Package deployment alone is not evidence that installed sites upgraded. +- Under Branch B, retain the legacy read alias until the removal condition is met across every production projection and replay target. +- If migration or verification validation fails, pause issuance, retain dual-read and active legacy positives, restore/rebuild projections from immutable collision-safe signed history, and do not mutate historical `val` or signatures. +- Remove compatibility when active legacy state is zero, every quarantined non-release row has an explicit disposition, clean replay yields the same effective blocks, reference and supported consumer versions meet the floor, no producer emits legacy values during the ratified retention window, and coordinator evidence records those checks. + +## Ratification Items + +Coordinator/Matt confirmation is required for: + +1. An authorized operator to identify every deployed aggregator environment and run the read-only preflight there. +2. Branch A versus Branch B based on that output. This audit recommends Branch A only conditionally. +3. Explicit replacement or retirement for each quarantined package, publisher, malformed, or unknown-scope legacy row. Automatic conversion is prohibited. +4. The W0.2 executable matrix, especially `package-disputed` direct-install behavior and exact manual-override interaction with pending/error states. +5. Whether publisher verification remains a separate signed record or becomes a defined non-moderation label. The current undocumented raw `verified` value should not be retained by accident. +6. The exact released versions/commits defining `A1`, `S1`, and `C1`, the supported old-installation floor, third-party attestation policy, and Branch B retention window before any legacy negation. + +Ratification recommendation: approve the canonical vocabulary and no-new-legacy-emission rule now; approve the no-compatibility implementation only after the production preflight is attached to the coordinating PR. From 5648ad0492e408e6c82594c0cd07735ffe5c78d5 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 13:28:42 +0100 Subject: [PATCH 007/137] test: prove ATProto label crypto interoperability (#1911) * test: prove label crypto interoperability * test: keep crypto vector with retained tests * docs: record crypto ratification --- .../gate-0/crypto-interop.md | 88 ++++ .../plugin-registry-labelling-service/spec.md | 8 +- .../test/label-crypto-interop.test.ts | 482 ++++++++++++++++++ .../tests/fixtures/p256-label-v1.json | 43 ++ .../tests/label-crypto-vector.test.ts | 49 ++ 5 files changed, 668 insertions(+), 2 deletions(-) create mode 100644 .opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md create mode 100644 apps/aggregator/test/label-crypto-interop.test.ts create mode 100644 packages/atproto-test-utils/tests/fixtures/p256-label-v1.json create mode 100644 packages/atproto-test-utils/tests/label-crypto-vector.test.ts diff --git a/.opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md b/.opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md new file mode 100644 index 0000000000..9bf67d75e1 --- /dev/null +++ b/.opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md @@ -0,0 +1,88 @@ +# Gate 0: Label crypto interoperability + +Status: P-256 interoperability and key-lifecycle contract ratified on 2026-07-10. + +## Decision + +Use P-256 (`secp256r1`) with `@atcute/cbor` 2.3.3 and `@atcute/crypto` 2.4.1. The production secret format is one unpadded base64url-encoded 32-byte P-256 private scalar. Decode it and pass the raw bytes to `P256PrivateKey.importRaw`. Publish the compressed public key as an ATProto P-256 multikey in the issuer DID document's exact `#atproto_label` verification method. + +P-256 is selected over k256 because the atcute implementation uses the WebCrypto `P-256`/`SHA-256` implementation available in workerd. Signatures are low-S, 64-byte IEEE-P1363 values. Do not accept DER signatures. A startup check must derive the public multikey from the secret and require it to equal the DID/configured key before issuance starts. + +The high-level atcute `P256PrivateKey.sign(data)` and `P256PublicKey.verify(sig, data)` APIs perform SHA-256 as part of WebCrypto ECDSA. They therefore receive canonical CBOR bytes, not an already computed digest. The fixture records the intermediate SHA-256 digest required by the protocol; passing that digest to these APIs would hash twice. + +## Signed shape + +The retained workerd harness reconstructs a label from this unsigned v1 allowlist only: + +`ver`, `src`, `uri`, `cid`, `val`, `neg`, `cts`, `exp` + +It requires `ver: 1`; validates `src` as a DID, `uri` as an ATProto generic URI, `cid` with the ATProto CID validator, and `cts`/`exp` as RFC3339 datetimes with valid calendar dates; requires `val` to contain 1 to 128 UTF-8 bytes; and rejects `sig`, `$type`, and every unknown field before DRISL encoding. An input `neg: false` is omitted from the reconstructed object. `sig` is attached only after signing. The signer must never accept an arbitrary object. + +## Key decoding and DID resolution + +Private scalar decoding is a signer boundary, not a permissive utility. It accepts only the unpadded base64url alphabet, requires canonical re-encoding to exactly the supplied string, requires exactly 32 decoded bytes, interprets them as an unsigned big-endian integer, and requires `1 <= d < n` for the P-256 order `n`. Padded, malformed, non-canonical, wrong-length, zero, and order-or-larger values fail before `P256PrivateKey.importRaw`. + +Resolve the issuer DID document and normalize relative `#atproto_label` and fully qualified `${issuerDid}#atproto_label` IDs to the same logical method. Either form is valid alone; any duplicate across relative or fully qualified forms fails independent of order. The DID document ID and method controller must equal the issuer DID. The method must use `type: "Multikey"`, include `publicKeyMultibase`, identify the P-256 codec, and contain an importable 33-byte compressed P-256 point. At startup, derive the signer public multikey from the configured private scalar and require exact equality with the resolved method's `publicKeyMultibase`; a valid but different P-256 key fails. A duplicate method, legacy key type, wrong controller, k256 key, malformed point, missing label key, or document containing only `#atproto` fails. There is no fallback to another verification method. + +## Independent source + +The independent implementation is Bluesky's `@atproto/crypto` 0.4.5 from , dual licensed MIT or Apache-2.0. It is independent of the Mary-ext atcute implementation and uses Noble Curves rather than WebCrypto for P-256. + +The committed fixture is reproducible from test-only private scalar `0x01`: + +- `@atcute/cbor` produced the canonical payload bytes. +- `@atproto/crypto` produced the deterministic `atprotoReferenceHex` signature; workerd/atcute verifies it. +- Actual workerd `@atcute/crypto` produced `atcuteWebcryptoHex`; the retained Node test calls `@atproto/crypto.verifySignature` over it. +- Both implementations derived the same compressed public key and P-256 `did:key` multikey. + +The vector is test material, not a usable deployment key. Its canonical CBOR bytes, SHA-256 hash, two signatures, raw compressed public key, public multikey, and reproducible test private scalar are retained at `packages/atproto-test-utils/tests/fixtures/p256-label-v1.json` for W1.4. This Gate 0 plan document is explanatory only; retained tests and production code must not import or otherwise depend on files under `.opencode/plans`. + +## Commands and results + +Run from the repository root: + +```sh +pnpm --filter @emdash-cms/atproto-test-utils exec vitest run tests/label-crypto-vector.test.ts +pnpm --filter @emdash-cms/aggregator exec vitest run test/label-crypto-interop.test.ts +``` + +The first command is the retained Node generator/assertion. It reproduces the canonical CBOR bytes, SHA-256 hash, public key forms, and deterministic `@atproto/crypto` reference signature from the test scalar, then independently verifies the retained atcute workerd signature. No fixture boolean stands in for execution. + +The second command runs the selected atcute API in workerd. It proves local signing and verification, verifies the independently generated signature, and exercises all signer/key rejection boundaries. Both commands must pass whenever the vector changes. + +## Rejections proved + +- Unknown fields, including `sig`, `$type`, and a representative arbitrary field, are rejected before encoding. +- Unsupported versions and malformed required/optional field types are rejected before encoding. +- Invalid DID, URI, CID, RFC3339/calendar datetime, empty/oversized UTF-8 value, and explicit false negation handling are covered. +- Private scalar decoding rejects malformed, padded, non-canonical, wrong-length, zero, and out-of-range inputs. +- DID method resolution accepts either relative or fully qualified label-key IDs alone, rejects mixed-form and same-form duplicates, and rejects missing IDs, wrong type/controller/codec, absent multikey data, signer-key mismatch, and fallback to `#atproto`. +- Passing the recorded protocol digest to `sign` produces a double-hashed signature that does not verify against canonical CBOR. +- The correct signature fails under a different P-256 key. +- The correct signature fails for a changed label payload. +- The correct signature fails against malformed non-DRISL payload bytes. +- A bit-flipped signature fails. +- Truncated, oversized, high-S equivalent, and ASN.1 DER signatures fail; only low-S compact 64-byte signatures are accepted. + +## Key lifecycle contract + +### Routine rotation + +1. Pause the issuance boundary before creating any label sequence that would need a signature. Assessment decisions may queue, but no sequence is allocated, stored, or broadcast while paused. +2. Generate a fresh P-256 key through the approved ceremony, store its base64url private scalar as a new Secrets Store version, derive its public multikey, and record a non-secret key-version identifier. Keep the old private key available for rollback until rotation is confirmed. +3. Update the issuer DID document's `#atproto_label` method to the new public multikey and verify the resolved document from outside the deployment. +4. Activate the matching secret version only after the DID update is observable. The startup/runtime guard must refuse issuance if the derived key does not match the resolved/configured public key. +5. Resume issuance. Persist the key-version identifier with every signature. Monitor signing failures, DID mismatch, and downstream verification failures. +6. On query, lazily re-sign labels carrying an old key version with the current key without changing `cts`, then persist the replacement signature/key version. Event-stream backfill may retain old signatures as allowed by the ATProto label specification. +7. Subscribers retry one failed verification after re-resolving the DID. They recover missed labels from their last durably accepted cursor; rotation alone does not reset sequence history. + +### Suspected or confirmed compromise + +1. Pause issuance immediately at the same pre-sequence boundary. Do not use the compromised key for rollback or historical re-signing. +2. Replace or remove the compromised `#atproto_label` key in the DID document, publish an incident notice through the policy endpoint, establish the last trusted sequence/time where possible, and deploy a fresh key only after its DID publication is observable. +3. Preserve compromised signatures and their key-version mapping for forensics, but never treat them as currently valid merely because they once verified. +4. Reissue the current effective label set with new signed events. Do not rewrite compromised history into an apparently continuous trusted history. +5. Declare a safe replay cursor when one can be established. Otherwise require subscribers to clear derived state for this labeller and replay the retained stream from cursor `0` after trustworthy history/current-state recovery is available. +6. Subscribers re-resolve the DID on signature failure, reject labels that still fail, and alert rather than falling back to `#atproto` or any other DID key. + +W3.7 owns implementing this state machine and key-version persistence. W11.4 owns the operator runbook, ceremony/custody details, external DID verification, monitoring, and subscriber communications. diff --git a/.opencode/plans/plugin-registry-labelling-service/spec.md b/.opencode/plans/plugin-registry-labelling-service/spec.md index 23b17893ec..38278e70e5 100644 --- a/.opencode/plans/plugin-registry-labelling-service/spec.md +++ b/.opencode/plans/plugin-registry-labelling-service/spec.md @@ -826,12 +826,16 @@ Signing follows the ATProto label specification exactly: 1. Construct only the allowed v1 label fields, excluding `sig` and `$type`. 2. Encode with deterministic DRISL CBOR. -3. SHA-256 the canonical bytes. -4. Sign the raw hash using the private key corresponding to `#atproto_label`. +3. At the protocol level, SHA-256 the canonical bytes and sign that digest. +4. With the selected high-level `@atcute/crypto` API, pass the canonical CBOR bytes directly to `P256PrivateKey.sign`, and pass those same bytes to `P256PublicKey.verify`. WebCrypto ECDSA performs the protocol SHA-256 operation internally. Never pass the precomputed digest to either high-level API, because that hashes the digest again and produces a non-interoperable signature. 5. Store signature bytes and signing key ID. No generic object-signing helper accepts arbitrary fields. The issuer takes a typed internal proposal and reconstructs the complete label object itself. +The issuer normalizes verification method IDs before selection: relative `#atproto_label` and fully qualified `${issuerDid}#atproto_label` identify the same logical method, and either form is valid alone. Exactly one logical label key must exist; duplicates across either or both forms are invalid regardless of order. The method must have `type: "Multikey"`, `controller` equal to the issuer DID, and a `publicKeyMultibase` using the P-256 multikey codec. At startup, the public multikey derived from the configured private scalar must exactly equal the resolved method's `publicKeyMultibase`; a structurally valid different P-256 key is a fatal mismatch. Missing, duplicate, differently controlled, legacy-typed, or non-P-256 methods are invalid. The issuer and subscribers never fall back to `#atproto` or another DID key. + +The private secret is an unpadded, canonically encoded base64url 32-byte P-256 scalar. Decoding rejects non-base64url characters, padding, non-canonical encodings, lengths other than 32 bytes, zero, and values greater than or equal to the P-256 group order. + The private key is stored in Cloudflare Secrets Store. A plain Worker secret is acceptable only for initial local/staging deployment, not production launch. ### 15.1 Subscriber Durable Object diff --git a/apps/aggregator/test/label-crypto-interop.test.ts b/apps/aggregator/test/label-crypto-interop.test.ts new file mode 100644 index 0000000000..f44d747a01 --- /dev/null +++ b/apps/aggregator/test/label-crypto-interop.test.ts @@ -0,0 +1,482 @@ +import { encode } from "@atcute/cbor"; +import { P256PrivateKey, P256PublicKey, parsePublicMultikey } from "@atcute/crypto"; +import { isCid, isDatetime, isDid, isGenericUri } from "@atcute/lexicons/syntax"; +import { fromBase64Url, toBase64Url } from "@atcute/multibase"; +import { describe, expect, it } from "vitest"; + +import vector from "../../../packages/atproto-test-utils/tests/fixtures/p256-label-v1.json" with { type: "json" }; + +const ALLOWED_UNSIGNED_V1_FIELDS = new Set([ + "ver", + "src", + "uri", + "cid", + "val", + "neg", + "cts", + "exp", +]); + +const P256_ORDER = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n; + +interface UnsignedLabelV1 { + ver: 1; + src: string; + uri: string; + cid?: string; + val: string; + neg?: boolean; + cts: string; + exp?: string; +} + +function constructUnsignedLabelV1(input: Record): UnsignedLabelV1 { + for (const field of Object.keys(input)) { + if (!ALLOWED_UNSIGNED_V1_FIELDS.has(field)) { + throw new TypeError(`unexpected label field: ${field}`); + } + } + + if (input.ver !== 1) throw new TypeError("label ver must be 1"); + const src = requireDid(input.src); + const uri = requireUri(input.uri); + const cid = optionalCid(input.cid); + const val = requireLabelValue(input.val); + const neg = optionalBoolean(input.neg, "neg"); + const cts = requireDatetime(input.cts, "cts"); + const exp = optionalDatetime(input.exp, "exp"); + + return { + ver: 1, + src, + uri, + ...(cid === undefined ? {} : { cid }), + val, + ...(neg === true ? { neg: true } : {}), + cts, + ...(exp === undefined ? {} : { exp }), + }; +} + +function requireDid(value: unknown): string { + if (!isDid(value)) throw new TypeError("label src must be a DID"); + return value; +} + +function requireUri(value: unknown): string { + if (!isGenericUri(value)) throw new TypeError("label uri must be a URI"); + return value; +} + +function optionalCid(value: unknown): string | undefined { + if (value !== undefined && !isCid(value)) throw new TypeError("label cid must be a CID"); + return value; +} + +function requireLabelValue(value: unknown): string { + if (typeof value !== "string") throw new TypeError("label val must be a string"); + const length = new TextEncoder().encode(value).length; + if (length < 1 || length > 128) + throw new TypeError("label val must contain 1 to 128 UTF-8 bytes"); + return value; +} + +function optionalBoolean(value: unknown, field: string): boolean | undefined { + if (value !== undefined && typeof value !== "boolean") { + throw new TypeError(`label ${field} must be a boolean`); + } + return value; +} + +function requireDatetime(value: unknown, field: string): string { + if (!isDatetime(value) || !hasValidCalendarDate(value)) { + throw new TypeError(`label ${field} must be an RFC3339 datetime`); + } + return value; +} + +function optionalDatetime(value: unknown, field: string): string | undefined { + if (value !== undefined && (!isDatetime(value) || !hasValidCalendarDate(value))) { + throw new TypeError(`label ${field} must be an RFC3339 datetime`); + } + return value; +} + +function hasValidCalendarDate(value: string): boolean { + const match = /^(\d{4})-(\d{2})-(\d{2})T/.exec(value); + if (!match) return false; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + return day <= new Date(Date.UTC(year, month, 0)).getUTCDate() && !Number.isNaN(Date.parse(value)); +} + +function decodePrivateScalar(value: string): Uint8Array { + if (!/^[A-Za-z0-9_-]+$/.test(value)) { + throw new TypeError("private scalar must be unpadded base64url"); + } + + const bytes = fromBase64Url(value); + if (toBase64Url(bytes) !== value) + throw new TypeError("private scalar must use canonical base64url"); + if (bytes.length !== 32) throw new TypeError("private scalar must be exactly 32 bytes"); + + const scalar = BigInt(`0x${toHex(bytes)}`); + if (scalar < 1n || scalar >= P256_ORDER) + throw new TypeError("private scalar is outside P-256 range"); + return bytes; +} + +interface DidVerificationMethod { + id: string; + type: string; + controller: string; + publicKeyMultibase: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function getLabelVerificationMethod( + document: unknown, + issuerDid: string, + signerPublicMultikey: string, +): Promise { + if (!isDid(issuerDid)) throw new TypeError("issuer must be a DID"); + if (!isRecord(document)) throw new TypeError("invalid DID document"); + if (document.id !== issuerDid) throw new TypeError("DID document does not belong to issuer"); + if (!Array.isArray(document.verificationMethod)) { + throw new TypeError("DID document verificationMethod must be an array"); + } + const id = `${issuerDid}#atproto_label`; + const matches = document.verificationMethod.filter( + (method): method is Record => + isRecord(method) && (method.id === "#atproto_label" || method.id === id), + ); + if (matches.length !== 1) + throw new TypeError("DID document must contain exactly one #atproto_label key"); + + const method = matches[0]; + if (!method) throw new TypeError("missing #atproto_label key"); + if (method.type !== "Multikey") throw new TypeError("#atproto_label must use Multikey"); + if (method.controller !== issuerDid) + throw new TypeError("#atproto_label controller must equal issuer DID"); + if (typeof method.publicKeyMultibase !== "string") { + throw new TypeError("#atproto_label must contain publicKeyMultibase"); + } + const parsed = parsePublicMultikey(method.publicKeyMultibase); + if (parsed.type !== "p256") + throw new TypeError("#atproto_label must use the P-256 multikey codec"); + if ( + parsed.publicKeyBytes.length !== 33 || + ![0x02, 0x03].includes(parsed.publicKeyBytes[0] ?? 0) + ) { + throw new TypeError("#atproto_label must contain a compressed P-256 public key"); + } + await P256PublicKey.importRaw(parsed.publicKeyBytes); + if (method.publicKeyMultibase !== signerPublicMultikey) { + throw new TypeError("#atproto_label public key must match the signer-derived multikey"); + } + return { + id, + type: method.type, + controller: method.controller, + publicKeyMultibase: method.publicKeyMultibase, + }; +} + +function fromHex(value: string): Uint8Array { + if (!/^(?:[0-9a-f]{2})+$/i.test(value)) throw new TypeError("invalid hex bytes"); + return Uint8Array.from(value.match(/.{2}/g) ?? [], (byte) => Number.parseInt(byte, 16)); +} + +function toHex(value: Uint8Array): string { + return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function highSEquivalent(signature: Uint8Array): Uint8Array { + const highS = P256_ORDER - BigInt(`0x${toHex(signature.slice(32))}`); + const result = signature.slice(); + result.set(fromHex(highS.toString(16).padStart(64, "0")), 32); + return result; +} + +function compactToDer(signature: Uint8Array): Uint8Array { + const integer = (value: Uint8Array): Uint8Array => { + let offset = 0; + while (offset < value.length - 1 && value[offset] === 0) offset++; + const bytes = value.slice(offset); + const needsPositivePrefix = ((bytes[0] ?? 0) & 0x80) !== 0; + return Uint8Array.of( + 0x02, + bytes.length + (needsPositivePrefix ? 1 : 0), + ...(needsPositivePrefix ? [0] : []), + ...bytes, + ); + }; + const r = integer(signature.slice(0, 32)); + const s = integer(signature.slice(32)); + return Uint8Array.of(0x30, r.length + s.length, ...r, ...s); +} + +describe("ATProto label v1 crypto interoperability", () => { + it("constructs deterministic DRISL bytes and SHA-256 hash", async () => { + const label = constructUnsignedLabelV1(vector.label); + const canonicalBytes = encode(label); + + expect(toHex(canonicalBytes)).toBe(vector.canonicalCborHex); + expect(toHex(new Uint8Array(await crypto.subtle.digest("SHA-256", canonicalBytes)))).toBe( + vector.sha256Hex, + ); + }); + + it("signs and verifies locally in workerd with P-256", async () => { + const label = constructUnsignedLabelV1(vector.label); + const canonicalBytes = encode(label); + const privateKey = await P256PrivateKey.importRaw( + decodePrivateScalar(vector.key.testPrivateKeyRawBase64Url), + ); + const signature = await privateKey.sign(canonicalBytes); + + expect(signature).toHaveLength(64); + expect(await privateKey.exportPublicKey("did")).toBe(vector.key.publicKeyDid); + expect(await privateKey.exportPublicKey("multikey")).toBe(vector.key.publicKeyMultikey); + expect(await privateKey.verify(signature, canonicalBytes)).toBe(true); + }); + + it("does not double-hash by passing the protocol digest to the high-level API", async () => { + const canonicalBytes = encode(constructUnsignedLabelV1(vector.label)); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", canonicalBytes)); + const privateKey = await P256PrivateKey.importRaw( + decodePrivateScalar(vector.key.testPrivateKeyRawBase64Url), + ); + const doubleHashedSignature = await privateKey.sign(digest); + + expect(await privateKey.verify(doubleHashedSignature, digest)).toBe(true); + expect(await privateKey.verify(doubleHashedSignature, canonicalBytes)).toBe(false); + }); + + it("verifies the independently signed @atproto/crypto vector", async () => { + const publicKey = await P256PublicKey.importRaw(fromHex(vector.key.publicKeyRawHex)); + const canonicalBytes = encode(constructUnsignedLabelV1(vector.label)); + + expect( + await publicKey.verify(fromHex(vector.signatures.atprotoReferenceHex), canonicalBytes), + ).toBe(true); + }); + + it("retains the workerd signature verified by the independent implementation", async () => { + const publicKey = await P256PublicKey.importRaw(fromHex(vector.key.publicKeyRawHex)); + const canonicalBytes = encode(constructUnsignedLabelV1(vector.label)); + + expect( + await publicKey.verify(fromHex(vector.signatures.atcuteWebcryptoHex), canonicalBytes), + ).toBe(true); + }); + + it("requires the exact P-256 #atproto_label DID verification method", async () => { + const method = await getLabelVerificationMethod( + vector.didDocument, + vector.label.src, + vector.key.publicKeyMultikey, + ); + expect(method.id).toBe(`${vector.label.src}#atproto_label`); + expect(method.publicKeyMultibase).toBe(vector.key.publicKeyMultikey); + + const relativeMethod = await getLabelVerificationMethod( + { ...vector.didDocument, verificationMethod: [{ ...method, id: "#atproto_label" }] }, + vector.label.src, + vector.key.publicKeyMultikey, + ); + expect(relativeMethod.id).toBe(`${vector.label.src}#atproto_label`); + expect(relativeMethod.publicKeyMultibase).toBe(vector.key.publicKeyMultikey); + const relative = { ...method, id: "#atproto_label" }; + for (const verificationMethod of [ + [method, relative], + [relative, method], + [method, method], + ]) { + await expect( + getLabelVerificationMethod( + { ...vector.didDocument, verificationMethod }, + vector.label.src, + vector.key.publicKeyMultikey, + ), + ).rejects.toThrow("exactly one #atproto_label key"); + } + await expect( + getLabelVerificationMethod( + { ...vector.didDocument, verificationMethod: [{ ...method, type: "JsonWebKey2020" }] }, + vector.label.src, + vector.key.publicKeyMultikey, + ), + ).rejects.toThrow("must use Multikey"); + await expect( + getLabelVerificationMethod( + { + ...vector.didDocument, + verificationMethod: [{ ...method, controller: "did:web:other.test" }], + }, + vector.label.src, + vector.key.publicKeyMultikey, + ), + ).rejects.toThrow("controller must equal issuer DID"); + await expect( + getLabelVerificationMethod( + { + ...vector.didDocument, + verificationMethod: [{ ...method, publicKeyMultibase: undefined as unknown as string }], + }, + vector.label.src, + vector.key.publicKeyMultikey, + ), + ).rejects.toThrow("must contain publicKeyMultibase"); + await expect( + getLabelVerificationMethod( + { + ...vector.didDocument, + verificationMethod: [ + { ...method, publicKeyMultibase: "zQ3shqwJEJyMBsBXCWyCBpUBMqxcon9oHB7mCvx4sSpMdLJwc" }, + ], + }, + vector.label.src, + vector.key.publicKeyMultikey, + ), + ).rejects.toThrow("must use the P-256 multikey codec"); + await expect( + getLabelVerificationMethod( + { + ...vector.didDocument, + verificationMethod: [{ ...method, id: `${vector.label.src}#atproto` }], + }, + vector.label.src, + vector.key.publicKeyMultikey, + ), + ).rejects.toThrow("exactly one #atproto_label key"); + + const otherKey = await P256PrivateKey.importRaw( + fromHex("0000000000000000000000000000000000000000000000000000000000000002"), + ); + const otherMultikey = await otherKey.exportPublicKey("multikey"); + await expect( + getLabelVerificationMethod( + { + ...vector.didDocument, + verificationMethod: [{ ...method, publicKeyMultibase: otherMultikey }], + }, + vector.label.src, + vector.key.publicKeyMultikey, + ), + ).rejects.toThrow("must match the signer-derived multikey"); + await expect( + getLabelVerificationMethod( + vector.didDocument, + "did:web:other.test", + vector.key.publicKeyMultikey, + ), + ).rejects.toThrow("DID document does not belong to issuer"); + await expect( + getLabelVerificationMethod( + { id: vector.label.src }, + vector.label.src, + vector.key.publicKeyMultikey, + ), + ).rejects.toThrow("verificationMethod must be an array"); + await expect( + getLabelVerificationMethod(null, vector.label.src, vector.key.publicKeyMultikey), + ).rejects.toThrow("invalid DID document"); + }); + + it.each(["sig", "$type", "purpose"])("rejects the extra %s field before encoding", (field) => { + expect(() => constructUnsignedLabelV1({ ...vector.label, [field]: "not-allowed" })).toThrow( + `unexpected label field: ${field}`, + ); + }); + + it("rejects malformed allowed fields before encoding", () => { + expect(() => constructUnsignedLabelV1({ ...vector.label, ver: 2 })).toThrow( + "label ver must be 1", + ); + expect(() => constructUnsignedLabelV1({ ...vector.label, src: "not-a-did" })).toThrow( + "label src must be a DID", + ); + expect(() => constructUnsignedLabelV1({ ...vector.label, uri: "not a uri" })).toThrow( + "label uri must be a URI", + ); + expect(() => constructUnsignedLabelV1({ ...vector.label, cid: "not-a-cid" })).toThrow( + "label cid must be a CID", + ); + expect(() => constructUnsignedLabelV1({ ...vector.label, val: "" })).toThrow( + "1 to 128 UTF-8 bytes", + ); + expect(() => constructUnsignedLabelV1({ ...vector.label, val: "é".repeat(65) })).toThrow( + "1 to 128 UTF-8 bytes", + ); + expect(() => constructUnsignedLabelV1({ ...vector.label, neg: "true" })).toThrow( + "label neg must be a boolean", + ); + expect(() => constructUnsignedLabelV1({ ...vector.label, cts: "2026-07-10" })).toThrow( + "label cts must be an RFC3339 datetime", + ); + expect(() => + constructUnsignedLabelV1({ ...vector.label, cts: "2026-02-31T12:34:56Z" }), + ).toThrow("label cts must be an RFC3339 datetime"); + expect(() => constructUnsignedLabelV1({ ...vector.label, exp: "tomorrow" })).toThrow( + "label exp must be an RFC3339 datetime", + ); + + const withFalse = constructUnsignedLabelV1({ ...vector.label, neg: false }); + expect(withFalse).not.toHaveProperty("neg"); + expect(encode(withFalse)).toEqual(encode(constructUnsignedLabelV1(vector.label))); + }); + + it("strictly decodes an in-range canonical 32-byte private scalar", () => { + expect(toBase64Url(decodePrivateScalar(vector.key.testPrivateKeyRawBase64Url))).toBe( + vector.key.testPrivateKeyRawBase64Url, + ); + expect(() => decodePrivateScalar(`${vector.key.testPrivateKeyRawBase64Url}=`)).toThrow( + "unpadded base64url", + ); + expect(() => decodePrivateScalar("not+base64url")).toThrow("unpadded base64url"); + expect(() => + decodePrivateScalar(`${vector.key.testPrivateKeyRawBase64Url.slice(0, -1)}F`), + ).toThrow("canonical base64url"); + expect(() => decodePrivateScalar(toBase64Url(new Uint8Array(31).fill(1)))).toThrow( + "exactly 32 bytes", + ); + expect(() => decodePrivateScalar(toBase64Url(new Uint8Array(32)))).toThrow( + "outside P-256 range", + ); + expect(() => decodePrivateScalar(toBase64Url(fromHex(P256_ORDER.toString(16))))).toThrow( + "outside P-256 range", + ); + }); + + it("rejects a wrong key, changed payload, and malformed signatures", async () => { + const canonicalBytes = encode(constructUnsignedLabelV1(vector.label)); + const signature = fromHex(vector.signatures.atprotoReferenceHex); + const wrongKey = await P256PrivateKey.importRaw( + fromHex("0000000000000000000000000000000000000000000000000000000000000002"), + ); + const publicKey = await P256PublicKey.importRaw(fromHex(vector.key.publicKeyRawHex)); + const changedBytes = encode( + constructUnsignedLabelV1({ ...vector.label, val: "assessment-warning" }), + ); + const changedSignature = signature.slice(); + changedSignature[0] = (changedSignature[0] ?? 0) ^ 0x01; + const highS = highSEquivalent(signature); + const der = compactToDer(signature); + + expect(await wrongKey.verify(signature, canonicalBytes)).toBe(false); + expect(await publicKey.verify(signature, changedBytes)).toBe(false); + expect(await publicKey.verify(signature, Uint8Array.of(0xff))).toBe(false); + expect(await publicKey.verify(changedSignature, canonicalBytes)).toBe(false); + expect(highS).toHaveLength(64); + expect(await publicKey.verify(highS, canonicalBytes)).toBe(false); + expect(der.length).toBeGreaterThan(64); + expect(await publicKey.verify(der, canonicalBytes)).toBe(false); + expect(await publicKey.verify(signature.slice(0, 63), canonicalBytes)).toBe(false); + expect(await publicKey.verify(new Uint8Array(65), canonicalBytes)).toBe(false); + }); +}); diff --git a/packages/atproto-test-utils/tests/fixtures/p256-label-v1.json b/packages/atproto-test-utils/tests/fixtures/p256-label-v1.json new file mode 100644 index 0000000000..3ae70f864d --- /dev/null +++ b/packages/atproto-test-utils/tests/fixtures/p256-label-v1.json @@ -0,0 +1,43 @@ +{ + "description": "ATProto label v1 P-256 cross-implementation interoperability vector", + "label": { + "ver": 1, + "src": "did:web:labeller.test", + "uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/com.emdashcms.experimental.package.release/crypto-interop", + "cid": "bafyreigh2akiscaildc4mscz4uzpcbap5jxg26eecmrf6cmnvkzkjmoixe", + "val": "assessment-passed", + "cts": "2026-07-10T12:34:56.000Z", + "exp": "2026-08-09T12:34:56.000Z" + }, + "canonicalCborHex": "a763636964783b62616679726569676832616b69736361696c6463346d73637a34757a7063626170356a786732366565636d726636636d6e766b7a6b6a6d6f697865636374737818323032362d30372d31305431323a33343a35362e3030305a636578707818323032362d30382d30395431323a33343a35362e3030305a63737263756469643a7765623a6c6162656c6c65722e7465737463757269785f61743a2f2f6469643a706c633a7a373269376864796e6d6b367232327a32376836747675722f636f6d2e656d64617368636d732e6578706572696d656e74616c2e7061636b6167652e72656c656173652f63727970746f2d696e7465726f706376616c716173736573736d656e742d7061737365646376657201", + "sha256Hex": "4ac798323da266b2a68caf3caf769a930bafc4403dd4f5921aeb33d2a1a26631", + "key": { + "curve": "P-256", + "publicKeyDid": "did:key:zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ", + "publicKeyMultikey": "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ", + "publicKeyRawHex": "036b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296", + "testPrivateKeyRawBase64Url": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE" + }, + "didDocument": { + "id": "did:web:labeller.test", + "verificationMethod": [ + { + "id": "did:web:labeller.test#atproto_label", + "type": "Multikey", + "controller": "did:web:labeller.test", + "publicKeyMultibase": "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ" + } + ] + }, + "signatures": { + "atcuteWebcryptoHex": "884bc6f1dabf7da026d8c7fcf20dfb0395eb5e9dce09958f54e00b64644af7950244e3d1ada5ef60d0eec8e6b3e0721fad27b555b48291893acc9ba6db4238de", + "atprotoReferenceHex": "183823d83ada5cb73cf29ada84cadeb4ffe76b6f5c65245f820fbe163ab4482b5d7e9f9bd7bf206dcebefe3877c7f547d34279235fe4efde497bbb5655685200" + }, + "provenance": { + "payloadCodec": "@atcute/cbor 2.3.3", + "workerdSigner": "@atcute/crypto 2.4.1 P256PrivateKey.sign", + "independentSignerAndVerifier": "@atproto/crypto 0.4.5 P256Keypair.sign/verifySignature", + "independentSource": "https://github.com/bluesky-social/atproto/tree/main/packages/crypto", + "independentLicense": "MIT OR Apache-2.0" + } +} diff --git a/packages/atproto-test-utils/tests/label-crypto-vector.test.ts b/packages/atproto-test-utils/tests/label-crypto-vector.test.ts new file mode 100644 index 0000000000..c8f4a26e80 --- /dev/null +++ b/packages/atproto-test-utils/tests/label-crypto-vector.test.ts @@ -0,0 +1,49 @@ +import { createHash } from "node:crypto"; + +import { encode } from "@atcute/cbor"; +import { fromBase64Url } from "@atcute/multibase"; +import { P256Keypair, verifySignature } from "@atproto/crypto"; +import { describe, expect, it } from "vitest"; + +import vector from "./fixtures/p256-label-v1.json" with { type: "json" }; + +function fromHex(value: string): Uint8Array { + return Uint8Array.from(value.match(/.{2}/g) ?? [], (byte) => Number.parseInt(byte, 16)); +} + +function toHex(value: Uint8Array): string { + return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +describe("ATProto label crypto vector generation", () => { + it("reproduces the reference vector and independently verifies the workerd signature", async () => { + const canonicalBytes = encode(vector.label); + const keypair = await P256Keypair.import(fromBase64Url(vector.key.testPrivateKeyRawBase64Url), { + exportable: true, + }); + const referenceSignature = await keypair.sign(canonicalBytes); + + expect(toHex(canonicalBytes)).toBe(vector.canonicalCborHex); + expect(createHash("sha256").update(canonicalBytes).digest("hex")).toBe(vector.sha256Hex); + expect(keypair.did()).toBe(vector.key.publicKeyDid); + expect(keypair.did().slice("did:key:".length)).toBe(vector.key.publicKeyMultikey); + expect(vector.didDocument.verificationMethod[0]?.publicKeyMultibase).toBe( + keypair.did().slice("did:key:".length), + ); + expect(toHex(keypair.publicKeyBytes())).toBe(vector.key.publicKeyRawHex); + expect(toHex(referenceSignature)).toBe(vector.signatures.atprotoReferenceHex); + expect(await verifySignature(vector.key.publicKeyDid, canonicalBytes, referenceSignature)).toBe( + true, + ); + expect( + await verifySignature( + vector.key.publicKeyDid, + canonicalBytes, + fromHex(vector.signatures.atcuteWebcryptoHex), + ), + ).toBe(true); + + const digest = createHash("sha256").update(canonicalBytes).digest(); + expect(await verifySignature(vector.key.publicKeyDid, digest, referenceSignature)).toBe(false); + }); +}); From e10dbef76c53e90e06b134d7166d73a9107917df Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 15:07:01 +0100 Subject: [PATCH 008/137] docs: narrow gate zero scope --- .../implementation-plan.md | 43 +++++++------------ 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index ef41c24faa..a12f050fef 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -34,7 +34,7 @@ The implementation is complete when: - No positive assessment requirement is enabled in production until first-party releases have been assessed and rollback is tested. - Incomplete integration remains unreachable by default. Feature flags may expose staging paths, but production clients must not depend on half-built label state. - Tests land with each workstream. Protocol, security, and failure tests are not deferred to the final gate. -- `.opencode/plans` is ephemeral coordination material. Retained runtime code and tests must never import it; ratified contracts, fixtures, and vectors move into normal package/test directories before consumers use them. +- `.opencode/plans` is ephemeral coordination material. Retained runtime code and tests must never import it. A feasibility spike does not become a product test merely because it was useful; vectors and fixtures move into normal package/test directories only when they test an implemented EmDash component. - The operator console uses Kumo, Lingui, and RTL-safe logical layout from its first UI change. - Existing unrelated worktree changes are left untouched. - Published-package changes receive changesets when their workstream lands. @@ -163,7 +163,7 @@ Required before production implementation is treated as stable: - The aggregator mirror contract can supply artifact bytes and metadata required by the assessor. - Initial dependency/advisory sources and critical-applicability rules are selected. - The initial Workers AI model and structured-output path can process representative plugin inputs through AI Gateway. -- Cloudflare Access JWT group/email claims for reviewer/admin mapping are confirmed with the production identity provider. +- Cloudflare Access JWT validation, configurable claim extraction, and human/service-token distinction are proved without assuming an organization-specific role schema. Gate owner: `W0`. @@ -227,7 +227,7 @@ Gate owners: `W9`, `W10`. - Signing-key rotation, compromised-key response, D1 restore, Jetstream cursor recovery, Queue/DLQ recovery, and notification recovery drills pass. - External security review has no unresolved critical or high findings. - Production smoke succeeds from release publication through assessment, label ingest, discovery, and clean-site install decision. -- Every retained contract/fixture/vector lives under a normal package or test directory, no source/test imports `.opencode/plans`, and this planning folder is removed before the umbrella PR merges. +- No source/test imports `.opencode/plans`, every retained fixture tests an implemented EmDash component, and this planning folder is removed before the umbrella PR merges. Gate owners: `W11`, `W12`, with all preceding gates complete. @@ -294,7 +294,7 @@ Build a focused workerd prototype using `@atcute/cbor` and `@atcute/crypto`: - Reject extra fields, wrong keys, malformed bytes, and invalid signatures. - Define the minimal routine-rotation and compromise procedure: issuance pause boundary, DID update ordering, old-key identification, historical re-signing, and subscriber recovery. -Output: committed vectors, selected crypto API, and key-lifecycle contract consumed by `W3.7` and documented by `W11.4`. +Output: selected crypto API and key-lifecycle contract consumed by `W3.7` and documented by `W11.4`. W1.4 adds vectors only alongside the implemented signer/verifier. Dependencies: signing-key format draft. @@ -341,30 +341,17 @@ Output: versioned adapter choices and calibration baseline. Dependencies: policy fixtures from `W0.2`. -### `W0.8` Prove Access role claims +### `W0.8` Prove Access JWT mechanics Create staging Access policies and verify: -- JWT issuer/audience validation. -- Reviewer/admin group or email claim shape. -- User and service-token distinction. -- Expiry, revoked membership, missing group, and spoofed header behavior. +- JWT issuer, audience, signature, expiry, and not-before validation. +- JWKS retrieval/cache/rotation behavior. +- Configurable extraction of a role claim without assuming a Cloudflare organization-specific group or email shape. +- Human and service-token identity distinction. +- Missing claim, malformed token, and spoofed header behavior. -Output: exact role-mapping configuration and JWT fixtures. - -Dependencies: none. - -### `W0.9` Decide retention and communications - -Ratify: - -- Private evidence and incident evidence retention. -- AI Gateway log retention/training controls. -- Email provider and sending domain. -- Monitored reconsideration address. -- Security contact fallback behavior. - -Output: deployable constants and privacy/communications checklist. +Output: generic Access validation contract and JWT fixtures consumed by W9. Deployment configuration supplies the actual role mapping. Dependencies: none. @@ -558,10 +545,10 @@ Dependencies: `W2.3`. - Hash every evidence object. - Store large private evidence in R2 and references in D1. - Separate public summaries from private detail at the type boundary. -- Apply retention class metadata. +- Support lifecycle metadata without deciding an organization retention policy. - Prevent public serializers from accepting private evidence types. -Dependencies: `W0.9`, `W2.3`. +Dependencies: `W2.3`. ### `W2.6` Add health and internal diagnostics @@ -1239,7 +1226,7 @@ Resolve in order: Do not access private PDS account email. Store delivery addresses only as long as needed and retain keyed hashes for audit/deduplication. -Dependencies: `W0.9`, registry profile/package reads. +Dependencies: registry profile/package reads. ### `W10.5` Implement notification outbox and email adapter @@ -1336,7 +1323,7 @@ Dependencies: `W3.7`. - Contact-data minimization. - AI Gateway retention configuration. -Dependencies: `W0.9`, `W2.5`. +Dependencies: `W2.5`. ### `W11.6` Write operational runbooks From 66ad9d916034029dd212a1f52eb3eddba77eef0f Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 15:14:48 +0100 Subject: [PATCH 009/137] chore: remove standalone crypto spike tests --- .../gate-0/crypto-interop.md | 23 +- .../implementation-plan.md | 17 +- .../test/label-crypto-interop.test.ts | 482 ------------------ .../tests/fixtures/p256-label-v1.json | 43 -- .../tests/label-crypto-vector.test.ts | 49 -- 5 files changed, 8 insertions(+), 606 deletions(-) delete mode 100644 apps/aggregator/test/label-crypto-interop.test.ts delete mode 100644 packages/atproto-test-utils/tests/fixtures/p256-label-v1.json delete mode 100644 packages/atproto-test-utils/tests/label-crypto-vector.test.ts diff --git a/.opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md b/.opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md index 9bf67d75e1..36ef9a429a 100644 --- a/.opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md +++ b/.opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md @@ -8,11 +8,11 @@ Use P-256 (`secp256r1`) with `@atcute/cbor` 2.3.3 and `@atcute/crypto` 2.4.1. Th P-256 is selected over k256 because the atcute implementation uses the WebCrypto `P-256`/`SHA-256` implementation available in workerd. Signatures are low-S, 64-byte IEEE-P1363 values. Do not accept DER signatures. A startup check must derive the public multikey from the secret and require it to equal the DID/configured key before issuance starts. -The high-level atcute `P256PrivateKey.sign(data)` and `P256PublicKey.verify(sig, data)` APIs perform SHA-256 as part of WebCrypto ECDSA. They therefore receive canonical CBOR bytes, not an already computed digest. The fixture records the intermediate SHA-256 digest required by the protocol; passing that digest to these APIs would hash twice. +The high-level atcute `P256PrivateKey.sign(data)` and `P256PublicKey.verify(sig, data)` APIs perform SHA-256 as part of WebCrypto ECDSA. They therefore receive canonical CBOR bytes, not an already computed digest. Passing that digest to these APIs would hash twice. ## Signed shape -The retained workerd harness reconstructs a label from this unsigned v1 allowlist only: +The W0 spike reconstructed a label from this unsigned v1 allowlist only: `ver`, `src`, `uri`, `cid`, `val`, `neg`, `cts`, `exp` @@ -28,27 +28,18 @@ Resolve the issuer DID document and normalize relative `#atproto_label` and full The independent implementation is Bluesky's `@atproto/crypto` 0.4.5 from , dual licensed MIT or Apache-2.0. It is independent of the Mary-ext atcute implementation and uses Noble Curves rather than WebCrypto for P-256. -The committed fixture is reproducible from test-only private scalar `0x01`: +The spike used test-only private scalar `0x01`: - `@atcute/cbor` produced the canonical payload bytes. - `@atproto/crypto` produced the deterministic `atprotoReferenceHex` signature; workerd/atcute verifies it. -- Actual workerd `@atcute/crypto` produced `atcuteWebcryptoHex`; the retained Node test calls `@atproto/crypto.verifySignature` over it. +- Actual workerd `@atcute/crypto` produced a signature that `@atproto/crypto.verifySignature` accepted. - Both implementations derived the same compressed public key and P-256 `did:key` multikey. -The vector is test material, not a usable deployment key. Its canonical CBOR bytes, SHA-256 hash, two signatures, raw compressed public key, public multikey, and reproducible test private scalar are retained at `packages/atproto-test-utils/tests/fixtures/p256-label-v1.json` for W1.4. This Gate 0 plan document is explanatory only; retained tests and production code must not import or otherwise depend on files under `.opencode/plans`. +The spike vector was test material, not a usable deployment key. It established the selected API/key contract but is not retained as a product test fixture because no EmDash signer/verifier exists yet. When W1.4 implements `signLabel` and `verifyLabel`, it must add fresh external vectors and cross-implementation tests alongside that implementation. This Gate 0 document is explanatory only; retained tests and production code must not import or otherwise depend on files under `.opencode/plans`. -## Commands and results +## Spike result -Run from the repository root: - -```sh -pnpm --filter @emdash-cms/atproto-test-utils exec vitest run tests/label-crypto-vector.test.ts -pnpm --filter @emdash-cms/aggregator exec vitest run test/label-crypto-interop.test.ts -``` - -The first command is the retained Node generator/assertion. It reproduces the canonical CBOR bytes, SHA-256 hash, public key forms, and deterministic `@atproto/crypto` reference signature from the test scalar, then independently verifies the retained atcute workerd signature. No fixture boolean stands in for execution. - -The second command runs the selected atcute API in workerd. It proves local signing and verification, verifies the independently generated signature, and exercises all signer/key rejection boundaries. Both commands must pass whenever the vector changes. +The feasibility spike ran both workerd atcute and independent `@atproto/crypto` verification. It confirmed canonical-CBOR input, P-256 compact signature interoperability, the field allowlist, scalar validation, normalized DID member IDs, signer/DID key equality, and rejection paths described below. The spike harness is intentionally not retained in the product test suite; W1.4 recreates equivalent coverage against EmDash's actual signer/verifier code. ## Rejections proved diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index a12f050fef..5b94bed80d 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -163,7 +163,6 @@ Required before production implementation is treated as stable: - The aggregator mirror contract can supply artifact bytes and metadata required by the assessor. - Initial dependency/advisory sources and critical-applicability rules are selected. - The initial Workers AI model and structured-output path can process representative plugin inputs through AI Gateway. -- Cloudflare Access JWT validation, configurable claim extraction, and human/service-token distinction are proved without assuming an organization-specific role schema. Gate owner: `W0`. @@ -341,23 +340,9 @@ Output: versioned adapter choices and calibration baseline. Dependencies: policy fixtures from `W0.2`. -### `W0.8` Prove Access JWT mechanics - -Create staging Access policies and verify: - -- JWT issuer, audience, signature, expiry, and not-before validation. -- JWKS retrieval/cache/rotation behavior. -- Configurable extraction of a role claim without assuming a Cloudflare organization-specific group or email shape. -- Human and service-token identity distinction. -- Missing claim, malformed token, and spoofed header behavior. - -Output: generic Access validation contract and JWT fixtures consumed by W9. Deployment configuration supplies the actual role mapping. - -Dependencies: none. - ### W0 Completion -Gate 0 passes. Failed crypto, identity, mirror, model, scanner, or Access assumptions change the spec before downstream production work continues. +Gate 0 passes. Failed crypto, identity, mirror, model, or scanner assumptions change the spec before downstream production work continues. ## Workstream W1: Shared Protocol and Policy diff --git a/apps/aggregator/test/label-crypto-interop.test.ts b/apps/aggregator/test/label-crypto-interop.test.ts deleted file mode 100644 index f44d747a01..0000000000 --- a/apps/aggregator/test/label-crypto-interop.test.ts +++ /dev/null @@ -1,482 +0,0 @@ -import { encode } from "@atcute/cbor"; -import { P256PrivateKey, P256PublicKey, parsePublicMultikey } from "@atcute/crypto"; -import { isCid, isDatetime, isDid, isGenericUri } from "@atcute/lexicons/syntax"; -import { fromBase64Url, toBase64Url } from "@atcute/multibase"; -import { describe, expect, it } from "vitest"; - -import vector from "../../../packages/atproto-test-utils/tests/fixtures/p256-label-v1.json" with { type: "json" }; - -const ALLOWED_UNSIGNED_V1_FIELDS = new Set([ - "ver", - "src", - "uri", - "cid", - "val", - "neg", - "cts", - "exp", -]); - -const P256_ORDER = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n; - -interface UnsignedLabelV1 { - ver: 1; - src: string; - uri: string; - cid?: string; - val: string; - neg?: boolean; - cts: string; - exp?: string; -} - -function constructUnsignedLabelV1(input: Record): UnsignedLabelV1 { - for (const field of Object.keys(input)) { - if (!ALLOWED_UNSIGNED_V1_FIELDS.has(field)) { - throw new TypeError(`unexpected label field: ${field}`); - } - } - - if (input.ver !== 1) throw new TypeError("label ver must be 1"); - const src = requireDid(input.src); - const uri = requireUri(input.uri); - const cid = optionalCid(input.cid); - const val = requireLabelValue(input.val); - const neg = optionalBoolean(input.neg, "neg"); - const cts = requireDatetime(input.cts, "cts"); - const exp = optionalDatetime(input.exp, "exp"); - - return { - ver: 1, - src, - uri, - ...(cid === undefined ? {} : { cid }), - val, - ...(neg === true ? { neg: true } : {}), - cts, - ...(exp === undefined ? {} : { exp }), - }; -} - -function requireDid(value: unknown): string { - if (!isDid(value)) throw new TypeError("label src must be a DID"); - return value; -} - -function requireUri(value: unknown): string { - if (!isGenericUri(value)) throw new TypeError("label uri must be a URI"); - return value; -} - -function optionalCid(value: unknown): string | undefined { - if (value !== undefined && !isCid(value)) throw new TypeError("label cid must be a CID"); - return value; -} - -function requireLabelValue(value: unknown): string { - if (typeof value !== "string") throw new TypeError("label val must be a string"); - const length = new TextEncoder().encode(value).length; - if (length < 1 || length > 128) - throw new TypeError("label val must contain 1 to 128 UTF-8 bytes"); - return value; -} - -function optionalBoolean(value: unknown, field: string): boolean | undefined { - if (value !== undefined && typeof value !== "boolean") { - throw new TypeError(`label ${field} must be a boolean`); - } - return value; -} - -function requireDatetime(value: unknown, field: string): string { - if (!isDatetime(value) || !hasValidCalendarDate(value)) { - throw new TypeError(`label ${field} must be an RFC3339 datetime`); - } - return value; -} - -function optionalDatetime(value: unknown, field: string): string | undefined { - if (value !== undefined && (!isDatetime(value) || !hasValidCalendarDate(value))) { - throw new TypeError(`label ${field} must be an RFC3339 datetime`); - } - return value; -} - -function hasValidCalendarDate(value: string): boolean { - const match = /^(\d{4})-(\d{2})-(\d{2})T/.exec(value); - if (!match) return false; - const year = Number(match[1]); - const month = Number(match[2]); - const day = Number(match[3]); - return day <= new Date(Date.UTC(year, month, 0)).getUTCDate() && !Number.isNaN(Date.parse(value)); -} - -function decodePrivateScalar(value: string): Uint8Array { - if (!/^[A-Za-z0-9_-]+$/.test(value)) { - throw new TypeError("private scalar must be unpadded base64url"); - } - - const bytes = fromBase64Url(value); - if (toBase64Url(bytes) !== value) - throw new TypeError("private scalar must use canonical base64url"); - if (bytes.length !== 32) throw new TypeError("private scalar must be exactly 32 bytes"); - - const scalar = BigInt(`0x${toHex(bytes)}`); - if (scalar < 1n || scalar >= P256_ORDER) - throw new TypeError("private scalar is outside P-256 range"); - return bytes; -} - -interface DidVerificationMethod { - id: string; - type: string; - controller: string; - publicKeyMultibase: string; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -async function getLabelVerificationMethod( - document: unknown, - issuerDid: string, - signerPublicMultikey: string, -): Promise { - if (!isDid(issuerDid)) throw new TypeError("issuer must be a DID"); - if (!isRecord(document)) throw new TypeError("invalid DID document"); - if (document.id !== issuerDid) throw new TypeError("DID document does not belong to issuer"); - if (!Array.isArray(document.verificationMethod)) { - throw new TypeError("DID document verificationMethod must be an array"); - } - const id = `${issuerDid}#atproto_label`; - const matches = document.verificationMethod.filter( - (method): method is Record => - isRecord(method) && (method.id === "#atproto_label" || method.id === id), - ); - if (matches.length !== 1) - throw new TypeError("DID document must contain exactly one #atproto_label key"); - - const method = matches[0]; - if (!method) throw new TypeError("missing #atproto_label key"); - if (method.type !== "Multikey") throw new TypeError("#atproto_label must use Multikey"); - if (method.controller !== issuerDid) - throw new TypeError("#atproto_label controller must equal issuer DID"); - if (typeof method.publicKeyMultibase !== "string") { - throw new TypeError("#atproto_label must contain publicKeyMultibase"); - } - const parsed = parsePublicMultikey(method.publicKeyMultibase); - if (parsed.type !== "p256") - throw new TypeError("#atproto_label must use the P-256 multikey codec"); - if ( - parsed.publicKeyBytes.length !== 33 || - ![0x02, 0x03].includes(parsed.publicKeyBytes[0] ?? 0) - ) { - throw new TypeError("#atproto_label must contain a compressed P-256 public key"); - } - await P256PublicKey.importRaw(parsed.publicKeyBytes); - if (method.publicKeyMultibase !== signerPublicMultikey) { - throw new TypeError("#atproto_label public key must match the signer-derived multikey"); - } - return { - id, - type: method.type, - controller: method.controller, - publicKeyMultibase: method.publicKeyMultibase, - }; -} - -function fromHex(value: string): Uint8Array { - if (!/^(?:[0-9a-f]{2})+$/i.test(value)) throw new TypeError("invalid hex bytes"); - return Uint8Array.from(value.match(/.{2}/g) ?? [], (byte) => Number.parseInt(byte, 16)); -} - -function toHex(value: Uint8Array): string { - return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(""); -} - -function highSEquivalent(signature: Uint8Array): Uint8Array { - const highS = P256_ORDER - BigInt(`0x${toHex(signature.slice(32))}`); - const result = signature.slice(); - result.set(fromHex(highS.toString(16).padStart(64, "0")), 32); - return result; -} - -function compactToDer(signature: Uint8Array): Uint8Array { - const integer = (value: Uint8Array): Uint8Array => { - let offset = 0; - while (offset < value.length - 1 && value[offset] === 0) offset++; - const bytes = value.slice(offset); - const needsPositivePrefix = ((bytes[0] ?? 0) & 0x80) !== 0; - return Uint8Array.of( - 0x02, - bytes.length + (needsPositivePrefix ? 1 : 0), - ...(needsPositivePrefix ? [0] : []), - ...bytes, - ); - }; - const r = integer(signature.slice(0, 32)); - const s = integer(signature.slice(32)); - return Uint8Array.of(0x30, r.length + s.length, ...r, ...s); -} - -describe("ATProto label v1 crypto interoperability", () => { - it("constructs deterministic DRISL bytes and SHA-256 hash", async () => { - const label = constructUnsignedLabelV1(vector.label); - const canonicalBytes = encode(label); - - expect(toHex(canonicalBytes)).toBe(vector.canonicalCborHex); - expect(toHex(new Uint8Array(await crypto.subtle.digest("SHA-256", canonicalBytes)))).toBe( - vector.sha256Hex, - ); - }); - - it("signs and verifies locally in workerd with P-256", async () => { - const label = constructUnsignedLabelV1(vector.label); - const canonicalBytes = encode(label); - const privateKey = await P256PrivateKey.importRaw( - decodePrivateScalar(vector.key.testPrivateKeyRawBase64Url), - ); - const signature = await privateKey.sign(canonicalBytes); - - expect(signature).toHaveLength(64); - expect(await privateKey.exportPublicKey("did")).toBe(vector.key.publicKeyDid); - expect(await privateKey.exportPublicKey("multikey")).toBe(vector.key.publicKeyMultikey); - expect(await privateKey.verify(signature, canonicalBytes)).toBe(true); - }); - - it("does not double-hash by passing the protocol digest to the high-level API", async () => { - const canonicalBytes = encode(constructUnsignedLabelV1(vector.label)); - const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", canonicalBytes)); - const privateKey = await P256PrivateKey.importRaw( - decodePrivateScalar(vector.key.testPrivateKeyRawBase64Url), - ); - const doubleHashedSignature = await privateKey.sign(digest); - - expect(await privateKey.verify(doubleHashedSignature, digest)).toBe(true); - expect(await privateKey.verify(doubleHashedSignature, canonicalBytes)).toBe(false); - }); - - it("verifies the independently signed @atproto/crypto vector", async () => { - const publicKey = await P256PublicKey.importRaw(fromHex(vector.key.publicKeyRawHex)); - const canonicalBytes = encode(constructUnsignedLabelV1(vector.label)); - - expect( - await publicKey.verify(fromHex(vector.signatures.atprotoReferenceHex), canonicalBytes), - ).toBe(true); - }); - - it("retains the workerd signature verified by the independent implementation", async () => { - const publicKey = await P256PublicKey.importRaw(fromHex(vector.key.publicKeyRawHex)); - const canonicalBytes = encode(constructUnsignedLabelV1(vector.label)); - - expect( - await publicKey.verify(fromHex(vector.signatures.atcuteWebcryptoHex), canonicalBytes), - ).toBe(true); - }); - - it("requires the exact P-256 #atproto_label DID verification method", async () => { - const method = await getLabelVerificationMethod( - vector.didDocument, - vector.label.src, - vector.key.publicKeyMultikey, - ); - expect(method.id).toBe(`${vector.label.src}#atproto_label`); - expect(method.publicKeyMultibase).toBe(vector.key.publicKeyMultikey); - - const relativeMethod = await getLabelVerificationMethod( - { ...vector.didDocument, verificationMethod: [{ ...method, id: "#atproto_label" }] }, - vector.label.src, - vector.key.publicKeyMultikey, - ); - expect(relativeMethod.id).toBe(`${vector.label.src}#atproto_label`); - expect(relativeMethod.publicKeyMultibase).toBe(vector.key.publicKeyMultikey); - const relative = { ...method, id: "#atproto_label" }; - for (const verificationMethod of [ - [method, relative], - [relative, method], - [method, method], - ]) { - await expect( - getLabelVerificationMethod( - { ...vector.didDocument, verificationMethod }, - vector.label.src, - vector.key.publicKeyMultikey, - ), - ).rejects.toThrow("exactly one #atproto_label key"); - } - await expect( - getLabelVerificationMethod( - { ...vector.didDocument, verificationMethod: [{ ...method, type: "JsonWebKey2020" }] }, - vector.label.src, - vector.key.publicKeyMultikey, - ), - ).rejects.toThrow("must use Multikey"); - await expect( - getLabelVerificationMethod( - { - ...vector.didDocument, - verificationMethod: [{ ...method, controller: "did:web:other.test" }], - }, - vector.label.src, - vector.key.publicKeyMultikey, - ), - ).rejects.toThrow("controller must equal issuer DID"); - await expect( - getLabelVerificationMethod( - { - ...vector.didDocument, - verificationMethod: [{ ...method, publicKeyMultibase: undefined as unknown as string }], - }, - vector.label.src, - vector.key.publicKeyMultikey, - ), - ).rejects.toThrow("must contain publicKeyMultibase"); - await expect( - getLabelVerificationMethod( - { - ...vector.didDocument, - verificationMethod: [ - { ...method, publicKeyMultibase: "zQ3shqwJEJyMBsBXCWyCBpUBMqxcon9oHB7mCvx4sSpMdLJwc" }, - ], - }, - vector.label.src, - vector.key.publicKeyMultikey, - ), - ).rejects.toThrow("must use the P-256 multikey codec"); - await expect( - getLabelVerificationMethod( - { - ...vector.didDocument, - verificationMethod: [{ ...method, id: `${vector.label.src}#atproto` }], - }, - vector.label.src, - vector.key.publicKeyMultikey, - ), - ).rejects.toThrow("exactly one #atproto_label key"); - - const otherKey = await P256PrivateKey.importRaw( - fromHex("0000000000000000000000000000000000000000000000000000000000000002"), - ); - const otherMultikey = await otherKey.exportPublicKey("multikey"); - await expect( - getLabelVerificationMethod( - { - ...vector.didDocument, - verificationMethod: [{ ...method, publicKeyMultibase: otherMultikey }], - }, - vector.label.src, - vector.key.publicKeyMultikey, - ), - ).rejects.toThrow("must match the signer-derived multikey"); - await expect( - getLabelVerificationMethod( - vector.didDocument, - "did:web:other.test", - vector.key.publicKeyMultikey, - ), - ).rejects.toThrow("DID document does not belong to issuer"); - await expect( - getLabelVerificationMethod( - { id: vector.label.src }, - vector.label.src, - vector.key.publicKeyMultikey, - ), - ).rejects.toThrow("verificationMethod must be an array"); - await expect( - getLabelVerificationMethod(null, vector.label.src, vector.key.publicKeyMultikey), - ).rejects.toThrow("invalid DID document"); - }); - - it.each(["sig", "$type", "purpose"])("rejects the extra %s field before encoding", (field) => { - expect(() => constructUnsignedLabelV1({ ...vector.label, [field]: "not-allowed" })).toThrow( - `unexpected label field: ${field}`, - ); - }); - - it("rejects malformed allowed fields before encoding", () => { - expect(() => constructUnsignedLabelV1({ ...vector.label, ver: 2 })).toThrow( - "label ver must be 1", - ); - expect(() => constructUnsignedLabelV1({ ...vector.label, src: "not-a-did" })).toThrow( - "label src must be a DID", - ); - expect(() => constructUnsignedLabelV1({ ...vector.label, uri: "not a uri" })).toThrow( - "label uri must be a URI", - ); - expect(() => constructUnsignedLabelV1({ ...vector.label, cid: "not-a-cid" })).toThrow( - "label cid must be a CID", - ); - expect(() => constructUnsignedLabelV1({ ...vector.label, val: "" })).toThrow( - "1 to 128 UTF-8 bytes", - ); - expect(() => constructUnsignedLabelV1({ ...vector.label, val: "é".repeat(65) })).toThrow( - "1 to 128 UTF-8 bytes", - ); - expect(() => constructUnsignedLabelV1({ ...vector.label, neg: "true" })).toThrow( - "label neg must be a boolean", - ); - expect(() => constructUnsignedLabelV1({ ...vector.label, cts: "2026-07-10" })).toThrow( - "label cts must be an RFC3339 datetime", - ); - expect(() => - constructUnsignedLabelV1({ ...vector.label, cts: "2026-02-31T12:34:56Z" }), - ).toThrow("label cts must be an RFC3339 datetime"); - expect(() => constructUnsignedLabelV1({ ...vector.label, exp: "tomorrow" })).toThrow( - "label exp must be an RFC3339 datetime", - ); - - const withFalse = constructUnsignedLabelV1({ ...vector.label, neg: false }); - expect(withFalse).not.toHaveProperty("neg"); - expect(encode(withFalse)).toEqual(encode(constructUnsignedLabelV1(vector.label))); - }); - - it("strictly decodes an in-range canonical 32-byte private scalar", () => { - expect(toBase64Url(decodePrivateScalar(vector.key.testPrivateKeyRawBase64Url))).toBe( - vector.key.testPrivateKeyRawBase64Url, - ); - expect(() => decodePrivateScalar(`${vector.key.testPrivateKeyRawBase64Url}=`)).toThrow( - "unpadded base64url", - ); - expect(() => decodePrivateScalar("not+base64url")).toThrow("unpadded base64url"); - expect(() => - decodePrivateScalar(`${vector.key.testPrivateKeyRawBase64Url.slice(0, -1)}F`), - ).toThrow("canonical base64url"); - expect(() => decodePrivateScalar(toBase64Url(new Uint8Array(31).fill(1)))).toThrow( - "exactly 32 bytes", - ); - expect(() => decodePrivateScalar(toBase64Url(new Uint8Array(32)))).toThrow( - "outside P-256 range", - ); - expect(() => decodePrivateScalar(toBase64Url(fromHex(P256_ORDER.toString(16))))).toThrow( - "outside P-256 range", - ); - }); - - it("rejects a wrong key, changed payload, and malformed signatures", async () => { - const canonicalBytes = encode(constructUnsignedLabelV1(vector.label)); - const signature = fromHex(vector.signatures.atprotoReferenceHex); - const wrongKey = await P256PrivateKey.importRaw( - fromHex("0000000000000000000000000000000000000000000000000000000000000002"), - ); - const publicKey = await P256PublicKey.importRaw(fromHex(vector.key.publicKeyRawHex)); - const changedBytes = encode( - constructUnsignedLabelV1({ ...vector.label, val: "assessment-warning" }), - ); - const changedSignature = signature.slice(); - changedSignature[0] = (changedSignature[0] ?? 0) ^ 0x01; - const highS = highSEquivalent(signature); - const der = compactToDer(signature); - - expect(await wrongKey.verify(signature, canonicalBytes)).toBe(false); - expect(await publicKey.verify(signature, changedBytes)).toBe(false); - expect(await publicKey.verify(signature, Uint8Array.of(0xff))).toBe(false); - expect(await publicKey.verify(changedSignature, canonicalBytes)).toBe(false); - expect(highS).toHaveLength(64); - expect(await publicKey.verify(highS, canonicalBytes)).toBe(false); - expect(der.length).toBeGreaterThan(64); - expect(await publicKey.verify(der, canonicalBytes)).toBe(false); - expect(await publicKey.verify(signature.slice(0, 63), canonicalBytes)).toBe(false); - expect(await publicKey.verify(new Uint8Array(65), canonicalBytes)).toBe(false); - }); -}); diff --git a/packages/atproto-test-utils/tests/fixtures/p256-label-v1.json b/packages/atproto-test-utils/tests/fixtures/p256-label-v1.json deleted file mode 100644 index 3ae70f864d..0000000000 --- a/packages/atproto-test-utils/tests/fixtures/p256-label-v1.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "description": "ATProto label v1 P-256 cross-implementation interoperability vector", - "label": { - "ver": 1, - "src": "did:web:labeller.test", - "uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/com.emdashcms.experimental.package.release/crypto-interop", - "cid": "bafyreigh2akiscaildc4mscz4uzpcbap5jxg26eecmrf6cmnvkzkjmoixe", - "val": "assessment-passed", - "cts": "2026-07-10T12:34:56.000Z", - "exp": "2026-08-09T12:34:56.000Z" - }, - "canonicalCborHex": "a763636964783b62616679726569676832616b69736361696c6463346d73637a34757a7063626170356a786732366565636d726636636d6e766b7a6b6a6d6f697865636374737818323032362d30372d31305431323a33343a35362e3030305a636578707818323032362d30382d30395431323a33343a35362e3030305a63737263756469643a7765623a6c6162656c6c65722e7465737463757269785f61743a2f2f6469643a706c633a7a373269376864796e6d6b367232327a32376836747675722f636f6d2e656d64617368636d732e6578706572696d656e74616c2e7061636b6167652e72656c656173652f63727970746f2d696e7465726f706376616c716173736573736d656e742d7061737365646376657201", - "sha256Hex": "4ac798323da266b2a68caf3caf769a930bafc4403dd4f5921aeb33d2a1a26631", - "key": { - "curve": "P-256", - "publicKeyDid": "did:key:zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ", - "publicKeyMultikey": "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ", - "publicKeyRawHex": "036b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296", - "testPrivateKeyRawBase64Url": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE" - }, - "didDocument": { - "id": "did:web:labeller.test", - "verificationMethod": [ - { - "id": "did:web:labeller.test#atproto_label", - "type": "Multikey", - "controller": "did:web:labeller.test", - "publicKeyMultibase": "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ" - } - ] - }, - "signatures": { - "atcuteWebcryptoHex": "884bc6f1dabf7da026d8c7fcf20dfb0395eb5e9dce09958f54e00b64644af7950244e3d1ada5ef60d0eec8e6b3e0721fad27b555b48291893acc9ba6db4238de", - "atprotoReferenceHex": "183823d83ada5cb73cf29ada84cadeb4ffe76b6f5c65245f820fbe163ab4482b5d7e9f9bd7bf206dcebefe3877c7f547d34279235fe4efde497bbb5655685200" - }, - "provenance": { - "payloadCodec": "@atcute/cbor 2.3.3", - "workerdSigner": "@atcute/crypto 2.4.1 P256PrivateKey.sign", - "independentSignerAndVerifier": "@atproto/crypto 0.4.5 P256Keypair.sign/verifySignature", - "independentSource": "https://github.com/bluesky-social/atproto/tree/main/packages/crypto", - "independentLicense": "MIT OR Apache-2.0" - } -} diff --git a/packages/atproto-test-utils/tests/label-crypto-vector.test.ts b/packages/atproto-test-utils/tests/label-crypto-vector.test.ts deleted file mode 100644 index c8f4a26e80..0000000000 --- a/packages/atproto-test-utils/tests/label-crypto-vector.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { createHash } from "node:crypto"; - -import { encode } from "@atcute/cbor"; -import { fromBase64Url } from "@atcute/multibase"; -import { P256Keypair, verifySignature } from "@atproto/crypto"; -import { describe, expect, it } from "vitest"; - -import vector from "./fixtures/p256-label-v1.json" with { type: "json" }; - -function fromHex(value: string): Uint8Array { - return Uint8Array.from(value.match(/.{2}/g) ?? [], (byte) => Number.parseInt(byte, 16)); -} - -function toHex(value: Uint8Array): string { - return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(""); -} - -describe("ATProto label crypto vector generation", () => { - it("reproduces the reference vector and independently verifies the workerd signature", async () => { - const canonicalBytes = encode(vector.label); - const keypair = await P256Keypair.import(fromBase64Url(vector.key.testPrivateKeyRawBase64Url), { - exportable: true, - }); - const referenceSignature = await keypair.sign(canonicalBytes); - - expect(toHex(canonicalBytes)).toBe(vector.canonicalCborHex); - expect(createHash("sha256").update(canonicalBytes).digest("hex")).toBe(vector.sha256Hex); - expect(keypair.did()).toBe(vector.key.publicKeyDid); - expect(keypair.did().slice("did:key:".length)).toBe(vector.key.publicKeyMultikey); - expect(vector.didDocument.verificationMethod[0]?.publicKeyMultibase).toBe( - keypair.did().slice("did:key:".length), - ); - expect(toHex(keypair.publicKeyBytes())).toBe(vector.key.publicKeyRawHex); - expect(toHex(referenceSignature)).toBe(vector.signatures.atprotoReferenceHex); - expect(await verifySignature(vector.key.publicKeyDid, canonicalBytes, referenceSignature)).toBe( - true, - ); - expect( - await verifySignature( - vector.key.publicKeyDid, - canonicalBytes, - fromHex(vector.signatures.atcuteWebcryptoHex), - ), - ).toBe(true); - - const digest = createHash("sha256").update(canonicalBytes).digest(); - expect(await verifySignature(vector.key.publicKeyDid, digest, referenceSignature)).toBe(false); - }); -}); From 622b42f1e6f0e10350da7ed1df260729d0e7bf6e Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 15:55:39 +0100 Subject: [PATCH 010/137] feat(registry): add moderation evaluator (#1917) --- .changeset/shiny-ants-deny.md | 5 + package.json | 2 +- packages/registry-moderation/package.json | 47 +++ packages/registry-moderation/src/index.ts | 395 ++++++++++++++++++ .../tests}/fixtures/moderation-cases.json | 0 .../tests}/fixtures/moderation-policy.json | 0 .../tests/moderation.test.ts | 364 ++++++++++++++++ packages/registry-moderation/tsconfig.json | 9 + packages/registry-moderation/tsdown.config.ts | 11 + pnpm-lock.yaml | 25 ++ 10 files changed, 857 insertions(+), 1 deletion(-) create mode 100644 .changeset/shiny-ants-deny.md create mode 100644 packages/registry-moderation/package.json create mode 100644 packages/registry-moderation/src/index.ts rename {.opencode/plans/plugin-registry-labelling-service/gate-0 => packages/registry-moderation/tests}/fixtures/moderation-cases.json (100%) rename {.opencode/plans/plugin-registry-labelling-service/gate-0 => packages/registry-moderation/tests}/fixtures/moderation-policy.json (100%) create mode 100644 packages/registry-moderation/tests/moderation.test.ts create mode 100644 packages/registry-moderation/tsconfig.json create mode 100644 packages/registry-moderation/tsdown.config.ts diff --git a/.changeset/shiny-ants-deny.md b/.changeset/shiny-ants-deny.md new file mode 100644 index 0000000000..b60827f61f --- /dev/null +++ b/.changeset/shiny-ants-deny.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-moderation": patch +--- + +Adds shared release moderation evaluation for accepted ATProto label streams. diff --git a/package.json b/package.json index 0d62e3cbf6..25bdf85f41 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-moderation 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-moderation/package.json b/packages/registry-moderation/package.json new file mode 100644 index 0000000000..e02938a20f --- /dev/null +++ b/packages/registry-moderation/package.json @@ -0,0 +1,47 @@ +{ + "name": "@emdash-cms/registry-moderation", + "version": "0.0.0", + "description": "Shared label reduction and release moderation policy 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", + "check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm --ignore-rules=no-resolution" + }, + "devDependencies": { + "@arethetypeswrong/cli": "catalog:", + "@types/node": "catalog:", + "publint": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "keywords": [ + "emdash", + "cms", + "plugin-registry", + "atproto", + "moderation" + ], + "author": "Matt Kane", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/emdash-cms/emdash.git", + "directory": "packages/registry-moderation" + }, + "homepage": "https://github.com/emdash-cms/emdash" +} diff --git a/packages/registry-moderation/src/index.ts b/packages/registry-moderation/src/index.ts new file mode 100644 index 0000000000..5053a862ca --- /dev/null +++ b/packages/registry-moderation/src/index.ts @@ -0,0 +1,395 @@ +export type ModerationLabelValue = + | "assessment-error" + | "assessment-overridden" + | "assessment-passed" + | "assessment-pending" + | "artifact-integrity-failure" + | "broken-release" + | "credential-harvesting" + | "critical-vulnerability" + | "data-exfiltration" + | "impersonation" + | "invalid-bundle" + | "low-quality" + | "malware" + | "misleading-metadata" + | "obfuscated-code" + | "package-disputed" + | "privacy-risk" + | "publisher-compromised" + | "security-yanked" + | "supply-chain-compromise" + | "suspicious-code" + | "undeclared-access" + | "!takedown"; + +/** The ATProto label fields used to reduce a signed label stream. */ +export interface ModerationLabel { + ver: 1; + src: string; + uri: string; + val: ModerationLabelValue | (string & {}); + cts: string; + cid?: string; + neg?: boolean; + exp?: string; +} + +export interface AcceptedLabelerPolicy { + did: string; + redact: boolean; +} + +export interface ReleaseSubjectContext { + publisherDid: string; + package: { + uri: string; + cid: string; + }; + release: { + uri: string; + cid: string; + }; +} + +export interface EvaluateReleaseModerationInput { + acceptedLabelers: AcceptedLabelerPolicy[]; + context: ReleaseSubjectContext; + evaluatedAt: Date | string; + labels: ModerationLabel[]; +} + +export type ReleaseEligibility = "eligible" | "pending" | "error" | "blocked"; + +export interface ReleaseModeration { + eligibility: ReleaseEligibility; + reasonCodes: string[]; + blockingLabels: string[]; + stateLabels: string[]; + warningLabels: string[]; + suppressedLabels: string[]; + applicableLabels: ModerationLabel[]; + redacted: boolean; +} + +const AUTOMATED_BLOCKS = new Set([ + "malware", + "data-exfiltration", + "credential-harvesting", + "supply-chain-compromise", + "critical-vulnerability", + "artifact-integrity-failure", + "invalid-bundle", + "undeclared-access", + "impersonation", +]); + +const WARNINGS = new Set([ + "suspicious-code", + "obfuscated-code", + "privacy-risk", + "misleading-metadata", + "low-quality", + "broken-release", + "package-disputed", +]); + +const RELEASE_VALUES = new Set([ + "assessment-error", + "assessment-overridden", + "assessment-passed", + "assessment-pending", + "security-yanked", + "!takedown", + ...AUTOMATED_BLOCKS, + ...WARNINGS, +]); + +interface ParsedInstant { + seconds: bigint; + fraction: string; +} + +interface LabelReduction { + active: ModerationLabel[]; + collisions: ModerationLabel[][]; +} + +const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})$/; + +function daysFromCivil(year: bigint, month: bigint, day: bigint): bigint { + const adjustedYear = year - (month <= 2n ? 1n : 0n); + const era = (adjustedYear >= 0n ? adjustedYear : adjustedYear - 399n) / 400n; + const yearOfEra = adjustedYear - era * 400n; + const shiftedMonth = month + (month > 2n ? -3n : 9n); + const dayOfYear = (153n * shiftedMonth + 2n) / 5n + day - 1n; + const dayOfEra = yearOfEra * 365n + yearOfEra / 4n - yearOfEra / 100n + dayOfYear; + return era * 146_097n + dayOfEra - 719_468n; +} + +function isLeapYear(year: number): boolean { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +function parseInstant(value: Date | string, field: string): ParsedInstant { + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) + throw new TypeError(`${field} must be a valid RFC 3339 timestamp`); + return { + seconds: BigInt(Math.floor(value.getTime() / 1000)), + fraction: `${value.getMilliseconds()}`.padStart(3, "0"), + }; + } + const match = RFC3339.exec(value); + if (!match) throw new TypeError(`${field} must be a valid RFC 3339 timestamp`); + const [, yearText, monthText, dayText, hourText, minuteText, secondText, fraction = "", zone] = + match; + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const hour = Number(hourText); + const minute = Number(minuteText); + const second = Number(secondText); + const zoneText = zone!; + const daysInMonth = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if ( + year === 0 || + month < 1 || + month > 12 || + day < 1 || + day > daysInMonth[month - 1]! || + hour > 23 || + minute > 59 || + second > 59 + ) { + throw new TypeError(`${field} must be a valid RFC 3339 timestamp`); + } + let offsetSeconds = 0n; + if (zoneText !== "Z") { + const offsetHour = Number(zoneText.slice(1, 3)); + const offsetMinute = Number(zoneText.slice(4, 6)); + if ( + (offsetHour === 0 && offsetMinute === 0 && zoneText[0] === "-") || + offsetHour > 23 || + offsetMinute > 59 + ) + throw new TypeError(`${field} must be a valid RFC 3339 timestamp`); + offsetSeconds = + BigInt(offsetHour * 3600 + offsetMinute * 60) * (zoneText[0] === "+" ? 1n : -1n); + } + return { + seconds: + daysFromCivil(BigInt(year), BigInt(month), BigInt(day)) * 86_400n + + BigInt(hour * 3600 + minute * 60 + second) - + offsetSeconds, + fraction, + }; +} + +function compareInstants(left: ParsedInstant, right: ParsedInstant): number { + if (left.seconds !== right.seconds) return left.seconds < right.seconds ? -1 : 1; + const length = Math.max(left.fraction.length, right.fraction.length); + for (let index = 0; index < length; index++) { + const leftDigit = left.fraction[index] ?? "0"; + const rightDigit = right.fraction[index] ?? "0"; + if (leftDigit !== rightDigit) return leftDigit < rightDigit ? -1 : 1; + } + return 0; +} + +function streamKey(label: ModerationLabel): string { + return `${label.src}\u0000${label.uri}\u0000${label.val}`; +} + +function isSameEvent(left: ModerationLabel, right: ModerationLabel): boolean { + return ( + left.ver === right.ver && + left.src === right.src && + left.uri === right.uri && + left.cid === right.cid && + left.val === right.val && + (left.neg === true) === (right.neg === true) && + left.cts === right.cts && + left.exp === right.exp + ); +} + +/** + * Reduces each `(src, uri, val)` label stream to its current winner. CID is + * deliberately excluded from the key so a CID-bearing negation replaces it. + */ +function reduceLabels(labels: ModerationLabel[], evaluatedAt: Date | string): LabelReduction { + const now = parseInstant(evaluatedAt, "evaluatedAt"); + const streams = new Map< + string, + { label: ModerationLabel; cts: ParsedInstant; exp?: ParsedInstant }[] + >(); + + for (const label of labels) { + const entry = { + label, + cts: parseInstant(label.cts, "label.cts"), + exp: label.exp === undefined ? undefined : parseInstant(label.exp, "label.exp"), + }; + const key = streamKey(label); + const entries = streams.get(key); + if (entries) entries.push(entry); + else streams.set(key, [entry]); + } + + const active: ModerationLabel[] = []; + const collisions: ModerationLabel[][] = []; + for (const entries of streams.values()) { + const first = entries[0]; + if (!first) continue; + const winners = entries.filter((entry) => + entries.every((other) => compareInstants(entry.cts, other.cts) >= 0), + ); + const winner = winners[0]; + if (!winner) continue; + + if (winners.some((entry) => !isSameEvent(winner.label, entry.label))) { + collisions.push(winners.map((entry) => entry.label)); + continue; + } + if ( + winner.label.neg === true || + (winner.exp !== undefined && compareInstants(winner.exp, now) <= 0) + ) { + continue; + } + active.push(winner.label); + } + + return { active, collisions }; +} + +function appliesToContext(label: ModerationLabel, context: ReleaseSubjectContext): boolean { + if (label.uri === context.release.uri) { + if (!RELEASE_VALUES.has(label.val)) return false; + if (label.cid !== undefined) return label.cid === context.release.cid; + return label.val === "security-yanked" || label.val === "!takedown"; + } + if (label.uri === context.package.uri) { + if (label.val !== "!takedown" && label.val !== "package-disputed") return false; + if (label.cid !== undefined) return label.cid === context.package.cid; + return true; + } + return ( + label.uri === context.publisherDid && + label.cid === undefined && + (label.val === "!takedown" || label.val === "publisher-compromised") + ); +} + +function collisionAppliesToContext( + labels: ModerationLabel[], + context: ReleaseSubjectContext, +): boolean { + return labels.some((label) => appliesToContext(label, context)); +} + +function orderedValues(labels: ModerationLabel[]): string[] { + const values: string[] = []; + new Set(labels.map((label) => label.val)).forEach((value) => values.push(value)); + return values.toSorted(); +} + +/** Evaluates accepted, current label state for one exact package release. */ +export function evaluateReleaseModeration( + input: EvaluateReleaseModerationInput, +): ReleaseModeration { + const policies = new Map(); + for (const policy of input.acceptedLabelers) { + const existing = policies.get(policy.did); + policies.set(policy.did, { + did: policy.did, + redact: existing?.redact === true || policy.redact, + }); + } + const unacceptedLabelsIgnored = input.labels.some((label) => !policies.has(label.src)); + const reduction = reduceLabels( + input.labels.filter((label) => policies.has(label.src)), + input.evaluatedAt, + ); + const applicableLabels = reduction.active + .filter((label) => appliesToContext(label, input.context)) + .toSorted((left, right) => streamKey(left).localeCompare(streamKey(right))); + const collisions = reduction.collisions.filter((labels) => + collisionAppliesToContext(labels, input.context), + ); + + const manualBlocks = applicableLabels.filter( + (label) => + label.val === "!takedown" || + label.val === "security-yanked" || + label.val === "publisher-compromised", + ); + const warnings = applicableLabels.filter((label) => WARNINGS.has(label.val)); + const suppressed: ModerationLabel[] = []; + const unsuppressedStates: ModerationLabel[] = []; + const unsuppressedBlocks: ModerationLabel[] = []; + const passSources = new Set(); + const overrideSources = new Set(); + + for (const [source] of policies) { + const sourceLabels = applicableLabels.filter((label) => label.src === source); + const hasPass = sourceLabels.some((label) => label.val === "assessment-passed"); + const hasOverride = sourceLabels.some((label) => label.val === "assessment-overridden"); + const override = hasPass && hasOverride; + if (override) overrideSources.add(source); + else if (hasPass) passSources.add(source); + + for (const label of sourceLabels) { + if (label.val === "assessment-pending" || label.val === "assessment-error") { + if (override) suppressed.push(label); + else unsuppressedStates.push(label); + } else if (AUTOMATED_BLOCKS.has(label.val)) { + if (override) suppressed.push(label); + else unsuppressedBlocks.push(label); + } + } + } + + const reasonCodes: string[] = []; + let eligibility: ReleaseEligibility; + if (manualBlocks.length > 0) { + eligibility = "blocked"; + reasonCodes.push("manual-block"); + } else if (collisions.length > 0) { + eligibility = "error"; + reasonCodes.push("label-state-collision"); + } else if (unsuppressedStates.some((label) => label.val === "assessment-error")) { + eligibility = "error"; + reasonCodes.push("assessment-error"); + } else if (unsuppressedStates.some((label) => label.val === "assessment-pending")) { + eligibility = "pending"; + reasonCodes.push("assessment-pending"); + } else if (unsuppressedBlocks.length > 0) { + eligibility = "blocked"; + reasonCodes.push("automated-block"); + } else if (passSources.size === 0 && overrideSources.size === 0) { + eligibility = "blocked"; + reasonCodes.push("missing-assessment-pass"); + } else { + eligibility = "eligible"; + reasonCodes.push( + overrideSources.size > 0 ? "eligible-manual-override" : "eligible-assessment-pass", + ); + if (warnings.length > 0) reasonCodes.push("warning-labels"); + } + if (unacceptedLabelsIgnored) reasonCodes.push("unaccepted-labels-ignored"); + + return { + eligibility, + reasonCodes, + blockingLabels: orderedValues([...manualBlocks, ...unsuppressedBlocks]), + stateLabels: orderedValues(unsuppressedStates), + warningLabels: orderedValues(warnings), + suppressedLabels: orderedValues(suppressed), + applicableLabels, + redacted: applicableLabels.some( + (label) => label.val === "!takedown" && policies.get(label.src)?.redact === true, + ), + }; +} diff --git a/.opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/moderation-cases.json b/packages/registry-moderation/tests/fixtures/moderation-cases.json similarity index 100% rename from .opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/moderation-cases.json rename to packages/registry-moderation/tests/fixtures/moderation-cases.json diff --git a/.opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/moderation-policy.json b/packages/registry-moderation/tests/fixtures/moderation-policy.json similarity index 100% rename from .opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/moderation-policy.json rename to packages/registry-moderation/tests/fixtures/moderation-policy.json diff --git a/packages/registry-moderation/tests/moderation.test.ts b/packages/registry-moderation/tests/moderation.test.ts new file mode 100644 index 0000000000..7474f0ea9e --- /dev/null +++ b/packages/registry-moderation/tests/moderation.test.ts @@ -0,0 +1,364 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { evaluateReleaseModeration, type ModerationLabel } from "../src/index.js"; + +const source = "did:example:trusted"; +const otherSource = "did:example:other"; +const context = { + publisherDid: "did:example:publisher", + package: { uri: "at://did:example:publisher/com.example.package/profile", cid: "package-cid" }, + release: { uri: "at://did:example:publisher/com.example.release/1", cid: "release-cid" }, +}; + +function label( + overrides: Partial & Pick, +): ModerationLabel { + return { + ver: 1, + src: source, + uri: context.release.uri, + cid: context.release.cid, + cts: "2026-07-10T12:00:00.000Z", + ...overrides, + }; +} + +function evaluate(labels: ModerationLabel[], redact = false) { + return evaluateReleaseModeration({ + acceptedLabelers: [{ did: source, redact }], + context, + evaluatedAt: "2026-07-10T13:00:00.000Z", + labels, + }); +} + +describe("release moderation", () => { + it("accepts an exact-CID assessment pass", () => { + expect(evaluate([label({ val: "assessment-passed" })])).toMatchObject({ + eligibility: "eligible", + reasonCodes: ["eligible-assessment-pass"], + }); + }); + + it("blocks when no accepted source has a pass", () => { + expect(evaluate([])).toMatchObject({ + eligibility: "blocked", + reasonCodes: ["missing-assessment-pass"], + }); + }); + + it("uses a current negation rather than an older pass", () => { + const pass = label({ val: "assessment-passed" }); + const negation = label({ + val: "assessment-passed", + neg: true, + cts: "2026-07-10T12:01:00.000Z", + }); + expect(evaluate([pass, negation]).eligibility).toBe("blocked"); + }); + + it("does not revive an older pass when the current winner expires", () => { + const pass = label({ val: "assessment-passed" }); + const expired = label({ + val: "assessment-passed", + cts: "2026-07-10T12:01:00.000Z", + exp: "2026-07-10T12:30:00.000Z", + }); + expect(evaluate([pass, expired]).eligibility).toBe("blocked"); + }); + + it("fails closed on non-identical equal-time stream events", () => { + const pass = label({ val: "assessment-passed" }); + const collision = label({ val: "assessment-passed", cid: "different-cid" }); + expect(evaluate([pass, collision])).toMatchObject({ + eligibility: "error", + reasonCodes: ["label-state-collision"], + }); + }); + + it("does not let a collision for unrelated CIDs block the current release", () => { + const first = label({ val: "assessment-passed", cid: "old-release-cid" }); + const second = label({ val: "assessment-passed", cid: "another-old-release-cid" }); + expect(evaluate([first, second])).toMatchObject({ + eligibility: "blocked", + reasonCodes: ["missing-assessment-pass"], + }); + }); + + it("does not apply a release label for another CID", () => { + expect( + evaluate([label({ val: "assessment-passed", cid: "old-release-cid" })]).eligibility, + ).toBe("blocked"); + }); + + it("cascades URI-wide package and publisher actions", () => { + const packageTakedown = label({ val: "!takedown", uri: context.package.uri, cid: undefined }); + const publisherBlock = label({ + val: "publisher-compromised", + uri: context.publisherDid, + cid: undefined, + }); + expect(evaluate([label({ val: "assessment-passed" }), packageTakedown]).eligibility).toBe( + "blocked", + ); + expect(evaluate([label({ val: "assessment-passed" }), publisherBlock]).eligibility).toBe( + "blocked", + ); + }); + + it("does not apply CID-bound manual actions to a different revision", () => { + const releaseTakedown = label({ val: "!takedown", cid: "old-release-cid" }); + const packageYank = label({ + val: "!takedown", + uri: context.package.uri, + cid: "old-package-cid", + }); + expect(evaluate([label({ val: "assessment-passed" }), releaseTakedown]).eligibility).toBe( + "eligible", + ); + expect(evaluate([label({ val: "assessment-passed" }), packageYank]).eligibility).toBe( + "eligible", + ); + }); + + it("keeps warning-only releases eligible", () => { + expect( + evaluate([label({ val: "assessment-passed" }), label({ val: "suspicious-code" })]), + ).toMatchObject({ + eligibility: "eligible", + warningLabels: ["suspicious-code"], + }); + }); + + it("suppresses only its source's automated findings with a valid override", () => { + const result = evaluate([ + label({ val: "assessment-passed" }), + label({ val: "assessment-overridden" }), + label({ val: "assessment-pending" }), + label({ val: "malware" }), + ]); + expect(result).toMatchObject({ + eligibility: "eligible", + suppressedLabels: ["assessment-pending", "malware"], + }); + }); + + it("does not let an override bypass a broader manual block", () => { + const publisherBlock = label({ + val: "publisher-compromised", + uri: context.publisherDid, + cid: undefined, + }); + expect( + evaluate([ + label({ val: "assessment-passed" }), + label({ val: "assessment-overridden" }), + publisherBlock, + ]), + ).toMatchObject({ + eligibility: "blocked", + reasonCodes: ["manual-block"], + }); + }); + + it("aggregates another accepted source's error, pending, and block over a pass", () => { + const acceptedLabelers = [ + { did: source, redact: false }, + { did: otherSource, redact: false }, + ]; + for (const value of ["assessment-error", "assessment-pending", "malware"] as const) { + const result = evaluateReleaseModeration({ + acceptedLabelers, + context, + evaluatedAt: "2026-07-10T13:00:00.000Z", + labels: [label({ val: "assessment-passed" }), label({ val: value, src: otherSource })], + }); + expect(result.eligibility).toBe( + value === "assessment-error" + ? "error" + : value === "assessment-pending" + ? "pending" + : "blocked", + ); + } + }); + + it("ignores an unaccepted source", () => { + const result = evaluate([label({ val: "assessment-passed", src: otherSource })]); + expect(result).toMatchObject({ + eligibility: "blocked", + reasonCodes: ["missing-assessment-pass", "unaccepted-labels-ignored"], + }); + }); + + it("does not parse malformed labels from unaccepted sources", () => { + const result = evaluate([ + label({ val: "assessment-passed" }), + label({ val: "malware", src: otherSource, cts: "not-a-datetime" }), + ]); + expect(result).toMatchObject({ + eligibility: "eligible", + reasonCodes: ["eligible-assessment-pass", "unaccepted-labels-ignored"], + }); + }); + + it("ignores unknown label values, including a colliding stream", () => { + const unknown = label({ val: "future-label" }); + const collision = label({ val: "future-label", cid: "other-cid" }); + expect(evaluate([label({ val: "assessment-passed" }), unknown, collision])).toMatchObject({ + eligibility: "eligible", + reasonCodes: ["eligible-assessment-pass"], + }); + }); + + it("always blocks accepted takedowns while redact controls only presentation", () => { + const takedown = label({ val: "!takedown", cid: undefined }); + expect(evaluate([label({ val: "assessment-passed" }), takedown], false)).toMatchObject({ + eligibility: "blocked", + redacted: false, + }); + expect(evaluate([label({ val: "assessment-passed" }), takedown], true)).toMatchObject({ + eligibility: "blocked", + redacted: true, + }); + }); + + it("ORs redact flags for duplicate accepted labeler policies", () => { + const result = evaluateReleaseModeration({ + acceptedLabelers: [ + { did: source, redact: false }, + { did: source, redact: true }, + ], + context, + evaluatedAt: "2026-07-10T13:00:00.000Z", + labels: [label({ val: "!takedown", cid: undefined })], + }); + expect(result).toMatchObject({ eligibility: "blocked", redacted: true }); + }); + + it("treats explicit false negation as an omitted negation", () => { + const pass = label({ val: "assessment-passed" }); + const currentPass = label({ + val: "assessment-passed", + cid: "release-cid", + neg: false, + }); + expect(evaluate([pass, currentPass]).eligibility).toBe("eligible"); + }); + + it("orders arbitrary fractional timestamps without truncating milliseconds", () => { + const oldPass = label({ val: "assessment-passed", cts: "2026-07-10T12:00:00.1234Z" }); + const pending = label({ + val: "assessment-passed", + neg: true, + cts: "2026-07-10T12:00:00.1235Z", + }); + expect(evaluate([oldPass, pending]).eligibility).toBe("blocked"); + }); + + it("rejects invalid RFC 3339 calendar and timestamp syntax", () => { + expect(() => + evaluate([label({ val: "assessment-passed", cts: "2026-02-30T12:00:00Z" })]), + ).toThrow(TypeError); + expect(() => + evaluate([label({ val: "assessment-passed", cts: "2026-07-10 12:00:00Z" })]), + ).toThrow(TypeError); + expect(() => + evaluate([label({ val: "assessment-passed", cts: "2026-07-10T12:00:00-00:00" })]), + ).toThrow(TypeError); + expect(() => + evaluate([label({ val: "assessment-passed", cts: "0000-01-01T00:00:00Z" })]), + ).toThrow(TypeError); + expect(() => + evaluate([label({ val: "assessment-passed", cts: "0000-01-01T00:00:00+01:00" })]), + ).toThrow(TypeError); + }); +}); + +interface FixtureLabel { + src: string; + subject: "release" | "package" | "publisher"; + cid?: string; + val: string; + cts: string; + neg?: boolean; + exp?: string; +} + +interface FixtureCase { + id: string; + acceptedLabellers?: { src: string; redact: boolean }[]; + subject?: Record; + labels?: FixtureLabel[]; + expected: { + eligibility: string; + reasonCodes: string[]; + blockingLabels: string[]; + stateLabels: string[]; + warningLabels: string[]; + suppressedLabels: string[]; + redacted: boolean; + }; +} + +const corpus = JSON.parse( + readFileSync(new URL("./fixtures/moderation-cases.json", import.meta.url), "utf8"), +) as { + evaluatedAt: string; + sources: Record; + caseDefaults: Required>; + cases: FixtureCase[]; +}; + +function expectedValues(references: string[]): string[] { + return references.map((reference) => reference.slice(reference.lastIndexOf(":") + 1)).toSorted(); +} + +describe("ratified moderation corpus", () => { + for (const fixtureCase of corpus.cases) { + it(fixtureCase.id, () => { + const subject = { ...corpus.caseDefaults.subject, ...fixtureCase.subject }; + const labels = fixtureCase.labels ?? corpus.caseDefaults.labels; + const result = evaluateReleaseModeration({ + acceptedLabelers: ( + fixtureCase.acceptedLabellers ?? corpus.caseDefaults.acceptedLabellers + ).map((policy) => ({ + did: corpus.sources[policy.src]!, + redact: policy.redact, + })), + context: { + publisherDid: subject.publisherDid!, + package: { uri: subject.packageUri!, cid: subject.packageCid! }, + release: { uri: subject.releaseUri!, cid: subject.releaseCid! }, + }, + evaluatedAt: corpus.evaluatedAt, + labels: labels.map((fixtureLabel) => ({ + ver: 1, + src: corpus.sources[fixtureLabel.src]!, + uri: + fixtureLabel.subject === "publisher" + ? subject.publisherDid! + : fixtureLabel.subject === "package" + ? subject.packageUri! + : subject.releaseUri!, + cid: fixtureLabel.cid, + val: fixtureLabel.val, + cts: fixtureLabel.cts, + neg: fixtureLabel.neg, + exp: fixtureLabel.exp, + })), + }); + expect(result).toMatchObject({ + eligibility: fixtureCase.expected.eligibility, + reasonCodes: fixtureCase.expected.reasonCodes, + blockingLabels: expectedValues(fixtureCase.expected.blockingLabels), + stateLabels: expectedValues(fixtureCase.expected.stateLabels), + warningLabels: expectedValues(fixtureCase.expected.warningLabels), + suppressedLabels: expectedValues(fixtureCase.expected.suppressedLabels), + redacted: fixtureCase.expected.redacted, + }); + }); + } +}); diff --git a/packages/registry-moderation/tsconfig.json b/packages/registry-moderation/tsconfig.json new file mode 100644 index 0000000000..ae7d1ab8ce --- /dev/null +++ b/packages/registry-moderation/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/registry-moderation/tsdown.config.ts b/packages/registry-moderation/tsdown.config.ts new file mode 100644 index 0000000000..246ca011f3 --- /dev/null +++ b/packages/registry-moderation/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + outExtensions: () => ({ js: ".js" }), + dts: true, + clean: true, + platform: "neutral", + target: "es2024", +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af45cfb8a9..3187c6ce59 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-moderation: + devDependencies: + '@arethetypeswrong/cli': + specifier: 'catalog:' + version: 0.18.2 + '@types/node': + specifier: 'catalog:' + version: 24.10.13 + 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@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/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==} @@ -10324,6 +10346,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 +11148,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==} @@ -11133,6 +11157,7 @@ packages: typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} + hasBin: true uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} From a01683bb5e0d16106c89a73c10737bc1eb49af6e Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 16:42:13 +0100 Subject: [PATCH 011/137] feat(registry): add label signing (#1922) --- .changeset/shiny-ants-deny.md | 2 +- packages/registry-moderation/package.json | 9 +- packages/registry-moderation/src/index.ts | 308 +++++++++++++++++- .../tests/fixtures/label-crypto.json | 15 + .../tests/label-crypto.test.ts | 200 ++++++++++++ .../tests/moderation.test.ts | 104 ++++-- pnpm-lock.yaml | 31 +- 7 files changed, 634 insertions(+), 35 deletions(-) create mode 100644 packages/registry-moderation/tests/fixtures/label-crypto.json create mode 100644 packages/registry-moderation/tests/label-crypto.test.ts diff --git a/.changeset/shiny-ants-deny.md b/.changeset/shiny-ants-deny.md index b60827f61f..1a12206d70 100644 --- a/.changeset/shiny-ants-deny.md +++ b/.changeset/shiny-ants-deny.md @@ -2,4 +2,4 @@ "@emdash-cms/registry-moderation": patch --- -Adds shared release moderation evaluation for accepted ATProto label streams. +Adds shared ATProto label verification and release moderation evaluation. diff --git a/packages/registry-moderation/package.json b/packages/registry-moderation/package.json index e02938a20f..9954155ba1 100644 --- a/packages/registry-moderation/package.json +++ b/packages/registry-moderation/package.json @@ -23,6 +23,7 @@ }, "devDependencies": { "@arethetypeswrong/cli": "catalog:", + "@atproto/crypto": "catalog:", "@types/node": "catalog:", "publint": "catalog:", "tsdown": "catalog:", @@ -43,5 +44,11 @@ "url": "git+https://github.com/emdash-cms/emdash.git", "directory": "packages/registry-moderation" }, - "homepage": "https://github.com/emdash-cms/emdash" + "homepage": "https://github.com/emdash-cms/emdash", + "dependencies": { + "@atcute/cbor": "catalog:", + "@atcute/cid": "catalog:", + "@atcute/crypto": "catalog:", + "@atcute/multibase": "catalog:" + } } diff --git a/packages/registry-moderation/src/index.ts b/packages/registry-moderation/src/index.ts index 5053a862ca..58831ae906 100644 --- a/packages/registry-moderation/src/index.ts +++ b/packages/registry-moderation/src/index.ts @@ -1,3 +1,8 @@ +import { encode } from "@atcute/cbor"; +import { fromString as cidFromString, toString as cidToString } from "@atcute/cid"; +import { P256PrivateKey, P256PublicKey, parsePublicMultikey } from "@atcute/crypto"; +import { fromBase64Url, toBase64Url } from "@atcute/multibase"; + export type ModerationLabelValue = | "assessment-error" | "assessment-overridden" @@ -35,6 +40,62 @@ export interface ModerationLabel { exp?: string; } +/** An ATProto label before its detached P-256 signature is added. */ +export interface UnsignedLabel { + ver: 1; + src: string; + uri: string; + val: string; + cts: string; + cid?: string; + neg?: boolean; + exp?: string; +} + +/** An ATProto label v1 with its compact P-256 signature. */ +export interface SignedLabel extends UnsignedLabel { + sig: Uint8Array; +} + +const verifiedModerationLabel = Symbol("verifiedModerationLabel"); + +/** A label whose signature and source DID key have been verified by this package. */ +export type VerifiedModerationLabel = ModerationLabel & { + readonly [verifiedModerationLabel]: true; +}; + +export interface CreateLabelSignerInput { + issuerDid: string; + /** A canonical, unpadded base64url encoding of the 32-byte P-256 scalar. */ + privateKey: string; + resolveDid: LabelDidResolver; +} + +export interface LabelSigner { + readonly issuerDid: string; + sign(label: Omit): Promise; +} + +export interface DidVerificationMethod { + id: string; + type: string; + controller: string; + publicKeyMultibase: string; +} + +export interface LabelDidDocument { + id: string; + verificationMethod?: readonly DidVerificationMethod[]; +} + +/** Resolves the DID document used exclusively for a label signature check. */ +export type LabelDidResolver = (did: string) => Promise; + +export interface LabelVerificationInput { + label: SignedLabel; + resolveDid: LabelDidResolver; +} + export interface AcceptedLabelerPolicy { did: string; redact: boolean; @@ -56,7 +117,15 @@ export interface EvaluateReleaseModerationInput { acceptedLabelers: AcceptedLabelerPolicy[]; context: ReleaseSubjectContext; evaluatedAt: Date | string; - labels: ModerationLabel[]; + labels: VerifiedModerationLabel[]; +} + +export interface VerifyAndEvaluateReleaseModerationInput extends Omit< + EvaluateReleaseModerationInput, + "labels" +> { + labels: SignedLabel[]; + resolveDid: LabelDidResolver; } export type ReleaseEligibility = "eligible" | "pending" | "error" | "blocked"; @@ -116,6 +185,14 @@ interface LabelReduction { } const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})$/; +const DID = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+(?:[:][A-Za-z0-9._:%-]+)*$/; +const P256_ORDER = BigInt("0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551"); +const LABEL_FIELDS = new Set(["ver", "src", "uri", "cid", "val", "neg", "cts", "exp"]); +const SIGNED_LABEL_FIELDS = new Set([...LABEL_FIELDS, "sig"]); +const PRINTABLE_LABEL_VALUE = /^[^\p{Cc}]{1,128}$/u; +const BASE64URL = /^[A-Za-z0-9_-]+$/; +const ATPROTO_URI = + /^at:\/\/(?:did:[a-z0-9]+:[A-Za-z0-9._:%-]+(?:[:][A-Za-z0-9._:%-]+)*|[A-Za-z0-9.-]+)\/[A-Za-z0-9.-]+(?:\/[A-Za-z0-9._~:%-]+)?$/; function daysFromCivil(year: bigint, month: bigint, day: bigint): bigint { const adjustedYear = year - (month <= 2n ? 1n : 0n); @@ -197,6 +274,216 @@ function compareInstants(left: ParsedInstant, right: ParsedInstant): number { return 0; } +function scalarToBigInt(bytes: Uint8Array): bigint { + let value = 0n; + for (const byte of bytes) value = (value << 8n) | BigInt(byte); + return value; +} + +function utf8Length(value: string): number { + let length = 0; + for (let index = 0; index < value.length; index++) { + const codeUnit = value.charCodeAt(index); + if (codeUnit <= 0x7f) length++; + else if (codeUnit <= 0x7ff) length += 2; + else if ( + codeUnit >= 0xd800 && + codeUnit <= 0xdbff && + value.charCodeAt(index + 1) >= 0xdc00 && + value.charCodeAt(index + 1) <= 0xdfff + ) { + length += 4; + index++; + } else length += 3; + } + return length; +} + +function validateDid(value: unknown, field: string): asserts value is string { + if (typeof value !== "string" || !DID.test(value)) + throw new TypeError(`${field} must be a valid DID`); +} + +function validateCid(value: unknown): asserts value is string { + if (typeof value !== "string") throw new TypeError("label.cid must be a valid CID"); + try { + const cid = cidFromString(value); + if (cidToString(cid) !== value) throw new TypeError("label.cid must be a valid CID"); + } catch { + throw new TypeError("label.cid must be a valid CID"); + } +} + +function validateLabelValue(value: unknown): asserts value is string { + if ( + typeof value !== "string" || + value.length === 0 || + !PRINTABLE_LABEL_VALUE.test(value) || + utf8Length(value) > 128 + ) { + throw new TypeError( + "label.val must be a non-empty printable string of at most 128 UTF-8 bytes", + ); + } +} + +function validateLabelUri(value: unknown): asserts value is string { + if (typeof value !== "string" || value.length === 0) + throw new TypeError("label.uri must be a URI"); + if (DID.test(value)) return; + if (!ATPROTO_URI.test(value)) throw new TypeError("label.uri must be an at:// URI or DID"); +} + +function getField(value: object, field: string): unknown { + return Object.getOwnPropertyDescriptor(value, field)?.value; +} + +function validateLabelObject(value: unknown, signed: true): SignedLabel; +function validateLabelObject(value: unknown, signed: false): UnsignedLabel; +function validateLabelObject(value: unknown, signed: boolean): SignedLabel | UnsignedLabel { + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new TypeError("label must be an object"); + const fields = signed ? SIGNED_LABEL_FIELDS : LABEL_FIELDS; + for (const field of Object.keys(value)) { + if (!fields.has(field)) throw new TypeError(`label contains unsupported field: ${field}`); + } + if (getField(value, "ver") !== 1) throw new TypeError("label.ver must be 1"); + const src = getField(value, "src"); + const uri = getField(value, "uri"); + const val = getField(value, "val"); + const cts = getField(value, "cts"); + const cid = getField(value, "cid"); + const neg = getField(value, "neg"); + const exp = getField(value, "exp"); + validateDid(src, "label.src"); + validateLabelUri(uri); + validateLabelValue(val); + if (typeof cts !== "string") throw new TypeError("label.cts must be a valid RFC 3339 timestamp"); + parseInstant(cts, "label.cts"); + if (exp !== undefined) { + if (typeof exp !== "string") + throw new TypeError("label.exp must be a valid RFC 3339 timestamp"); + parseInstant(exp, "label.exp"); + } + if (cid !== undefined) validateCid(cid); + if (neg !== undefined && typeof neg !== "boolean") + throw new TypeError("label.neg must be a boolean"); + const sig = getField(value, "sig"); + + const canonical = { + ver: 1 as const, + src, + uri, + ...(cid === undefined ? {} : { cid }), + val, + ...(neg === true ? { neg: true } : {}), + cts, + ...(exp === undefined ? {} : { exp }), + }; + if (!signed) return canonical; + if (!(sig instanceof Uint8Array) || sig.length !== 64) + throw new TypeError("label.sig must be a 64-byte compact P-256 signature"); + return { ...canonical, sig }; +} + +function canonicalLabelBytes(label: UnsignedLabel): Uint8Array { + return encode(validateLabelObject(label, false)); +} + +function importPrivateScalar(value: string): Promise { + if (!BASE64URL.test(value)) + throw new TypeError("privateKey must be canonical unpadded base64url"); + let bytes: Uint8Array; + try { + bytes = fromBase64Url(value); + } catch { + throw new TypeError("privateKey must be canonical unpadded base64url"); + } + if (bytes.length !== 32 || toBase64Url(bytes) !== value) { + throw new TypeError("privateKey must be canonical unpadded base64url for exactly 32 bytes"); + } + const scalar = scalarToBigInt(bytes); + if (scalar === 0n || scalar >= P256_ORDER) + throw new TypeError("privateKey must be in the P-256 scalar range"); + return P256PrivateKey.importRaw(bytes); +} + +function normalizedMethodId(documentId: string, methodId: string): string { + if (methodId.startsWith("#")) return `${documentId}${methodId}`; + return methodId; +} + +async function resolveLabelPublicKey( + did: string, + resolveDid: LabelDidResolver, +): Promise { + const document = await resolveDid(did); + validateDid(document.id, "DID document id"); + if (document.id !== did) throw new TypeError("DID document id does not match label source"); + const methods = document.verificationMethod ?? []; + const ids = new Set(); + let signingMethod: DidVerificationMethod | undefined; + for (const method of methods) { + const id = normalizedMethodId(document.id, method.id); + if (ids.has(id)) throw new TypeError("DID document has duplicate verification method ids"); + ids.add(id); + if (id === `${did}#atproto_label`) signingMethod = { ...method, id }; + } + if (!signingMethod) throw new TypeError("DID document has no #atproto_label verification method"); + if (signingMethod.type !== "Multikey" || signingMethod.controller !== did) { + throw new TypeError("#atproto_label verification method must be a controller-owned Multikey"); + } + let parsed: ReturnType; + try { + parsed = parsePublicMultikey(signingMethod.publicKeyMultibase); + } catch { + throw new TypeError("#atproto_label verification method has an invalid Multikey"); + } + if ( + parsed.type !== "p256" || + parsed.publicKeyBytes.length !== 33 || + ![2, 3].includes(parsed.publicKeyBytes[0]!) + ) { + throw new TypeError("#atproto_label verification method must contain a compressed P-256 key"); + } + const key = await P256PublicKey.importRaw(parsed.publicKeyBytes); + if ((await key.exportPublicKey("multikey")) !== signingMethod.publicKeyMultibase) { + throw new TypeError("#atproto_label verification method uses a non-canonical P-256 Multikey"); + } + return key; +} + +/** Creates a signer whose scalar is bound to the issuer DID's exact `#atproto_label` key. */ +export async function createLabelSigner(input: CreateLabelSignerInput): Promise { + validateDid(input.issuerDid, "issuerDid"); + const key = await importPrivateScalar(input.privateKey); + const resolved = await resolveLabelPublicKey(input.issuerDid, input.resolveDid); + if ((await key.exportPublicKey("multikey")) !== (await resolved.exportPublicKey("multikey"))) { + throw new TypeError( + "privateKey does not match the issuer DID #atproto_label verification method", + ); + } + return { + issuerDid: input.issuerDid, + async sign(label) { + const unsigned = validateLabelObject({ ...label, src: input.issuerDid }, false); + return { ...unsigned, sig: await key.sign(canonicalLabelBytes(unsigned)) }; + }, + }; +} + +/** Verifies a signed label against the source DID's exact `#atproto_label` P-256 key. */ +export async function verifyLabel(input: LabelVerificationInput): Promise { + const label = validateLabelObject(input.label, true); + const key = await resolveLabelPublicKey(label.src, input.resolveDid); + const { sig, ...verified } = label; + if (!(await key.verify(sig, canonicalLabelBytes(verified)))) + throw new TypeError("label signature is invalid"); + const verifiedLabel: VerifiedModerationLabel = { ...verified, [verifiedModerationLabel]: true }; + Object.defineProperty(verifiedLabel, verifiedModerationLabel, { enumerable: false }); + return verifiedLabel; +} + function streamKey(label: ModerationLabel): string { return `${label.src}\u0000${label.uri}\u0000${label.val}`; } @@ -299,6 +586,15 @@ function orderedValues(labels: ModerationLabel[]): string[] { export function evaluateReleaseModeration( input: EvaluateReleaseModerationInput, ): ReleaseModeration { + for (const label of input.labels) { + if ( + typeof label !== "object" || + label === null || + Object.getOwnPropertyDescriptor(label, verifiedModerationLabel)?.value !== true + ) { + throw new TypeError("labels must be verified by verifyLabel before moderation evaluation"); + } + } const policies = new Map(); for (const policy of input.acceptedLabelers) { const existing = policies.get(policy.did); @@ -393,3 +689,13 @@ export function evaluateReleaseModeration( ), }; } + +/** Verifies labels before evaluating their moderation effect for one release. */ +export async function verifyAndEvaluateReleaseModeration( + input: VerifyAndEvaluateReleaseModerationInput, +): Promise { + const labels = await Promise.all( + input.labels.map((label) => verifyLabel({ label, resolveDid: input.resolveDid })), + ); + return evaluateReleaseModeration({ ...input, labels }); +} diff --git a/packages/registry-moderation/tests/fixtures/label-crypto.json b/packages/registry-moderation/tests/fixtures/label-crypto.json new file mode 100644 index 0000000000..7d162b7b2a --- /dev/null +++ b/packages/registry-moderation/tests/fixtures/label-crypto.json @@ -0,0 +1,15 @@ +{ + "privateKey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE", + "multikey": "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ", + "otherMultikey": "zDnaer52RTwabaBeMkKYYwZmEFqPabLW78cRK62iovMUQhFif", + "nonP256Multikey": "zQ3shVc2UkAfJCdc1TR8E66J85h48P43r93q8jGPkPpjF9Ef9", + "label": { + "ver": 1, + "src": "did:example:labeller", + "uri": "at://did:example:publisher/com.example.release/1", + "cid": "bafkreif4oaymum54i5qefbwoblrt5zasfjhpyhyvacpseqtehi3queew5m", + "val": "assessment-passed", + "neg": false, + "cts": "2026-07-10T12:00:00.000Z" + } +} diff --git a/packages/registry-moderation/tests/label-crypto.test.ts b/packages/registry-moderation/tests/label-crypto.test.ts new file mode 100644 index 0000000000..03e354895d --- /dev/null +++ b/packages/registry-moderation/tests/label-crypto.test.ts @@ -0,0 +1,200 @@ +import { readFileSync } from "node:fs"; + +import { encode } from "@atcute/cbor"; +import { verifySignature } from "@atproto/crypto"; +import { describe, expect, it } from "vitest"; + +import { + createLabelSigner, + verifyAndEvaluateReleaseModeration, + verifyLabel, + type LabelDidDocument, + type SignedLabel, + type UnsignedLabel, +} from "../src/index.js"; + +const fixture = JSON.parse( + readFileSync(new URL("./fixtures/label-crypto.json", import.meta.url), "utf8"), +) as { + privateKey: string; + multikey: string; + otherMultikey: string; + nonP256Multikey: string; + label: UnsignedLabel; +}; + +function document( + options: Partial & { methods?: LabelDidDocument["verificationMethod"] } = {}, +): LabelDidDocument { + return { + id: fixture.label.src, + verificationMethod: options.methods ?? [ + { + id: "#atproto_label", + type: "Multikey", + controller: fixture.label.src, + publicKeyMultibase: fixture.multikey, + }, + ], + ...options, + }; +} + +function signingLabel(): Omit { + const { src: _src, ...label } = fixture.label; + return label; +} + +async function signer(didDocument = document()) { + return createLabelSigner({ + issuerDid: fixture.label.src, + privateKey: fixture.privateKey, + resolveDid: async () => didDocument, + }); +} + +async function signedLabel(): Promise { + return (await signer()).sign(signingLabel()); +} + +async function verify(label: SignedLabel, didDocument = document()) { + return verifyLabel({ label, resolveDid: async () => didDocument }); +} + +function unsignedBytes(label: UnsignedLabel): Uint8Array { + const { neg: _neg, ...withoutNeg } = label; + return encode(withoutNeg); +} + +function asHighS(signature: Uint8Array): Uint8Array { + const order = BigInt("0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551"); + let s = 0n; + for (const byte of signature.slice(32)) s = (s << 8n) | BigInt(byte); + const high = order - s; + const result = signature.slice(); + for (let index = 63; index >= 32; index--) { + result[index] = Number((high >> BigInt((63 - index) * 8)) & 0xffn); + } + return result; +} + +describe("ATProto label v1 crypto", () => { + it("binds a signer to #atproto_label and signs canonical label CBOR", async () => { + const signed = await signedLabel(); + const verified = await verify(signed); + + const { neg: _neg, ...expected } = fixture.label; + expect(verified).toEqual(expected); + expect("neg" in verified).toBe(false); + expect( + await verifySignature( + `did:key:${fixture.multikey}`, + unsignedBytes(fixture.label), + signed.sig, + ), + ).toBe(true); + }); + + it("passes canonical bytes directly rather than a digest", async () => { + const signed = await signedLabel(); + const bytes = unsignedBytes(fixture.label); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); + + expect(await verifySignature(`did:key:${fixture.multikey}`, bytes, signed.sig)).toBe(true); + expect(await verifySignature(`did:key:${fixture.multikey}`, digest, signed.sig)).toBe(false); + }); + + it("rejects unsupported fields, invalid dates, and values exceeding 128 UTF-8 bytes", async () => { + const boundSigner = await signer(); + await expect( + boundSigner.sign({ ...signingLabel(), $type: "com.atproto.label.defs#label" }), + ).rejects.toThrow("unsupported field"); + await expect( + boundSigner.sign({ ...signingLabel(), cts: "2026-02-30T12:00:00Z" }), + ).rejects.toThrow("valid RFC 3339"); + await expect(boundSigner.sign({ ...signingLabel(), val: "😀".repeat(33) })).rejects.toThrow( + "128 UTF-8 bytes", + ); + }); + + it("rejects non-canonical, zero, and out-of-range private scalar forms", async () => { + for (const privateKey of [ + fixture.privateKey + "=", + "AA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ]) { + await expect( + createLabelSigner({ + issuerDid: fixture.label.src, + privateKey, + resolveDid: async () => document(), + }), + ).rejects.toThrow("privateKey"); + } + }); + + it("requires the exact normalized #atproto_label method without fallback", async () => { + const signed = await signedLabel(); + await expect( + verify( + signed, + document({ + methods: [ + { ...document().verificationMethod![0]!, id: `${fixture.label.src}#atproto_label` }, + ], + }), + ), + ).resolves.toEqual(expect.objectContaining({ src: fixture.label.src })); + await expect( + verify( + signed, + document({ methods: [{ ...document().verificationMethod![0]!, id: "#atproto" }] }), + ), + ).rejects.toThrow("no #atproto_label"); + }); + + it("rejects malformed configured signer DID key material", async () => { + const baseMethod = document().verificationMethod![0]!; + for (const didDocument of [ + document({ methods: [] }), + document({ methods: [{ ...baseMethod, id: "#atproto" }] }), + document({ + methods: [baseMethod, { ...baseMethod, id: `${fixture.label.src}#atproto_label` }], + }), + document({ methods: [{ ...baseMethod, controller: "did:example:other" }] }), + document({ methods: [{ ...baseMethod, publicKeyMultibase: fixture.nonP256Multikey }] }), + document({ methods: [{ ...baseMethod, publicKeyMultibase: fixture.otherMultikey }] }), + ]) { + await expect(signer(didDocument)).rejects.toThrow(TypeError); + } + }); + + it("rejects malleable, DER, and altered signatures or labels", async () => { + const signed = await signedLabel(); + await expect(verify({ ...signed, sig: asHighS(signed.sig) })).rejects.toThrow( + "signature is invalid", + ); + await expect(verify({ ...signed, sig: new Uint8Array(70) })).rejects.toThrow("64-byte compact"); + await expect(verify({ ...signed, val: "assessment-pending" })).rejects.toThrow( + "signature is invalid", + ); + }); + + it("verifies labels before evaluating their moderation effect", async () => { + const result = await verifyAndEvaluateReleaseModeration({ + acceptedLabelers: [{ did: fixture.label.src, redact: false }], + context: { + publisherDid: "did:example:publisher", + package: { + uri: "at://did:example:publisher/com.example.package/profile", + cid: "package-cid", + }, + release: { uri: fixture.label.uri, cid: fixture.label.cid! }, + }, + evaluatedAt: "2026-07-10T13:00:00.000Z", + labels: [await signedLabel()], + resolveDid: async () => document(), + }); + expect(result.eligibility).toBe("eligible"); + }); +}); diff --git a/packages/registry-moderation/tests/moderation.test.ts b/packages/registry-moderation/tests/moderation.test.ts index 7474f0ea9e..482089d7ac 100644 --- a/packages/registry-moderation/tests/moderation.test.ts +++ b/packages/registry-moderation/tests/moderation.test.ts @@ -1,8 +1,15 @@ import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; -import { evaluateReleaseModeration, type ModerationLabel } from "../src/index.js"; +import { + createLabelSigner, + evaluateReleaseModeration, + verifyLabel, + type LabelDidDocument, + type ModerationLabel, + type VerifiedModerationLabel, +} from "../src/index.js"; const source = "did:example:trusted"; const otherSource = "did:example:other"; @@ -25,16 +32,69 @@ function label( }; } +let verifiedBrand: symbol; + +beforeAll(async () => { + const resolveDid = async (): Promise => ({ + id: source, + verificationMethod: [ + { + id: "#atproto_label", + type: "Multikey", + controller: source, + publicKeyMultibase: "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ", + }, + ], + }); + const signer = await createLabelSigner({ + issuerDid: source, + privateKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE", + resolveDid, + }); + const verifiedLabel = await verifyLabel({ + label: await signer.sign({ + ver: 1, + uri: context.release.uri, + cid: "bafkreif4oaymum54i5qefbwoblrt5zasfjhpyhyvacpseqtehi3queew5m", + val: "assessment-passed", + cts: "2026-07-10T12:00:00.000Z", + }), + resolveDid, + }); + const brand = Reflect.ownKeys(verifiedLabel).find( + (key): key is symbol => typeof key === "symbol", + ); + if (!brand) throw new Error("verified label is missing its runtime brand"); + verifiedBrand = brand; +}); + +// Evaluator policy tests use a brand recovered from a real verified label. +function verified(rawLabel: ModerationLabel): VerifiedModerationLabel { + Object.defineProperty(rawLabel, verifiedBrand, { value: true }); + return rawLabel as unknown as VerifiedModerationLabel; +} + function evaluate(labels: ModerationLabel[], redact = false) { return evaluateReleaseModeration({ acceptedLabelers: [{ did: source, redact }], context, evaluatedAt: "2026-07-10T13:00:00.000Z", - labels, + labels: labels.map(verified), }); } describe("release moderation", () => { + it("rejects a raw label forged with a TypeScript cast", () => { + expect(() => + evaluateReleaseModeration({ + acceptedLabelers: [{ did: source, redact: false }], + context, + evaluatedAt: "2026-07-10T13:00:00.000Z", + labels: [label({ val: "assessment-passed" }) as unknown as VerifiedModerationLabel], + }), + ).toThrow("must be verified"); + }); + it("accepts an exact-CID assessment pass", () => { expect(evaluate([label({ val: "assessment-passed" })])).toMatchObject({ eligibility: "eligible", @@ -173,7 +233,9 @@ describe("release moderation", () => { acceptedLabelers, context, evaluatedAt: "2026-07-10T13:00:00.000Z", - labels: [label({ val: "assessment-passed" }), label({ val: value, src: otherSource })], + labels: [label({ val: "assessment-passed" }), label({ val: value, src: otherSource })].map( + verified, + ), }); expect(result.eligibility).toBe( value === "assessment-error" @@ -233,7 +295,7 @@ describe("release moderation", () => { ], context, evaluatedAt: "2026-07-10T13:00:00.000Z", - labels: [label({ val: "!takedown", cid: undefined })], + labels: [label({ val: "!takedown", cid: undefined })].map(verified), }); expect(result).toMatchObject({ eligibility: "blocked", redacted: true }); }); @@ -334,21 +396,23 @@ describe("ratified moderation corpus", () => { release: { uri: subject.releaseUri!, cid: subject.releaseCid! }, }, evaluatedAt: corpus.evaluatedAt, - labels: labels.map((fixtureLabel) => ({ - ver: 1, - src: corpus.sources[fixtureLabel.src]!, - uri: - fixtureLabel.subject === "publisher" - ? subject.publisherDid! - : fixtureLabel.subject === "package" - ? subject.packageUri! - : subject.releaseUri!, - cid: fixtureLabel.cid, - val: fixtureLabel.val, - cts: fixtureLabel.cts, - neg: fixtureLabel.neg, - exp: fixtureLabel.exp, - })), + labels: labels.map((fixtureLabel) => + verified({ + ver: 1, + src: corpus.sources[fixtureLabel.src]!, + uri: + fixtureLabel.subject === "publisher" + ? subject.publisherDid! + : fixtureLabel.subject === "package" + ? subject.packageUri! + : subject.releaseUri!, + cid: fixtureLabel.cid, + val: fixtureLabel.val, + cts: fixtureLabel.cts, + neg: fixtureLabel.neg, + exp: fixtureLabel.exp, + }), + ), }); expect(result).toMatchObject({ eligibility: fixtureCase.expected.eligibility, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3187c6ce59..40c6bf7bf9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2233,10 +2233,26 @@ 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-moderation: + dependencies: + '@atcute/cbor': + specifier: 'catalog:' + version: 2.3.3(@atcute/cid@2.4.1) + '@atcute/cid': + specifier: 'catalog:' + version: 2.4.1 + '@atcute/crypto': + specifier: 'catalog:' + version: 2.4.1 + '@atcute/multibase': + specifier: 'catalog:' + version: 1.2.0 devDependencies: '@arethetypeswrong/cli': specifier: 'catalog:' version: 0.18.2 + '@atproto/crypto': + specifier: 'catalog:' + version: 0.4.5 '@types/node': specifier: 'catalog:' version: 24.10.13 @@ -2924,9 +2940,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: @@ -12659,7 +12672,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 @@ -12671,12 +12684,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 @@ -12825,7 +12832,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 @@ -12920,7 +12927,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 4510fb4994d986c747d3b11920f17307d1bcaf17 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 16:42:42 +0100 Subject: [PATCH 012/137] feat(registry): add labeller Lexicons (#1923) --- .changeset/lucky-labellers-contract.md | 5 + .../emdashcms/experimental/labeller/defs.json | 330 +++++++++ .../experimental/labeller/getAssessment.json | 27 + .../labeller/getCurrentAssessment.json | 32 + .../experimental/labeller/getPolicy.json | 15 + .../labeller/listAssessments.json | 48 ++ .../registry-lexicons/src/generated/index.ts | 5 + .../emdashcms/experimental/labeller/defs.ts | 685 ++++++++++++++++++ .../experimental/labeller/getAssessment.ts | 40 + .../labeller/getCurrentAssessment.ts | 36 + .../experimental/labeller/getPolicy.ts | 32 + .../experimental/labeller/listAssessments.ts | 84 +++ packages/registry-lexicons/src/index.ts | 15 + .../registry-lexicons/tests/labeller.test.ts | 154 ++++ .../registry-lexicons/tests/types.test.ts | 5 + 15 files changed, 1513 insertions(+) create mode 100644 .changeset/lucky-labellers-contract.md create mode 100644 packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/defs.json create mode 100644 packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/getAssessment.json create mode 100644 packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/getCurrentAssessment.json create mode 100644 packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/getPolicy.json create mode 100644 packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/listAssessments.json create mode 100644 packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/defs.ts create mode 100644 packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/getAssessment.ts create mode 100644 packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/getCurrentAssessment.ts create mode 100644 packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/getPolicy.ts create mode 100644 packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/listAssessments.ts create mode 100644 packages/registry-lexicons/tests/labeller.test.ts diff --git a/.changeset/lucky-labellers-contract.md b/.changeset/lucky-labellers-contract.md new file mode 100644 index 0000000000..b680ee9d2b --- /dev/null +++ b/.changeset/lucky-labellers-contract.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-lexicons": patch +--- + +Adds experimental labeller assessment and policy query contracts. diff --git a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/defs.json b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/defs.json new file mode 100644 index 0000000000..ee6925e5d6 --- /dev/null +++ b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/defs.json @@ -0,0 +1,330 @@ +{ + "lexicon": 1, + "id": "com.emdashcms.experimental.labeller.defs", + "description": "Shared public views for the experimental EmDash registry labeller. These views explain standard ATProto labels; label distribution remains com.atproto.label.*. EXPERIMENTAL.", + "defs": { + "assessmentSubject": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" } + } + }, + "manualActionSubject": { + "type": "object", + "required": ["uri"], + "properties": { + "uri": { "type": "string", "format": "uri" }, + "cid": { "type": "string", "format": "cid" } + } + }, + "labelSummary": { + "type": "object", + "required": ["val", "active", "issuedAt"], + "properties": { + "val": { "type": "string", "minLength": 1, "maxLength": 128 }, + "active": { "type": "boolean" }, + "issuedAt": { "type": "string", "format": "datetime" }, + "expiresAt": { "type": "string", "format": "datetime" } + } + }, + "coverage": { + "type": "object", + "required": ["code", "metadata", "images", "dependencies"], + "properties": { + "code": { "type": "string", "knownValues": ["complete", "partial", "unavailable"] }, + "metadata": { "type": "string", "knownValues": ["complete", "partial", "unavailable"] }, + "images": { + "type": "string", + "knownValues": ["complete", "not-present", "partial", "unavailable"] + }, + "dependencies": { "type": "string", "knownValues": ["complete", "partial", "unavailable"] } + } + }, + "artifact": { + "type": "object", + "required": ["checksum"], + "properties": { + "id": { "type": "string", "maxLength": 256 }, + "checksum": { "type": "string", "minLength": 1, "maxLength": 256 } + } + }, + "model": { + "type": "object", + "required": ["provider", "modelId", "promptVersion"], + "properties": { + "provider": { "type": "string", "knownValues": ["workers-ai"] }, + "modelId": { "type": "string", "minLength": 1, "maxLength": 256 }, + "promptVersion": { "type": "string", "minLength": 1, "maxLength": 128 } + } + }, + "scannerVersion": { + "type": "object", + "required": ["scanner", "version"], + "properties": { + "scanner": { "type": "string", "minLength": 1, "maxLength": 128 }, + "version": { "type": "string", "minLength": 1, "maxLength": 128 } + } + }, + "publicAssessment": { + "type": "object", + "required": [ + "id", + "src", + "subject", + "state", + "summary", + "coverage", + "labels", + "policyVersion", + "assessmentSchemaVersion", + "scannerVersions", + "createdAt", + "reconsiderationUrl" + ], + "properties": { + "id": { "type": "string", "minLength": 1, "maxLength": 64 }, + "src": { "type": "string", "format": "did" }, + "subject": { "type": "ref", "ref": "#assessmentSubject" }, + "artifact": { "type": "ref", "ref": "#artifact" }, + "state": { + "type": "string", + "knownValues": ["pending", "passed", "warned", "blocked", "error", "superseded"] + }, + "summary": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "coverage": { "type": "ref", "ref": "#coverage" }, + "labels": { + "type": "array", + "maxLength": 64, + "items": { "type": "ref", "ref": "#labelSummary" } + }, + "policyVersion": { "type": "string", "minLength": 1, "maxLength": 128 }, + "assessmentSchemaVersion": { "type": "integer", "minimum": 1 }, + "model": { "type": "ref", "ref": "#model" }, + "scannerVersions": { + "type": "array", + "maxLength": 64, + "items": { "type": "ref", "ref": "#scannerVersion" } + }, + "createdAt": { "type": "string", "format": "datetime" }, + "completedAt": { "type": "string", "format": "datetime" }, + "supersedesAssessmentId": { "type": "string", "minLength": 1, "maxLength": 64 }, + "reconsiderationUrl": { "type": "string", "format": "uri", "maxLength": 2048 } + } + }, + "publicManualAction": { + "type": "object", + "required": ["id", "src", "subject", "type", "summary", "labels", "createdAt"], + "properties": { + "id": { "type": "string", "minLength": 1, "maxLength": 64 }, + "src": { "type": "string", "format": "did" }, + "subject": { "type": "ref", "ref": "#manualActionSubject" }, + "type": { + "type": "string", + "knownValues": ["override", "label-issue", "label-retraction", "emergency-takedown"] + }, + "summary": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "labels": { + "type": "array", + "maxLength": 64, + "items": { "type": "ref", "ref": "#labelSummary" } + }, + "createdAt": { "type": "string", "format": "datetime" } + } + }, + "publisherSubject": { + "type": "object", + "required": ["kind"], + "properties": { "kind": { "type": "string", "knownValues": ["did"] } } + }, + "supportedSubjects": { + "type": "object", + "required": ["publisher", "packageCollections", "releaseCollections"], + "properties": { + "publisher": { "type": "ref", "ref": "#publisherSubject" }, + "packageCollections": { + "type": "array", + "maxLength": 32, + "items": { "type": "string", "format": "nsid" } + }, + "releaseCollections": { + "type": "array", + "maxLength": 32, + "items": { "type": "string", "format": "nsid" } + } + } + }, + "policyLabel": { + "type": "object", + "required": ["value", "category", "officialEffect", "subjectRules", "locales"], + "properties": { + "value": { "type": "string", "minLength": 1, "maxLength": 128 }, + "category": { + "type": "string", + "knownValues": ["eligibility", "automated-block", "warning", "manual-system"] + }, + "officialEffect": { + "type": "string", + "knownValues": ["pass", "pending", "error", "block", "warn", "redact"] + }, + "subjectRules": { + "type": "array", + "maxLength": 8, + "items": { "type": "ref", "ref": "#subjectRule" } + }, + "locales": { + "type": "array", + "maxLength": 64, + "items": { "type": "ref", "ref": "#policyLocale" } + } + } + }, + "subjectRule": { + "type": "object", + "required": ["subject", "cidRule", "issuanceModes"], + "properties": { + "subject": { "type": "string", "knownValues": ["release", "package", "publisher"] }, + "cidRule": { "type": "string", "knownValues": ["required", "optional", "forbidden"] }, + "issuanceModes": { + "type": "array", + "minLength": 1, + "maxLength": 3, + "items": { "type": "string", "knownValues": ["automated", "reviewer", "admin"] } + } + } + }, + "policyLocale": { + "type": "object", + "required": ["lang", "name", "description"], + "properties": { + "lang": { "type": "string", "format": "language" }, + "name": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "description": { "type": "string", "minLength": 1, "maxLength": 4096 } + } + }, + "labelerPolicy": { + "type": "object", + "required": [ + "schemaVersion", + "policyVersion", + "effectiveAt", + "labellerDid", + "assessmentSchemaVersion", + "supportedSubjects", + "reasonCodes", + "labels", + "overrideRule", + "precedence", + "publicApi", + "contact", + "transparency" + ], + "properties": { + "schemaVersion": { "type": "integer", "minimum": 1, "maximum": 1 }, + "policyVersion": { "type": "string", "minLength": 1, "maxLength": 128 }, + "effectiveAt": { "type": "string", "format": "datetime" }, + "labellerDid": { "type": "string", "format": "did" }, + "assessmentSchemaVersion": { "type": "integer", "minimum": 1 }, + "supportedSubjects": { "type": "ref", "ref": "#supportedSubjects" }, + "reasonCodes": { + "type": "array", + "maxLength": 256, + "items": { "type": "ref", "ref": "#reasonCode" } + }, + "labels": { + "type": "array", + "maxLength": 256, + "items": { "type": "ref", "ref": "#policyLabel" } + }, + "overrideRule": { "type": "ref", "ref": "#overrideRule" }, + "precedence": { + "type": "array", + "maxLength": 32, + "items": { "type": "string", "minLength": 1, "maxLength": 128 } + }, + "publicApi": { "type": "ref", "ref": "#publicApi" }, + "contact": { "type": "ref", "ref": "#contact" }, + "transparency": { "type": "ref", "ref": "#transparency" } + } + }, + "reasonCode": { + "type": "object", + "required": ["code", "description"], + "properties": { + "code": { "type": "string", "minLength": 1, "maxLength": 128 }, + "description": { "type": "string", "minLength": 1, "maxLength": 4096 } + } + }, + "overrideRule": { + "type": "object", + "required": [ + "subject", + "cidRule", + "reviewerLabels", + "requireSameSource", + "requireAtomicIssuance" + ], + "properties": { + "subject": { "type": "string", "knownValues": ["release"] }, + "cidRule": { "type": "string", "knownValues": ["required"] }, + "reviewerLabels": { + "type": "array", + "minLength": 1, + "maxLength": 8, + "items": { "type": "string", "minLength": 1, "maxLength": 128 } + }, + "requireSameSource": { "type": "boolean" }, + "requireAtomicIssuance": { "type": "boolean" } + } + }, + "publicApi": { + "type": "object", + "required": [ + "baseUrl", + "policyUrl", + "getAssessmentNsid", + "getCurrentAssessmentNsid", + "listAssessmentsNsid", + "getPolicyNsid" + ], + "properties": { + "baseUrl": { "type": "string", "format": "uri", "maxLength": 2048 }, + "policyUrl": { "type": "string", "format": "uri", "maxLength": 2048 }, + "getAssessmentNsid": { "type": "string", "format": "nsid" }, + "getCurrentAssessmentNsid": { "type": "string", "format": "nsid" }, + "listAssessmentsNsid": { "type": "string", "format": "nsid" }, + "getPolicyNsid": { "type": "string", "format": "nsid" } + } + }, + "contact": { + "type": "object", + "required": ["reconsiderationUrl", "reconsiderationEmail"], + "properties": { + "reconsiderationUrl": { "type": "string", "format": "uri", "maxLength": 2048 }, + "reconsiderationEmail": { "type": "string", "minLength": 1, "maxLength": 256 } + } + }, + "transparency": { + "type": "object", + "required": ["modelOutputIsAdvisoryEvidence"], + "properties": { "modelOutputIsAdvisoryEvidence": { "type": "boolean" } } + }, + "currentAssessmentView": { + "type": "object", + "required": ["src", "subject", "activeLabels"], + "properties": { + "src": { "type": "string", "format": "did" }, + "subject": { "type": "ref", "ref": "#assessmentSubject" }, + "current": { "type": "ref", "ref": "#publicAssessment" }, + "pending": { "type": "ref", "ref": "#publicAssessment" }, + "activeLabels": { + "type": "array", + "maxLength": 64, + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } + }, + "override": { "type": "ref", "ref": "#publicManualAction" } + } + } + } +} diff --git a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/getAssessment.json b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/getAssessment.json new file mode 100644 index 0000000000..8a48f792f4 --- /dev/null +++ b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/getAssessment.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "com.emdashcms.experimental.labeller.getAssessment", + "description": "Fetch one immutable public assessment by ID. EXPERIMENTAL.", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "required": ["id"], + "properties": { "id": { "type": "string", "minLength": 1, "maxLength": 64 } } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "com.emdashcms.experimental.labeller.defs#publicAssessment" + } + }, + "errors": [ + { "name": "InvalidRequest" }, + { "name": "NotFound" }, + { "name": "RateLimitExceeded" } + ] + } + } +} diff --git a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/getCurrentAssessment.json b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/getCurrentAssessment.json new file mode 100644 index 0000000000..17c51de8bb --- /dev/null +++ b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/getCurrentAssessment.json @@ -0,0 +1,32 @@ +{ + "lexicon": 1, + "id": "com.emdashcms.experimental.labeller.getCurrentAssessment", + "description": "Fetch effective assessment state for an exact release URI and CID. EXPERIMENTAL.", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "required": ["uri", "cid"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "src": { "type": "string", "format": "did" } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "com.emdashcms.experimental.labeller.defs#currentAssessmentView" + } + }, + "errors": [ + { "name": "InvalidRequest" }, + { "name": "NotFound" }, + { "name": "UnsupportedSource" }, + { "name": "RateLimitExceeded" } + ] + } + } +} diff --git a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/getPolicy.json b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/getPolicy.json new file mode 100644 index 0000000000..6adcebe84e --- /dev/null +++ b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/getPolicy.json @@ -0,0 +1,15 @@ +{ + "lexicon": 1, + "id": "com.emdashcms.experimental.labeller.getPolicy", + "description": "Fetch the current machine-readable labeller policy document. EXPERIMENTAL.", + "defs": { + "main": { + "type": "query", + "output": { + "encoding": "application/json", + "schema": { "type": "ref", "ref": "com.emdashcms.experimental.labeller.defs#labelerPolicy" } + }, + "errors": [{ "name": "RateLimitExceeded" }] + } + } +} diff --git a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/listAssessments.json b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/listAssessments.json new file mode 100644 index 0000000000..1a8b0b3a0b --- /dev/null +++ b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/labeller/listAssessments.json @@ -0,0 +1,48 @@ +{ + "lexicon": 1, + "id": "com.emdashcms.experimental.labeller.listAssessments", + "description": "Page through public assessments using bounded exact-subject and state filters. EXPERIMENTAL.", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "properties": { + "src": { "type": "string", "format": "did" }, + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "state": { + "type": "string", + "knownValues": ["pending", "passed", "warned", "blocked", "error", "superseded"] + }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }, + "cursor": { "type": "string", "maxLength": 1024 } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["assessments"], + "properties": { + "assessments": { + "type": "array", + "maxLength": 100, + "items": { + "type": "ref", + "ref": "com.emdashcms.experimental.labeller.defs#publicAssessment" + } + }, + "cursor": { "type": "string", "maxLength": 1024 } + } + } + }, + "errors": [ + { "name": "InvalidRequest" }, + { "name": "InvalidCursor" }, + { "name": "UnsupportedSource" }, + { "name": "RateLimitExceeded" } + ] + } + } +} diff --git a/packages/registry-lexicons/src/generated/index.ts b/packages/registry-lexicons/src/generated/index.ts index 7898b48245..78b6ba332c 100644 --- a/packages/registry-lexicons/src/generated/index.ts +++ b/packages/registry-lexicons/src/generated/index.ts @@ -4,6 +4,11 @@ export * as ComEmdashcmsExperimentalAggregatorGetPackage from "./types/com/emdas export * as ComEmdashcmsExperimentalAggregatorListReleases from "./types/com/emdashcms/experimental/aggregator/listReleases.js"; export * as ComEmdashcmsExperimentalAggregatorResolvePackage from "./types/com/emdashcms/experimental/aggregator/resolvePackage.js"; export * as ComEmdashcmsExperimentalAggregatorSearchPackages from "./types/com/emdashcms/experimental/aggregator/searchPackages.js"; +export * as ComEmdashcmsExperimentalLabellerDefs from "./types/com/emdashcms/experimental/labeller/defs.js"; +export * as ComEmdashcmsExperimentalLabellerGetAssessment from "./types/com/emdashcms/experimental/labeller/getAssessment.js"; +export * as ComEmdashcmsExperimentalLabellerGetCurrentAssessment from "./types/com/emdashcms/experimental/labeller/getCurrentAssessment.js"; +export * as ComEmdashcmsExperimentalLabellerGetPolicy from "./types/com/emdashcms/experimental/labeller/getPolicy.js"; +export * as ComEmdashcmsExperimentalLabellerListAssessments from "./types/com/emdashcms/experimental/labeller/listAssessments.js"; export * as ComEmdashcmsExperimentalPackageProfile from "./types/com/emdashcms/experimental/package/profile.js"; export * as ComEmdashcmsExperimentalPackageRelease from "./types/com/emdashcms/experimental/package/release.js"; export * as ComEmdashcmsExperimentalPackageReleaseExtension from "./types/com/emdashcms/experimental/package/releaseExtension.js"; diff --git a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/defs.ts b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/defs.ts new file mode 100644 index 0000000000..5378922b0e --- /dev/null +++ b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/defs.ts @@ -0,0 +1,685 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import * as ComAtprotoLabelDefs from "@atcute/atproto/types/label/defs"; + +const _artifactSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.labeller.defs#artifact", + ), + ), + /** + * @minLength 1 + * @maxLength 256 + */ + checksum: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 256), + ]), + /** + * @maxLength 256 + */ + id: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 256), + ]), + ), +}); +const _assessmentSubjectSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.labeller.defs#assessmentSubject", + ), + ), + cid: /*#__PURE__*/ v.cidString(), + uri: /*#__PURE__*/ v.resourceUriString(), +}); +const _contactSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("com.emdashcms.experimental.labeller.defs#contact"), + ), + /** + * @minLength 1 + * @maxLength 256 + */ + reconsiderationEmail: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 256), + ]), + /** + * @maxLength 2048 + */ + reconsiderationUrl: /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.genericUriString(), + [/*#__PURE__*/ v.stringLength(0, 2048)], + ), +}); +const _coverageSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.labeller.defs#coverage", + ), + ), + code: /*#__PURE__*/ v.string< + "complete" | "partial" | "unavailable" | (string & {}) + >(), + dependencies: /*#__PURE__*/ v.string< + "complete" | "partial" | "unavailable" | (string & {}) + >(), + images: /*#__PURE__*/ v.string< + "complete" | "not-present" | "partial" | "unavailable" | (string & {}) + >(), + metadata: /*#__PURE__*/ v.string< + "complete" | "partial" | "unavailable" | (string & {}) + >(), +}); +const _currentAssessmentViewSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.labeller.defs#currentAssessmentView", + ), + ), + /** + * @maxLength 64 + */ + get activeLabels() { + return /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(ComAtprotoLabelDefs.labelSchema), + [/*#__PURE__*/ v.arrayLength(0, 64)], + ); + }, + get current() { + return /*#__PURE__*/ v.optional(publicAssessmentSchema); + }, + get override() { + return /*#__PURE__*/ v.optional(publicManualActionSchema); + }, + get pending() { + return /*#__PURE__*/ v.optional(publicAssessmentSchema); + }, + src: /*#__PURE__*/ v.didString(), + get subject() { + return assessmentSubjectSchema; + }, +}); +const _labelSummarySchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.labeller.defs#labelSummary", + ), + ), + active: /*#__PURE__*/ v.boolean(), + expiresAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), + issuedAt: /*#__PURE__*/ v.datetimeString(), + /** + * @minLength 1 + * @maxLength 128 + */ + val: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 128), + ]), +}); +const _labelerPolicySchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.labeller.defs#labelerPolicy", + ), + ), + /** + * @minimum 1 + */ + assessmentSchemaVersion: /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.integer(), + [/*#__PURE__*/ v.integerRange(1)], + ), + get contact() { + return contactSchema; + }, + effectiveAt: /*#__PURE__*/ v.datetimeString(), + labellerDid: /*#__PURE__*/ v.didString(), + /** + * @maxLength 256 + */ + get labels() { + return /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.array(policyLabelSchema), [ + /*#__PURE__*/ v.arrayLength(0, 256), + ]); + }, + get overrideRule() { + return overrideRuleSchema; + }, + /** + * @minLength 1 + * @maxLength 128 + */ + policyVersion: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 128), + ]), + /** + * @maxLength 32 + */ + precedence: /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 128), + ]), + ), + [/*#__PURE__*/ v.arrayLength(0, 32)], + ), + get publicApi() { + return publicApiSchema; + }, + /** + * @maxLength 256 + */ + get reasonCodes() { + return /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.array(reasonCodeSchema), [ + /*#__PURE__*/ v.arrayLength(0, 256), + ]); + }, + /** + * @minimum 1 + * @maximum 1 + */ + schemaVersion: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ + /*#__PURE__*/ v.integerRange(1, 1), + ]), + get supportedSubjects() { + return supportedSubjectsSchema; + }, + get transparency() { + return transparencySchema; + }, +}); +const _manualActionSubjectSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.labeller.defs#manualActionSubject", + ), + ), + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), + uri: /*#__PURE__*/ v.genericUriString(), +}); +const _modelSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("com.emdashcms.experimental.labeller.defs#model"), + ), + /** + * @minLength 1 + * @maxLength 256 + */ + modelId: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 256), + ]), + /** + * @minLength 1 + * @maxLength 128 + */ + promptVersion: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 128), + ]), + provider: /*#__PURE__*/ v.string<"workers-ai" | (string & {})>(), +}); +const _overrideRuleSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.labeller.defs#overrideRule", + ), + ), + cidRule: /*#__PURE__*/ v.string<"required" | (string & {})>(), + requireAtomicIssuance: /*#__PURE__*/ v.boolean(), + requireSameSource: /*#__PURE__*/ v.boolean(), + /** + * @minLength 1 + * @maxLength 8 + */ + reviewerLabels: /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 128), + ]), + ), + [/*#__PURE__*/ v.arrayLength(1, 8)], + ), + subject: /*#__PURE__*/ v.string<"release" | (string & {})>(), +}); +const _policyLabelSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.labeller.defs#policyLabel", + ), + ), + category: /*#__PURE__*/ v.string< + | "automated-block" + | "eligibility" + | "manual-system" + | "warning" + | (string & {}) + >(), + /** + * @maxLength 64 + */ + get locales() { + return /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(policyLocaleSchema), + [/*#__PURE__*/ v.arrayLength(0, 64)], + ); + }, + officialEffect: /*#__PURE__*/ v.string< + "block" | "error" | "pass" | "pending" | "redact" | "warn" | (string & {}) + >(), + /** + * @maxLength 8 + */ + get subjectRules() { + return /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.array(subjectRuleSchema), [ + /*#__PURE__*/ v.arrayLength(0, 8), + ]); + }, + /** + * @minLength 1 + * @maxLength 128 + */ + value: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 128), + ]), +}); +const _policyLocaleSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.labeller.defs#policyLocale", + ), + ), + /** + * @minLength 1 + * @maxLength 4096 + */ + description: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 4096), + ]), + lang: /*#__PURE__*/ v.languageCodeString(), + /** + * @minLength 1 + * @maxLength 1024 + */ + name: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 1024), + ]), +}); +const _publicApiSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.labeller.defs#publicApi", + ), + ), + /** + * @maxLength 2048 + */ + baseUrl: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.genericUriString(), [ + /*#__PURE__*/ v.stringLength(0, 2048), + ]), + getAssessmentNsid: /*#__PURE__*/ v.nsidString(), + getCurrentAssessmentNsid: /*#__PURE__*/ v.nsidString(), + getPolicyNsid: /*#__PURE__*/ v.nsidString(), + listAssessmentsNsid: /*#__PURE__*/ v.nsidString(), + /** + * @maxLength 2048 + */ + policyUrl: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.genericUriString(), [ + /*#__PURE__*/ v.stringLength(0, 2048), + ]), +}); +const _publicAssessmentSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.labeller.defs#publicAssessment", + ), + ), + get artifact() { + return /*#__PURE__*/ v.optional(artifactSchema); + }, + /** + * @minimum 1 + */ + assessmentSchemaVersion: /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.integer(), + [/*#__PURE__*/ v.integerRange(1)], + ), + completedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), + get coverage() { + return coverageSchema; + }, + createdAt: /*#__PURE__*/ v.datetimeString(), + /** + * @minLength 1 + * @maxLength 64 + */ + id: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 64), + ]), + /** + * @maxLength 64 + */ + get labels() { + return /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(labelSummarySchema), + [/*#__PURE__*/ v.arrayLength(0, 64)], + ); + }, + get model() { + return /*#__PURE__*/ v.optional(modelSchema); + }, + /** + * @minLength 1 + * @maxLength 128 + */ + policyVersion: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 128), + ]), + /** + * @maxLength 2048 + */ + reconsiderationUrl: /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.genericUriString(), + [/*#__PURE__*/ v.stringLength(0, 2048)], + ), + /** + * @maxLength 64 + */ + get scannerVersions() { + return /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(scannerVersionSchema), + [/*#__PURE__*/ v.arrayLength(0, 64)], + ); + }, + src: /*#__PURE__*/ v.didString(), + state: /*#__PURE__*/ v.string< + | "blocked" + | "error" + | "passed" + | "pending" + | "superseded" + | "warned" + | (string & {}) + >(), + get subject() { + return assessmentSubjectSchema; + }, + /** + * @minLength 1 + * @maxLength 4096 + */ + summary: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 4096), + ]), + /** + * @minLength 1 + * @maxLength 64 + */ + supersedesAssessmentId: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 64), + ]), + ), +}); +const _publicManualActionSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.labeller.defs#publicManualAction", + ), + ), + createdAt: /*#__PURE__*/ v.datetimeString(), + /** + * @minLength 1 + * @maxLength 64 + */ + id: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 64), + ]), + /** + * @maxLength 64 + */ + get labels() { + return /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(labelSummarySchema), + [/*#__PURE__*/ v.arrayLength(0, 64)], + ); + }, + src: /*#__PURE__*/ v.didString(), + get subject() { + return manualActionSubjectSchema; + }, + /** + * @minLength 1 + * @maxLength 4096 + */ + summary: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 4096), + ]), + type: /*#__PURE__*/ v.string< + | "emergency-takedown" + | "label-issue" + | "label-retraction" + | "override" + | (string & {}) + >(), +}); +const _publisherSubjectSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.labeller.defs#publisherSubject", + ), + ), + kind: /*#__PURE__*/ v.string<"did" | (string & {})>(), +}); +const _reasonCodeSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.labeller.defs#reasonCode", + ), + ), + /** + * @minLength 1 + * @maxLength 128 + */ + code: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 128), + ]), + /** + * @minLength 1 + * @maxLength 4096 + */ + description: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 4096), + ]), +}); +const _scannerVersionSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.labeller.defs#scannerVersion", + ), + ), + /** + * @minLength 1 + * @maxLength 128 + */ + scanner: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 128), + ]), + /** + * @minLength 1 + * @maxLength 128 + */ + version: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 128), + ]), +}); +const _subjectRuleSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.labeller.defs#subjectRule", + ), + ), + cidRule: /*#__PURE__*/ v.string< + "forbidden" | "optional" | "required" | (string & {}) + >(), + /** + * @minLength 1 + * @maxLength 3 + */ + issuanceModes: /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array( + /*#__PURE__*/ v.string< + "admin" | "automated" | "reviewer" | (string & {}) + >(), + ), + [/*#__PURE__*/ v.arrayLength(1, 3)], + ), + subject: /*#__PURE__*/ v.string< + "package" | "publisher" | "release" | (string & {}) + >(), +}); +const _supportedSubjectsSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.labeller.defs#supportedSubjects", + ), + ), + /** + * @maxLength 32 + */ + packageCollections: /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(/*#__PURE__*/ v.nsidString()), + [/*#__PURE__*/ v.arrayLength(0, 32)], + ), + get publisher() { + return publisherSubjectSchema; + }, + /** + * @maxLength 32 + */ + releaseCollections: /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(/*#__PURE__*/ v.nsidString()), + [/*#__PURE__*/ v.arrayLength(0, 32)], + ), +}); +const _transparencySchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.labeller.defs#transparency", + ), + ), + modelOutputIsAdvisoryEvidence: /*#__PURE__*/ v.boolean(), +}); + +type artifact$schematype = typeof _artifactSchema; +type assessmentSubject$schematype = typeof _assessmentSubjectSchema; +type contact$schematype = typeof _contactSchema; +type coverage$schematype = typeof _coverageSchema; +type currentAssessmentView$schematype = typeof _currentAssessmentViewSchema; +type labelSummary$schematype = typeof _labelSummarySchema; +type labelerPolicy$schematype = typeof _labelerPolicySchema; +type manualActionSubject$schematype = typeof _manualActionSubjectSchema; +type model$schematype = typeof _modelSchema; +type overrideRule$schematype = typeof _overrideRuleSchema; +type policyLabel$schematype = typeof _policyLabelSchema; +type policyLocale$schematype = typeof _policyLocaleSchema; +type publicApi$schematype = typeof _publicApiSchema; +type publicAssessment$schematype = typeof _publicAssessmentSchema; +type publicManualAction$schematype = typeof _publicManualActionSchema; +type publisherSubject$schematype = typeof _publisherSubjectSchema; +type reasonCode$schematype = typeof _reasonCodeSchema; +type scannerVersion$schematype = typeof _scannerVersionSchema; +type subjectRule$schematype = typeof _subjectRuleSchema; +type supportedSubjects$schematype = typeof _supportedSubjectsSchema; +type transparency$schematype = typeof _transparencySchema; + +export interface artifactSchema extends artifact$schematype {} +export interface assessmentSubjectSchema extends assessmentSubject$schematype {} +export interface contactSchema extends contact$schematype {} +export interface coverageSchema extends coverage$schematype {} +export interface currentAssessmentViewSchema extends currentAssessmentView$schematype {} +export interface labelSummarySchema extends labelSummary$schematype {} +export interface labelerPolicySchema extends labelerPolicy$schematype {} +export interface manualActionSubjectSchema extends manualActionSubject$schematype {} +export interface modelSchema extends model$schematype {} +export interface overrideRuleSchema extends overrideRule$schematype {} +export interface policyLabelSchema extends policyLabel$schematype {} +export interface policyLocaleSchema extends policyLocale$schematype {} +export interface publicApiSchema extends publicApi$schematype {} +export interface publicAssessmentSchema extends publicAssessment$schematype {} +export interface publicManualActionSchema extends publicManualAction$schematype {} +export interface publisherSubjectSchema extends publisherSubject$schematype {} +export interface reasonCodeSchema extends reasonCode$schematype {} +export interface scannerVersionSchema extends scannerVersion$schematype {} +export interface subjectRuleSchema extends subjectRule$schematype {} +export interface supportedSubjectsSchema extends supportedSubjects$schematype {} +export interface transparencySchema extends transparency$schematype {} + +export const artifactSchema = _artifactSchema as artifactSchema; +export const assessmentSubjectSchema = + _assessmentSubjectSchema as assessmentSubjectSchema; +export const contactSchema = _contactSchema as contactSchema; +export const coverageSchema = _coverageSchema as coverageSchema; +export const currentAssessmentViewSchema = + _currentAssessmentViewSchema as currentAssessmentViewSchema; +export const labelSummarySchema = _labelSummarySchema as labelSummarySchema; +export const labelerPolicySchema = _labelerPolicySchema as labelerPolicySchema; +export const manualActionSubjectSchema = + _manualActionSubjectSchema as manualActionSubjectSchema; +export const modelSchema = _modelSchema as modelSchema; +export const overrideRuleSchema = _overrideRuleSchema as overrideRuleSchema; +export const policyLabelSchema = _policyLabelSchema as policyLabelSchema; +export const policyLocaleSchema = _policyLocaleSchema as policyLocaleSchema; +export const publicApiSchema = _publicApiSchema as publicApiSchema; +export const publicAssessmentSchema = + _publicAssessmentSchema as publicAssessmentSchema; +export const publicManualActionSchema = + _publicManualActionSchema as publicManualActionSchema; +export const publisherSubjectSchema = + _publisherSubjectSchema as publisherSubjectSchema; +export const reasonCodeSchema = _reasonCodeSchema as reasonCodeSchema; +export const scannerVersionSchema = + _scannerVersionSchema as scannerVersionSchema; +export const subjectRuleSchema = _subjectRuleSchema as subjectRuleSchema; +export const supportedSubjectsSchema = + _supportedSubjectsSchema as supportedSubjectsSchema; +export const transparencySchema = _transparencySchema as transparencySchema; + +export interface Artifact extends v.InferInput {} +export interface AssessmentSubject extends v.InferInput< + typeof assessmentSubjectSchema +> {} +export interface Contact extends v.InferInput {} +export interface Coverage extends v.InferInput {} +export interface CurrentAssessmentView extends v.InferInput< + typeof currentAssessmentViewSchema +> {} +export interface LabelSummary extends v.InferInput {} +export interface LabelerPolicy extends v.InferInput< + typeof labelerPolicySchema +> {} +export interface ManualActionSubject extends v.InferInput< + typeof manualActionSubjectSchema +> {} +export interface Model extends v.InferInput {} +export interface OverrideRule extends v.InferInput {} +export interface PolicyLabel extends v.InferInput {} +export interface PolicyLocale extends v.InferInput {} +export interface PublicApi extends v.InferInput {} +export interface PublicAssessment extends v.InferInput< + typeof publicAssessmentSchema +> {} +export interface PublicManualAction extends v.InferInput< + typeof publicManualActionSchema +> {} +export interface PublisherSubject extends v.InferInput< + typeof publisherSubjectSchema +> {} +export interface ReasonCode extends v.InferInput {} +export interface ScannerVersion extends v.InferInput< + typeof scannerVersionSchema +> {} +export interface SubjectRule extends v.InferInput {} +export interface SupportedSubjects extends v.InferInput< + typeof supportedSubjectsSchema +> {} +export interface Transparency extends v.InferInput {} diff --git a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/getAssessment.ts b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/getAssessment.ts new file mode 100644 index 0000000000..3e8162d769 --- /dev/null +++ b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/getAssessment.ts @@ -0,0 +1,40 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as ComEmdashcmsExperimentalLabellerDefs from "./defs.js"; + +const _mainSchema = /*#__PURE__*/ v.query( + "com.emdashcms.experimental.labeller.getAssessment", + { + params: /*#__PURE__*/ v.object({ + /** + * @minLength 1 + * @maxLength 64 + */ + id: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 64), + ]), + }), + output: { + type: "lex", + get schema() { + return ComEmdashcmsExperimentalLabellerDefs.publicAssessmentSchema; + }, + }, + }, +); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params extends v.InferInput {} +export type $output = v.InferXRPCBodyInput; + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "com.emdashcms.experimental.labeller.getAssessment": mainSchema; + } +} diff --git a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/getCurrentAssessment.ts b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/getCurrentAssessment.ts new file mode 100644 index 0000000000..2cc5c6f3dc --- /dev/null +++ b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/getCurrentAssessment.ts @@ -0,0 +1,36 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as ComEmdashcmsExperimentalLabellerDefs from "./defs.js"; + +const _mainSchema = /*#__PURE__*/ v.query( + "com.emdashcms.experimental.labeller.getCurrentAssessment", + { + params: /*#__PURE__*/ v.object({ + cid: /*#__PURE__*/ v.cidString(), + src: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()), + uri: /*#__PURE__*/ v.resourceUriString(), + }), + output: { + type: "lex", + get schema() { + return ComEmdashcmsExperimentalLabellerDefs.currentAssessmentViewSchema; + }, + }, + }, +); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params extends v.InferInput {} +export type $output = v.InferXRPCBodyInput; + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "com.emdashcms.experimental.labeller.getCurrentAssessment": mainSchema; + } +} diff --git a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/getPolicy.ts b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/getPolicy.ts new file mode 100644 index 0000000000..6b4866331e --- /dev/null +++ b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/getPolicy.ts @@ -0,0 +1,32 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as ComEmdashcmsExperimentalLabellerDefs from "./defs.js"; + +const _mainSchema = /*#__PURE__*/ v.query( + "com.emdashcms.experimental.labeller.getPolicy", + { + params: null, + output: { + type: "lex", + get schema() { + return ComEmdashcmsExperimentalLabellerDefs.labelerPolicySchema; + }, + }, + }, +); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params {} +export type $output = v.InferXRPCBodyInput; + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "com.emdashcms.experimental.labeller.getPolicy": mainSchema; + } +} diff --git a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/listAssessments.ts b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/listAssessments.ts new file mode 100644 index 0000000000..648ade806a --- /dev/null +++ b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/labeller/listAssessments.ts @@ -0,0 +1,84 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as ComEmdashcmsExperimentalLabellerDefs from "./defs.js"; + +const _mainSchema = /*#__PURE__*/ v.query( + "com.emdashcms.experimental.labeller.listAssessments", + { + params: /*#__PURE__*/ v.object({ + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), + /** + * @maxLength 1024 + */ + cursor: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 1024), + ]), + ), + /** + * @minimum 1 + * @maximum 100 + * @default 50 + */ + limit: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ + /*#__PURE__*/ v.integerRange(1, 100), + ]), + 50, + ), + src: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()), + state: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.string< + | "blocked" + | "error" + | "passed" + | "pending" + | "superseded" + | "warned" + | (string & {}) + >(), + ), + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), + }), + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + /** + * @maxLength 100 + */ + get assessments() { + return /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array( + ComEmdashcmsExperimentalLabellerDefs.publicAssessmentSchema, + ), + [/*#__PURE__*/ v.arrayLength(0, 100)], + ); + }, + /** + * @maxLength 1024 + */ + cursor: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 1024), + ]), + ), + }), + }, + }, +); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params extends v.InferInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "com.emdashcms.experimental.labeller.listAssessments": mainSchema; + } +} diff --git a/packages/registry-lexicons/src/index.ts b/packages/registry-lexicons/src/index.ts index c38ba88d05..9c9439972d 100644 --- a/packages/registry-lexicons/src/index.ts +++ b/packages/registry-lexicons/src/index.ts @@ -28,6 +28,12 @@ export * as AggregatorListReleases from "./generated/types/com/emdashcms/experim export * as AggregatorResolvePackage from "./generated/types/com/emdashcms/experimental/aggregator/resolvePackage.js"; export * as AggregatorSearchPackages from "./generated/types/com/emdashcms/experimental/aggregator/searchPackages.js"; +export * as LabellerDefs from "./generated/types/com/emdashcms/experimental/labeller/defs.js"; +export * as LabellerGetAssessment from "./generated/types/com/emdashcms/experimental/labeller/getAssessment.js"; +export * as LabellerGetCurrentAssessment from "./generated/types/com/emdashcms/experimental/labeller/getCurrentAssessment.js"; +export * as LabellerGetPolicy from "./generated/types/com/emdashcms/experimental/labeller/getPolicy.js"; +export * as LabellerListAssessments from "./generated/types/com/emdashcms/experimental/labeller/listAssessments.js"; + export * as PackageProfile from "./generated/types/com/emdashcms/experimental/package/profile.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"; @@ -52,6 +58,11 @@ export const NSID = { aggregatorListReleases: "com.emdashcms.experimental.aggregator.listReleases", aggregatorResolvePackage: "com.emdashcms.experimental.aggregator.resolvePackage", aggregatorSearchPackages: "com.emdashcms.experimental.aggregator.searchPackages", + labellerDefs: "com.emdashcms.experimental.labeller.defs", + labellerGetAssessment: "com.emdashcms.experimental.labeller.getAssessment", + labellerGetCurrentAssessment: "com.emdashcms.experimental.labeller.getCurrentAssessment", + labellerGetPolicy: "com.emdashcms.experimental.labeller.getPolicy", + labellerListAssessments: "com.emdashcms.experimental.labeller.listAssessments", } as const; export type NSIDValue = (typeof NSID)[keyof typeof NSID]; @@ -84,6 +95,10 @@ export const QUERY_NSIDS = [ NSID.aggregatorListReleases, NSID.aggregatorResolvePackage, NSID.aggregatorSearchPackages, + NSID.labellerGetAssessment, + NSID.labellerGetCurrentAssessment, + NSID.labellerGetPolicy, + NSID.labellerListAssessments, ] as const; import type * as PackageProfileNs from "./generated/types/com/emdashcms/experimental/package/profile.js"; diff --git a/packages/registry-lexicons/tests/labeller.test.ts b/packages/registry-lexicons/tests/labeller.test.ts new file mode 100644 index 0000000000..81931c0ae6 --- /dev/null +++ b/packages/registry-lexicons/tests/labeller.test.ts @@ -0,0 +1,154 @@ +import { is } from "@atcute/lexicons/validations"; +import { describe, expect, it } from "vitest"; + +import { + LabellerDefs, + LabellerGetAssessment, + LabellerGetCurrentAssessment, + LabellerGetPolicy, + LabellerListAssessments, + NSID, +} from "../src/index.js"; + +const assessment: LabellerDefs.PublicAssessment = { + id: "asmt_01J2Q5Y7V8N9M0K1H2G3F4E5D6", + src: "did:web:labels.emdashcms.com", + subject: { + uri: "at://did:plc:publisher/com.emdashcms.experimental.package.release/gallery:1.0.0", + cid: "bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + state: "passed", + summary: "No blocking condition found.", + coverage: { + code: "complete", + metadata: "complete", + images: "not-present", + dependencies: "complete", + }, + labels: [{ val: "assessment-passed", active: true, issuedAt: "2026-07-10T12:00:00Z" }], + policyVersion: "2026-07-10", + assessmentSchemaVersion: 1, + scannerVersions: [{ scanner: "dependency", version: "1.0.0" }], + createdAt: "2026-07-10T12:00:00Z", + completedAt: "2026-07-10T12:01:00Z", + reconsiderationUrl: "https://emdashcms.com/plugin-moderation/reconsideration", +}; + +describe("labeller Lexicons", () => { + it("validates the public assessment and current-assessment output", () => { + const output: LabellerGetCurrentAssessment.$output = { + src: assessment.src, + subject: assessment.subject, + current: assessment, + activeLabels: [ + { + ver: 1, + src: assessment.src, + uri: assessment.subject.uri, + cid: assessment.subject.cid, + val: "assessment-passed", + cts: assessment.completedAt, + }, + ], + }; + + expect(is(LabellerDefs.publicAssessmentSchema, assessment)).toBe(true); + expect( + is(LabellerDefs.publicAssessmentSchema, { + ...assessment, + subject: { ...assessment.subject, uri: "did:web:publisher.example.com" }, + }), + ).toBe(false); + expect(is(LabellerGetCurrentAssessment.mainSchema.output.schema, output)).toBe(true); + }); + + it("validates a publisher-level manual action with a DID subject", () => { + const action: LabellerDefs.PublicManualAction = { + id: "action_01J2Q5Y7V8N9M0K1H2G3F4E5D6", + src: assessment.src, + subject: { uri: "did:web:publisher.example.com" }, + type: "emergency-takedown", + summary: "The publisher identity is believed to be compromised.", + labels: [ + { + val: "publisher-compromised", + active: true, + issuedAt: "2026-07-10T12:00:00Z", + }, + ], + createdAt: "2026-07-10T12:00:00Z", + }; + + expect(is(LabellerDefs.publicManualActionSchema, action)).toBe(true); + }); + + it("validates query parameters and rejects schema-invalid inputs", () => { + const current: LabellerGetCurrentAssessment.$params = { + uri: assessment.subject.uri, + cid: assessment.subject.cid, + src: assessment.src, + }; + const list: LabellerListAssessments.$params = { state: "passed", limit: 50 }; + + expect(is(LabellerGetCurrentAssessment.mainSchema.params, current)).toBe(true); + expect(is(LabellerListAssessments.mainSchema.params, list)).toBe(true); + expect( + is(LabellerGetCurrentAssessment.mainSchema.params, { ...current, src: "not-a-did" }), + ).toBe(false); + expect( + is(LabellerGetCurrentAssessment.mainSchema.params, { + ...current, + uri: "https://example.com/release", + }), + ).toBe(false); + expect(is(LabellerListAssessments.mainSchema.params, { cursor: "a".repeat(1025) })).toBe(false); + expect(is(LabellerListAssessments.mainSchema.params, { limit: 101 })).toBe(false); + }); + + it("validates endpoint output shapes and the fixed-field policy document", () => { + const list: LabellerListAssessments.$output = { assessments: [assessment] }; + const policy: LabellerGetPolicy.$output = { + schemaVersion: 1, + policyVersion: "2026-07-10", + effectiveAt: "2026-07-10T00:00:00Z", + labellerDid: assessment.src, + assessmentSchemaVersion: 1, + supportedSubjects: { + publisher: { kind: "did" }, + packageCollections: [NSID.packageProfile], + releaseCollections: [NSID.packageRelease], + }, + reasonCodes: [{ code: "missing-assessment-pass", description: "No active pass label." }], + labels: [], + overrideRule: { + subject: "release", + cidRule: "required", + reviewerLabels: ["assessment-passed", "assessment-overridden"], + requireSameSource: true, + requireAtomicIssuance: true, + }, + precedence: ["manual-block", "eligible"], + publicApi: { + baseUrl: "https://labels.emdashcms.com/xrpc/", + policyUrl: "https://labels.emdashcms.com/.well-known/emdash-labeler-policy.json", + getAssessmentNsid: NSID.labellerGetAssessment, + getCurrentAssessmentNsid: NSID.labellerGetCurrentAssessment, + listAssessmentsNsid: NSID.labellerListAssessments, + getPolicyNsid: NSID.labellerGetPolicy, + }, + contact: { + reconsiderationUrl: assessment.reconsiderationUrl, + reconsiderationEmail: "plugin-moderation@emdashcms.com", + }, + transparency: { modelOutputIsAdvisoryEvidence: true }, + }; + + expect(is(LabellerGetAssessment.mainSchema.params, { id: assessment.id })).toBe(true); + expect(is(LabellerGetAssessment.mainSchema.params, { id: "" })).toBe(false); + expect(is(LabellerListAssessments.mainSchema.output.schema, list)).toBe(true); + expect(is(LabellerGetPolicy.mainSchema.output.schema, policy)).toBe(true); + expect(is(LabellerGetPolicy.mainSchema.output.schema, { ...policy, schemaVersion: 2 })).toBe( + false, + ); + }); +}); diff --git a/packages/registry-lexicons/tests/types.test.ts b/packages/registry-lexicons/tests/types.test.ts index d8f8e08ba5..8acf0ed076 100644 --- a/packages/registry-lexicons/tests/types.test.ts +++ b/packages/registry-lexicons/tests/types.test.ts @@ -167,6 +167,11 @@ describe("NSID map", () => { "com.emdashcms.experimental.aggregator.listReleases", "com.emdashcms.experimental.aggregator.resolvePackage", "com.emdashcms.experimental.aggregator.searchPackages", + "com.emdashcms.experimental.labeller.defs", + "com.emdashcms.experimental.labeller.getAssessment", + "com.emdashcms.experimental.labeller.getCurrentAssessment", + "com.emdashcms.experimental.labeller.getPolicy", + "com.emdashcms.experimental.labeller.listAssessments", ].toSorted(); const actual = Object.values(NSID).toSorted(); From e87f81f855aab06b7eb02a96b3a7a230b65eca2d Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 19:48:18 +0100 Subject: [PATCH 013/137] feat(registry): add labeler service core (#1926) --- .changeset/lucky-labelers-contract.md | 5 + .changeset/lucky-labellers-contract.md | 5 - .github/workflows/ci.yml | 3 + apps/aggregator/migrations/0001_init.sql | 12 +- apps/aggregator/src/routes/xrpc/getPackage.ts | 2 +- apps/aggregator/src/routes/xrpc/router.ts | 2 +- .../src/routes/xrpc/searchPackages.ts | 4 +- apps/labeler/migrations/0001_init.sql | 80 + apps/labeler/package.json | 29 + apps/labeler/src/config.ts | 10 + apps/labeler/src/index.ts | 23 + apps/labeler/src/query-labels.ts | 109 + apps/labeler/src/service.ts | 205 + apps/labeler/src/xrpc.ts | 8 + apps/labeler/test/service.test.ts | 282 + apps/labeler/tsconfig.json | 9 + apps/labeler/vite.config.ts | 6 + apps/labeler/vitest.config.ts | 16 + apps/labeler/worker-configuration.d.ts | 14489 ++++++++++++++++ apps/labeler/wrangler.jsonc | 19 + docs/src/content/docs/plugins/registry.mdx | 10 +- .../content/docs/reference/configuration.mdx | 4 +- package.json | 4 +- .../src/components/RegistryPluginDetail.tsx | 26 +- .../components/RegistryPluginDetail.test.tsx | 8 +- packages/core/src/api/handlers/registry.ts | 4 +- packages/core/src/registry/types.ts | 12 +- packages/plugin-cli/src/commands/publish.ts | 2 +- packages/plugin-cli/src/publish/api.ts | 2 +- packages/registry-client/README.md | 2 +- .../registry-client/src/discovery/index.ts | 8 +- .../registry-client/tests/discovery.test.ts | 4 +- .../experimental/aggregator/defs.json | 6 +- .../aggregator/getLatestRelease.json | 2 +- .../experimental/aggregator/listReleases.json | 2 +- .../aggregator/searchPackages.json | 2 +- .../{labeller => labeler}/defs.json | 8 +- .../{labeller => labeler}/getAssessment.json | 4 +- .../getCurrentAssessment.json | 4 +- .../{labeller => labeler}/getPolicy.json | 6 +- .../listAssessments.json | 4 +- .../registry-lexicons/src/generated/index.ts | 10 +- .../emdashcms/experimental/aggregator/defs.ts | 4 +- .../{labeller => labeler}/defs.ts | 48 +- .../{labeller => labeler}/getAssessment.ts | 8 +- .../getCurrentAssessment.ts | 8 +- .../{labeller => labeler}/getPolicy.ts | 8 +- .../{labeller => labeler}/listAssessments.ts | 8 +- packages/registry-lexicons/src/index.ts | 28 +- .../{labeller.test.ts => labeler.test.ts} | 66 +- .../registry-lexicons/tests/types.test.ts | 10 +- .../tests/fixtures/label-crypto.json | 2 +- .../tests/fixtures/moderation-policy.json | 10 +- pnpm-lock.yaml | 28 + 54 files changed, 15496 insertions(+), 184 deletions(-) create mode 100644 .changeset/lucky-labelers-contract.md delete mode 100644 .changeset/lucky-labellers-contract.md create mode 100644 apps/labeler/migrations/0001_init.sql create mode 100644 apps/labeler/package.json create mode 100644 apps/labeler/src/config.ts create mode 100644 apps/labeler/src/index.ts create mode 100644 apps/labeler/src/query-labels.ts create mode 100644 apps/labeler/src/service.ts create mode 100644 apps/labeler/src/xrpc.ts create mode 100644 apps/labeler/test/service.test.ts create mode 100644 apps/labeler/tsconfig.json create mode 100644 apps/labeler/vite.config.ts create mode 100644 apps/labeler/vitest.config.ts create mode 100644 apps/labeler/worker-configuration.d.ts create mode 100644 apps/labeler/wrangler.jsonc rename packages/registry-lexicons/lexicons/com/emdashcms/experimental/{labeller => labeler}/defs.json (97%) rename packages/registry-lexicons/lexicons/com/emdashcms/experimental/{labeller => labeler}/getAssessment.json (79%) rename packages/registry-lexicons/lexicons/com/emdashcms/experimental/{labeller => labeler}/getCurrentAssessment.json (82%) rename packages/registry-lexicons/lexicons/com/emdashcms/experimental/{labeller => labeler}/getPolicy.json (56%) rename packages/registry-lexicons/lexicons/com/emdashcms/experimental/{labeller => labeler}/listAssessments.json (89%) rename packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/{labeller => labeler}/defs.ts (93%) rename packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/{labeller => labeler}/getAssessment.ts (75%) rename packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/{labeller => labeler}/getCurrentAssessment.ts (74%) rename packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/{labeller => labeler}/getPolicy.ts (70%) rename packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/{labeller => labeler}/listAssessments.ts (87%) rename packages/registry-lexicons/tests/{labeller.test.ts => labeler.test.ts} (62%) diff --git a/.changeset/lucky-labelers-contract.md b/.changeset/lucky-labelers-contract.md new file mode 100644 index 0000000000..9baa130613 --- /dev/null +++ b/.changeset/lucky-labelers-contract.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-lexicons": patch +--- + +Adds experimental labeler assessment and policy query contracts. diff --git a/.changeset/lucky-labellers-contract.md b/.changeset/lucky-labellers-contract.md deleted file mode 100644 index b680ee9d2b..0000000000 --- a/.changeset/lucky-labellers-contract.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@emdash-cms/registry-lexicons": patch ---- - -Adds experimental labeller assessment and policy query contracts. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bc2cb39ca..c098affa7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,6 +124,9 @@ jobs: - run: pnpm test:unit env: EMDASH_TEST_PG: postgres://postgres:test@localhost:5432/emdash_test + - run: pnpm --filter @emdash-cms/labeler build + - run: pnpm --filter @emdash-cms/labeler typecheck + - run: pnpm --filter @emdash-cms/labeler test # Render tests use the Astro Vite plugin (vitest.repro.config.ts); # they can't run under the plain-node config in test:unit. - run: pnpm --filter emdash exec vitest run --config vitest.repro.config.ts diff --git a/apps/aggregator/migrations/0001_init.sql b/apps/aggregator/migrations/0001_init.sql index ed3a6a1f41..9cf42c7f29 100644 --- a/apps/aggregator/migrations/0001_init.sql +++ b/apps/aggregator/migrations/0001_init.sql @@ -178,14 +178,14 @@ CREATE TABLE mirrored_artifacts ( ); ------------------------------------------------------------------------------ --- Labels (populated when the labeller integration lands) +-- Labels (populated when the labeler integration lands) ------------------------------------------------------------------------------ -- Append-only label history. Every label received is written here, including -- negations. Current state is derived from latest cts per (src, uri, val) and -- projected into label_state below for hot-path lookups. CREATE TABLE labels ( - src TEXT NOT NULL, -- labeller DID + src TEXT NOT NULL, -- labeler DID uri TEXT NOT NULL, -- AT URI of subject cid TEXT, -- optional version-specific CID val TEXT NOT NULL, -- e.g. 'security:yanked', '!takedown' @@ -229,8 +229,8 @@ CREATE TABLE label_state ( CREATE INDEX idx_label_state_enforce ON label_state(uri, val, trusted) WHERE neg = 0 AND trusted = 1; --- Trusted/known labellers (operator config, edited via deployment). -CREATE TABLE labellers ( +-- Trusted/known labelers (operator config, edited via deployment). +CREATE TABLE labelers ( did TEXT PRIMARY KEY, endpoint TEXT NOT NULL, -- subscribeLabels URL signing_key TEXT NOT NULL, -- cached #atproto_label key @@ -278,9 +278,9 @@ END; ------------------------------------------------------------------------------ -- Cursor state for ingest sources (Jetstream microsecond timestamp, --- subscribeLabels seq cursors per labeller, etc.). +-- subscribeLabels seq cursors per labeler, etc.). CREATE TABLE ingest_state ( - source TEXT PRIMARY KEY, -- 'jetstream', 'labeller:did:web:labels.example.com', etc. + source TEXT PRIMARY KEY, -- 'jetstream', 'labeler:did:web:labels.example.com', etc. cursor TEXT NOT NULL, updated_at TEXT NOT NULL ); diff --git a/apps/aggregator/src/routes/xrpc/getPackage.ts b/apps/aggregator/src/routes/xrpc/getPackage.ts index d9efb1793a..9ad7691251 100644 --- a/apps/aggregator/src/routes/xrpc/getPackage.ts +++ b/apps/aggregator/src/routes/xrpc/getPackage.ts @@ -19,7 +19,7 @@ export async function getPackage( params: AggregatorGetPackage.$params, ): Promise { // `first-primary` because the same row could become subject to a takedown - // label between two reads; once the labeller (Slice 2) writes, the next + // label between two reads; once the labeler (Slice 2) writes, the next // read everywhere should reflect it. Per plan §XRPC endpoints. const session = env.DB.withSession("first-primary"); const row = await session diff --git a/apps/aggregator/src/routes/xrpc/router.ts b/apps/aggregator/src/routes/xrpc/router.ts index a38cb78be4..f1569ff230 100644 --- a/apps/aggregator/src/routes/xrpc/router.ts +++ b/apps/aggregator/src/routes/xrpc/router.ts @@ -52,7 +52,7 @@ const SYNC_GET_RECORD_PATH = "/xrpc/com.atproto.sync.getRecord"; * caller's origin or credentials -- there are no cookies, no auth, no * per-origin policy. We allow `atproto-accept-labelers` and * `content-type` as request headers (the only two clients send), echo - * back the labellers header for symmetry with atproto's labeller-aware + * back the labelers header for symmetry with atproto's labeler-aware * clients, and cap preflight cache at 24h. */ const CORS_HEADERS: Record = { diff --git a/apps/aggregator/src/routes/xrpc/searchPackages.ts b/apps/aggregator/src/routes/xrpc/searchPackages.ts index ee377e4370..dd11b0f1e8 100644 --- a/apps/aggregator/src/routes/xrpc/searchPackages.ts +++ b/apps/aggregator/src/routes/xrpc/searchPackages.ts @@ -10,7 +10,7 @@ * a non-issue. * * Takedown filter joins `label_state` even though that table is empty in - * Slice 1 — keeps the contract honest for Slice 2 (when the labeller starts + * Slice 1 — keeps the contract honest for Slice 2 (when the labeler starts * writing), and the optimiser short-circuits the NOT EXISTS subquery * cheaply when no rows match. See plan §Search. * @@ -145,7 +145,7 @@ function buildBrowseBindings( return out; } -/** Hard-enforcement label filter. The Slice 2 labeller writes to +/** Hard-enforcement label filter. The Slice 2 labeler writes to * `label_state`; until then the table is empty so this clause is a no-op * (the NOT EXISTS short-circuits). The plan calls for shipping the filter * now to lock the contract — adding it later would be a behaviour change diff --git a/apps/labeler/migrations/0001_init.sql b/apps/labeler/migrations/0001_init.sql new file mode 100644 index 0000000000..b2ed21fe53 --- /dev/null +++ b/apps/labeler/migrations/0001_init.sql @@ -0,0 +1,80 @@ +CREATE TABLE issuance_actions ( + id INTEGER PRIMARY KEY, + actor TEXT NOT NULL, + type TEXT NOT NULL, + reason TEXT NOT NULL, + idempotency_key TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL +); + +CREATE TRIGGER issuance_actions_immutable_update +BEFORE UPDATE ON issuance_actions +BEGIN + SELECT RAISE(ABORT, 'issuance actions are immutable'); +END; + +CREATE TRIGGER issuance_actions_immutable_delete +BEFORE DELETE ON issuance_actions +BEGIN + SELECT RAISE(ABORT, 'issuance actions are immutable'); +END; + +CREATE TABLE label_sequence ( + name TEXT PRIMARY KEY CHECK (name = 'issued_labels'), + next_sequence INTEGER NOT NULL CHECK (next_sequence > 0) +); + +INSERT INTO label_sequence (name, next_sequence) VALUES ('issued_labels', 1); + +CREATE TABLE issued_labels ( + id INTEGER PRIMARY KEY, + action_id INTEGER NOT NULL UNIQUE REFERENCES issuance_actions(id), + sequence INTEGER UNIQUE, + ver INTEGER NOT NULL CHECK (ver = 1), + src TEXT NOT NULL, + uri TEXT NOT NULL, + cid TEXT, + val TEXT NOT NULL, + neg INTEGER NOT NULL DEFAULT 0 CHECK (neg IN (0, 1)), + cts TEXT NOT NULL, + exp TEXT, + sig BLOB NOT NULL, + signing_key_id TEXT NOT NULL +); + +CREATE TRIGGER issued_labels_allocate_sequence +AFTER INSERT ON issued_labels +BEGIN + UPDATE issued_labels + SET sequence = (SELECT next_sequence FROM label_sequence WHERE name = 'issued_labels') + WHERE id = NEW.id; + UPDATE label_sequence SET next_sequence = next_sequence + 1 WHERE name = 'issued_labels'; +END; + +CREATE TRIGGER issued_labels_immutable_update +BEFORE UPDATE ON issued_labels +WHEN NEW.sequence IS NULL + OR (OLD.sequence IS NOT NULL AND OLD.sequence IS NOT NEW.sequence) + OR OLD.id IS NOT NEW.id + OR OLD.action_id IS NOT NEW.action_id + OR OLD.ver IS NOT NEW.ver + OR OLD.src IS NOT NEW.src + OR OLD.uri IS NOT NEW.uri + OR OLD.cid IS NOT NEW.cid + OR OLD.val IS NOT NEW.val + OR OLD.neg IS NOT NEW.neg + OR OLD.cts IS NOT NEW.cts + OR OLD.exp IS NOT NEW.exp +BEGIN + SELECT RAISE(ABORT, 'issued labels are immutable'); +END; + +CREATE TRIGGER issued_labels_immutable_delete +BEFORE DELETE ON issued_labels +BEGIN + SELECT RAISE(ABORT, 'issued labels are immutable'); +END; + +CREATE INDEX issued_labels_query_order ON issued_labels(sequence); +CREATE INDEX issued_labels_uri_sequence ON issued_labels(uri, sequence); +CREATE INDEX issued_labels_source_sequence ON issued_labels(src, sequence); diff --git a/apps/labeler/package.json b/apps/labeler/package.json new file mode 100644 index 0000000000..81492d99a6 --- /dev/null +++ b/apps/labeler/package.json @@ -0,0 +1,29 @@ +{ + "name": "@emdash-cms/labeler", + "version": "0.0.0", + "private": true, + "description": "EmDash registry moderation label service.", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "deploy": "vite build && wrangler deploy", + "typecheck": "tsgo --noEmit", + "test": "vitest run", + "db:migrate:local": "wrangler d1 migrations apply emdash-labeler --local", + "db:migrate": "wrangler d1 migrations apply emdash-labeler --remote" + }, + "dependencies": { + "@emdash-cms/registry-moderation": "workspace:*" + }, + "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/labeler/src/config.ts b/apps/labeler/src/config.ts new file mode 100644 index 0000000000..3674b9d719 --- /dev/null +++ b/apps/labeler/src/config.ts @@ -0,0 +1,10 @@ +const DID = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+(?:[:][A-Za-z0-9._:%-]+)*$/; + +export interface LabelerConfig { + labelerDid: string; +} + +export function getLabelerConfig(env: Pick): LabelerConfig { + if (!DID.test(env.LABELER_DID)) throw new TypeError("LABELER_DID must be a DID"); + return { labelerDid: env.LABELER_DID }; +} diff --git a/apps/labeler/src/index.ts b/apps/labeler/src/index.ts new file mode 100644 index 0000000000..f37fa6e9d8 --- /dev/null +++ b/apps/labeler/src/index.ts @@ -0,0 +1,23 @@ +import { getLabelerConfig } from "./config.js"; +import { queryLabels } from "./query-labels.js"; +import { xrpcError } from "./xrpc.js"; + +const QUERY_LABELS_PATH = "/xrpc/com.atproto.label.queryLabels"; + +export default { + async fetch(request: Request, env: Env): Promise { + const pathname = new URL(request.url).pathname; + if (pathname.startsWith("/xrpc/")) { + // Require the deployment identity even though this first public route + // is read-only, so issuance cannot be configured against another DID. + try { + getLabelerConfig(env); + } catch { + return xrpcError("InternalServerError", "labeler is not configured", 500); + } + if (pathname === QUERY_LABELS_PATH) return queryLabels(env.DB, request); + return xrpcError("MethodNotSupported", "XRPC method not found", 404); + } + return new Response("emdash-labeler: not found", { status: 404 }); + }, +}; diff --git a/apps/labeler/src/query-labels.ts b/apps/labeler/src/query-labels.ts new file mode 100644 index 0000000000..e341633913 --- /dev/null +++ b/apps/labeler/src/query-labels.ts @@ -0,0 +1,109 @@ +import { xrpcError } from "./xrpc.js"; + +const DID = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+(?:[:][A-Za-z0-9._:%-]+)*$/; +const DIGITS = /^\d+$/; +const POSITIVE_INTEGER = /^[1-9]\d*$/; +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 250; + +interface LabelRow { + sequence: number; + ver: number; + src: string; + uri: string; + cid: string | null; + val: string; + neg: number; + cts: string; + exp: string | null; + sig: ArrayBuffer; +} + +export async function queryLabels(db: D1Database, request: Request): Promise { + if (request.method !== "GET") { + return xrpcError("MethodNotSupported", "queryLabels only supports GET", 405, { allow: "GET" }); + } + const params = new URL(request.url).searchParams; + const uriPatterns = params.getAll("uriPatterns"); + if (uriPatterns.length === 0) return badRequest("uriPatterns is required"); + const patterns = uriPatterns.map(parseUriPattern); + if (patterns.some((pattern) => pattern === null)) return badRequest("invalid uriPatterns"); + const sources = params.getAll("sources"); + if (sources.some((source) => !DID.test(source))) return badRequest("sources must contain DIDs"); + const limit = parseLimit(params.get("limit")); + if (limit === null) return badRequest("limit must be an integer between 1 and 250"); + const cursor = parseCursor(params.get("cursor")); + if (cursor === null) return badRequest("cursor must be a positive integer"); + + const patternClauses: string[] = []; + const values: (string | number)[] = []; + for (const pattern of patterns) { + if (!pattern) continue; + if (pattern.endsWith("*")) { + patternClauses.push("substr(uri, 1, ?) = ?"); + const prefix = pattern.slice(0, -1); + values.push(prefix.length, prefix); + } else { + patternClauses.push("uri = ?"); + values.push(pattern); + } + } + const sourceClause = + sources.length > 0 ? ` AND src IN (${sources.map(() => "?").join(", ")})` : ""; + values.push(...sources, cursor ?? 0, limit + 1); + const rows = await db + .prepare( + `SELECT sequence, ver, src, uri, cid, val, neg, cts, exp, sig + FROM issued_labels + WHERE (${patternClauses.join(" OR ")})${sourceClause} AND sequence > ? + ORDER BY sequence ASC LIMIT ?`, + ) + .bind(...values) + .all(); + const labels = rows.results ?? []; + const page = labels.slice(0, limit); + const last = page.at(-1); + return Response.json({ + labels: page.map((label) => ({ + ver: label.ver, + src: label.src, + uri: label.uri, + ...(label.cid === null ? {} : { cid: label.cid }), + val: label.val, + ...(label.neg === 1 ? { neg: true } : {}), + cts: label.cts, + ...(label.exp === null ? {} : { exp: label.exp }), + sig: { $bytes: toBase64(new Uint8Array(label.sig)) }, + })), + ...(labels.length > limit && last ? { cursor: `${last.sequence}` } : {}), + }); +} + +function parseUriPattern(value: string): string | null { + if (value.length === 0 || value.length > 2_000) return null; + const firstStar = value.indexOf("*"); + if (firstStar !== -1 && firstStar !== value.length - 1) return null; + return value; +} + +function parseLimit(value: string | null): number | null { + if (value === null) return DEFAULT_LIMIT; + if (!DIGITS.test(value)) return null; + const limit = Number(value); + return Number.isSafeInteger(limit) && limit >= 1 && limit <= MAX_LIMIT ? limit : null; +} + +function parseCursor(value: string | null): number | null { + if (value === null) return 0; + if (!POSITIVE_INTEGER.test(value)) return null; + const cursor = Number(value); + return Number.isSafeInteger(cursor) ? cursor : null; +} + +function badRequest(message: string): Response { + return xrpcError("InvalidRequest", message, 400); +} + +function toBase64(value: Uint8Array): string { + return btoa(String.fromCharCode(...value)); +} diff --git a/apps/labeler/src/service.ts b/apps/labeler/src/service.ts new file mode 100644 index 0000000000..a0025d2ddd --- /dev/null +++ b/apps/labeler/src/service.ts @@ -0,0 +1,205 @@ +import type { LabelSigner, SignedLabel } from "@emdash-cms/registry-moderation"; + +import type { LabelerConfig } from "./config.js"; + +const DID = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+(?:[:][A-Za-z0-9._:%-]+)*$/; +const REGISTRY_RECORD = + /^at:\/\/(did:[a-z0-9]+:[A-Za-z0-9._:%-]+(?:[:][A-Za-z0-9._:%-]+)*)\/(com\.emdashcms\.experimental\.package\.(?:profile|release))\/([A-Za-z0-9._~:%-]+)$/; + +export type ManualLabelValue = + | "!takedown" + | "package-disputed" + | "publisher-compromised" + | "security-yanked"; + +export interface AuthorizedIssuanceAction { + actor: string; + type: "manual-label"; + reason: string; + idempotencyKey: string; +} + +export interface AllowedLabelProposal { + uri: string; + val: ManualLabelValue; + cid?: string; + neg?: boolean; + exp?: string; +} + +export interface IssuedLabel { + action: AuthorizedIssuanceAction; + label: SignedLabel; + sequence: number; + signingKeyId: string; +} + +interface StoredLabelRow { + actor: string; + type: string; + reason: string; + idempotency_key: string; + sequence: number; + ver: number; + src: string; + uri: string; + cid: string | null; + val: string; + neg: number; + cts: string; + exp: string | null; + sig: ArrayBuffer; + signing_key_id: string; +} + +export async function issueManualLabel( + db: D1Database, + config: LabelerConfig, + signer: LabelSigner, + action: AuthorizedIssuanceAction, + proposal: AllowedLabelProposal, + now = new Date(), +): Promise { + if (signer.issuerDid !== config.labelerDid) + throw new TypeError("signer issuer does not match the configured labeler DID"); + validateAction(action); + validateProposal(proposal); + + const existing = await getIssuedLabel(db, action.idempotencyKey); + if (existing) return assertMatches(existing, signer, action, proposal); + + const label = await signer.sign({ + ver: 1, + uri: proposal.uri, + ...(proposal.cid === undefined ? {} : { cid: proposal.cid }), + val: proposal.val, + ...(proposal.neg === true ? { neg: true } : {}), + cts: now.toISOString(), + ...(proposal.exp === undefined ? {} : { exp: proposal.exp }), + }); + const signingKeyId = `${signer.issuerDid}#atproto_label`; + + await db.batch([ + db + .prepare( + `INSERT INTO issuance_actions (actor, type, reason, idempotency_key, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(idempotency_key) DO NOTHING`, + ) + .bind(action.actor, action.type, action.reason, action.idempotencyKey, now.toISOString()), + db + .prepare( + `INSERT INTO issued_labels + (action_id, ver, src, uri, cid, val, neg, cts, exp, sig, signing_key_id) + SELECT id, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + FROM issuance_actions + WHERE idempotency_key = ? + AND NOT EXISTS (SELECT 1 FROM issued_labels WHERE action_id = issuance_actions.id)`, + ) + .bind( + label.ver, + label.src, + label.uri, + label.cid ?? null, + label.val, + label.neg === true ? 1 : 0, + label.cts, + label.exp ?? null, + label.sig, + signingKeyId, + action.idempotencyKey, + ), + ]); + + const issued = await getIssuedLabel(db, action.idempotencyKey); + if (!issued) throw new Error("label issuance did not persist"); + return assertMatches(issued, signer, action, proposal); +} + +async function getIssuedLabel( + db: D1Database, + idempotencyKey: string, +): Promise { + return db + .prepare( + `SELECT a.actor, a.type, a.reason, a.idempotency_key, l.sequence, l.ver, l.src, l.uri, + l.cid, l.val, l.neg, l.cts, l.exp, l.sig, l.signing_key_id + FROM issuance_actions a + JOIN issued_labels l ON l.action_id = a.id + WHERE a.idempotency_key = ?`, + ) + .bind(idempotencyKey) + .first(); +} + +function assertMatches( + stored: StoredLabelRow, + signer: LabelSigner, + action: AuthorizedIssuanceAction, + proposal: AllowedLabelProposal, +): IssuedLabel { + if ( + stored.actor !== action.actor || + stored.type !== action.type || + stored.reason !== action.reason || + stored.src !== signer.issuerDid || + stored.signing_key_id !== `${signer.issuerDid}#atproto_label` || + stored.uri !== proposal.uri || + stored.cid !== (proposal.cid ?? null) || + stored.val !== proposal.val || + (stored.neg === 1) !== (proposal.neg === true) || + stored.exp !== (proposal.exp ?? null) + ) { + throw new TypeError("idempotency key is already bound to a different issuance"); + } + return { + action, + label: { + ver: 1, + src: stored.src, + uri: stored.uri, + ...(stored.cid === null ? {} : { cid: stored.cid }), + val: stored.val, + ...(stored.neg === 1 ? { neg: true } : {}), + cts: stored.cts, + ...(stored.exp === null ? {} : { exp: stored.exp }), + sig: new Uint8Array(stored.sig), + }, + sequence: stored.sequence, + signingKeyId: stored.signing_key_id, + }; +} + +function validateAction(action: AuthorizedIssuanceAction): void { + if (!DID.test(action.actor)) throw new TypeError("action.actor must be a DID"); + if (action.type !== "manual-label") throw new TypeError("action.type must be manual-label"); + if (action.reason.trim().length === 0 || action.reason.length > 1_000) + throw new TypeError("action.reason must be between 1 and 1000 characters"); + if (action.idempotencyKey.length < 1 || action.idempotencyKey.length > 200) + throw new TypeError("action.idempotencyKey must be between 1 and 200 characters"); +} + +function validateProposal(proposal: AllowedLabelProposal): void { + const record = REGISTRY_RECORD.exec(proposal.uri); + const isDidSubject = DID.test(proposal.uri); + switch (proposal.val) { + case "publisher-compromised": + if (!isDidSubject || proposal.cid !== undefined) + throw new TypeError("publisher-compromised must target a DID without a CID"); + return; + case "!takedown": + if (!isDidSubject && !record) + throw new TypeError("!takedown must target a DID, package profile, or release record"); + if (proposal.cid !== undefined) throw new TypeError("!takedown must not include a CID"); + return; + case "package-disputed": + if (!record || !record[2]!.endsWith(".profile")) + throw new TypeError("package-disputed must target a package profile record"); + return; + case "security-yanked": + if (!record || !record[2]!.endsWith(".release")) + throw new TypeError("security-yanked must target a release record"); + if (proposal.cid !== undefined) throw new TypeError("security-yanked must not include a CID"); + return; + } +} diff --git a/apps/labeler/src/xrpc.ts b/apps/labeler/src/xrpc.ts new file mode 100644 index 0000000000..ec2aebe155 --- /dev/null +++ b/apps/labeler/src/xrpc.ts @@ -0,0 +1,8 @@ +export function xrpcError( + error: string, + message: string, + status: number, + headers?: HeadersInit, +): Response { + return Response.json({ error, message }, { status, headers }); +} diff --git a/apps/labeler/test/service.test.ts b/apps/labeler/test/service.test.ts new file mode 100644 index 0000000000..e36505e836 --- /dev/null +++ b/apps/labeler/test/service.test.ts @@ -0,0 +1,282 @@ +import { + createLabelSigner, + verifyLabel, + type LabelDidDocument, +} from "@emdash-cms/registry-moderation"; +import { applyD1Migrations, env, SELF } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { issueManualLabel, type AllowedLabelProposal } from "../src/service.js"; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; +} + +const testEnv = env as unknown as TestEnv; +const LABELER_DID = "did:example:labeler"; +const PUBLISHER_DID = "did:plc:publisher000000000000000000"; +const PRIVATE_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE"; +const MULTIKEY = "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ"; +const CID = "bafkreif4oaymum54i5qefbwoblrt5zasfjhpyhyvacpseqtehi3queew5m"; +const config = { labelerDid: LABELER_DID }; +let actionNumber = 0; + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); +}); + +function releaseUri(name: string): string { + return `at://${PUBLISHER_DID}/com.emdashcms.experimental.package.release/${name}:1.0.0`; +} + +function profileUri(name: string): string { + return `at://${PUBLISHER_DID}/com.emdashcms.experimental.package.profile/${name}`; +} + +function action(reason: string) { + actionNumber++; + return { + actor: "did:example:moderator", + type: "manual-label" as const, + reason, + idempotencyKey: `test-${actionNumber}`, + }; +} + +function proposal(uri: string, val: AllowedLabelProposal["val"] = "security-yanked") { + return { uri, val }; +} + +function document(did = LABELER_DID): LabelDidDocument { + return { + id: did, + verificationMethod: [ + { + id: "#atproto_label", + type: "Multikey", + controller: did, + publicKeyMultibase: MULTIKEY, + }, + ], + }; +} + +async function signer() { + return createLabelSigner({ + issuerDid: LABELER_DID, + privateKey: PRIVATE_KEY, + resolveDid: async () => document(), + }); +} + +async function foreignSigner() { + const issuerDid = "did:example:foreign"; + return createLabelSigner({ + issuerDid, + privateKey: PRIVATE_KEY, + resolveDid: async () => document(issuerDid), + }); +} + +async function issue(uri: string, val?: AllowedLabelProposal["val"], neg = false) { + return issueManualLabel( + testEnv.DB, + config, + await signer(), + action(`test issue ${actionNumber + 1}`), + { + ...proposal(uri, val), + ...(neg ? { neg: true } : {}), + }, + ); +} + +describe("manual label issuance", () => { + it("issues a signed label that the public query returns without a secret", async () => { + const uri = releaseUri("round-trip"); + const issued = await issue(uri); + + const response = await SELF.fetch( + `https://test/xrpc/com.atproto.label.queryLabels?uriPatterns=${encodeURIComponent(uri)}`, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + labels: Array<{ + sig: { $bytes: string }; + src: string; + uri: string; + val: string; + cts: string; + }>; + }; + expect(body.labels).toHaveLength(1); + expect(body.labels[0]).toMatchObject({ src: LABELER_DID, uri, val: "security-yanked" }); + + const returned = body.labels[0]!; + await expect( + verifyLabel({ + label: { ...returned, ver: 1, sig: fromBase64(returned.sig.$bytes) }, + resolveDid: async () => document(), + }), + ).resolves.toMatchObject({ src: LABELER_DID, uri, val: "security-yanked" }); + expect(issued.signingKeyId).toBe(`${LABELER_DID}#atproto_label`); + }); + + it("allocates monotonically increasing sequences", async () => { + const first = await issue(releaseUri("sequence-a")); + const second = await issue(releaseUri("sequence-b")); + expect(second.sequence).toBeGreaterThan(first.sequence); + }); + + it("returns the original signed label for a duplicate action", async () => { + const labelSigner = await signer(); + const originalAction = action("idempotent issue"); + const input = proposal(releaseUri("idempotent")); + const first = await issueManualLabel(testEnv.DB, config, labelSigner, originalAction, input); + const second = await issueManualLabel(testEnv.DB, config, labelSigner, originalAction, input); + expect(second.sequence).toBe(first.sequence); + expect(second.label).toEqual(first.label); + const count = await testEnv.DB.prepare( + `SELECT COUNT(*) AS count FROM issued_labels l + JOIN issuance_actions a ON a.id = l.action_id + WHERE a.idempotency_key = ?`, + ) + .bind(originalAction.idempotencyKey) + .first<{ count: number }>(); + expect(count?.count).toBe(1); + }); + + it("records a later negation as a new signed history entry", async () => { + const uri = releaseUri("negation"); + const active = await issue(uri, "security-yanked"); + const negated = await issue(uri, "security-yanked", true); + expect(negated.sequence).toBeGreaterThan(active.sequence); + + const response = await SELF.fetch( + `https://test/xrpc/com.atproto.label.queryLabels?uriPatterns=${encodeURIComponent(uri)}`, + ); + const body = (await response.json()) as { labels: Array<{ neg?: boolean }> }; + expect(body.labels).toHaveLength(2); + expect(body.labels.map((label) => label.neg === true)).toEqual([false, true]); + }); + + it("enforces the manual proposal subject table", async () => { + await expect(issue(PUBLISHER_DID, "!takedown")).resolves.toBeDefined(); + await expect(issue(profileUri("disputed"), "package-disputed")).resolves.toBeDefined(); + await expect( + issueManualLabel(testEnv.DB, config, await signer(), action("CID takedown"), { + uri: releaseUri("exact-cid"), + cid: CID, + val: "!takedown", + }), + ).rejects.toThrow("!takedown must not include a CID"); + await expect( + issueManualLabel(testEnv.DB, config, await signer(), action("CID security yank"), { + uri: releaseUri("yanked-cid"), + cid: CID, + val: "security-yanked", + }), + ).rejects.toThrow("security-yanked must not include a CID"); + await expect( + issueManualLabel(testEnv.DB, config, await signer(), action("invalid disputed release"), { + uri: releaseUri("not-disputed"), + val: "package-disputed", + }), + ).rejects.toThrow("package profile"); + await expect( + issueManualLabel(testEnv.DB, config, await signer(), action("invalid yanked package"), { + uri: profileUri("not-yanked"), + val: "security-yanked", + }), + ).rejects.toThrow("release record"); + }); + + it("rejects a signer that is not bound to the configured labeler DID", async () => { + await expect( + issueManualLabel( + testEnv.DB, + config, + await foreignSigner(), + action("foreign signer"), + proposal(releaseUri("foreign-signer")), + ), + ).rejects.toThrow("configured labeler DID"); + }); + + it("allows only signature metadata rotation after sequence allocation", async () => { + const issued = await issue(releaseUri("key-rotation")); + await expect( + testEnv.DB.prepare("UPDATE issued_labels SET sig = ?, signing_key_id = ? WHERE sequence = ?") + .bind(issued.label.sig, "did:example:labeler#rotated", issued.sequence) + .run(), + ).resolves.toBeDefined(); + await expect( + testEnv.DB.prepare("UPDATE issued_labels SET val = ? WHERE sequence = ?") + .bind("package-disputed", issued.sequence) + .run(), + ).rejects.toThrow("issued labels are immutable"); + }); + + it("filters URI prefixes and sources, then paginates in sequence order", async () => { + const base = `at://${PUBLISHER_DID}/com.emdashcms.experimental.package.release/paging-`; + await issue(`${base}a:1.0.0`); + await issue(`${base}b:1.0.0`); + await issue(`${base}c:1.0.0`); + const first = await SELF.fetch( + `https://test/xrpc/com.atproto.label.queryLabels?uriPatterns=${encodeURIComponent(`${base}*`)}&sources=${LABELER_DID}&limit=2`, + ); + const firstBody = (await first.json()) as { labels: Array<{ uri: string }>; cursor: string }; + expect(firstBody.labels).toHaveLength(2); + expect(firstBody.cursor).toBeTruthy(); + const second = await SELF.fetch( + `https://test/xrpc/com.atproto.label.queryLabels?uriPatterns=${encodeURIComponent(`${base}*`)}&sources=${LABELER_DID}&limit=2&cursor=${firstBody.cursor}`, + ); + const secondBody = (await second.json()) as { labels: Array<{ uri: string }>; cursor?: string }; + expect(secondBody.labels).toHaveLength(1); + expect(secondBody.cursor).toBeUndefined(); + const wrongSource = await SELF.fetch( + `https://test/xrpc/com.atproto.label.queryLabels?uriPatterns=${encodeURIComponent(`${base}*`)}&sources=did:example:other`, + ); + expect(((await wrongSource.json()) as { labels: unknown[] }).labels).toEqual([]); + }); + + it("rejects invalid issuance and query input", async () => { + await expect( + issueManualLabel(testEnv.DB, config, await signer(), action("invalid subject"), { + uri: "https://example.com/not-a-subject", + val: "security-yanked", + }), + ).rejects.toThrow("security-yanked must target a release record"); + await expect( + issueManualLabel(testEnv.DB, config, await signer(), action("invalid publisher CID"), { + uri: PUBLISHER_DID, + cid: CID, + val: "publisher-compromised", + }), + ).rejects.toThrow("publisher-compromised"); + const missingPatterns = await SELF.fetch( + "https://test/xrpc/com.atproto.label.queryLabels?limit=2", + ); + expect(missingPatterns.status).toBe(400); + const badCursor = await SELF.fetch( + "https://test/xrpc/com.atproto.label.queryLabels?uriPatterns=at%3A%2F%2Fdid%3Aexample%3Aone%2F*&cursor=0", + ); + expect(badCursor.status).toBe(400); + const methodNotAllowed = await SELF.fetch( + "https://test/xrpc/com.atproto.label.queryLabels?uriPatterns=did%3Aexample%3Atest", + { method: "POST" }, + ); + expect(methodNotAllowed.status).toBe(405); + expect(methodNotAllowed.headers.get("allow")).toBe("GET"); + expect(await methodNotAllowed.json()).toMatchObject({ error: "MethodNotSupported" }); + const unknown = await SELF.fetch("https://test/xrpc/com.example.unknown"); + expect(unknown.status).toBe(404); + expect(await unknown.json()).toMatchObject({ error: "MethodNotSupported" }); + }); +}); + +function fromBase64(value: string): Uint8Array { + const decoded = atob(value); + return Uint8Array.from(decoded, (char) => char.charCodeAt(0)); +} diff --git a/apps/labeler/tsconfig.json b/apps/labeler/tsconfig.json new file mode 100644 index 0000000000..34ce731d08 --- /dev/null +++ b/apps/labeler/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/labeler/vite.config.ts b/apps/labeler/vite.config.ts new file mode 100644 index 0000000000..f6268f8e95 --- /dev/null +++ b/apps/labeler/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/labeler/vitest.config.ts b/apps/labeler/vitest.config.ts new file mode 100644 index 0000000000..990e50e615 --- /dev/null +++ b/apps/labeler/vitest.config.ts @@ -0,0 +1,16 @@ +import { fileURLToPath } from "node:url"; + +import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +const migrationsPath = fileURLToPath(new URL("./migrations", import.meta.url)); +const migrations = await readD1Migrations(migrationsPath); + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.jsonc" }, + miniflare: { bindings: { TEST_MIGRATIONS: migrations } }, + }), + ], +}); diff --git a/apps/labeler/worker-configuration.d.ts b/apps/labeler/worker-configuration.d.ts new file mode 100644 index 0000000000..ba40d353c9 --- /dev/null +++ b/apps/labeler/worker-configuration.d.ts @@ -0,0 +1,14489 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types` (hash: 4fdf0a8766e86aa199985f14decef948) +// Runtime types generated with workerd@1.20260611.1 2026-02-24 nodejs_compat +interface __BaseEnv_Env { + DB: D1Database; + LABELER_DID: "did:web:labels.emdashcms.com"; +} +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 ` + + diff --git a/apps/labeler/console/src/App.tsx b/apps/labeler/console/src/App.tsx new file mode 100644 index 0000000000..2c8b21f5d4 --- /dev/null +++ b/apps/labeler/console/src/App.tsx @@ -0,0 +1,66 @@ +import { LinkProvider, Toasty, type LinkComponentProps } from "@cloudflare/kumo"; +import { DirectionProvider } from "@cloudflare/kumo/primitives"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Link, RouterProvider, type LinkProps } from "@tanstack/react-router"; +import * as React from "react"; + +import { createConsoleRouter } from "./router.js"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60, + retry: 1, + }, + }, +}); + +const router = createConsoleRouter(queryClient); + +/** Kumo components navigate via a plain `href`; this maps router-internal + * paths (leading "/", no target/download) to a TanStack Router Link so they + * get client-side navigation, and falls back to a real anchor otherwise — + * mirrors packages/admin's App.tsx without that package's admin-basepath + * href rewriting, which this console's flat `/admin` basepath doesn't need. */ +const KumoRouterLink = React.forwardRef( + function KumoRouterLink({ href, to, target, download, children, ...props }, ref) { + const destination = href ?? to ?? ""; + const isRouterPath = destination.startsWith("/") && !destination.startsWith("//"); + + if (!isRouterPath || target || download != null) { + return ( + + {children} + + ); + } + + return ( + )} + ref={ref} + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- Kumo provides runtime hrefs; TanStack requires literal route types. + to={destination as "/"} + > + {children} + + ); + }, +); + +export function ConsoleApp() { + return ( + + + + + + + + + + ); +} + +export default ConsoleApp; diff --git a/apps/labeler/console/src/api/client.test.ts b/apps/labeler/console/src/api/client.test.ts new file mode 100644 index 0000000000..ea83dd019f --- /dev/null +++ b/apps/labeler/console/src/api/client.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { apiClient } from "./client.js"; + +describe("fixture apiClient", () => { + it("lists assessments newest first", async () => { + const page = await apiClient.listAssessments(); + expect(page.items.length).toBeGreaterThan(0); + const timestamps = page.items.map((a) => Date.parse(a.createdAt)); + expect(timestamps).toEqual(timestamps.toSorted((a, b) => b - a)); + }); + + it("filters assessments by public state", async () => { + const page = await apiClient.listAssessments({ state: "blocked" }); + expect(page.items.length).toBeGreaterThan(0); + expect(page.items.every((a) => a.publicState === "blocked")).toBe(true); + }); + + it("returns findings for a blocked assessment", async () => { + const [blocked] = (await apiClient.listAssessments({ state: "blocked" })).items; + expect(blocked).toBeDefined(); + const findings = await apiClient.listFindings(blocked!.id); + expect(findings.length).toBeGreaterThan(0); + for (const finding of findings) { + expect(finding.assessmentId).toBe(blocked!.id); + } + }); + + it("returns null for an unknown assessment id", async () => { + expect(await apiClient.getAssessment("asmt_does_not_exist")).toBeNull(); + }); + + it("returns subject history keyed by the exact URI", async () => { + const [assessment] = (await apiClient.listAssessments()).items; + expect(assessment).toBeDefined(); + const history = await apiClient.getSubjectHistory(assessment!.uri); + expect(history?.subject.uri).toBe(assessment!.uri); + expect(history?.assessments.some((a) => a.id === assessment!.id)).toBe(true); + }); +}); diff --git a/apps/labeler/console/src/api/client.ts b/apps/labeler/console/src/api/client.ts new file mode 100644 index 0000000000..7bf3107512 --- /dev/null +++ b/apps/labeler/console/src/api/client.ts @@ -0,0 +1,147 @@ +import { + FIXTURE_ASSESSMENTS, + FIXTURE_FINDINGS_BY_ASSESSMENT, + FIXTURE_LABELS_BY_ASSESSMENT, + FIXTURE_OPERATOR_ACTIONS, + FIXTURE_SUBJECT_HISTORY, + FIXTURE_SYSTEM_STATUS, +} from "../fixtures/index.js"; +import type { + AssessmentRun, + IssuedLabel, + ListAssessmentsParams, + ListAuditLogParams, + OperatorAction, + OperatorFinding, + Page, + SubjectHistoryView, + SystemStatusSnapshot, +} from "./types.js"; + +export const CONSOLE_API_BASE = "/admin/api"; + +/** The console's data-access contract. `fixtureClient` below is the only + * implementation wired up today; `createFetchClient` talks to the real + * Worker routes once W9.4-W9.6 land — swapping `apiClient`'s assignment at + * the bottom of this file is the only change either side needs. */ +export interface LabelerConsoleClient { + listAssessments(params?: ListAssessmentsParams): Promise>; + getAssessment(id: string): Promise; + listFindings(assessmentId: string): Promise; + listLabels(assessmentId: string): Promise; + getSubjectHistory(uri: string): Promise; + listAuditLog(params?: ListAuditLogParams): Promise>; + getSystemStatus(): Promise; +} + +interface ApiErrorBody { + error?: { code?: string; message?: string }; +} + +function isApiErrorBody(value: unknown): value is ApiErrorBody { + return typeof value === "object" && value !== null; +} + +/** Fetch wrapper for the future `/admin/api/*` surface — required headers + * per the labeler's mutation-guard CSRF contract (plan W9.2), carried here + * even though every route this client calls today is a read. */ +function consoleApiFetch(path: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers); + headers.set("X-EmDash-Request", "1"); + return fetch(`${CONSOLE_API_BASE}${path}`, { ...init, headers, credentials: "same-origin" }); +} + +async function parseJson(response: Response, fallback: string): Promise { + if (!response.ok) { + const body: unknown = await response.json().catch(() => undefined); + const message = + isApiErrorBody(body) && typeof body.error?.message === "string" + ? body.error.message + : fallback; + throw new Error(message); + } + const body: { data: T } = await response.json(); + return body.data; +} + +/** Talks to the real `/admin/api/*` routes. Not wired up yet — see + * `apiClient` at the bottom of this file. */ +export function createFetchClient(): LabelerConsoleClient { + return { + async listAssessments(params = {}) { + const search = new URLSearchParams(); + if (params.state) search.set("state", params.state); + if (params.cursor) search.set("cursor", params.cursor); + if (params.limit) search.set("limit", String(params.limit)); + const response = await consoleApiFetch(`/assessments?${search.toString()}`); + return parseJson(response, "Failed to load assessments"); + }, + async getAssessment(id) { + const response = await consoleApiFetch(`/assessments/${encodeURIComponent(id)}`); + if (response.status === 404) return null; + return parseJson(response, "Failed to load assessment"); + }, + async listFindings(assessmentId) { + const response = await consoleApiFetch( + `/assessments/${encodeURIComponent(assessmentId)}/findings`, + ); + return parseJson(response, "Failed to load findings"); + }, + async listLabels(assessmentId) { + const response = await consoleApiFetch( + `/assessments/${encodeURIComponent(assessmentId)}/labels`, + ); + return parseJson(response, "Failed to load labels"); + }, + async getSubjectHistory(uri) { + const response = await consoleApiFetch(`/subjects/${encodeURIComponent(uri)}`); + if (response.status === 404) return null; + return parseJson(response, "Failed to load subject history"); + }, + async listAuditLog(params = {}) { + const search = new URLSearchParams(); + if (params.cursor) search.set("cursor", params.cursor); + if (params.limit) search.set("limit", String(params.limit)); + const response = await consoleApiFetch(`/audit-log?${search.toString()}`); + return parseJson(response, "Failed to load audit log"); + }, + async getSystemStatus() { + const response = await consoleApiFetch("/status"); + return parseJson(response, "Failed to load system status"); + }, + }; +} + +/** Reads the static fixtures under src/fixtures/ — the only client wired + * up until the labeler's `/admin/api/*` routes exist. */ +function createFixtureClient(): LabelerConsoleClient { + return { + async listAssessments(params = {}) { + const filtered = params.state + ? FIXTURE_ASSESSMENTS.filter((a) => a.publicState === params.state) + : FIXTURE_ASSESSMENTS; + const limit = params.limit ?? 50; + return { items: filtered.slice(0, limit) }; + }, + async getAssessment(id) { + return FIXTURE_ASSESSMENTS.find((a) => a.id === id) ?? null; + }, + async listFindings(assessmentId) { + return [...(FIXTURE_FINDINGS_BY_ASSESSMENT[assessmentId] ?? [])]; + }, + async listLabels(assessmentId) { + return [...(FIXTURE_LABELS_BY_ASSESSMENT[assessmentId] ?? [])]; + }, + async getSubjectHistory(uri) { + return FIXTURE_SUBJECT_HISTORY[uri] ?? null; + }, + async listAuditLog() { + return { items: [...FIXTURE_OPERATOR_ACTIONS] }; + }, + async getSystemStatus() { + return FIXTURE_SYSTEM_STATUS; + }, + }; +} + +export const apiClient: LabelerConsoleClient = createFixtureClient(); diff --git a/apps/labeler/console/src/api/types.ts b/apps/labeler/console/src/api/types.ts new file mode 100644 index 0000000000..f6c61a803e --- /dev/null +++ b/apps/labeler/console/src/api/types.ts @@ -0,0 +1,145 @@ +/** + * Console-side mirrors of the labeler's stored shapes (assessment-store.ts, + * findings.ts, policy-resolver.ts, assessment-lifecycle.ts). Deliberately + * redeclared rather than imported: those modules pull in D1/Workers types + * that don't belong in a browser bundle, and the console's contract is + * "what an authenticated operator API returns," which only needs to stay + * shape-compatible with the store, not import it directly. + */ + +export type AssessmentState = + | "observed" + | "verifying" + | "pending" + | "running" + | "passed" + | "warned" + | "blocked" + | "error" + | "stale" + | "cancelled"; + +/** The public assessment API's narrower state vocabulary (see + * apps/labeler/src/public-assessment.ts's `derivePublicState`). Assessments + * in a pre-decision or inconclusive-terminal internal state have no public + * state at all — the operator console still shows them via `state`. */ +export type PublicAssessmentState = + | "pending" + | "passed" + | "warned" + | "blocked" + | "error" + | "superseded"; + +export type FindingSeverity = "critical" | "high" | "medium" | "low" | "info"; + +export type FindingSource = "deterministic" | "capability" | "model" | "image" | "history"; + +export interface AssessmentRun { + id: string; + runKey: string; + uri: string; + cid: string; + artifactId: string | null; + artifactChecksum: string | null; + state: AssessmentState; + /** Precomputed by the server from `state` + supersession (see + * `derivePublicState`) — `null` for internal-only states. */ + publicState: PublicAssessmentState | null; + trigger: string; + triggerId: string; + policyVersion: string; + modelId: string | null; + promptHash: string | null; + publicSummary: string | null; + supersedesAssessmentId: string | null; + startedAt: string | null; + completedAt: string | null; + createdAt: string; + isSuperseded: boolean; +} + +/** + * An operator-facing finding view. Unlike the public assessment API's + * `PublicFindingView` (apps/labeler/src/evidence.ts), this carries + * `privateDetail` and `evidenceRefs` — fields the labeler's public routes + * never serialize. Components rendering a finding must keep the public and + * private field groups visually distinct (see FindingCard) so this type + * can't be reused for a public-facing view by accident. + */ +export interface OperatorFinding { + id: string; + assessmentId: string; + source: FindingSource; + category: string; + severity: FindingSeverity; + confidence?: number; + title: string; + publicSummary: string; + privateDetail: string; + evidenceRefs: readonly string[]; + affectedFiles?: readonly string[]; + affectedImages?: readonly string[]; + createdAt: string; +} + +export interface IssuedLabel { + val: string; + cts: string; + exp: string | null; + neg: boolean; + sequence: number; +} + +export interface SubjectRecord { + uri: string; + cid: string; + did: string; + collection: string; + rkey: string; + observedAt: string; + deletedAt: string | null; +} + +export interface SubjectHistoryView { + subject: SubjectRecord; + assessments: AssessmentRun[]; +} + +/** + * Placeholder shape for the append-only `operator_actions` audit table + * (plan W9.2) — that table doesn't exist yet, so the audit log route + * renders an empty state rather than calling this through the client. + */ +export interface OperatorAction { + id: string; + actorDid: string; + action: string; + subjectUri: string | null; + reason: string; + createdAt: string; +} + +export interface SystemStatusSnapshot { + labelerDid: string; + jetstreamConnected: boolean; + pendingAssessments: number; + discoveryQueueDepth: number; + lastReconciliationAt: string | null; +} + +export interface Page { + items: T[]; + nextCursor?: string; +} + +export interface ListAssessmentsParams { + state?: PublicAssessmentState; + cursor?: string; + limit?: number; +} + +export interface ListAuditLogParams { + cursor?: string; + limit?: number; +} diff --git a/apps/labeler/console/src/components/FindingCard.tsx b/apps/labeler/console/src/components/FindingCard.tsx new file mode 100644 index 0000000000..5af01c8f97 --- /dev/null +++ b/apps/labeler/console/src/components/FindingCard.tsx @@ -0,0 +1,46 @@ +import { Badge, LayerCard } from "@cloudflare/kumo"; + +import type { OperatorFinding } from "../api/types.js"; +import { SeverityBadge } from "./SeverityBadge.js"; + +export interface FindingCardProps { + finding: OperatorFinding; +} + +/** + * Renders a finding with the public/private field groups visually + * separated — `publicSummary` is what a `publicAssessment` API response + * would show; `privateDetail` and `evidenceRefs` never leave the labeler's + * public routes (see apps/labeler/src/evidence.ts's `toPublicFinding`). + */ +export function FindingCard({ finding }: FindingCardProps) { + return ( + +
+
+ + {finding.category} +
+ {finding.source} +
+

{finding.title}

+
+

+ Public summary +

+

{finding.publicSummary}

+
+
+

+ Private detail — operators only +

+

{finding.privateDetail}

+ {finding.evidenceRefs.length > 0 && ( +

+ Evidence: {finding.evidenceRefs.join(", ")} +

+ )} +
+
+ ); +} diff --git a/apps/labeler/console/src/components/Header.tsx b/apps/labeler/console/src/components/Header.tsx new file mode 100644 index 0000000000..b274a30842 --- /dev/null +++ b/apps/labeler/console/src/components/Header.tsx @@ -0,0 +1,16 @@ +import { Badge } from "@cloudflare/kumo"; + +import { Sidebar } from "./Sidebar.js"; + +/** + * Console header with the sidebar toggle. Operator identity (W9.1) and + * session actions land with the auth PR — this scaffold has no user menu. + */ +export function Header() { + return ( +
+ + Fixture data +
+ ); +} diff --git a/apps/labeler/console/src/components/QueryError.test.tsx b/apps/labeler/console/src/components/QueryError.test.tsx new file mode 100644 index 0000000000..fc4ad2fe6a --- /dev/null +++ b/apps/labeler/console/src/components/QueryError.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { QueryError } from "./QueryError.js"; + +describe("QueryError", () => { + it("renders the failure title and the underlying error message", () => { + render(); + + expect(screen.getByText("Failed to load assessments")).toBeTruthy(); + expect(screen.getByText(/network down/)).toBeTruthy(); + }); + + it("renders distinctly from a not-found state rather than masquerading as one", () => { + render(); + + expect(screen.queryByText(/not found/i)).toBeNull(); + }); + + it("falls back to a generic message for a non-Error rejection", () => { + render(); + + expect(screen.getByText(/An unexpected error occurred/)).toBeTruthy(); + }); +}); diff --git a/apps/labeler/console/src/components/QueryError.tsx b/apps/labeler/console/src/components/QueryError.tsx new file mode 100644 index 0000000000..75a0dfbf5f --- /dev/null +++ b/apps/labeler/console/src/components/QueryError.tsx @@ -0,0 +1,24 @@ +import { Banner } from "@cloudflare/kumo"; +import { WarningCircle } from "@phosphor-icons/react"; + +export interface QueryErrorProps { + title: string; + error: unknown; +} + +/** + * Shared error presentation for a failed query. No retry machinery — once + * the console talks to a real API a reload is enough to retry, and this + * scaffold never mutates state a retry button would need to redo. + */ +export function QueryError({ title, error }: QueryErrorProps) { + const detail = error instanceof Error ? error.message : "An unexpected error occurred."; + return ( + } + title={title} + description={`${detail} Try reloading the page.`} + /> + ); +} diff --git a/apps/labeler/console/src/components/SeverityBadge.tsx b/apps/labeler/console/src/components/SeverityBadge.tsx new file mode 100644 index 0000000000..4db61bc2f1 --- /dev/null +++ b/apps/labeler/console/src/components/SeverityBadge.tsx @@ -0,0 +1,29 @@ +import { Badge } from "@cloudflare/kumo"; + +import type { FindingSeverity } from "../api/types.js"; + +type BadgeVariant = "neutral" | "blue" | "warning" | "orange" | "error"; + +const VARIANT_BY_SEVERITY: Record = { + critical: "error", + high: "orange", + medium: "warning", + low: "blue", + info: "neutral", +}; + +const LABEL_BY_SEVERITY: Record = { + critical: "Critical", + high: "High", + medium: "Medium", + low: "Low", + info: "Info", +}; + +export interface SeverityBadgeProps { + severity: FindingSeverity; +} + +export function SeverityBadge({ severity }: SeverityBadgeProps) { + return {LABEL_BY_SEVERITY[severity]}; +} diff --git a/apps/labeler/console/src/components/Shell.tsx b/apps/labeler/console/src/components/Shell.tsx new file mode 100644 index 0000000000..185f10a266 --- /dev/null +++ b/apps/labeler/console/src/components/Shell.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; + +import { Header } from "./Header.js"; +import { Sidebar, SidebarNav } from "./Sidebar.js"; + +export interface ShellProps { + children: React.ReactNode; +} + +/** Console shell layout with Kumo's Sidebar component, mirroring + * packages/admin's Shell.tsx. */ +export function Shell({ children }: ShellProps) { + return ( + + +
+
+
{children}
+
+
+ ); +} diff --git a/apps/labeler/console/src/components/Sidebar.tsx b/apps/labeler/console/src/components/Sidebar.tsx new file mode 100644 index 0000000000..4bd94a4235 --- /dev/null +++ b/apps/labeler/console/src/components/Sidebar.tsx @@ -0,0 +1,70 @@ +import { Sidebar as KumoSidebar, useSidebar } from "@cloudflare/kumo"; +import { ClockCounterClockwise, ShieldCheck, SquaresFour } from "@phosphor-icons/react"; +import { useLocation } from "@tanstack/react-router"; +import * as React from "react"; + +// Re-exported so Shell.tsx and Header.tsx share one import path, mirroring +// packages/admin's Sidebar.tsx. +export { KumoSidebar as Sidebar, useSidebar }; + +interface NavItem { + to: string; + label: string; + icon: React.ElementType; +} + +/** Kumo's LinkProvider (wired up in App.tsx) maps `href` to a TanStack + * Router Link, so this renders through client-side navigation. */ +function NavMenuLink({ item, isActive }: { item: NavItem; isActive: boolean }) { + const { state } = useSidebar(); + return ( + + {item.label} + + ); +} + +/** Checks if a nav item is active based on the current router path. */ +function isItemActive(itemPath: string, currentPath: string): boolean { + return itemPath === "/" + ? currentPath === "/" + : currentPath === itemPath || currentPath.startsWith(`${itemPath}/`); +} + +export function SidebarNav() { + const currentPath = useLocation({ select: (location) => location.pathname }); + + const items: NavItem[] = [ + { to: "/", label: "Dashboard", icon: SquaresFour }, + { to: "/assessments", label: "Assessments", icon: ShieldCheck }, + { to: "/audit", label: "Audit log", icon: ClockCounterClockwise }, + ]; + + return ( + + + + Labeler console + + + + + + {items.map((item) => ( + + ))} + + + + + ); +} diff --git a/apps/labeler/console/src/components/StateBadge.test.tsx b/apps/labeler/console/src/components/StateBadge.test.tsx new file mode 100644 index 0000000000..994f7d1740 --- /dev/null +++ b/apps/labeler/console/src/components/StateBadge.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { AssessmentState } from "../api/types.js"; +import { StateBadge } from "./StateBadge.js"; + +const ALL_STATES: readonly AssessmentState[] = [ + "observed", + "verifying", + "pending", + "running", + "passed", + "warned", + "blocked", + "error", + "stale", + "cancelled", +]; + +describe("StateBadge", () => { + it("renders a label for every assessment state", () => { + for (const state of ALL_STATES) { + const { unmount } = render(); + expect(screen.getByText(new RegExp(state, "i"))).toBeTruthy(); + unmount(); + } + }); +}); diff --git a/apps/labeler/console/src/components/StateBadge.tsx b/apps/labeler/console/src/components/StateBadge.tsx new file mode 100644 index 0000000000..71c8cc3a3f --- /dev/null +++ b/apps/labeler/console/src/components/StateBadge.tsx @@ -0,0 +1,46 @@ +import { Badge } from "@cloudflare/kumo"; + +import type { AssessmentState } from "../api/types.js"; + +type BadgeVariant = "neutral" | "info" | "success" | "warning" | "error"; + +const VARIANT_BY_STATE: Record = { + observed: "neutral", + verifying: "neutral", + pending: "info", + running: "info", + passed: "success", + warned: "warning", + blocked: "error", + error: "error", + stale: "neutral", + cancelled: "neutral", +}; + +const LABEL_BY_STATE: Record = { + observed: "Observed", + verifying: "Verifying", + pending: "Pending", + running: "Running", + passed: "Passed", + warned: "Warned", + blocked: "Blocked", + error: "Error", + stale: "Stale", + cancelled: "Cancelled", +}; + +export interface StateBadgeProps { + state: AssessmentState; +} + +/** Renders the labeler's full internal assessment-state vocabulary — wider + * than the public assessment API's, since an operator needs to see + * pre-decision and inconclusive-terminal states too. */ +export function StateBadge({ state }: StateBadgeProps) { + return ( + + {LABEL_BY_STATE[state]} + + ); +} diff --git a/apps/labeler/console/src/fixtures/assessments.ts b/apps/labeler/console/src/fixtures/assessments.ts new file mode 100644 index 0000000000..10fad5d34e --- /dev/null +++ b/apps/labeler/console/src/fixtures/assessments.ts @@ -0,0 +1,158 @@ +import type { AssessmentRun } from "../api/types.js"; +import { + SUBJECT_ALPHA, + SUBJECT_BETA, + SUBJECT_DELTA, + SUBJECT_EPSILON, + SUBJECT_GAMMA, +} from "./subjects.js"; + +const POLICY_VERSION = "2026-07-10.experimental.2"; +const MODEL_ID = "@cf/meta/llama-3.1-70b-instruct"; +const PROMPT_HASH = "sha256:2f1a9c7e0b4d6f8a1c3e5b7d9f0a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d4f6a"; + +/** Superseded run for pkg-alpha — a manual re-run later replaced this + * decision (asmt_R79G below), so it's the row `isSuperseded` flags. */ +export const ASSESSMENT_ALPHA_INITIAL: AssessmentRun = { + id: "asmt_SQCP5CP1X1RBMM0V0TQP2WR9PD", + runKey: "run-alpha-initial", + uri: SUBJECT_ALPHA.uri, + cid: SUBJECT_ALPHA.cid, + artifactId: "artifact-alpha-0001", + artifactChecksum: "sha256:9a1e3f5c7b9d1f3e5a7c9b1d3f5e7a9c1b3d5f7e9a1c3b5d7f9e1a3c5b7d9f1e", + state: "passed", + publicState: "superseded", + trigger: "initial", + triggerId: "initial:bafyreirk3vaepohxuw5ujlpiwztlnr3xj3bdiuivzl7dkxvzqqqzyxbziy", + policyVersion: POLICY_VERSION, + modelId: MODEL_ID, + promptHash: PROMPT_HASH, + publicSummary: "No blocking findings for this release.", + supersedesAssessmentId: null, + startedAt: "2026-07-08T09:12:05.000Z", + completedAt: "2026-07-08T09:13:40.000Z", + createdAt: "2026-07-08T09:12:00.000Z", + isSuperseded: true, +}; + +/** Current pointer for pkg-alpha — an operator-triggered re-run. */ +export const ASSESSMENT_ALPHA_RERUN: AssessmentRun = { + id: "asmt_R79G2W700J4F005EAG66HP66JR", + runKey: "run-alpha-rerun", + uri: SUBJECT_ALPHA.uri, + cid: SUBJECT_ALPHA.cid, + artifactId: "artifact-alpha-0001", + artifactChecksum: "sha256:9a1e3f5c7b9d1f3e5a7c9b1d3f5e7a9c1b3d5f7e9a1c3b5d7f9e1a3c5b7d9f1e", + state: "passed", + publicState: "passed", + trigger: "operator", + triggerId: "operator:act_7SPDHNJ1BHZEDZBBXC3RYX06WK", + policyVersion: POLICY_VERSION, + modelId: MODEL_ID, + promptHash: PROMPT_HASH, + publicSummary: "Re-run confirmed no blocking findings.", + supersedesAssessmentId: ASSESSMENT_ALPHA_INITIAL.id, + startedAt: "2026-07-11T16:40:10.000Z", + completedAt: "2026-07-11T16:41:55.000Z", + createdAt: "2026-07-11T16:40:00.000Z", + isSuperseded: false, +}; + +export const ASSESSMENT_BETA: AssessmentRun = { + id: "asmt_34XM75YB5AJ9Z84B9EJBWM0CV1", + runKey: "run-beta-initial", + uri: SUBJECT_BETA.uri, + cid: SUBJECT_BETA.cid, + artifactId: "artifact-beta-0002", + artifactChecksum: "sha256:1b3d5f7e9a1c3b5d7f9e1a3c5b7d9f1e3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b", + state: "warned", + publicState: "warned", + trigger: "initial", + triggerId: "initial:bafyreidmvd2b5c2cjdwk34ymwydyy3eh6n7ce5p4dm37bltiaputjjvb6w", + policyVersion: POLICY_VERSION, + modelId: MODEL_ID, + promptHash: PROMPT_HASH, + publicSummary: "Passed with a low-quality packaging warning.", + supersedesAssessmentId: null, + startedAt: "2026-07-12T08:05:20.000Z", + completedAt: "2026-07-12T08:07:05.000Z", + createdAt: "2026-07-12T08:05:00.000Z", + isSuperseded: false, +}; + +export const ASSESSMENT_GAMMA: AssessmentRun = { + id: "asmt_RT0G62FEF7MAX2R6K0K0P3E3RN", + runKey: "run-gamma-initial", + uri: SUBJECT_GAMMA.uri, + cid: SUBJECT_GAMMA.cid, + artifactId: "artifact-gamma-0003", + artifactChecksum: "sha256:5e7a9c1b3d5f7e9a1c3b5d7f9e1a3c5b7d9f1e3a5c7e9b1d3f5a7c9e1b3d5f79", + state: "blocked", + publicState: "blocked", + trigger: "initial", + triggerId: "initial:bafyreirtyymrfqymf5zhtpyzzotvg3fy67qgmemz2ux6iheu6wvetl3ias", + policyVersion: POLICY_VERSION, + modelId: null, + promptHash: null, + publicSummary: "Blocked: malware and obfuscated code detected.", + supersedesAssessmentId: null, + startedAt: "2026-07-12T11:30:15.000Z", + completedAt: "2026-07-12T11:32:50.000Z", + createdAt: "2026-07-12T11:30:00.000Z", + isSuperseded: false, +}; + +/** Still in flight — an internal `running` state, publicly reported as `pending`. */ +export const ASSESSMENT_DELTA: AssessmentRun = { + id: "asmt_BZ7JNKG1TYRMZMVP0XQEGEC1Y7", + runKey: "run-delta-initial", + uri: SUBJECT_DELTA.uri, + cid: SUBJECT_DELTA.cid, + artifactId: "artifact-delta-0004", + artifactChecksum: "sha256:3c5e7a9c1b3d5f7e9a1c3b5d7f9e1a3c5b7d9f1e3a5c7e9b1d3f5a7c9e1b3d57", + state: "running", + publicState: "pending", + trigger: "initial", + triggerId: "initial:bafyreiq2fy4ydmg6jz5aljtgdwgwp4fr6tgvte25pv55k4ynibtva2p3hx", + policyVersion: POLICY_VERSION, + modelId: null, + promptHash: null, + publicSummary: null, + supersedesAssessmentId: null, + startedAt: "2026-07-13T07:50:05.000Z", + completedAt: null, + createdAt: "2026-07-13T07:50:00.000Z", + isSuperseded: false, +}; + +export const ASSESSMENT_EPSILON: AssessmentRun = { + id: "asmt_K2Y9KK9SSTD43VVT780P6HMT8A", + runKey: "run-epsilon-initial", + uri: SUBJECT_EPSILON.uri, + cid: SUBJECT_EPSILON.cid, + artifactId: "artifact-epsilon-0005", + artifactChecksum: null, + state: "error", + publicState: "error", + trigger: "initial", + triggerId: "initial:bafyreic4ioya7s4elpagcfebx3hwhltmbmzgag7yx3q2smjhvt45i2dmts", + policyVersion: POLICY_VERSION, + modelId: null, + promptHash: null, + publicSummary: "Assessment failed to complete due to a scanner timeout.", + supersedesAssessmentId: null, + startedAt: "2026-07-12T22:15:10.000Z", + completedAt: "2026-07-12T22:25:00.000Z", + createdAt: "2026-07-12T22:15:00.000Z", + isSuperseded: false, +}; + +/** Newest first, matching the labeler's `getAssessmentsPage` ordering. */ +export const FIXTURE_ASSESSMENTS: readonly AssessmentRun[] = [ + ASSESSMENT_DELTA, + ASSESSMENT_EPSILON, + ASSESSMENT_GAMMA, + ASSESSMENT_BETA, + ASSESSMENT_ALPHA_RERUN, + ASSESSMENT_ALPHA_INITIAL, +]; diff --git a/apps/labeler/console/src/fixtures/findings.ts b/apps/labeler/console/src/fixtures/findings.ts new file mode 100644 index 0000000000..3b2cc7f7ad --- /dev/null +++ b/apps/labeler/console/src/fixtures/findings.ts @@ -0,0 +1,74 @@ +import type { OperatorFinding } from "../api/types.js"; +import { ASSESSMENT_BETA, ASSESSMENT_GAMMA } from "./assessments.js"; + +export const FINDING_BETA_LOW_QUALITY: OperatorFinding = { + id: "find_8VNBZKZ2XA5MVJ1GC4DBE3JHY1", + assessmentId: ASSESSMENT_BETA.id, + source: "model", + category: "low-quality", + severity: "medium", + confidence: 0.72, + title: "Packaging metadata inconsistent with declared entry point", + publicSummary: "The release's declared entry point does not match the bundled files.", + privateDetail: + "Model flagged package.json main field '/dist/index.js' but the bundle only contains " + + "/lib/index.cjs — likely a broken build step upstream rather than malicious intent.", + evidenceRefs: ["evid_JVDZA75GM4G8JZPZTNQCBVVYZT"], + createdAt: "2026-07-12T08:06:40.000Z", +}; + +export const FINDING_BETA_MISLEADING_METADATA: OperatorFinding = { + id: "find_N5727F57820GD7NGTPNACG7SPK", + assessmentId: ASSESSMENT_BETA.id, + source: "deterministic", + category: "misleading-metadata", + severity: "low", + title: "README references an unrelated repository", + publicSummary: "The README links to a repository that does not match the declared package.", + privateDetail: + "README badge links to github.com/other-org/other-repo; no redirect or fork relationship " + + "found via the identity resolver.", + evidenceRefs: [], + createdAt: "2026-07-12T08:06:55.000Z", +}; + +export const FINDING_GAMMA_MALWARE: OperatorFinding = { + id: "find_T36FX53QSY7WP3NQ6Y8N7V39A8", + assessmentId: ASSESSMENT_GAMMA.id, + source: "deterministic", + category: "malware", + severity: "critical", + title: "Known malware signature match", + publicSummary: "This release matches a known malware signature.", + privateDetail: + "YARA rule 'stealer-generic-v3' matched the packed payload at dist/postinstall.js:1 — " + + "matches 4 prior confirmed npm supply-chain incidents in the corpus.", + evidenceRefs: ["evid_2JQKN2WMB5PHN6W3D59ACBNSMJ"], + affectedFiles: ["dist/postinstall.js"], + createdAt: "2026-07-12T11:31:20.000Z", +}; + +export const FINDING_GAMMA_OBFUSCATED_CODE: OperatorFinding = { + id: "find_75AZDBSW7D1S62TZ8BE07XY5B1", + assessmentId: ASSESSMENT_GAMMA.id, + source: "capability", + category: "obfuscated-code", + severity: "high", + confidence: 0.94, + title: "Heavily obfuscated postinstall script", + publicSummary: + "A postinstall script uses obfuscation techniques inconsistent with normal minification.", + privateDetail: + "String-array rotation plus an eval-based deobfuscation loop detected via AST capability " + + "scan; entropy score 7.91/8.", + evidenceRefs: ["evid_2JQKN2WMB5PHN6W3D59ACBNSMJ"], + affectedFiles: ["dist/postinstall.js"], + createdAt: "2026-07-12T11:31:35.000Z", +}; + +/** Findings keyed by assessment id, matching `recordFinding`'s grouping. */ +export const FIXTURE_FINDINGS_BY_ASSESSMENT: Readonly> = + { + [ASSESSMENT_BETA.id]: [FINDING_BETA_LOW_QUALITY, FINDING_BETA_MISLEADING_METADATA], + [ASSESSMENT_GAMMA.id]: [FINDING_GAMMA_MALWARE, FINDING_GAMMA_OBFUSCATED_CODE], + }; diff --git a/apps/labeler/console/src/fixtures/index.ts b/apps/labeler/console/src/fixtures/index.ts new file mode 100644 index 0000000000..c540b33da9 --- /dev/null +++ b/apps/labeler/console/src/fixtures/index.ts @@ -0,0 +1,28 @@ +export { + ASSESSMENT_ALPHA_INITIAL, + ASSESSMENT_ALPHA_RERUN, + ASSESSMENT_BETA, + ASSESSMENT_DELTA, + ASSESSMENT_EPSILON, + ASSESSMENT_GAMMA, + FIXTURE_ASSESSMENTS, +} from "./assessments.js"; +export { + FINDING_BETA_LOW_QUALITY, + FINDING_BETA_MISLEADING_METADATA, + FINDING_GAMMA_MALWARE, + FINDING_GAMMA_OBFUSCATED_CODE, + FIXTURE_FINDINGS_BY_ASSESSMENT, +} from "./findings.js"; +export { FIXTURE_LABELS_BY_ASSESSMENT } from "./labels.js"; +export { FIXTURE_OPERATOR_ACTIONS } from "./operator-actions.js"; +export { FIXTURE_SUBJECT_HISTORY } from "./subject-history.js"; +export { + FIXTURE_SUBJECTS, + SUBJECT_ALPHA, + SUBJECT_BETA, + SUBJECT_DELTA, + SUBJECT_EPSILON, + SUBJECT_GAMMA, +} from "./subjects.js"; +export { FIXTURE_SYSTEM_STATUS } from "./system-status.js"; diff --git a/apps/labeler/console/src/fixtures/labels.ts b/apps/labeler/console/src/fixtures/labels.ts new file mode 100644 index 0000000000..e0633e94dd --- /dev/null +++ b/apps/labeler/console/src/fixtures/labels.ts @@ -0,0 +1,49 @@ +import type { IssuedLabel } from "../api/types.js"; +import { + ASSESSMENT_ALPHA_INITIAL, + ASSESSMENT_ALPHA_RERUN, + ASSESSMENT_BETA, + ASSESSMENT_GAMMA, +} from "./assessments.js"; + +/** Positive label ops keyed by assessment id, matching `getLabelsForAssessment`. */ +export const FIXTURE_LABELS_BY_ASSESSMENT: Readonly> = { + [ASSESSMENT_ALPHA_INITIAL.id]: [ + { + val: "assessment-passed", + cts: "2026-07-08T09:13:40.000Z", + exp: null, + neg: false, + sequence: 1, + }, + ], + [ASSESSMENT_ALPHA_RERUN.id]: [ + { + val: "assessment-passed", + cts: "2026-07-11T16:41:55.000Z", + exp: null, + neg: false, + sequence: 2, + }, + ], + [ASSESSMENT_BETA.id]: [ + { val: "low-quality", cts: "2026-07-12T08:07:05.000Z", exp: null, neg: false, sequence: 3 }, + { + val: "assessment-passed", + cts: "2026-07-12T08:07:05.000Z", + exp: null, + neg: false, + sequence: 4, + }, + ], + [ASSESSMENT_GAMMA.id]: [ + { val: "malware", cts: "2026-07-12T11:32:50.000Z", exp: null, neg: false, sequence: 5 }, + { + val: "obfuscated-code", + cts: "2026-07-12T11:32:50.000Z", + exp: null, + neg: false, + sequence: 6, + }, + ], +}; diff --git a/apps/labeler/console/src/fixtures/operator-actions.ts b/apps/labeler/console/src/fixtures/operator-actions.ts new file mode 100644 index 0000000000..66777f04ae --- /dev/null +++ b/apps/labeler/console/src/fixtures/operator-actions.ts @@ -0,0 +1,8 @@ +import type { OperatorAction } from "../api/types.js"; + +/** + * Empty until the `operator_actions` audit table ships (plan W9.2) — + * the audit log route renders its empty state from this rather than + * fabricating action history that doesn't correspond to any real schema. + */ +export const FIXTURE_OPERATOR_ACTIONS: readonly OperatorAction[] = []; diff --git a/apps/labeler/console/src/fixtures/subject-history.ts b/apps/labeler/console/src/fixtures/subject-history.ts new file mode 100644 index 0000000000..a069b129a3 --- /dev/null +++ b/apps/labeler/console/src/fixtures/subject-history.ts @@ -0,0 +1,16 @@ +import type { SubjectHistoryView } from "../api/types.js"; +import { FIXTURE_ASSESSMENTS } from "./assessments.js"; +import { FIXTURE_SUBJECTS } from "./subjects.js"; + +/** Subject history keyed by URI, matching `listNonTerminalAssessmentsForUri` + + * `getAssessmentsPage`'s per-uri grouping, newest run first. */ +export const FIXTURE_SUBJECT_HISTORY: Readonly> = + Object.fromEntries( + FIXTURE_SUBJECTS.map((subject) => [ + subject.uri, + { + subject, + assessments: FIXTURE_ASSESSMENTS.filter((a) => a.uri === subject.uri), + }, + ]), + ); diff --git a/apps/labeler/console/src/fixtures/subjects.ts b/apps/labeler/console/src/fixtures/subjects.ts new file mode 100644 index 0000000000..bf2af7b4a2 --- /dev/null +++ b/apps/labeler/console/src/fixtures/subjects.ts @@ -0,0 +1,64 @@ +import type { SubjectRecord } from "../api/types.js"; + +/** + * Read-only sample data (plan W9.3, static W8.1 fixtures dependency). + * Shapes mirror apps/labeler/src/assessment-store.ts's `subjects` table. + */ + +export const SUBJECT_ALPHA: SubjectRecord = { + uri: "at://did:plc:z7x3g4k9m2q8w1r5t6y0u3i7/com.emdashcms.experimental.package.release/3lduzalpha0001", + cid: "bafyreirk3vaepohxuw5ujlpiwztlnr3xj3bdiuivzl7dkxvzqqqzyxbziy", + did: "did:plc:z7x3g4k9m2q8w1r5t6y0u3i7", + collection: "com.emdashcms.experimental.package.release", + rkey: "3lduzalpha0001", + observedAt: "2026-07-08T09:10:00.000Z", + deletedAt: null, +}; + +export const SUBJECT_BETA: SubjectRecord = { + uri: "at://did:plc:h4n8b2v6c1x9z3m7k5j0q8w2/com.emdashcms.experimental.package.release/3lduzbeta0002", + cid: "bafyreidmvd2b5c2cjdwk34ymwydyy3eh6n7ce5p4dm37bltiaputjjvb6w", + did: "did:plc:h4n8b2v6c1x9z3m7k5j0q8w2", + collection: "com.emdashcms.experimental.package.release", + rkey: "3lduzbeta0002", + observedAt: "2026-07-12T08:00:00.000Z", + deletedAt: null, +}; + +export const SUBJECT_GAMMA: SubjectRecord = { + uri: "at://did:plc:p9q2r5t8v1w4x7y0z3a6b9c2/com.emdashcms.experimental.package.release/3lduzgamma0003", + cid: "bafyreirtyymrfqymf5zhtpyzzotvg3fy67qgmemz2ux6iheu6wvetl3ias", + did: "did:plc:p9q2r5t8v1w4x7y0z3a6b9c2", + collection: "com.emdashcms.experimental.package.release", + rkey: "3lduzgamma0003", + observedAt: "2026-07-12T11:25:00.000Z", + deletedAt: null, +}; + +export const SUBJECT_DELTA: SubjectRecord = { + uri: "at://did:plc:d2f5g8h1j4k7m0n3p6q9r2s5/com.emdashcms.experimental.package.release/3lduzdelta0004", + cid: "bafyreiq2fy4ydmg6jz5aljtgdwgwp4fr6tgvte25pv55k4ynibtva2p3hx", + did: "did:plc:d2f5g8h1j4k7m0n3p6q9r2s5", + collection: "com.emdashcms.experimental.package.release", + rkey: "3lduzdelta0004", + observedAt: "2026-07-13T07:48:00.000Z", + deletedAt: null, +}; + +export const SUBJECT_EPSILON: SubjectRecord = { + uri: "at://did:plc:e3g6i9k2m5o8q1s4u7w0y3a6/com.emdashcms.experimental.package.release/3lduzepsilon05", + cid: "bafyreic4ioya7s4elpagcfebx3hwhltmbmzgag7yx3q2smjhvt45i2dmts", + did: "did:plc:e3g6i9k2m5o8q1s4u7w0y3a6", + collection: "com.emdashcms.experimental.package.release", + rkey: "3lduzepsilon05", + observedAt: "2026-07-12T22:10:00.000Z", + deletedAt: null, +}; + +export const FIXTURE_SUBJECTS: readonly SubjectRecord[] = [ + SUBJECT_ALPHA, + SUBJECT_BETA, + SUBJECT_GAMMA, + SUBJECT_DELTA, + SUBJECT_EPSILON, +]; diff --git a/apps/labeler/console/src/fixtures/system-status.ts b/apps/labeler/console/src/fixtures/system-status.ts new file mode 100644 index 0000000000..cbcfe7539b --- /dev/null +++ b/apps/labeler/console/src/fixtures/system-status.ts @@ -0,0 +1,10 @@ +import type { SystemStatusSnapshot } from "../api/types.js"; + +/** Matches the `LABELER_DID` / discovery-queue bindings in wrangler.jsonc. */ +export const FIXTURE_SYSTEM_STATUS: SystemStatusSnapshot = { + labelerDid: "did:web:labels.emdashcms.com", + jetstreamConnected: true, + pendingAssessments: 1, + discoveryQueueDepth: 3, + lastReconciliationAt: "2026-07-13T07:45:00.000Z", +}; diff --git a/apps/labeler/console/src/main.tsx b/apps/labeler/console/src/main.tsx new file mode 100644 index 0000000000..fd1e0f8190 --- /dev/null +++ b/apps/labeler/console/src/main.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; +import { createRoot } from "react-dom/client"; + +import { ConsoleApp } from "./App.js"; + +import "./styles.css"; + +const container = document.getElementById("console-root"); +if (!container) throw new Error("#console-root element not found"); + +createRoot(container).render( + + + , +); diff --git a/apps/labeler/console/src/router.tsx b/apps/labeler/console/src/router.tsx new file mode 100644 index 0000000000..9e52b8c81d --- /dev/null +++ b/apps/labeler/console/src/router.tsx @@ -0,0 +1,37 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { createRouter } from "@tanstack/react-router"; + +import { assessmentDetailRoute } from "./routes/AssessmentDetail.js"; +import { assessmentListRoute } from "./routes/AssessmentList.js"; +import { auditLogRoute } from "./routes/AuditLog.js"; +import { dashboardRoute } from "./routes/Dashboard.js"; +import { notFoundRoute } from "./routes/NotFound.js"; +import { rootRoute, shellRoute } from "./routes/root.js"; +import { subjectHistoryRoute } from "./routes/SubjectHistory.js"; + +const shellRoutes = shellRoute.addChildren([ + dashboardRoute, + assessmentListRoute, + assessmentDetailRoute, + subjectHistoryRoute, + auditLogRoute, + notFoundRoute, +]); + +const routeTree = rootRoute.addChildren([shellRoutes]); + +export function createConsoleRouter(queryClient: QueryClient) { + return createRouter({ + routeTree, + context: { queryClient }, + // Served as Workers static assets under /admin (plan W9.3). + basepath: "/admin", + defaultPreload: "intent", + }); +} + +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType; + } +} diff --git a/apps/labeler/console/src/routes/AssessmentDetail.tsx b/apps/labeler/console/src/routes/AssessmentDetail.tsx new file mode 100644 index 0000000000..9af3e43135 --- /dev/null +++ b/apps/labeler/console/src/routes/AssessmentDetail.tsx @@ -0,0 +1,168 @@ +import { Badge, LayerCard, Loader } from "@cloudflare/kumo"; +import { useQuery } from "@tanstack/react-query"; +import { createRoute, Link } from "@tanstack/react-router"; +import type { ReactNode } from "react"; + +import { apiClient } from "../api/client.js"; +import { FindingCard } from "../components/FindingCard.js"; +import { QueryError } from "../components/QueryError.js"; +import { StateBadge } from "../components/StateBadge.js"; +import { shellRoute } from "./root.js"; + +function MetaRow({ label, value }: { label: string; value: ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +function AssessmentDetail() { + const { id } = assessmentDetailRoute.useParams(); + + const { + data: assessment, + isLoading: isLoadingAssessment, + isError: isAssessmentError, + error: assessmentError, + } = useQuery({ + queryKey: ["assessment", id], + queryFn: () => apiClient.getAssessment(id), + }); + const { + data: findings, + isLoading: isLoadingFindings, + isError: isFindingsError, + error: findingsError, + } = useQuery({ + queryKey: ["assessment", id, "findings"], + queryFn: () => apiClient.listFindings(id), + enabled: !!assessment, + }); + const { + data: labels, + isLoading: isLoadingLabels, + isError: isLabelsError, + error: labelsError, + } = useQuery({ + queryKey: ["assessment", id, "labels"], + queryFn: () => apiClient.listLabels(id), + enabled: !!assessment, + }); + + if (isAssessmentError) { + return ; + } + + if (isLoadingAssessment) { + return ( +
+ +
+ ); + } + + // A successful query that resolved to null is a genuine not-found, distinct + // from isAssessmentError above -- that branch already returned. + if (!assessment) { + return
Assessment not found.
; + } + + return ( +
+
+
+

Assessment

+ + {assessment.isSuperseded && Superseded} +
+ + {assessment.uri} + +

+ CID: {assessment.cid} +

+ {assessment.publicSummary &&

{assessment.publicSummary}

} +
+ + + + + + + + + {assessment.supersedesAssessmentId && ( + + {assessment.supersedesAssessmentId} + + } + /> + )} + + +
+

Labels

+ {isLabelsError ? ( + + ) : isLoadingLabels || !labels ? ( + + ) : labels.length === 0 ? ( +

No labels issued for this run.

+ ) : ( +
+ {labels.map((label) => ( + + {label.val} + + ))} +
+ )} +
+ +
+

Findings

+ {isFindingsError ? ( + + ) : isLoadingFindings || !findings ? ( + + ) : findings.length === 0 ? ( +

No findings recorded for this run.

+ ) : ( +
+ {findings.map((finding) => ( + + ))} +
+ )} +
+
+ ); +} + +export const assessmentDetailRoute = createRoute({ + getParentRoute: () => shellRoute, + path: "/assessments/$id", + component: AssessmentDetail, +}); diff --git a/apps/labeler/console/src/routes/AssessmentList.tsx b/apps/labeler/console/src/routes/AssessmentList.tsx new file mode 100644 index 0000000000..b7e8c5c063 --- /dev/null +++ b/apps/labeler/console/src/routes/AssessmentList.tsx @@ -0,0 +1,134 @@ +import { Badge, LayerCard, Loader, Select, Table } from "@cloudflare/kumo"; +import { useQuery } from "@tanstack/react-query"; +import { createRoute, Link, useNavigate } from "@tanstack/react-router"; + +import { apiClient } from "../api/client.js"; +import type { PublicAssessmentState } from "../api/types.js"; +import { QueryError } from "../components/QueryError.js"; +import { StateBadge } from "../components/StateBadge.js"; +import { shellRoute } from "./root.js"; + +const PUBLIC_STATES: readonly PublicAssessmentState[] = [ + "pending", + "passed", + "warned", + "blocked", + "error", + "superseded", +]; + +function isPublicAssessmentState(value: string): value is PublicAssessmentState { + return (PUBLIC_STATES as readonly string[]).includes(value); +} + +export interface AssessmentListSearch { + state?: PublicAssessmentState; +} + +const STATE_LABELS: Record = { + pending: "Pending", + passed: "Passed", + warned: "Warned", + blocked: "Blocked", + error: "Error", + superseded: "Superseded", +}; + +function AssessmentList() { + const navigate = useNavigate(); + const { state } = assessmentListRoute.useSearch(); + + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["assessments", { state }], + queryFn: () => apiClient.listAssessments({ state }), + }); + + const handleStateChange = (value: string | null) => { + void navigate({ + to: "/assessments", + search: value && isPublicAssessmentState(value) ? { state: value } : {}, + }); + }; + + return ( +
+
+

Assessments

+ +
+ + {isError ? ( + + ) : ( + + {isLoading || !data ? ( +
+ +
+ ) : data.items.length === 0 ? ( +
No assessments found.
+ ) : ( + + + + Subject + State + Trigger + Created + + + + {data.items.map((assessment) => ( + + + + {assessment.uri.split("/").pop()} + + {assessment.isSuperseded && ( + + Superseded + + )} + + + + + {assessment.trigger} + {new Date(assessment.createdAt).toLocaleString()} + + ))} + +
+ )} +
+ )} +
+ ); +} + +export const assessmentListRoute = createRoute({ + getParentRoute: () => shellRoute, + path: "/assessments", + component: AssessmentList, + validateSearch: (search: Record): AssessmentListSearch => ({ + state: + typeof search.state === "string" && isPublicAssessmentState(search.state) + ? search.state + : undefined, + }), +}); diff --git a/apps/labeler/console/src/routes/AuditLog.tsx b/apps/labeler/console/src/routes/AuditLog.tsx new file mode 100644 index 0000000000..3dcbdb496c --- /dev/null +++ b/apps/labeler/console/src/routes/AuditLog.tsx @@ -0,0 +1,45 @@ +import { Empty, Loader } from "@cloudflare/kumo"; +import { ClockCounterClockwise } from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; +import { createRoute } from "@tanstack/react-router"; + +import { apiClient } from "../api/client.js"; +import { QueryError } from "../components/QueryError.js"; +import { shellRoute } from "./root.js"; + +/** + * The `operator_actions` audit table (plan W9.2) hasn't landed yet, so the + * fixture client always resolves an empty list here — the loading/error + * states below exist for when a real `/admin/api/audit-log` backs this. + */ +function AuditLog() { + const { isLoading, isError, error } = useQuery({ + queryKey: ["audit-log"], + queryFn: () => apiClient.listAuditLog(), + }); + + return ( +
+

Audit log

+ {isError ? ( + + ) : isLoading ? ( +
+ +
+ ) : ( + } + title="Audit log not yet available" + description="Operator action history ships once the audit table lands." + /> + )} +
+ ); +} + +export const auditLogRoute = createRoute({ + getParentRoute: () => shellRoute, + path: "/audit", + component: AuditLog, +}); diff --git a/apps/labeler/console/src/routes/Dashboard.tsx b/apps/labeler/console/src/routes/Dashboard.tsx new file mode 100644 index 0000000000..f8fd291fd7 --- /dev/null +++ b/apps/labeler/console/src/routes/Dashboard.tsx @@ -0,0 +1,81 @@ +import { Badge, Grid, LayerCard, Loader } from "@cloudflare/kumo"; +import { useQuery } from "@tanstack/react-query"; +import { createRoute } from "@tanstack/react-router"; +import * as React from "react"; + +import { apiClient } from "../api/client.js"; +import { QueryError } from "../components/QueryError.js"; +import { shellRoute } from "./root.js"; + +function StatCard({ label, value }: { label: string; value: React.ReactNode }) { + return ( + + {label} + {value} + + ); +} + +function Dashboard() { + const { + data: status, + isLoading, + isError, + error, + } = useQuery({ + queryKey: ["system-status"], + queryFn: () => apiClient.getSystemStatus(), + }); + + if (isError) { + return ( +
+

Dashboard

+ +
+ ); + } + + if (isLoading || !status) { + return ( +
+ +
+ ); + } + + return ( +
+

Dashboard

+ + + {status.jetstreamConnected ? "Connected" : "Disconnected"} + + } + /> + + + + + + Labeler DID: {status.labelerDid} + +
+ ); +} + +export const dashboardRoute = createRoute({ + getParentRoute: () => shellRoute, + path: "/", + component: Dashboard, +}); diff --git a/apps/labeler/console/src/routes/NotFound.tsx b/apps/labeler/console/src/routes/NotFound.tsx new file mode 100644 index 0000000000..5cbdc577c4 --- /dev/null +++ b/apps/labeler/console/src/routes/NotFound.tsx @@ -0,0 +1,20 @@ +import { createRoute } from "@tanstack/react-router"; + +import { shellRoute } from "./root.js"; + +function NotFoundPage() { + return ( +
+
+

Not found

+

This page doesn't exist.

+
+
+ ); +} + +export const notFoundRoute = createRoute({ + getParentRoute: () => shellRoute, + path: "*", + component: NotFoundPage, +}); diff --git a/apps/labeler/console/src/routes/SubjectHistory.tsx b/apps/labeler/console/src/routes/SubjectHistory.tsx new file mode 100644 index 0000000000..69363fc4cb --- /dev/null +++ b/apps/labeler/console/src/routes/SubjectHistory.tsx @@ -0,0 +1,104 @@ +import { LayerCard, Loader, Table } from "@cloudflare/kumo"; +import { useQuery } from "@tanstack/react-query"; +import { createRoute, Link } from "@tanstack/react-router"; + +import { apiClient } from "../api/client.js"; +import { QueryError } from "../components/QueryError.js"; +import { StateBadge } from "../components/StateBadge.js"; +import { shellRoute } from "./root.js"; + +function SubjectHistory() { + const { uri } = subjectHistoryRoute.useParams(); + + const { + data: history, + isLoading, + isError, + error, + } = useQuery({ + queryKey: ["subject-history", uri], + queryFn: () => apiClient.getSubjectHistory(uri), + }); + + if (isError) { + return ; + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + // A successful query that resolved to null is a genuine not-found, distinct + // from isError above -- that branch already returned. + if (!history) { + return
Subject not found.
; + } + + const { subject, assessments } = history; + + return ( +
+
+

Subject history

+

{subject.uri}

+

+ Current CID: {subject.cid} +

+ {subject.deletedAt && ( +

+ Deleted at {new Date(subject.deletedAt).toLocaleString()} +

+ )} +
+ + + {assessments.length === 0 ? ( +
+ No assessments recorded for this subject. +
+ ) : ( + + + + CID + State + Trigger + Created + + + + {assessments.map((assessment) => ( + + + + {assessment.cid.slice(0, 16)}… + + + + + + {assessment.trigger} + {new Date(assessment.createdAt).toLocaleString()} + + ))} + +
+ )} +
+
+ ); +} + +export const subjectHistoryRoute = createRoute({ + getParentRoute: () => shellRoute, + path: "/subjects/$uri", + component: SubjectHistory, +}); diff --git a/apps/labeler/console/src/routes/root.tsx b/apps/labeler/console/src/routes/root.tsx new file mode 100644 index 0000000000..9d7f813325 --- /dev/null +++ b/apps/labeler/console/src/routes/root.tsx @@ -0,0 +1,23 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { createRootRouteWithContext, createRoute, Outlet } from "@tanstack/react-router"; + +import { Shell } from "../components/Shell.js"; + +export interface RouterContext { + queryClient: QueryClient; +} + +export const rootRoute = createRootRouteWithContext()({ + component: () => , +}); + +/** Pathless layout route: every console page renders inside the Shell. */ +export const shellRoute = createRoute({ + getParentRoute: () => rootRoute, + id: "_shell", + component: () => ( + + + + ), +}); diff --git a/apps/labeler/console/src/styles.css b/apps/labeler/console/src/styles.css new file mode 100644 index 0000000000..972eafb785 --- /dev/null +++ b/apps/labeler/console/src/styles.css @@ -0,0 +1,27 @@ +/** + * EmDash Labeler Console styles. + * + * Uses Kumo's semantic token system exclusively (no raw Tailwind colors). + */ + +/* Scan Kumo component JS for Tailwind utility classes (e.g. Dialog centering). + * @cloudflare/kumo installs under apps/labeler/node_modules (the package + * root), not console/node_modules -- console has no package.json of its + * own, so this path goes up two levels rather than packages/admin's one. */ +@source "../../node_modules/@cloudflare/kumo/dist/**/*.{js,jsx,ts,tsx}"; + +@import "@cloudflare/kumo/styles"; +@import "tailwindcss"; + +* { + border-color: var(--color-kumo-line); +} + +body { + background-color: var(--color-kumo-elevated); + color: var(--text-color-kumo-default); +} + +#console-root { + isolation: isolate; +} diff --git a/apps/labeler/console/tsconfig.json b/apps/labeler/console/tsconfig.json new file mode 100644 index 0000000000..43350f9fb9 --- /dev/null +++ b/apps/labeler/console/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "noEmit": true + }, + "include": ["src/**/*", "vite.config.ts", "vitest.config.ts"] +} diff --git a/apps/labeler/console/vite.config.ts b/apps/labeler/console/vite.config.ts new file mode 100644 index 0000000000..20d71fd18a --- /dev/null +++ b/apps/labeler/console/vite.config.ts @@ -0,0 +1,19 @@ +import { fileURLToPath } from "node:url"; + +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +const consoleRoot = fileURLToPath(new URL(".", import.meta.url)); + +export default defineConfig({ + root: consoleRoot, + // Served as Workers static assets from the labeler Worker under /admin + // (plan W9.3) — same-origin /admin/api/* calls need matching asset paths. + base: "/admin/", + plugins: [react(), tailwindcss()], + build: { + outDir: fileURLToPath(new URL("../dist/console", import.meta.url)), + emptyOutDir: true, + }, +}); diff --git a/apps/labeler/console/vitest.config.ts b/apps/labeler/console/vitest.config.ts new file mode 100644 index 0000000000..7f56cf1734 --- /dev/null +++ b/apps/labeler/console/vitest.config.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from "node:url"; + +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + root: fileURLToPath(new URL(".", import.meta.url)), + plugins: [react()], + test: { + globals: true, + environment: "jsdom", + include: ["src/**/*.test.{ts,tsx}"], + }, +}); diff --git a/apps/labeler/package.json b/apps/labeler/package.json index fea41cb00d..eae106c940 100644 --- a/apps/labeler/package.json +++ b/apps/labeler/package.json @@ -9,10 +9,14 @@ "build": "vite build", "preview": "vite preview", "deploy": "vite build && wrangler deploy", - "typecheck": "tsgo --noEmit", - "test": "vitest run", + "typecheck": "tsgo --noEmit && tsgo --noEmit -p console/tsconfig.json", + "test": "vitest run && vitest run --config console/vitest.config.ts", "db:migrate:local": "wrangler d1 migrations apply emdash-labeler --local", - "db:migrate": "wrangler d1 migrations apply emdash-labeler --remote" + "db:migrate": "wrangler d1 migrations apply emdash-labeler --remote", + "console:dev": "vite dev --config console/vite.config.ts", + "console:build": "vite build --config console/vite.config.ts", + "console:typecheck": "tsgo --noEmit -p console/tsconfig.json", + "console:test": "vitest run --config console/vitest.config.ts" }, "dependencies": { "@atcute/cbor": "catalog:", @@ -30,10 +34,23 @@ }, "devDependencies": { "@atcute/cid": "catalog:", + "@cloudflare/kumo": "catalog:", "@cloudflare/vite-plugin": "catalog:", "@cloudflare/vitest-pool-workers": "catalog:", "@emdash-cms/atproto-test-utils": "workspace:*", + "@phosphor-icons/react": "catalog:", + "@tailwindcss/vite": "^4.3.2", + "@tanstack/react-query": "catalog:", + "@tanstack/react-router": "catalog:", + "@testing-library/react": "^16.3.0", "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "^5.2.0", + "jsdom": "^26.1.0", + "react": "catalog:", + "react-dom": "catalog:", + "tailwindcss": "^4.3.2", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:", diff --git a/apps/labeler/vitest.config.ts b/apps/labeler/vitest.config.ts index c15863f2ea..9a2154b89f 100644 --- a/apps/labeler/vitest.config.ts +++ b/apps/labeler/vitest.config.ts @@ -1,12 +1,17 @@ import { fileURLToPath } from "node:url"; import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-pool-workers"; -import { defineConfig } from "vitest/config"; +import { configDefaults, defineConfig } from "vitest/config"; const migrationsPath = fileURLToPath(new URL("./migrations", import.meta.url)); const migrations = await readD1Migrations(migrationsPath); export default defineConfig({ + // The console (console/vitest.config.ts) runs its own jsdom-based suite + // separately -- it doesn't run in the workerd pool this config sets up. + test: { + exclude: [...configDefaults.exclude, "console/**"], + }, plugins: [ cloudflareTest({ wrangler: { configPath: "./wrangler.jsonc" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 961c17caf5..1d20c7d532 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -457,6 +457,9 @@ importers: '@atcute/cid': specifier: 'catalog:' version: 2.4.1 + '@cloudflare/kumo': + specifier: 'catalog:' + version: 2.6.0(@date-fns/tz@1.4.1)(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(date-fns@4.1.0)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1) '@cloudflare/vite-plugin': specifier: 'catalog:' version: 1.44.0(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))(workerd@1.20260708.1)(wrangler@4.110.0(@cloudflare/workers-types@5.20260712.1)) @@ -466,9 +469,45 @@ importers: '@emdash-cms/atproto-test-utils': specifier: workspace:* version: link:../../packages/atproto-test-utils + '@phosphor-icons/react': + specifier: 'catalog:' + version: 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tailwindcss/vite': + specifier: ^4.3.2 + version: 4.3.2(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + '@tanstack/react-query': + specifier: 'catalog:' + version: 5.90.21(react@19.2.4) + '@tanstack/react-router': + specifier: 'catalog:' + version: 1.163.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/node': specifier: 'catalog:' version: 24.10.13 + '@types/react': + specifier: 'catalog:' + version: 19.2.14 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^5.2.0 + version: 5.2.0(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) + tailwindcss: + specifier: ^4.3.2 + version: 4.3.2 typescript: specifier: 'catalog:' version: 6.0.3 @@ -3310,10 +3349,6 @@ packages: resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} @@ -3404,10 +3439,6 @@ packages: resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -3416,10 +3447,6 @@ packages: resolution: {integrity: sha512-BCkFy+zN6kXQed3YOT7aJl93NfDSzQc3pBfsvTVPs9gU9X3V0aefEF5kwBT0E+mDWH9QgKaZstYUQN9VdQZT4g==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} @@ -3436,17 +3463,15 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.0': - resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} - engines: {node: '>=6.0.0'} - '@babel/parser@7.29.7': resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} + hasBin: true '@babel/parser@8.0.0-rc.1': resolution: {integrity: sha512-6HyyU5l1yK/7h9Ki52i5h6mDAx4qJdiLQO4FdCyJNoB/gy3T3GGJdhQzzbZgvgZCugYBvwtQiWRt94QKedHnkA==} engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true '@babel/plugin-proposal-decorators@7.29.0': resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==} @@ -3488,10 +3513,6 @@ packages: resolution: {integrity: sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.28.6': - resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} @@ -3504,10 +3525,6 @@ packages: resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -4776,9 +4793,6 @@ packages: '@jitl/quickjs-wasmfile-release-sync@0.32.0': resolution: {integrity: sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==} - '@jridgewell/gen-mapping@0.3.12': - resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -4792,9 +4806,6 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.29': - resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} - '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -4879,6 +4890,7 @@ packages: '@lingui/cli@5.9.5': resolution: {integrity: sha512-gonY7U75nzKic8GvEciy1/otQv1WpfwGW5wGMjmBXUMaMnIsycm/wo3t0+2hzqFp+RNfEKZcScoM7aViK3XuLQ==} engines: {node: '>=20.0.0'} + hasBin: true '@lingui/conf@5.9.5': resolution: {integrity: sha512-k5r9ssOZirhS5BlqdsK5L0rzlqnHeryoJHAQIpUpeh8g5ymgpbUN7L4+4C4hAX/tddAFiCFN8boHTiu6Wbt83Q==} @@ -6810,36 +6822,69 @@ packages: '@tailwindcss/node@4.3.1': resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==} + '@tailwindcss/node@4.3.2': + resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==} + '@tailwindcss/oxide-android-arm64@4.3.1': resolution: {integrity: sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==} engines: {node: '>= 20'} cpu: [arm64] os: [android] + '@tailwindcss/oxide-android-arm64@4.3.2': + resolution: {integrity: sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + '@tailwindcss/oxide-darwin-arm64@4.3.1': resolution: {integrity: sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] + '@tailwindcss/oxide-darwin-arm64@4.3.2': + resolution: {integrity: sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + '@tailwindcss/oxide-darwin-x64@4.3.1': resolution: {integrity: sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] + '@tailwindcss/oxide-darwin-x64@4.3.2': + resolution: {integrity: sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + '@tailwindcss/oxide-freebsd-x64@4.3.1': resolution: {integrity: sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] + '@tailwindcss/oxide-freebsd-x64@4.3.2': + resolution: {integrity: sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': resolution: {integrity: sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==} engines: {node: '>= 20'} cpu: [arm] os: [linux] + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + resolution: {integrity: sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': resolution: {integrity: sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==} engines: {node: '>= 20'} @@ -6847,6 +6892,13 @@ packages: os: [linux] libc: [glibc] + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + resolution: {integrity: sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@tailwindcss/oxide-linux-arm64-musl@4.3.1': resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==} engines: {node: '>= 20'} @@ -6854,6 +6906,13 @@ packages: os: [linux] libc: [musl] + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + resolution: {integrity: sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@tailwindcss/oxide-linux-x64-gnu@4.3.1': resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==} engines: {node: '>= 20'} @@ -6861,6 +6920,13 @@ packages: os: [linux] libc: [glibc] + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + resolution: {integrity: sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@tailwindcss/oxide-linux-x64-musl@4.3.1': resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==} engines: {node: '>= 20'} @@ -6868,6 +6934,13 @@ packages: os: [linux] libc: [musl] + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + resolution: {integrity: sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + '@tailwindcss/oxide-wasm32-wasi@4.3.1': resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==} engines: {node: '>=14.0.0'} @@ -6880,22 +6953,50 @@ packages: - '@emnapi/wasi-threads' - tslib + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + resolution: {integrity: sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': resolution: {integrity: sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + resolution: {integrity: sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + '@tailwindcss/oxide-win32-x64-msvc@4.3.1': resolution: {integrity: sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==} engines: {node: '>= 20'} cpu: [x64] os: [win32] + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + resolution: {integrity: sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + '@tailwindcss/oxide@4.3.1': resolution: {integrity: sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==} engines: {node: '>= 20'} + '@tailwindcss/oxide@4.3.2': + resolution: {integrity: sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==} + engines: {node: '>= 20'} + '@tailwindcss/typography@0.5.20': resolution: {integrity: sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==} peerDependencies: @@ -6906,6 +7007,11 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tailwindcss/vite@4.3.2': + resolution: {integrity: sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/history@1.161.4': resolution: {integrity: sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww==} engines: {node: '>=20.19'} @@ -7412,12 +7518,6 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitejs/plugin-react@5.1.4': - resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitejs/plugin-react@5.2.0': resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8426,6 +8526,7 @@ packages: esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} + hasBin: true esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} @@ -8770,6 +8871,7 @@ packages: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} @@ -9142,6 +9244,7 @@ packages: jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -9160,6 +9263,7 @@ packages: js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} @@ -9179,6 +9283,7 @@ packages: jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} + hasBin: true json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} @@ -10379,6 +10484,7 @@ packages: pseudolocale@2.2.0: resolution: {integrity: sha512-O+D2eU7fO9wVLqrohvt9V/9fwMadnJQ4jxwiK+LeNEqhMx8JYx4xQHkArDCJFAdPPOp/pQq6z5L37eBvAoc8jw==} engines: {node: '>=16.0.0'} + hasBin: true publint@0.3.17: resolution: {integrity: sha512-Q3NLegA9XM6usW+dYQRG1g9uEHiYUzcCVBJDJ7yMcWRqVU9LYZUWdqbwMZfmTCFC5PZLQpLAmhvRcQRl3exqkw==} @@ -10977,6 +11083,9 @@ packages: tailwindcss@4.3.1: resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} + tailwindcss@4.3.2: + resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -11068,6 +11177,7 @@ packages: tldts@6.1.86: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true to-buffer@1.2.2: resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} @@ -12403,7 +12513,7 @@ snapshots: remark-parse: 11.0.0 remark-rehype: 11.1.2 remark-smartypants: 3.0.2 - shiki: 4.0.1 + shiki: 4.0.2 smol-toml: 1.6.0 unified: 11.0.5 unist-util-remove-position: 5.0.0 @@ -12529,7 +12639,7 @@ snapshots: '@astrojs/internal-helpers': 0.8.0-beta.3 '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@vitejs/plugin-react': 5.1.4(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + '@vitejs/plugin-react': 5.2.0(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) devalue: 5.6.3 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -13353,14 +13463,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.29 - jsesc: 3.1.0 - '@babel/generator@7.29.7': dependencies: '@babel/parser': 7.29.7 @@ -13496,14 +13598,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-string-parser@7.29.7': {} '@babel/helper-string-parser@8.0.0-rc.6': {} - '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-identifier@7.29.7': {} '@babel/helper-validator-identifier@8.0.0-rc.1': {} @@ -13515,10 +13613,6 @@ snapshots: '@babel/template': 7.29.7 '@babel/types': 7.29.7 - '@babel/parser@7.29.0': - dependencies: - '@babel/types': 7.29.7 - '@babel/parser@7.29.7': dependencies: '@babel/types': 7.29.7 @@ -13558,19 +13652,17 @@ snapshots: '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/runtime-corejs3@7.29.2': dependencies: core-js-pure: 3.49.0 - '@babel/runtime@7.28.6': {} - '@babel/runtime@7.29.7': {} '@babel/template@7.29.7': @@ -13591,11 +13683,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -14788,11 +14875,6 @@ snapshots: dependencies: '@jitl/quickjs-ffi-types': 0.32.0 - '@jridgewell/gen-mapping@0.3.12': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -14807,11 +14889,6 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.29': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -14905,8 +14982,8 @@ snapshots: '@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3)': dependencies: '@babel/core': 7.29.7 - '@babel/runtime': 7.28.6 - '@babel/types': 7.29.0 + '@babel/runtime': 7.29.7 + '@babel/types': 7.29.7 '@lingui/conf': 5.9.5(typescript@6.0.3) '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3)) '@lingui/message-utils': 5.9.5 @@ -14917,10 +14994,10 @@ snapshots: '@lingui/cli@5.9.5(typescript@6.0.3)': dependencies: '@babel/core': 7.29.7 - '@babel/generator': 7.29.1 - '@babel/parser': 7.29.0 - '@babel/runtime': 7.28.6 - '@babel/types': 7.29.0 + '@babel/generator': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/runtime': 7.29.7 + '@babel/types': 7.29.7 '@lingui/babel-plugin-extract-messages': 5.9.5 '@lingui/babel-plugin-lingui-macro': 5.9.5(typescript@6.0.3) '@lingui/conf': 5.9.5(typescript@6.0.3) @@ -14950,7 +15027,7 @@ snapshots: '@lingui/conf@5.9.5(typescript@6.0.3)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 cosmiconfig: 8.3.6(typescript@6.0.3) jest-validate: 29.7.0 jiti: 2.7.0 @@ -14960,7 +15037,7 @@ snapshots: '@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 '@lingui/message-utils': 5.9.5 optionalDependencies: '@lingui/babel-plugin-lingui-macro': 5.9.5(typescript@6.0.3) @@ -14990,7 +15067,7 @@ snapshots: '@lingui/react@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3)) react: 19.2.4 optionalDependencies: @@ -16610,42 +16687,88 @@ snapshots: source-map-js: 1.2.1 tailwindcss: 4.3.1 + '@tailwindcss/node@4.3.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.6 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.2 + '@tailwindcss/oxide-android-arm64@4.3.1': optional: true + '@tailwindcss/oxide-android-arm64@4.3.2': + optional: true + '@tailwindcss/oxide-darwin-arm64@4.3.1': optional: true + '@tailwindcss/oxide-darwin-arm64@4.3.2': + optional: true + '@tailwindcss/oxide-darwin-x64@4.3.1': optional: true + '@tailwindcss/oxide-darwin-x64@4.3.2': + optional: true + '@tailwindcss/oxide-freebsd-x64@4.3.1': optional: true + '@tailwindcss/oxide-freebsd-x64@4.3.2': + optional: true + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': optional: true + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + optional: true + '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': optional: true + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + optional: true + '@tailwindcss/oxide-linux-arm64-musl@4.3.1': optional: true + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + optional: true + '@tailwindcss/oxide-linux-x64-gnu@4.3.1': optional: true + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + optional: true + '@tailwindcss/oxide-linux-x64-musl@4.3.1': optional: true + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + optional: true + '@tailwindcss/oxide-wasm32-wasi@4.3.1': optional: true + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + optional: true + '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': optional: true + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + optional: true + '@tailwindcss/oxide-win32-x64-msvc@4.3.1': optional: true + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + optional: true + '@tailwindcss/oxide@4.3.1': optionalDependencies: '@tailwindcss/oxide-android-arm64': 4.3.1 @@ -16661,6 +16784,21 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.1 '@tailwindcss/oxide-win32-x64-msvc': 4.3.1 + '@tailwindcss/oxide@4.3.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-x64': 4.3.2 + '@tailwindcss/oxide-freebsd-x64': 4.3.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-x64-musl': 4.3.2 + '@tailwindcss/oxide-wasm32-wasi': 4.3.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 + '@tailwindcss/typography@0.5.20(tailwindcss@4.2.1)': dependencies: postcss-selector-parser: 6.0.10 @@ -16673,6 +16811,13 @@ snapshots: tailwindcss: 4.3.1 vite: 6.4.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + '@tailwindcss/vite@4.3.2(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': + dependencies: + '@tailwindcss/node': 4.3.2 + '@tailwindcss/oxide': 4.3.2 + tailwindcss: 4.3.2 + vite: 8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + '@tanstack/history@1.161.4': {} '@tanstack/query-core@5.90.20': {} @@ -16725,7 +16870,7 @@ snapshots: '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 '@testing-library/dom': 10.4.1 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -17239,7 +17384,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))': + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7) @@ -17251,6 +17396,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-react@5.2.0(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + '@vitejs/plugin-react@5.2.0(vite@8.0.16(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 @@ -19508,7 +19665,7 @@ snapshots: i18next@23.16.8: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 iconv-lite@0.6.3: dependencies: @@ -22144,6 +22301,8 @@ snapshots: tailwindcss@4.3.1: {} + tailwindcss@4.3.2: {} + tapable@2.3.3: {} tar-fs@2.1.4: From 943b2548ef378af8aa379c30b92e1e468b843531 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 13 Jul 2026 15:23:55 +0100 Subject: [PATCH 048/137] feat(labeler): serve the operator console and Access-guarded read API (W9.3 server) (#2015) * feat(labeler): serve the operator console and Access-guarded read API (W9.3 server) Static assets under /admin with run_worker_first, and seven read-only /admin/api routes behind per-request Access verification with a reviewer role gate. Console client flips from fixtures to fetch. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM * fix(labeler): degrade jetstream probe on store failure; build console before worker The system-status route now reports disconnected instead of 500 when both the DO probe and the D1 fallback fail. Package build produces the console assets before the worker bundle so a clean deploy always has dist/console. Adds per-route unauthenticated-rejection coverage and full audit-field sanitization asserts. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM * fix(labeler): strip the /admin prefix before fetching console assets The console builds with base /admin/ but the assets binding maps paths one-to-one into dist/console, so JS/CSS requests fell through to the SPA fallback and the shell never booted. API paths are excluded from the rewrite in the helper itself, independent of dispatch order. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- apps/labeler/console/src/api/client.test.ts | 6 +- apps/labeler/console/src/api/client.ts | 20 +- apps/labeler/console/src/api/types.ts | 21 +- .../console/src/fixtures/operator-actions.ts | 42 +- .../console/src/fixtures/system-status.ts | 5 +- apps/labeler/console/src/routes/Dashboard.tsx | 12 +- apps/labeler/package.json | 4 +- apps/labeler/src/assessment-store.ts | 194 +++++ apps/labeler/src/console-api.ts | 343 +++++++++ apps/labeler/src/console-serialize.ts | 198 +++++ apps/labeler/src/index.ts | 60 +- apps/labeler/src/mutation-guard.ts | 4 +- apps/labeler/src/operator-actions.ts | 34 + apps/labeler/src/operator-read-guard.ts | 90 +++ apps/labeler/test/console-api.test.ts | 682 ++++++++++++++++++ apps/labeler/test/console-serialize.test.ts | 189 +++++ apps/labeler/test/operator-read-guard.test.ts | 155 ++++ apps/labeler/worker-configuration.d.ts | 4 +- apps/labeler/wrangler.jsonc | 15 + 19 files changed, 2037 insertions(+), 41 deletions(-) create mode 100644 apps/labeler/src/console-api.ts create mode 100644 apps/labeler/src/console-serialize.ts create mode 100644 apps/labeler/src/operator-read-guard.ts create mode 100644 apps/labeler/test/console-api.test.ts create mode 100644 apps/labeler/test/console-serialize.test.ts create mode 100644 apps/labeler/test/operator-read-guard.test.ts diff --git a/apps/labeler/console/src/api/client.test.ts b/apps/labeler/console/src/api/client.test.ts index ea83dd019f..45e54e8aea 100644 --- a/apps/labeler/console/src/api/client.test.ts +++ b/apps/labeler/console/src/api/client.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; -import { apiClient } from "./client.js"; +import { createFixtureClient } from "./client.js"; -describe("fixture apiClient", () => { +const apiClient = createFixtureClient(); + +describe("fixture client", () => { it("lists assessments newest first", async () => { const page = await apiClient.listAssessments(); expect(page.items.length).toBeGreaterThan(0); diff --git a/apps/labeler/console/src/api/client.ts b/apps/labeler/console/src/api/client.ts index 7bf3107512..31e5df8206 100644 --- a/apps/labeler/console/src/api/client.ts +++ b/apps/labeler/console/src/api/client.ts @@ -20,10 +20,10 @@ import type { export const CONSOLE_API_BASE = "/admin/api"; -/** The console's data-access contract. `fixtureClient` below is the only - * implementation wired up today; `createFetchClient` talks to the real - * Worker routes once W9.4-W9.6 land — swapping `apiClient`'s assignment at - * the bottom of this file is the only change either side needs. */ +/** The console's data-access contract. `createFetchClient` talks to the real + * Access-guarded `/admin/api/*` Worker routes and is what `apiClient` uses; + * `createFixtureClient` reads the static sample data and is kept exported for + * tests and offline UI work. */ export interface LabelerConsoleClient { listAssessments(params?: ListAssessmentsParams): Promise>; getAssessment(id: string): Promise; @@ -64,8 +64,8 @@ async function parseJson(response: Response, fallback: string): Promise { return body.data; } -/** Talks to the real `/admin/api/*` routes. Not wired up yet — see - * `apiClient` at the bottom of this file. */ +/** Talks to the real Access-guarded `/admin/api/*` routes — the client + * `apiClient` uses. */ export function createFetchClient(): LabelerConsoleClient { return { async listAssessments(params = {}) { @@ -112,9 +112,9 @@ export function createFetchClient(): LabelerConsoleClient { }; } -/** Reads the static fixtures under src/fixtures/ — the only client wired - * up until the labeler's `/admin/api/*` routes exist. */ -function createFixtureClient(): LabelerConsoleClient { +/** Reads the static fixtures under src/fixtures/ — used by tests and offline + * UI work. */ +export function createFixtureClient(): LabelerConsoleClient { return { async listAssessments(params = {}) { const filtered = params.state @@ -144,4 +144,4 @@ function createFixtureClient(): LabelerConsoleClient { }; } -export const apiClient: LabelerConsoleClient = createFixtureClient(); +export const apiClient: LabelerConsoleClient = createFetchClient(); diff --git a/apps/labeler/console/src/api/types.ts b/apps/labeler/console/src/api/types.ts index f6c61a803e..770a3357b6 100644 --- a/apps/labeler/console/src/api/types.ts +++ b/apps/labeler/console/src/api/types.ts @@ -107,15 +107,23 @@ export interface SubjectHistoryView { } /** - * Placeholder shape for the append-only `operator_actions` audit table - * (plan W9.2) — that table doesn't exist yet, so the audit log route - * renders an empty state rather than calling this through the client. + * A row from the append-only `operator_actions` audit table (plan W9.2), + * sanitized by the server's `serializeOperatorActionView` — the internal + * replay fields (`idempotencyKey`, `requestFingerprint`, `resultJson`) are + * never sent. Actor identity is the Cloudflare Access subject (`actorId`), not + * a DID; humans carry `actorEmail`, service tokens carry `actorCommonName`. */ export interface OperatorAction { id: string; - actorDid: string; + actorType: "human" | "service"; + actorId: string; + actorEmail: string | null; + actorCommonName: string | null; + role: "admin" | "reviewer"; action: string; subjectUri: string | null; + subjectCid: string | null; + labelValue: string | null; reason: string; createdAt: string; } @@ -124,8 +132,9 @@ export interface SystemStatusSnapshot { labelerDid: string; jetstreamConnected: boolean; pendingAssessments: number; - discoveryQueueDepth: number; - lastReconciliationAt: string | null; + /** Dead-letter backlog — the observable stand-in for discovery-queue depth, + * which the Queues API does not expose to the consumer Worker. */ + deadLetterDepth: number; } export interface Page { diff --git a/apps/labeler/console/src/fixtures/operator-actions.ts b/apps/labeler/console/src/fixtures/operator-actions.ts index 66777f04ae..ed037bb609 100644 --- a/apps/labeler/console/src/fixtures/operator-actions.ts +++ b/apps/labeler/console/src/fixtures/operator-actions.ts @@ -1,8 +1,42 @@ import type { OperatorAction } from "../api/types.js"; +import { SUBJECT_ALPHA, SUBJECT_GAMMA } from "./subjects.js"; /** - * Empty until the `operator_actions` audit table ships (plan W9.2) — - * the audit log route renders its empty state from this rather than - * fabricating action history that doesn't correspond to any real schema. + * Sample rows from the append-only `operator_actions` audit table (plan W9.2), + * in the sanitized `serializeOperatorActionView` shape the server returns — + * newest first, no internal replay fields. */ -export const FIXTURE_OPERATOR_ACTIONS: readonly OperatorAction[] = []; +export const OPERATOR_ACTION_GAMMA_RETRACT: OperatorAction = { + id: "oact_01J9ZK7Q2M4T8V0X2R4W6Y8B1C", + actorType: "human", + actorId: "access|8f2c1a90-6b3d-4e57-9a12-77c0de9b4a11", + actorEmail: "reviewer@emdashcms.com", + actorCommonName: null, + role: "reviewer", + action: "label-retract", + subjectUri: SUBJECT_GAMMA.uri, + subjectCid: SUBJECT_GAMMA.cid, + labelValue: "malware", + reason: "Retracting after upstream confirmed the flagged payload was a false positive.", + createdAt: "2026-07-12T14:02:15.000Z", +}; + +export const OPERATOR_ACTION_ALPHA_RERUN: OperatorAction = { + id: "oact_01J9YH3F5N7P9R1T3V5X7Z9A2B", + actorType: "service", + actorId: "access|ci-automation", + actorEmail: null, + actorCommonName: "ci-automation", + role: "admin", + action: "assessment-rerun", + subjectUri: SUBJECT_ALPHA.uri, + subjectCid: SUBJECT_ALPHA.cid, + labelValue: null, + reason: "Re-running against the updated policy version after the scanner upgrade.", + createdAt: "2026-07-11T16:40:00.000Z", +}; + +export const FIXTURE_OPERATOR_ACTIONS: readonly OperatorAction[] = [ + OPERATOR_ACTION_GAMMA_RETRACT, + OPERATOR_ACTION_ALPHA_RERUN, +]; diff --git a/apps/labeler/console/src/fixtures/system-status.ts b/apps/labeler/console/src/fixtures/system-status.ts index cbcfe7539b..02ccd62a72 100644 --- a/apps/labeler/console/src/fixtures/system-status.ts +++ b/apps/labeler/console/src/fixtures/system-status.ts @@ -1,10 +1,9 @@ import type { SystemStatusSnapshot } from "../api/types.js"; -/** Matches the `LABELER_DID` / discovery-queue bindings in wrangler.jsonc. */ +/** Matches the `LABELER_DID` binding in wrangler.jsonc. */ export const FIXTURE_SYSTEM_STATUS: SystemStatusSnapshot = { labelerDid: "did:web:labels.emdashcms.com", jetstreamConnected: true, pendingAssessments: 1, - discoveryQueueDepth: 3, - lastReconciliationAt: "2026-07-13T07:45:00.000Z", + deadLetterDepth: 2, }; diff --git a/apps/labeler/console/src/routes/Dashboard.tsx b/apps/labeler/console/src/routes/Dashboard.tsx index f8fd291fd7..872ec2b07f 100644 --- a/apps/labeler/console/src/routes/Dashboard.tsx +++ b/apps/labeler/console/src/routes/Dashboard.tsx @@ -47,7 +47,7 @@ function Dashboard() { return (

Dashboard

- + - - + Labeler DID: {status.labelerDid} diff --git a/apps/labeler/package.json b/apps/labeler/package.json index eae106c940..2c9fdb227a 100644 --- a/apps/labeler/package.json +++ b/apps/labeler/package.json @@ -6,9 +6,9 @@ "type": "module", "scripts": { "dev": "vite dev", - "build": "vite build", + "build": "node --run console:build && vite build", "preview": "vite preview", - "deploy": "vite build && wrangler deploy", + "deploy": "node --run build && wrangler deploy", "typecheck": "tsgo --noEmit && tsgo --noEmit -p console/tsconfig.json", "test": "vitest run && vitest run --config console/vitest.config.ts", "db:migrate:local": "wrangler d1 migrations apply emdash-labeler --local", diff --git a/apps/labeler/src/assessment-store.ts b/apps/labeler/src/assessment-store.ts index 6f52a9c121..b4835ec964 100644 --- a/apps/labeler/src/assessment-store.ts +++ b/apps/labeler/src/assessment-store.ts @@ -9,6 +9,8 @@ import { type AssessmentState, type PublicAssessmentState, } from "./assessment-lifecycle.js"; +import type { FindingSeverity } from "./evidence.js"; +import type { FindingSource } from "./findings.js"; const DECISION_OUTCOME_STATES: ReadonlySet = new Set([ "passed", @@ -870,6 +872,198 @@ export async function subjectWasObserved( return row !== null; } +/** Full-lifecycle assessment history for a URI (all states, all CIDs), + * newest-first, capped at {@link SUBJECT_HISTORY_LIMIT}. Powers the operator + * console's subject view, which — unlike the public list — surfaces + * `observed`/`stale`/`cancelled` runs too. `isSuperseded` is computed inline + * via the same correlated subquery as {@link getAssessmentsPage}. */ +const SUBJECT_HISTORY_LIMIT = 100; + +export async function getAssessmentsForUri( + db: D1Database, + uri: string, +): Promise { + const rows = await db + .prepare( + `SELECT a.id, a.run_key, a.uri, a.cid, a.artifact_id, a.artifact_checksum, a.state, + a.trigger, a.trigger_id, a.policy_version, a.model_id, a.prompt_hash, + a.public_summary, a.coverage_json, a.supersedes_assessment_id, + a.started_at, a.completed_at, a.created_at, + ${SUPERSEDED_EXISTS_SQL} AS is_superseded + FROM assessments a + WHERE a.uri = ? + ORDER BY a.created_at_epoch_ms DESC, a.id DESC + LIMIT ?`, + ) + .bind(uri, SUBJECT_HISTORY_LIMIT) + .all(); + return (rows.results ?? []).map((row) => ({ + ...rowToAssessment(row), + isSuperseded: row.is_superseded === 1, + })); +} + +/** Count of in-flight runs (`pending` or `running`) across all subjects — the + * operator dashboard's "pending assessments" figure. */ +export async function countInFlightAssessments(db: D1Database): Promise { + const row = await db + .prepare(`SELECT COUNT(*) AS n FROM assessments WHERE state IN ('pending', 'running')`) + .first<{ n: number }>(); + return row?.n ?? 0; +} + +export interface Subject { + uri: string; + cid: string; + did: string; + collection: string; + rkey: string; + observedAt: string; + deletedAt: string | null; +} + +interface SubjectRow { + uri: string; + cid: string; + did: string; + collection: string; + rkey: string; + observed_at: string; + deleted_at: string | null; +} + +/** The current subject row at a URI: the newest non-deleted CID (same + * observed-then-CID tie-break as {@link isSubjectCurrent}), falling back to the + * newest deleted row when every CID has been tombstoned. Returns null only when + * the URI was never observed at all — the operator console maps that to a 404. */ +export async function getCurrentSubjectByUri(db: D1Database, uri: string): Promise { + const row = await db + .prepare( + `SELECT uri, cid, did, collection, rkey, observed_at, deleted_at + FROM subjects WHERE uri = ? + ORDER BY (deleted_at IS NULL) DESC, observed_at_epoch_ms DESC, cid DESC + LIMIT 1`, + ) + .bind(uri) + .first(); + return row + ? { + uri: row.uri, + cid: row.cid, + did: row.did, + collection: row.collection, + rkey: row.rkey, + observedAt: row.observed_at, + deletedAt: row.deleted_at, + } + : null; +} + +export interface AssessmentFinding { + id: string; + assessmentId: string; + source: FindingSource; + category: string; + severity: FindingSeverity; + confidence: number | null; + title: string; + publicSummary: string; + privateDetail: string; + evidenceRefs: string[]; + createdAt: string; +} + +interface FindingRow { + id: string; + assessment_id: string; + source: FindingSource; + category: string; + severity: FindingSeverity; + confidence: number | null; + title: string; + public_summary: string; + private_detail: string; + evidence_refs_json: string; + created_at: string; +} + +/** Every finding for a run, oldest-first, with the operator-only + * `privateDetail`/`evidenceRefs` intact — the console serves the full record, + * not the public API's stripped view (spec §19.2). A malformed + * `evidence_refs_json` degrades to an empty list rather than throwing. */ +export async function getFindingsForAssessment( + db: D1Database, + assessmentId: string, +): Promise { + const rows = await db + .prepare( + `SELECT id, assessment_id, source, category, severity, confidence, title, + public_summary, private_detail, evidence_refs_json, created_at + FROM findings WHERE assessment_id = ? + ORDER BY created_at ASC, id ASC`, + ) + .bind(assessmentId) + .all(); + return (rows.results ?? []).map((row) => ({ + id: row.id, + assessmentId: row.assessment_id, + source: row.source, + category: row.category, + severity: row.severity, + confidence: row.confidence, + title: row.title, + publicSummary: row.public_summary, + privateDetail: row.private_detail, + evidenceRefs: parseEvidenceRefs(row.evidence_refs_json), + createdAt: row.created_at, + })); +} + +function parseEvidenceRefs(json: string): string[] { + try { + const parsed: unknown = JSON.parse(json); + if (Array.isArray(parsed)) + return parsed.filter((entry): entry is string => typeof entry === "string"); + } catch { + // falls through to the empty default + } + return []; +} + +export interface AssessmentIssuedLabel { + val: string; + cts: string; + exp: string | null; + neg: boolean; + sequence: number; +} + +/** Every label op an assessment produced, including negations, oldest-first. + * Unlike {@link getLabelsForAssessment} (which filters `neg=0` for the public + * view), the operator console needs the full issue/retract timeline. */ +export async function getAllLabelsForAssessment( + db: D1Database, + assessmentId: string, +): Promise { + const rows = await db + .prepare( + `SELECT l.val, l.cts, l.exp, l.neg, l.sequence + FROM issued_labels l + JOIN issuance_actions a ON a.id = l.action_id + WHERE a.assessment_id = ? + ORDER BY l.sequence ASC`, + ) + .bind(assessmentId) + .all<{ val: string; cts: string; exp: string | null; neg: number; sequence: number }>(); + return (rows.results ?? []).map((row) => ({ + val: row.val, + cts: row.cts, + exp: row.exp, + neg: row.neg === 1, + sequence: row.sequence, + })); +} + function rowToAssessment(row: AssessmentRow): Assessment { return { id: row.id, diff --git a/apps/labeler/src/console-api.ts b/apps/labeler/src/console-api.ts new file mode 100644 index 0000000000..6905d8cfdc --- /dev/null +++ b/apps/labeler/src/console-api.ts @@ -0,0 +1,343 @@ +/** + * Operator console read API (plan W9.3). Dispatches the seven Access-guarded, + * read-only `/admin/api/*` routes the console's `createFetchClient` consumes. + * Mirrors `xrpc-router.ts`'s hand-rolled dispatch + pure-D1-read style; each + * route is `guardRead` (once, at the top — every route requires the same + * reviewer gate) → store read → serialize → `{ data }`. + * + * Confidentiality of the private-evidence responses rests on: the edge Access + * policy on `/admin/*`, the per-request `verifyAccessRequest` + reviewer gate in + * `guardRead`, and — load-bearing, carried from W9.2 verbatim — **never emitting + * any `Access-Control-Allow-*` header**. A forged cross-origin GET can at most + * trigger a harmless read; without a CORS allow-origin the browser refuses to + * expose the body. No route here sets a CORS header. + */ + +import { + computeFilterHash, + decodeCursor, + encodeCursor, + InvalidCursorError, +} from "./assessment-cursor.js"; +import { + countInFlightAssessments, + getAllLabelsForAssessment, + getAssessment, + getAssessmentsForUri, + getAssessmentsPage, + getCurrentSubjectByUri, + getFindingsForAssessment, + isSuperseded, + type ListAssessmentsFilters, + type ListedAssessment, +} from "./assessment-store.js"; +import { + serializeAssessmentRun, + serializeIssuedLabel, + serializeOperatorActionView, + serializeOperatorFinding, + serializeSubjectRecord, + type Page, +} from "./console-serialize.js"; +import { LABELER_DISCOVERY_DO_NAME } from "./discovery-do.js"; +import { MutationGuardError } from "./mutation-guard.js"; +import { getOperatorActionsPage } from "./operator-actions.js"; +import { guardRead, ReadGuardError, type ReadGuardDeps } from "./operator-read-guard.js"; + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 100; +const NON_NEGATIVE_INTEGER = /^\d+$/; +const ADMIN_API_PREFIX = /^\/admin\/api\/?/; + +const PUBLIC_STATES: ReadonlySet = new Set([ + "pending", + "passed", + "warned", + "blocked", + "error", + "superseded", +]); + +export interface ConsoleApiDeps extends ReadGuardDeps { + db: D1Database; + labelerDid: string; + /** Resolves the operator dashboard's `jetstreamConnected` flag (a discovery + * DO round-trip with a D1 freshness fallback). Injected so the dispatcher + * stays unit-testable without a live DO. */ + jetstreamConnected: () => Promise; +} + +/** + * Serves a `/admin/api/*` request. The caller (index.ts) has already matched the + * prefix; unmatched sub-paths 404 here. Every request is reviewer-gated before + * any store read runs. + */ +export async function handleConsoleApi(request: Request, deps: ConsoleApiDeps): Promise { + try { + await guardRead(request, deps, { minRole: "reviewer" }); + // `matchRoute` is synchronous, so a rejection from the handler is consumed + // by this `await` directly — never adopted by an intermediate async layer, + // which would surface a spurious unhandled rejection under workerd. + const handler = matchRoute(request, deps); + const response = await handler(); + return response; + } catch (error) { + if (error instanceof MutationGuardError || error instanceof ReadGuardError) + return error.toResponse(); + console.error("[console-api] unhandled error", error); + return Response.json( + { error: { code: "INTERNAL", message: "Internal error" } }, + { status: 500 }, + ); + } +} + +function matchRoute(request: Request, deps: ConsoleApiDeps): () => Promise { + const url = new URL(request.url); + // pathname keeps percent-encoding; the subject URI segment is decoded per route. + const segments = url.pathname.replace(ADMIN_API_PREFIX, "").split("/").filter(Boolean); + + if (segments[0] === "assessments") { + if (segments.length === 1) return () => handleListAssessments(request, url, deps); + const id = segments[1]; + if (id !== undefined && segments.length === 2) + return () => handleGetAssessment(request, deps, id); + if (id !== undefined && segments.length === 3 && segments[2] === "findings") + return () => handleListFindings(request, deps, id); + if (id !== undefined && segments.length === 3 && segments[2] === "labels") + return () => handleListLabels(request, deps, id); + } else if (segments[0] === "subjects" && segments.length === 2 && segments[1] !== undefined) { + const uri = decodeSubjectUri(segments[1]); + return () => handleGetSubjectHistory(request, deps, uri); + } else if (segments[0] === "audit-log" && segments.length === 1) { + return () => handleListAuditLog(request, url, deps); + } else if (segments[0] === "status" && segments.length === 1) { + return () => handleGetStatus(request, deps); + } + + throw new ReadGuardError("NOT_FOUND"); +} + +function requireGet(request: Request): void { + if (request.method !== "GET") throw new ReadGuardError("METHOD_NOT_ALLOWED"); +} + +function decodeSubjectUri(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + throw new ReadGuardError("INVALID_REQUEST"); + } +} + +function jsonData(data: T): Response { + // Operator data is private and volatile — never cached, never CORS-exposed. + return Response.json({ data }, { headers: { "cache-control": "no-store" } }); +} + +function parseLimit(params: URLSearchParams): number { + const raw = params.get("limit"); + if (raw === null) return DEFAULT_LIMIT; + if (!NON_NEGATIVE_INTEGER.test(raw)) throw new ReadGuardError("INVALID_REQUEST"); + return Math.min(Math.max(Number(raw), 1), MAX_LIMIT); +} + +function parseState(params: URLSearchParams): ListAssessmentsFilters["state"] { + const raw = params.get("state"); + if (raw === null) return undefined; + if (!PUBLIC_STATES.has(raw)) throw new ReadGuardError("INVALID_REQUEST"); + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- validated against PUBLIC_STATES above + return raw as ListAssessmentsFilters["state"]; +} + +function decodeReadCursor( + raw: string | null, + filterHash: string, +): { createdAt: string; id: string } | null { + try { + return decodeCursor(raw ?? undefined, filterHash); + } catch (error) { + if (error instanceof InvalidCursorError) throw new ReadGuardError("INVALID_CURSOR"); + throw error; + } +} + +async function handleListAssessments( + request: Request, + url: URL, + deps: ConsoleApiDeps, +): Promise { + requireGet(request); + const filters: ListAssessmentsFilters = { state: parseState(url.searchParams) }; + const limit = parseLimit(url.searchParams); + const filterHash = await computeFilterHash({ state: filters.state }); + const keyset = decodeReadCursor(url.searchParams.get("cursor"), filterHash); + + const rows = await getAssessmentsPage(deps.db, filters, keyset, limit); + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + const last = page.at(-1); + const body: Page> = { + items: page.map(serializeAssessmentRun), + ...(hasMore && last + ? { nextCursor: encodeCursor({ createdAt: last.createdAt, id: last.id }, filterHash) } + : {}), + }; + return jsonData(body); +} + +async function handleGetAssessment( + request: Request, + deps: ConsoleApiDeps, + id: string, +): Promise { + requireGet(request); + let row; + try { + row = await getAssessment(deps.db, id); + } catch (error) { + // getAssessment throws TypeError on a malformed id; the client maps 404 → null. + if (error instanceof TypeError) throw new ReadGuardError("NOT_FOUND"); + throw error; + } + if (!row) throw new ReadGuardError("NOT_FOUND"); + const superseded = await isSuperseded(deps.db, row.id); + const listed: ListedAssessment = { ...row, isSuperseded: superseded }; + return jsonData(serializeAssessmentRun(listed)); +} + +async function handleListFindings( + request: Request, + deps: ConsoleApiDeps, + assessmentId: string, +): Promise { + requireGet(request); + const findings = await getFindingsForAssessment(deps.db, assessmentId); + return jsonData(findings.map(serializeOperatorFinding)); +} + +async function handleListLabels( + request: Request, + deps: ConsoleApiDeps, + assessmentId: string, +): Promise { + requireGet(request); + const labels = await getAllLabelsForAssessment(deps.db, assessmentId); + return jsonData(labels.map(serializeIssuedLabel)); +} + +async function handleGetSubjectHistory( + request: Request, + deps: ConsoleApiDeps, + uri: string, +): Promise { + requireGet(request); + const subject = await getCurrentSubjectByUri(deps.db, uri); + if (!subject) throw new ReadGuardError("NOT_FOUND"); + const assessments = await getAssessmentsForUri(deps.db, uri); + return jsonData({ + subject: serializeSubjectRecord(subject), + assessments: assessments.map(serializeAssessmentRun), + }); +} + +async function handleListAuditLog( + request: Request, + url: URL, + deps: ConsoleApiDeps, +): Promise { + requireGet(request); + const limit = parseLimit(url.searchParams); + const filterHash = await computeFilterHash({}); + const keyset = decodeReadCursor(url.searchParams.get("cursor"), filterHash); + + const rows = await getOperatorActionsPage(deps.db, keyset, limit); + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + const last = page.at(-1); + const body: Page> = { + items: page.map(serializeOperatorActionView), + ...(hasMore && last + ? { nextCursor: encodeCursor({ createdAt: last.createdAt, id: last.id }, filterHash) } + : {}), + }; + return jsonData(body); +} + +async function handleGetStatus(request: Request, deps: ConsoleApiDeps): Promise { + requireGet(request); + const [pendingAssessments, deadLetterDepth, jetstreamConnected] = await Promise.all([ + countInFlightAssessments(deps.db), + countDeadLetters(deps.db), + deps.jetstreamConnected(), + ]); + return jsonData({ + labelerDid: deps.labelerDid, + jetstreamConnected, + pendingAssessments, + deadLetterDepth, + }); +} + +/** Dead-letter backlog — the observable stand-in for discovery-queue depth, + * which the Queues API does not expose to the consumer Worker (spec §11.1's + * `/admin/system` "queue/DLQ health"). */ +async function countDeadLetters(db: D1Database): Promise { + const row = await db.prepare(`SELECT COUNT(*) AS n FROM dead_letters`).first<{ n: number }>(); + return row?.n ?? 0; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * Maps an `/admin` console request path onto the asset binding's namespace, or + * returns `null` when the path is not a console asset request. The binding serves + * `./dist/console` one-to-one from the root, but the SPA is built with + * `base: "/admin/"`, so its `index.html` references `/admin/assets/*`. Without + * this strip the binding resolves those against `dist/console/admin/*`, misses, + * and hands every JS/CSS request the SPA `index.html` fallback — the shell would + * load but never execute. `/admin` and `/admin/` map to `/` (the shell); a deep + * link like `/admin/assessments/x` maps to `/assessments/x` (no such file, so the + * SPA fallback correctly serves the shell). `/admin/api/*` is the read API, + * dispatched before the asset branch, and is excluded here so an asset rewrite + * can never swallow an API path regardless of call order. + */ +export function consoleAssetPath(pathname: string): string | null { + if (pathname === "/admin/api" || pathname.startsWith("/admin/api/")) return null; + if (pathname !== "/admin" && !pathname.startsWith("/admin/")) return null; + const rest = pathname.slice("/admin".length); + return rest === "" ? "/" : rest; +} + +/** + * `jetstreamConnected` probe (index.ts glue). Primary signal: the discovery + * DO's `{ cursor, consecutiveFailures }` status (`0` failures ⇒ connected). + * Fallback when the DO is unreachable/evicted: whether the D1 `ingest_state` + * jetstream cursor was written within the last 15 minutes — the comparison runs + * in SQLite so the `datetime('now')` string format never round-trips through JS. + */ +export async function probeJetstreamConnected(env: Env): Promise { + try { + const id = env.LABELER_DISCOVERY_DO.idFromName(LABELER_DISCOVERY_DO_NAME); + const response = await env.LABELER_DISCOVERY_DO.get(id).fetch("https://do.internal/status"); + const body: unknown = await response.json(); + if (isRecord(body) && typeof body.consecutiveFailures === "number") + return body.consecutiveFailures === 0; + } catch { + // falls through to the D1 freshness fallback + } + try { + const fresh = await env.DB.prepare( + `SELECT 1 FROM ingest_state + WHERE source = 'jetstream' AND updated_at >= datetime('now', '-15 minutes') + LIMIT 1`, + ).first(); + return fresh !== null; + } catch { + // Neither signal is reachable; report disconnected rather than throwing and + // failing the whole status route on a degraded observability field. + return false; + } +} diff --git a/apps/labeler/src/console-serialize.ts b/apps/labeler/src/console-serialize.ts new file mode 100644 index 0000000000..938f2e1897 --- /dev/null +++ b/apps/labeler/src/console-serialize.ts @@ -0,0 +1,198 @@ +/** + * Operator-console wire serializers (plan W9.3): stored rows → the JSON shapes + * the console's `createFetchClient` consumes (mirrored in + * `console/src/api/types.ts`). The inverse of `evidence.ts`'s public boundary — + * here we deliberately **include** the operator-only fields (`privateDetail`, + * `evidenceRefs`, `modelId`/`promptHash`, label negations), because every + * `/admin/api/*` route is Access-edge-gated and per-request reviewer-gated + * (spec §19.2: private evidence "Access is limited to reviewer/admin roles"). + * + * The one non-privacy exclusion is the audit view, which drops the internal + * replay machinery (`idempotency_key`, `request_fingerprint`, `result_json`): + * those are `commitMutation` plumbing, not evidence, and never displayed. + * + * Wire types are redeclared here rather than imported from the console (which + * pulls in browser/React types); the two sides stay shape-compatible, with + * `console/src/api/types.ts` as the shared truth. + */ + +import type { OperatorRole } from "./access-auth.js"; +import type { AssessmentState, PublicAssessmentState } from "./assessment-lifecycle.js"; +import type { + AssessmentFinding, + AssessmentIssuedLabel, + ListedAssessment, + Subject, +} from "./assessment-store.js"; +import type { FindingSeverity } from "./evidence.js"; +import type { FindingSource } from "./findings.js"; +import type { StoredOperatorAction } from "./operator-actions.js"; +import { derivePublicState } from "./public-assessment.js"; + +export interface AssessmentRun { + id: string; + runKey: string; + uri: string; + cid: string; + artifactId: string | null; + artifactChecksum: string | null; + state: AssessmentState; + publicState: PublicAssessmentState | null; + trigger: string; + triggerId: string; + policyVersion: string; + modelId: string | null; + promptHash: string | null; + publicSummary: string | null; + supersedesAssessmentId: string | null; + startedAt: string | null; + completedAt: string | null; + createdAt: string; + isSuperseded: boolean; +} + +export interface OperatorFinding { + id: string; + assessmentId: string; + source: FindingSource; + category: string; + severity: FindingSeverity; + confidence?: number; + title: string; + publicSummary: string; + privateDetail: string; + evidenceRefs: readonly string[]; + createdAt: string; +} + +export interface IssuedLabel { + val: string; + cts: string; + exp: string | null; + neg: boolean; + sequence: number; +} + +export interface SubjectRecord { + uri: string; + cid: string; + did: string; + collection: string; + rkey: string; + observedAt: string; + deletedAt: string | null; +} + +export interface SubjectHistoryView { + subject: SubjectRecord; + assessments: AssessmentRun[]; +} + +export interface OperatorActionView { + id: string; + actorType: "human" | "service"; + actorId: string; + actorEmail: string | null; + actorCommonName: string | null; + role: OperatorRole; + action: string; + subjectUri: string | null; + subjectCid: string | null; + labelValue: string | null; + reason: string; + createdAt: string; +} + +export interface SystemStatusSnapshot { + labelerDid: string; + jetstreamConnected: boolean; + pendingAssessments: number; + deadLetterDepth: number; +} + +export interface Page { + items: T[]; + nextCursor?: string; +} + +export function serializeAssessmentRun(row: ListedAssessment): AssessmentRun { + return { + id: row.id, + runKey: row.runKey, + uri: row.uri, + cid: row.cid, + artifactId: row.artifactId, + artifactChecksum: row.artifactChecksum, + state: row.state, + publicState: derivePublicState(row.state, row.isSuperseded), + trigger: row.trigger, + triggerId: row.triggerId, + policyVersion: row.policyVersion, + modelId: row.modelId, + promptHash: row.promptHash, + publicSummary: row.publicSummary, + supersedesAssessmentId: row.supersedesAssessmentId, + startedAt: row.startedAt, + completedAt: row.completedAt, + createdAt: row.createdAt, + isSuperseded: row.isSuperseded, + }; +} + +export function serializeOperatorFinding(finding: AssessmentFinding): OperatorFinding { + return { + id: finding.id, + assessmentId: finding.assessmentId, + source: finding.source, + category: finding.category, + severity: finding.severity, + ...(finding.confidence !== null ? { confidence: finding.confidence } : {}), + title: finding.title, + publicSummary: finding.publicSummary, + privateDetail: finding.privateDetail, + evidenceRefs: finding.evidenceRefs, + createdAt: finding.createdAt, + }; +} + +export function serializeIssuedLabel(label: AssessmentIssuedLabel): IssuedLabel { + return { + val: label.val, + cts: label.cts, + exp: label.exp, + neg: label.neg, + sequence: label.sequence, + }; +} + +export function serializeSubjectRecord(subject: Subject): SubjectRecord { + return { + uri: subject.uri, + cid: subject.cid, + did: subject.did, + collection: subject.collection, + rkey: subject.rkey, + observedAt: subject.observedAt, + deletedAt: subject.deletedAt, + }; +} + +/** Drops the internal replay fields (`idempotencyKey`, `requestFingerprint`, + * `resultJson`) and the epoch-ms sibling; keeps the "who did what, under which + * role, why" record the console displays. */ +export function serializeOperatorActionView(action: StoredOperatorAction): OperatorActionView { + return { + id: action.id, + actorType: action.actorType, + actorId: action.actorId, + actorEmail: action.actorEmail, + actorCommonName: action.actorCommonName, + role: action.role, + action: action.action, + subjectUri: action.subjectUri, + subjectCid: action.subjectCid, + labelValue: action.labelValue, + reason: action.reason, + createdAt: action.createdAt, + }; +} diff --git a/apps/labeler/src/index.ts b/apps/labeler/src/index.ts index 7db8e1155d..bea3cb6310 100644 --- a/apps/labeler/src/index.ts +++ b/apps/labeler/src/index.ts @@ -1,4 +1,10 @@ -import { getLabelerIdentityConfig } from "./config.js"; +import { + getAccessKeyResolver, + parseAccessAuthConfig, + type AccessAuthConfig, +} from "./access-auth.js"; +import { getLabelerIdentityConfig, type LabelerConfig } from "./config.js"; +import { consoleAssetPath, handleConsoleApi, probeJetstreamConnected } from "./console-api.js"; import { drainDiscoveryDeadLetterBatch, processDiscoveryBatch } from "./discovery-consumer.js"; import { LABELER_DISCOVERY_DO_NAME } from "./discovery-do.js"; import type { DiscoveryJob } from "./env.js"; @@ -44,6 +50,20 @@ export default { if (pathname === CREATE_REPORT_PATH) return rejectModerationReport(request); return handleAssessmentXrpc(env, request, config); } + // `/admin/api/*` is the Access-guarded operator read API; anything else + // under `/admin` is the console SPA, served (with SPA deep-link + // fallback) by the assets binding. The Access edge policy on `/admin/*` + // redirects an unauthenticated browser before it reaches the Worker, so + // the shell needs no in-Worker auth check — the API re-verifies per + // request regardless (see console-api.ts / operator-read-guard.ts). + if (pathname === "/admin/api" || pathname.startsWith("/admin/api/")) + return handleConsoleApiRequest(env, request, config); + const assetPath = consoleAssetPath(pathname); + if (assetPath !== null) { + const assetUrl = new URL(request.url); + assetUrl.pathname = assetPath; + return env.ASSETS.fetch(new Request(assetUrl, request)); + } return new Response("emdash-labeler: not found", { status: 404 }); }, @@ -82,6 +102,44 @@ export default { }, }; +const OPERATOR_ACCESS_CONFIG_CACHE_KEY = Symbol.for("emdash-labeler:operator-access-config"); + +/** Parse `OPERATOR_ACCESS_CONFIG` (a JSON string var) once and cache the result + * on `globalThis` — Vite can duplicate this module across SSR chunks, so a + * plain module-scope binding would parse per chunk. */ +function getOperatorAccessConfig(env: Env): AccessAuthConfig { + const g = globalThis as Record; + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern + const cached = g[OPERATOR_ACCESS_CONFIG_CACHE_KEY] as AccessAuthConfig | undefined; + if (cached) return cached; + const parsed = parseAccessAuthConfig(JSON.parse(env.OPERATOR_ACCESS_CONFIG)); + g[OPERATOR_ACCESS_CONFIG_CACHE_KEY] = parsed; + return parsed; +} + +async function handleConsoleApiRequest( + env: Env, + request: Request, + config: LabelerConfig, +): Promise { + let accessConfig: AccessAuthConfig; + try { + accessConfig = getOperatorAccessConfig(env); + } catch { + return Response.json( + { error: { code: "NOT_CONFIGURED", message: "Operator console is not configured" } }, + { status: 500 }, + ); + } + return handleConsoleApi(request, { + db: env.DB, + config: accessConfig, + keys: getAccessKeyResolver(accessConfig.teamDomain), + labelerDid: config.labelerDid, + jetstreamConnected: () => probeJetstreamConnected(env), + }); +} + function rejectModerationReport(request: Request): Response { if (request.method !== "POST") return xrpcError("MethodNotSupported", "createReport only supports POST", 405, { diff --git a/apps/labeler/src/mutation-guard.ts b/apps/labeler/src/mutation-guard.ts index 38552f5059..c24b20ea49 100644 --- a/apps/labeler/src/mutation-guard.ts +++ b/apps/labeler/src/mutation-guard.ts @@ -280,14 +280,14 @@ function assertJsonContentType(request: Request): void { } } -function assertSameOrigin(request: Request, expectedOrigin: string): void { +export function assertSameOrigin(request: Request, expectedOrigin: string): void { const origin = request.headers.get("Origin"); if (origin !== null && origin !== expectedOrigin) throw new MutationGuardError("CROSS_ORIGIN"); const site = request.headers.get("Sec-Fetch-Site"); if (site !== null && site !== "same-origin") throw new MutationGuardError("CROSS_ORIGIN"); } -function assertCsrfHeader(request: Request): void { +export function assertCsrfHeader(request: Request): void { if (request.headers.get(OPERATOR_REQUEST_HEADER) !== "1") throw new MutationGuardError("CSRF_HEADER_MISSING"); } diff --git a/apps/labeler/src/operator-actions.ts b/apps/labeler/src/operator-actions.ts index 3bf8a92554..3f5a27d622 100644 --- a/apps/labeler/src/operator-actions.ts +++ b/apps/labeler/src/operator-actions.ts @@ -135,6 +135,40 @@ export async function getOperatorActionByKey( return row ? rowToStoredOperatorAction(row) : null; } +/** + * Audit-log page, newest first, exclusive keyset on `(created_at_epoch_ms, id)` + * over `idx_operator_actions_created`. `keyset.createdAt` is the ISO timestamp + * carried in the opaque cursor; the epoch comparison derives from it via + * `Date.parse`, matching `getAssessmentsPage`. Fetches `limit + 1` so the caller + * detects a next page without a trailing COUNT. + */ +export async function getOperatorActionsPage( + db: D1Database, + keyset: { createdAt: string; id: string } | null, + limit: number, +): Promise { + const bindings: (string | number)[] = []; + let where = ""; + if (keyset !== null) { + const epochMs = Date.parse(keyset.createdAt); + where = `WHERE (created_at_epoch_ms < ? OR (created_at_epoch_ms = ? AND id < ?))`; + bindings.push(epochMs, epochMs, keyset.id); + } + bindings.push(limit + 1); + const rows = await db + .prepare( + `SELECT id, actor_type, actor_id, actor_email, actor_common_name, role, action, + subject_uri, subject_cid, label_value, reason, idempotency_key, + request_fingerprint, result_json, metadata_json, created_at, created_at_epoch_ms + FROM operator_actions ${where} + ORDER BY created_at_epoch_ms DESC, id DESC + LIMIT ?`, + ) + .bind(...bindings) + .all(); + return (rows.results ?? []).map(rowToStoredOperatorAction); +} + /** * True when `error` is the D1 UNIQUE violation on `operator_actions.idempotency_key`. * Matches the qualified column so it does not catch the `id` primary-key conflict diff --git a/apps/labeler/src/operator-read-guard.ts b/apps/labeler/src/operator-read-guard.ts new file mode 100644 index 0000000000..b29b0144ff --- /dev/null +++ b/apps/labeler/src/operator-read-guard.ts @@ -0,0 +1,90 @@ +/** + * Read counterpart of `guardMutation` (plan W9.3). Every `/admin/api/*` read + * runs the same defense-in-depth chain as a mutation, minus the body/idempotency + * machinery a GET has no use for: same-origin + CSRF-header transport checks + * (reusing the mutation guard's asserts so the CSRF/origin semantics have one + * source of truth), a fresh `verifyAccessRequest`, then a reviewer-role gate + * (admin satisfies it by inheritance — spec §19.2/§12). Returns the verified + * identity or throws a guard error the dispatcher renders via `toResponse`. + * + * Transport/auth rejections reuse `MutationGuardError` (`CROSS_ORIGIN`, + * `CSRF_HEADER_MISSING`, `UNAUTHENTICATED`, `FORBIDDEN_ROLE`); the read-only + * codes (`NOT_FOUND`, `INVALID_REQUEST`, `INVALID_CURSOR`, `METHOD_NOT_ALLOWED`) + * live on `ReadGuardError`, which shares `MutationGuardError`'s wire shape. + */ + +import { + AccessAuthError, + hasRole, + verifyAccessRequest, + type AccessAuthConfig, + type AccessKeyResolver, + type OperatorIdentity, + type OperatorRole, +} from "./access-auth.js"; +import { assertCsrfHeader, assertSameOrigin, MutationGuardError } from "./mutation-guard.js"; + +export type ReadGuardCode = + | "NOT_FOUND" + | "INVALID_REQUEST" + | "INVALID_CURSOR" + | "METHOD_NOT_ALLOWED"; + +const READ_GUARD_ERROR: Readonly> = { + NOT_FOUND: { status: 404, message: "Resource not found" }, + INVALID_REQUEST: { status: 400, message: "Request parameters are invalid" }, + INVALID_CURSOR: { status: 400, message: "Cursor is invalid or does not match the request" }, + METHOD_NOT_ALLOWED: { status: 405, message: "Method not allowed" }, +}; + +/** + * Read-side guard rejection. Messages are static per code — they never echo the + * request, so `toResponse` cannot leak caller-supplied content. Same wire shape + * as `MutationGuardError` / core's `apiError`. + */ +export class ReadGuardError extends Error { + override readonly name = "ReadGuardError"; + readonly code: ReadGuardCode; + readonly status: number; + + constructor(code: ReadGuardCode) { + const { status, message } = READ_GUARD_ERROR[code]; + super(message); + this.code = code; + this.status = status; + } + + toResponse(): Response { + return Response.json( + { error: { code: this.code, message: this.message } }, + { status: this.status }, + ); + } +} + +export interface ReadGuardDeps { + config: AccessAuthConfig; + keys: AccessKeyResolver; + /** Defaults to the request URL's origin; override for proxy edge cases. */ + expectedOrigin?: string; +} + +export async function guardRead( + request: Request, + deps: ReadGuardDeps, + options: { minRole: OperatorRole }, +): Promise { + assertSameOrigin(request, deps.expectedOrigin ?? new URL(request.url).origin); + assertCsrfHeader(request); + + let identity: OperatorIdentity; + try { + identity = await verifyAccessRequest(request, deps.config, deps.keys); + } catch (error) { + if (error instanceof AccessAuthError) throw new MutationGuardError("UNAUTHENTICATED"); + throw error; + } + + if (!hasRole(identity, options.minRole)) throw new MutationGuardError("FORBIDDEN_ROLE"); + return identity; +} diff --git a/apps/labeler/test/console-api.test.ts b/apps/labeler/test/console-api.test.ts new file mode 100644 index 0000000000..ba3fe52213 --- /dev/null +++ b/apps/labeler/test/console-api.test.ts @@ -0,0 +1,682 @@ +import { applyD1Migrations, env } from "cloudflare:test"; +import { generateKeyPair, SignJWT } from "jose"; +import { beforeAll, describe, expect, it } from "vitest"; + +import type { AccessKeyResolver } from "../src/access-auth.js"; +import { createSubject, recordFinding } from "../src/assessment-store.js"; +import { + consoleAssetPath, + handleConsoleApi, + probeJetstreamConnected, + type ConsoleApiDeps, +} from "../src/console-api.js"; +import { OPERATOR_REQUEST_HEADER } from "../src/mutation-guard.js"; +import { buildOperatorActionInsert, type OperatorActionType } from "../src/operator-actions.js"; + +const TEAM_DOMAIN = "https://example-team.cloudflareaccess.com"; +const AUDIENCE = "test-audience"; +const ORIGIN = "https://labeler.example.com"; +const LABELER_DID = "did:web:labels.emdashcms.com"; + +const URI_A = + "at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/com.emdashcms.experimental.package.release/rkA"; +const CID_A = "bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const URI_B = + "at://did:plc:bbbbbbbbbbbbbbbbbbbbbbbb/com.emdashcms.experimental.package.release/rkB"; +const CID_B = "bafyreibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const URI_S = + "at://did:plc:ssssssssssssssssssssssss/com.emdashcms.experimental.package.release/rkS"; +const CID_S_OLD = "bafyreisssssssssssssssssssssssssssssssssssssssssssssssold"; +const CID_S_NEW = "bafyreisssssssssssssssssssssssssssssssssssssssssssssssnew"; + +// Assessment ids must satisfy assessment-lifecycle's ASSESSMENT_ID (ULID) regex, +// which the detail route validates via getAssessment. +const ASMT_PASS_A = "asmt_SQCP5CP1X1RBMM0V0TQP2WR9PD"; +const ASMT_BLOCK_B = "asmt_R79G2W700J4F005EAG66HP66JR"; +const ASMT_RUN = "asmt_34XM75YB5AJ9Z84B9EJBWM0CV1"; +const ASMT_PENDING = "asmt_RT0G62FEF7MAX2R6K0K0P3E3RN"; +const ASMT_S_STALE = "asmt_BZ7JNKG1TYRMZMVP0XQEGEC1Y7"; +const ASMT_S_PASS = "asmt_K2Y9KK9SSTD43VVT780P6HMT8A"; +const ASMT_S_CANCEL = "asmt_7ZH8QK2M4T8V0X2R4W6Y8B1C3D"; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; +} +const testEnv = env as unknown as TestEnv; + +let signKey: CryptoKey; +let resolver: AccessKeyResolver; +let reviewerToken: string; +let adminToken: string; +let noRoleToken: string; + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); + const pair = await generateKeyPair("RS256"); + signKey = pair.privateKey; + resolver = (async () => pair.publicKey) as AccessKeyResolver; + reviewerToken = await mintToken({ email: "reviewer@example.com" }); + adminToken = await mintToken({ email: "admin@example.com" }); + noRoleToken = await mintToken({ email: "nobody@example.com" }); + + await seedSubjects(); + await seedAssessments(); + await seedFindings(); + await seedLabels(); + await seedOperatorActions(); + await seedDeadLetters(3); +}); + +async function mintToken(claims: Record): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ sub: "user-sub-1", ...claims }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer(TEAM_DOMAIN) + .setAudience(AUDIENCE) + .setIssuedAt(now) + .setExpirationTime(now + 3600) + .sign(signKey); +} + +function deps(overrides: Partial = {}): ConsoleApiDeps { + return { + db: testEnv.DB, + config: { + teamDomain: TEAM_DOMAIN, + audience: AUDIENCE, + admins: ["admin@example.com"], + reviewers: ["reviewer@example.com"], + }, + keys: resolver, + expectedOrigin: ORIGIN, + labelerDid: LABELER_DID, + jetstreamConnected: async () => true, + ...overrides, + }; +} + +interface ReqOptions { + method?: string; + token?: string; + csrf?: string | null; + spoofEmail?: string; +} + +function req(path: string, opts: ReqOptions = {}): Request { + const headers = new Headers(); + const csrf = opts.csrf === undefined ? "1" : opts.csrf; + if (csrf !== null) headers.set(OPERATOR_REQUEST_HEADER, csrf); + if (opts.token !== undefined) headers.set("Cf-Access-Jwt-Assertion", opts.token); + if (opts.spoofEmail !== undefined) + headers.set("Cf-Access-Authenticated-User-Email", opts.spoofEmail); + return new Request(`${ORIGIN}${path}`, { method: opts.method ?? "GET", headers }); +} + +async function seedSubjects(): Promise { + await createSubject(testEnv.DB, { + uri: URI_A, + cid: CID_A, + did: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa", + collection: "com.emdashcms.experimental.package.release", + rkey: "rkA", + now: new Date("2026-07-08T08:55:00.000Z"), + }); + await createSubject(testEnv.DB, { + uri: URI_B, + cid: CID_B, + did: "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb", + collection: "com.emdashcms.experimental.package.release", + rkey: "rkB", + now: new Date("2026-07-08T09:55:00.000Z"), + }); + await createSubject(testEnv.DB, { + uri: URI_S, + cid: CID_S_OLD, + did: "did:plc:ssssssssssssssssssssssss", + collection: "com.emdashcms.experimental.package.release", + rkey: "rkS", + now: new Date("2026-07-01T10:00:00.000Z"), + }); + await createSubject(testEnv.DB, { + uri: URI_S, + cid: CID_S_NEW, + did: "did:plc:ssssssssssssssssssssssss", + collection: "com.emdashcms.experimental.package.release", + rkey: "rkS", + now: new Date("2026-07-05T10:00:00.000Z"), + }); +} + +interface SeedAssessment { + id: string; + uri: string; + cid: string; + state: string; + createdAt: string; + modelId?: string; + promptHash?: string; +} + +async function seedAssessment(a: SeedAssessment): Promise { + const epoch = Date.parse(a.createdAt); + await testEnv.DB.prepare( + `INSERT INTO assessments + (id, run_key, uri, cid, artifact_id, artifact_checksum, state, trigger, trigger_id, + policy_version, model_id, prompt_hash, public_summary, coverage_json, + supersedes_assessment_id, started_at, started_at_epoch_ms, completed_at, + completed_at_epoch_ms, created_at, created_at_epoch_ms) + VALUES (?, ?, ?, ?, NULL, NULL, ?, 'initial', ?, '2026-07-10.experimental.2', ?, ?, NULL, + '{"code":"complete","images":"not-present","metadata":"complete"}', + NULL, NULL, NULL, NULL, NULL, ?, ?)`, + ) + .bind( + a.id, + `run-${a.id}`, + a.uri, + a.cid, + a.state, + `initial:${a.id}`, + a.modelId ?? null, + a.promptHash ?? null, + a.createdAt, + epoch, + ) + .run(); +} + +async function seedAssessments(): Promise { + await seedAssessment({ + id: ASMT_PASS_A, + uri: URI_A, + cid: CID_A, + state: "passed", + createdAt: "2026-07-08T09:00:00.000Z", + }); + await seedAssessment({ + id: ASMT_BLOCK_B, + uri: URI_B, + cid: CID_B, + state: "blocked", + createdAt: "2026-07-08T10:00:00.000Z", + modelId: "@cf/meta/llama-3.1-70b-instruct", + promptHash: "sha256:prompt", + }); + await seedAssessment({ + id: ASMT_RUN, + uri: URI_A, + cid: CID_A, + state: "running", + createdAt: "2026-07-09T08:00:00.000Z", + }); + await seedAssessment({ + id: ASMT_PENDING, + uri: URI_A, + cid: CID_A, + state: "pending", + createdAt: "2026-07-09T09:00:00.000Z", + }); + // Subject-history subject: passed + stale + cancelled across two CIDs. + await seedAssessment({ + id: ASMT_S_STALE, + uri: URI_S, + cid: CID_S_OLD, + state: "stale", + createdAt: "2026-07-02T10:00:00.000Z", + }); + await seedAssessment({ + id: ASMT_S_PASS, + uri: URI_S, + cid: CID_S_NEW, + state: "passed", + createdAt: "2026-07-05T10:00:00.000Z", + }); + await seedAssessment({ + id: ASMT_S_CANCEL, + uri: URI_S, + cid: CID_S_NEW, + state: "cancelled", + createdAt: "2026-07-05T11:00:00.000Z", + }); +} + +async function seedFindings(): Promise { + await recordFinding(testEnv.DB, { + assessmentId: ASMT_BLOCK_B, + source: "deterministic", + category: "malware", + severity: "critical", + title: "Known malware signature match", + publicSummary: "This release matches a known malware signature.", + privateDetail: "YARA rule stealer-generic-v3 matched dist/postinstall.js:1.", + evidenceRefs: ["evid_01"], + now: new Date("2026-07-08T10:01:00.000Z"), + }); + await recordFinding(testEnv.DB, { + assessmentId: ASMT_BLOCK_B, + source: "capability", + category: "obfuscated-code", + severity: "high", + confidence: 0.94, + title: "Heavily obfuscated postinstall script", + publicSummary: "A postinstall script uses obfuscation techniques.", + privateDetail: "String-array rotation plus eval-based deobfuscation; entropy 7.91/8.", + evidenceRefs: ["evid_01"], + now: new Date("2026-07-08T10:02:00.000Z"), + }); +} + +async function seedLabel(input: { + actionId: number; + val: string; + neg: 0 | 1; + cts: string; +}): Promise { + await testEnv.DB.prepare( + `INSERT INTO issuance_actions (id, actor, type, reason, idempotency_key, created_at, assessment_id) + VALUES (?, ?, 'automated-assessment', 'r', ?, ?, ?)`, + ) + .bind(input.actionId, LABELER_DID, `idem-label-${input.actionId}`, input.cts, ASMT_BLOCK_B) + .run(); + await testEnv.DB.prepare( + `INSERT INTO issued_labels (action_id, ver, src, uri, cid, val, neg, cts, sig, signing_key_id) + VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, 'v1')`, + ) + .bind( + input.actionId, + LABELER_DID, + URI_B, + CID_B, + input.val, + input.neg, + input.cts, + new Uint8Array([0]), + ) + .run(); +} + +async function seedLabels(): Promise { + await seedLabel({ actionId: 1001, val: "malware", neg: 0, cts: "2026-07-08T10:05:00.000Z" }); + await seedLabel({ + actionId: 1002, + val: "obfuscated-code", + neg: 0, + cts: "2026-07-08T10:06:00.000Z", + }); + await seedLabel({ actionId: 1003, val: "malware", neg: 1, cts: "2026-07-08T10:10:00.000Z" }); +} + +async function seedOperatorActions(): Promise { + const rows: { id: string; action: OperatorActionType; createdAt: string }[] = [ + { id: "oact_1", action: "label-issue", createdAt: "2026-07-10T01:00:00.000Z" }, + { id: "oact_2", action: "assessment-rerun", createdAt: "2026-07-10T02:00:00.000Z" }, + { id: "oact_3", action: "label-retract", createdAt: "2026-07-10T03:00:00.000Z" }, + ]; + for (const row of rows) { + await buildOperatorActionInsert(testEnv.DB, { + id: row.id, + actorType: "human", + actorId: "access|sub-1", + actorEmail: "reviewer@example.com", + actorCommonName: null, + role: "reviewer", + action: row.action, + subjectUri: URI_B, + subjectCid: CID_B, + labelValue: "malware", + reason: "operator reason", + idempotencyKey: `idem-${row.id}-abcdefgh`, + requestFingerprint: "a".repeat(64), + resultJson: '{"ok":true}', + metadataJson: "{}", + createdAt: row.createdAt, + createdAtEpochMs: Date.parse(row.createdAt), + }).run(); + } +} + +async function seedDeadLetters(count: number): Promise { + for (let i = 0; i < count; i++) { + await testEnv.DB.prepare( + `INSERT INTO dead_letters (did, collection, rkey, reason, payload, received_at) + VALUES (?, 'col', ?, 'verify-failed', ?, '2026-07-10 00:00:00')`, + ) + .bind(`did:plc:dead${i}`, `rk${i}`, new Uint8Array([0])) + .run(); + } +} + +async function body(response: Response): Promise<{ data?: unknown; error?: { code: string } }> { + return response.json(); +} + +describe("handleConsoleApi — guard rejections", () => { + it("rejects a request with no assertion header (401)", async () => { + const res = await handleConsoleApi(req("/admin/api/assessments"), deps()); + expect(res.status).toBe(401); + expect((await body(res)).error?.code).toBe("UNAUTHENTICATED"); + }); + + it("rejects a spoofed email header without a verified assertion (401)", async () => { + const res = await handleConsoleApi( + req("/admin/api/assessments", { spoofEmail: "admin@example.com" }), + deps(), + ); + expect(res.status).toBe(401); + }); + + it("rejects an edge-authenticated identity with no role (403)", async () => { + const res = await handleConsoleApi( + req("/admin/api/assessments", { token: noRoleToken }), + deps(), + ); + expect(res.status).toBe(403); + expect((await body(res)).error?.code).toBe("FORBIDDEN_ROLE"); + }); + + it("rejects a missing CSRF header (403)", async () => { + const res = await handleConsoleApi( + req("/admin/api/assessments", { token: reviewerToken, csrf: null }), + deps(), + ); + expect(res.status).toBe(403); + expect((await body(res)).error?.code).toBe("CSRF_HEADER_MISSING"); + }); + + it("admits an admin by inheritance", async () => { + const res = await handleConsoleApi( + req("/admin/api/assessments", { token: adminToken }), + deps(), + ); + expect(res.status).toBe(200); + }); + + // The guard runs before route matching, so every family must reject an + // unauthenticated caller identically — no route reaches a store read (and so + // no private evidence) without a verified assertion. + it.each([ + "/admin/api/assessments", + `/admin/api/assessments/${ASMT_BLOCK_B}`, + `/admin/api/assessments/${ASMT_BLOCK_B}/findings`, + `/admin/api/assessments/${ASMT_BLOCK_B}/labels`, + `/admin/api/subjects/${encodeURIComponent(URI_S)}`, + "/admin/api/audit-log", + "/admin/api/status", + ])("rejects an unauthenticated request to %s (401)", async (path) => { + const res = await handleConsoleApi(req(path), deps()); + expect(res.status).toBe(401); + expect((await body(res)).error?.code).toBe("UNAUTHENTICATED"); + }); +}); + +describe("handleConsoleApi — method + routing", () => { + it("rejects a non-GET method (405)", async () => { + const res = await handleConsoleApi( + req("/admin/api/assessments", { method: "POST", token: reviewerToken }), + deps(), + ); + expect(res.status).toBe(405); + expect((await body(res)).error?.code).toBe("METHOD_NOT_ALLOWED"); + }); + + it("404s an unknown sub-path", async () => { + const res = await handleConsoleApi(req("/admin/api/nope", { token: reviewerToken }), deps()); + expect(res.status).toBe(404); + expect((await body(res)).error?.code).toBe("NOT_FOUND"); + }); + + it("never emits a CORS allow-origin header", async () => { + const res = await handleConsoleApi( + req("/admin/api/assessments", { token: reviewerToken }), + deps(), + ); + expect(res.headers.get("access-control-allow-origin")).toBeNull(); + expect(res.headers.get("cache-control")).toBe("no-store"); + }); +}); + +describe("handleConsoleApi — assessments", () => { + it("lists decision/pending runs and filters by state", async () => { + const res = await handleConsoleApi( + req("/admin/api/assessments?state=blocked", { token: reviewerToken }), + deps(), + ); + expect(res.status).toBe(200); + const data = (await body(res)).data as { items: { id: string; publicState: string }[] }; + expect(data.items.map((a) => a.id)).toContain(ASMT_BLOCK_B); + expect(data.items.every((a) => a.publicState === "blocked")).toBe(true); + }); + + it("serves modelId/promptHash on the detail view", async () => { + const res = await handleConsoleApi( + req(`/admin/api/assessments/${ASMT_BLOCK_B}`, { token: reviewerToken }), + deps(), + ); + const run = (await body(res)).data as { + modelId: string; + promptHash: string; + coverageJson?: unknown; + }; + expect(run.modelId).toBe("@cf/meta/llama-3.1-70b-instruct"); + expect(run).not.toHaveProperty("coverageJson"); + }); + + it("404s an absent assessment id (client maps to null)", async () => { + const res = await handleConsoleApi( + req("/admin/api/assessments/asmt_ZZZZZZZZZZZZZZZZZZZZZZZZZZ", { token: reviewerToken }), + deps(), + ); + expect(res.status).toBe(404); + }); + + it("404s a malformed assessment id (client maps to null)", async () => { + const res = await handleConsoleApi( + req("/admin/api/assessments/not-a-valid-id", { token: reviewerToken }), + deps(), + ); + expect(res.status).toBe(404); + }); +}); + +describe("handleConsoleApi — findings and labels", () => { + it("serves findings with the operator-only privateDetail", async () => { + const res = await handleConsoleApi( + req(`/admin/api/assessments/${ASMT_BLOCK_B}/findings`, { token: reviewerToken }), + deps(), + ); + const findings = (await body(res)).data as { privateDetail: string; category: string }[]; + expect(findings.length).toBe(2); + expect( + findings.every((f) => typeof f.privateDetail === "string" && f.privateDetail.length > 0), + ).toBe(true); + }); + + it("serves the full label history including negations", async () => { + const res = await handleConsoleApi( + req(`/admin/api/assessments/${ASMT_BLOCK_B}/labels`, { token: reviewerToken }), + deps(), + ); + const labels = (await body(res)).data as { val: string; neg: boolean }[]; + expect(labels.length).toBe(3); + expect(labels.some((l) => l.neg === true)).toBe(true); + }); +}); + +describe("handleConsoleApi — subject history", () => { + it("returns the current CID and full-lifecycle runs (stale/cancelled visible)", async () => { + const res = await handleConsoleApi( + req(`/admin/api/subjects/${encodeURIComponent(URI_S)}`, { token: reviewerToken }), + deps(), + ); + expect(res.status).toBe(200); + const view = (await body(res)).data as { + subject: { cid: string }; + assessments: { state: string }[]; + }; + expect(view.subject.cid).toBe(CID_S_NEW); + const states = view.assessments.map((a) => a.state); + expect(states).toContain("stale"); + expect(states).toContain("cancelled"); + }); + + it("404s a never-observed subject", async () => { + const res = await handleConsoleApi( + req(`/admin/api/subjects/${encodeURIComponent("at://did:plc:missing/col/rk")}`, { + token: reviewerToken, + }), + deps(), + ); + expect(res.status).toBe(404); + }); +}); + +describe("handleConsoleApi — audit log", () => { + it("returns sanitized rows without the internal replay fields", async () => { + const res = await handleConsoleApi( + req("/admin/api/audit-log", { token: reviewerToken }), + deps(), + ); + const data = (await body(res)).data as { items: Record[] }; + expect(data.items.length).toBe(3); + const [newest] = data.items; + expect(newest!.id).toBe("oact_3"); + for (const item of data.items) { + expect(item).not.toHaveProperty("idempotencyKey"); + expect(item).not.toHaveProperty("requestFingerprint"); + expect(item).not.toHaveProperty("resultJson"); + } + }); + + it("paginates with a keyset cursor", async () => { + const p1 = await handleConsoleApi( + req("/admin/api/audit-log?limit=2", { token: reviewerToken }), + deps(), + ); + const d1 = (await body(p1)).data as { items: { id: string }[]; nextCursor?: string }; + expect(d1.items.map((i) => i.id)).toEqual(["oact_3", "oact_2"]); + expect(d1.nextCursor).toBeDefined(); + + const p2 = await handleConsoleApi( + req(`/admin/api/audit-log?limit=2&cursor=${d1.nextCursor}`, { token: reviewerToken }), + deps(), + ); + const d2 = (await body(p2)).data as { items: { id: string }[]; nextCursor?: string }; + expect(d2.items.map((i) => i.id)).toEqual(["oact_1"]); + expect(d2.nextCursor).toBeUndefined(); + }); +}); + +describe("handleConsoleApi — limit and cursor validation", () => { + it("clamps an over-max limit to 100", async () => { + const res = await handleConsoleApi( + req("/admin/api/assessments?limit=500", { token: reviewerToken }), + deps(), + ); + expect(res.status).toBe(200); + }); + + it("400s a non-numeric limit", async () => { + const res = await handleConsoleApi( + req("/admin/api/assessments?limit=abc", { token: reviewerToken }), + deps(), + ); + expect(res.status).toBe(400); + expect((await body(res)).error?.code).toBe("INVALID_REQUEST"); + }); + + it("400s a cursor whose filter hash no longer matches", async () => { + const p1 = await handleConsoleApi( + req("/admin/api/assessments?limit=1", { token: reviewerToken }), + deps(), + ); + const d1 = (await body(p1)).data as { nextCursor?: string }; + expect(d1.nextCursor).toBeDefined(); + const res = await handleConsoleApi( + req(`/admin/api/assessments?state=passed&cursor=${d1.nextCursor}`, { token: reviewerToken }), + deps(), + ); + expect(res.status).toBe(400); + expect((await body(res)).error?.code).toBe("INVALID_CURSOR"); + }); +}); + +describe("handleConsoleApi — system status", () => { + it("grounds every field: pending count, dead-letter depth, injected jetstream flag", async () => { + const res = await handleConsoleApi( + req("/admin/api/status", { token: reviewerToken }), + deps({ jetstreamConnected: async () => false }), + ); + const status = (await body(res)).data as { + labelerDid: string; + jetstreamConnected: boolean; + pendingAssessments: number; + deadLetterDepth: number; + }; + expect(status.labelerDid).toBe(LABELER_DID); + expect(status.jetstreamConnected).toBe(false); + // asmt_run (running) + asmt_pending (pending). + expect(status.pendingAssessments).toBe(2); + expect(status.deadLetterDepth).toBe(3); + expect(status).not.toHaveProperty("lastReconciliationAt"); + }); +}); + +describe("consoleAssetPath — asset prefix rewrite", () => { + // The SPA is built with base "/admin/" but the asset binding serves + // ./dist/console one-to-one, so the /admin prefix must be stripped before + // ASSETS.fetch or every hashed asset falls through to the SPA shell. + it.each([ + ["/admin", "/"], + ["/admin/", "/"], + ["/admin/assets/index-abc123.js", "/assets/index-abc123.js"], + ["/admin/assets/index-abc123.css", "/assets/index-abc123.css"], + ["/admin/index.html", "/index.html"], + // Client-side deep links resolve to no file, so the SPA fallback serves + // the shell — the rewrite just has to land them under the binding root. + ["/admin/assessments/abc", "/assessments/abc"], + ["/admin/subjects", "/subjects"], + ])("rewrites %s to %s", (input, expected) => { + expect(consoleAssetPath(input)).toBe(expected); + }); + + // Must NOT be treated as console assets: near-miss prefixes and the public + // surface stay out of the asset branch (they fall through to the 404). + it.each(["/adminx", "/administrator", "/admin-console", "/", "/xrpc/x", "/.well-known/did.json"])( + "returns null for the non-asset path %s", + (input) => { + expect(consoleAssetPath(input)).toBeNull(); + }, + ); + + // The read API is dispatched before the asset branch; the helper also + // excludes it so an asset rewrite can never swallow an API path. + it.each(["/admin/api", "/admin/api/", "/admin/api/status", "/admin/api/assessments/x"])( + "never treats the API path %s as an asset", + (input) => { + expect(consoleAssetPath(input)).toBeNull(); + }, + ); +}); + +describe("probeJetstreamConnected — degradation", () => { + it("reports disconnected (not an error) when both the DO and the D1 fallback fail", async () => { + const brokenEnv = { + LABELER_DISCOVERY_DO: { + idFromName: () => ({}), + get: () => ({ + fetch: async () => { + throw new Error("DO unreachable"); + }, + }), + }, + DB: { + prepare: () => ({ + first: async () => { + throw new Error("no such table: ingest_state"); + }, + }), + }, + } as unknown as Parameters[0]; + expect(await probeJetstreamConnected(brokenEnv)).toBe(false); + }); +}); diff --git a/apps/labeler/test/console-serialize.test.ts b/apps/labeler/test/console-serialize.test.ts new file mode 100644 index 0000000000..d532e6267d --- /dev/null +++ b/apps/labeler/test/console-serialize.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; + +import type { AssessmentState } from "../src/assessment-lifecycle.js"; +import type { + AssessmentFinding, + AssessmentIssuedLabel, + ListedAssessment, + Subject, +} from "../src/assessment-store.js"; +import { + serializeAssessmentRun, + serializeIssuedLabel, + serializeOperatorActionView, + serializeOperatorFinding, + serializeSubjectRecord, +} from "../src/console-serialize.js"; +import type { StoredOperatorAction } from "../src/operator-actions.js"; +import { derivePublicState } from "../src/public-assessment.js"; + +function listedAssessment(overrides: Partial = {}): ListedAssessment { + return { + id: "asmt_01", + runKey: "run-01", + uri: "at://did:plc:x/com.emdashcms.experimental.package.release/rk1", + cid: "bafyreialpha", + artifactId: "artifact-1", + artifactChecksum: "sha256:abc", + state: "passed", + trigger: "initial", + triggerId: "initial:x", + policyVersion: "2026-07-10.experimental.2", + modelId: "@cf/meta/llama-3.1-70b-instruct", + promptHash: "sha256:prompt", + publicSummary: "No blocking findings.", + coverageJson: '{"code":"complete","images":"not-present","metadata":"complete"}', + supersedesAssessmentId: null, + startedAt: "2026-07-08T09:12:05.000Z", + completedAt: "2026-07-08T09:13:40.000Z", + createdAt: "2026-07-08T09:12:00.000Z", + isSuperseded: false, + ...overrides, + }; +} + +describe("serializeAssessmentRun", () => { + it("omits coverageJson but keeps operator provenance (modelId/promptHash)", () => { + const run = serializeAssessmentRun(listedAssessment()); + expect(run).not.toHaveProperty("coverageJson"); + expect(run.modelId).toBe("@cf/meta/llama-3.1-70b-instruct"); + expect(run.promptHash).toBe("sha256:prompt"); + expect(run.isSuperseded).toBe(false); + }); + + it("computes publicState as derivePublicState(state, isSuperseded) across the state matrix", () => { + const states: AssessmentState[] = [ + "observed", + "verifying", + "pending", + "running", + "passed", + "warned", + "blocked", + "error", + "stale", + "cancelled", + ]; + for (const state of states) { + for (const isSuperseded of [false, true]) { + const run = serializeAssessmentRun(listedAssessment({ state, isSuperseded })); + expect(run.publicState).toBe(derivePublicState(state, isSuperseded)); + } + } + }); + + it("marks a superseded decision run as publicState superseded", () => { + const run = serializeAssessmentRun(listedAssessment({ state: "passed", isSuperseded: true })); + expect(run.publicState).toBe("superseded"); + }); +}); + +describe("serializeOperatorFinding", () => { + const base: AssessmentFinding = { + id: "find_01", + assessmentId: "asmt_01", + source: "model", + category: "low-quality", + severity: "medium", + confidence: 0.72, + title: "Packaging metadata inconsistent", + publicSummary: "Declared entry point does not match the bundle.", + privateDetail: "Model flagged package.json main field — likely a broken build step.", + evidenceRefs: ["evid_01"], + createdAt: "2026-07-12T08:06:40.000Z", + }; + + it("includes the operator-only privateDetail and evidenceRefs", () => { + const finding = serializeOperatorFinding(base); + expect(finding.privateDetail).toBe(base.privateDetail); + expect(finding.evidenceRefs).toEqual(["evid_01"]); + }); + + it("includes confidence when present and omits it when null", () => { + expect(serializeOperatorFinding(base).confidence).toBe(0.72); + const withoutConfidence = serializeOperatorFinding({ ...base, confidence: null }); + expect(withoutConfidence).not.toHaveProperty("confidence"); + }); +}); + +describe("serializeIssuedLabel", () => { + it("maps the integer neg flag to a boolean", () => { + const label: AssessmentIssuedLabel = { + val: "malware", + cts: "2026-07-12T11:32:50.000Z", + exp: null, + neg: true, + sequence: 5, + }; + expect(serializeIssuedLabel(label)).toEqual({ + val: "malware", + cts: "2026-07-12T11:32:50.000Z", + exp: null, + neg: true, + sequence: 5, + }); + }); +}); + +describe("serializeSubjectRecord", () => { + it("maps snake_case store fields to the wire shape", () => { + const subject: Subject = { + uri: "at://did:plc:x/col/rk", + cid: "bafyreialpha", + did: "did:plc:x", + collection: "col", + rkey: "rk", + observedAt: "2026-07-08T09:10:00.000Z", + deletedAt: null, + }; + expect(serializeSubjectRecord(subject)).toEqual(subject); + }); +}); + +describe("serializeOperatorActionView", () => { + const stored: StoredOperatorAction = { + id: "oact_01", + actorType: "human", + actorId: "access|sub-1", + actorEmail: "reviewer@example.com", + actorCommonName: null, + role: "reviewer", + action: "label-retract", + subjectUri: "at://did:plc:x/col/rk", + subjectCid: "bafyreialpha", + labelValue: "malware", + reason: "false positive", + idempotencyKey: "idem-abcdefgh", + requestFingerprint: "a".repeat(64), + resultJson: '{"retracted":true}', + metadataJson: '{"note":"internal"}', + createdAt: "2026-07-12T14:02:15.000Z", + createdAtEpochMs: 1_752_328_935_000, + }; + + it("excludes the internal replay fields", () => { + const view = serializeOperatorActionView(stored); + expect(view).not.toHaveProperty("idempotencyKey"); + expect(view).not.toHaveProperty("requestFingerprint"); + expect(view).not.toHaveProperty("resultJson"); + expect(view).not.toHaveProperty("metadataJson"); + expect(view).not.toHaveProperty("createdAtEpochMs"); + }); + + it("keeps the displayed who/what/why fields", () => { + const view = serializeOperatorActionView(stored); + expect(view).toMatchObject({ + id: "oact_01", + actorType: "human", + actorId: "access|sub-1", + actorEmail: "reviewer@example.com", + role: "reviewer", + action: "label-retract", + subjectUri: "at://did:plc:x/col/rk", + subjectCid: "bafyreialpha", + labelValue: "malware", + reason: "false positive", + createdAt: "2026-07-12T14:02:15.000Z", + }); + }); +}); diff --git a/apps/labeler/test/operator-read-guard.test.ts b/apps/labeler/test/operator-read-guard.test.ts new file mode 100644 index 0000000000..fb68d3351c --- /dev/null +++ b/apps/labeler/test/operator-read-guard.test.ts @@ -0,0 +1,155 @@ +import { generateKeyPair, SignJWT } from "jose"; +import { beforeAll, describe, expect, it } from "vitest"; + +import type { AccessAuthConfig, AccessKeyResolver } from "../src/access-auth.js"; +import { OPERATOR_REQUEST_HEADER } from "../src/mutation-guard.js"; +import { guardRead, type ReadGuardDeps } from "../src/operator-read-guard.js"; + +const TEAM_DOMAIN = "https://example-team.cloudflareaccess.com"; +const AUDIENCE = "test-audience"; +const ORIGIN = "https://labeler.example.com"; + +let signKey: CryptoKey; +let resolver: AccessKeyResolver; +let adminToken: string; +let reviewerToken: string; +let noRoleToken: string; + +beforeAll(async () => { + const pair = await generateKeyPair("RS256"); + signKey = pair.privateKey; + resolver = (async () => pair.publicKey) as AccessKeyResolver; + adminToken = await mintToken({ email: "admin@example.com" }); + reviewerToken = await mintToken({ email: "reviewer@example.com" }); + noRoleToken = await mintToken({ email: "nobody@example.com" }); +}); + +async function mintToken(claims: Record): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ sub: "user-sub-1", ...claims }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer(TEAM_DOMAIN) + .setAudience(AUDIENCE) + .setIssuedAt(now) + .setExpirationTime(now + 3600) + .sign(signKey); +} + +function baseConfig(): AccessAuthConfig { + return { + teamDomain: TEAM_DOMAIN, + audience: AUDIENCE, + admins: ["admin@example.com"], + reviewers: ["reviewer@example.com"], + }; +} + +function deps(overrides: Partial = {}): ReadGuardDeps { + return { config: baseConfig(), keys: resolver, expectedOrigin: ORIGIN, ...overrides }; +} + +interface RequestOptions { + token?: string; + csrf?: string | null; + origin?: string; + secFetchSite?: string; + spoofEmail?: string; +} + +function buildRequest(opts: RequestOptions = {}): Request { + const headers = new Headers(); + const csrf = opts.csrf === undefined ? "1" : opts.csrf; + if (csrf !== null) headers.set(OPERATOR_REQUEST_HEADER, csrf); + if (opts.token !== undefined) headers.set("Cf-Access-Jwt-Assertion", opts.token); + if (opts.origin !== undefined) headers.set("Origin", opts.origin); + if (opts.secFetchSite !== undefined) headers.set("Sec-Fetch-Site", opts.secFetchSite); + if (opts.spoofEmail !== undefined) + headers.set("Cf-Access-Authenticated-User-Email", opts.spoofEmail); + return new Request(`${ORIGIN}/admin/api/assessments`, { method: "GET", headers }); +} + +async function expectRejection(request: Request, code: string, status: number): Promise { + await expect(guardRead(request, deps(), { minRole: "reviewer" })).rejects.toMatchObject({ + code, + status, + }); +} + +describe("guardRead — transport checks", () => { + it("rejects a mismatching Origin", async () => { + await expectRejection( + buildRequest({ token: reviewerToken, origin: "https://evil.example.com" }), + "CROSS_ORIGIN", + 403, + ); + }); + + it("rejects a cross-site Sec-Fetch-Site", async () => { + await expectRejection( + buildRequest({ token: reviewerToken, secFetchSite: "cross-site" }), + "CROSS_ORIGIN", + 403, + ); + }); + + it("rejects a missing CSRF header", async () => { + await expectRejection( + buildRequest({ token: reviewerToken, csrf: null }), + "CSRF_HEADER_MISSING", + 403, + ); + }); + + it("rejects a wrong CSRF header value", async () => { + await expectRejection( + buildRequest({ token: reviewerToken, csrf: "0" }), + "CSRF_HEADER_MISSING", + 403, + ); + }); + + it("passes when both Origin and Sec-Fetch-Site are absent (non-browser client)", async () => { + const identity = await guardRead(buildRequest({ token: reviewerToken }), deps(), { + minRole: "reviewer", + }); + expect(identity.roles).toContain("reviewer"); + }); +}); + +describe("guardRead — authentication", () => { + it("rejects a request with no assertion header", async () => { + await expectRejection(buildRequest({}), "UNAUTHENTICATED", 401); + }); + + it("rejects an invalid assertion", async () => { + await expectRejection(buildRequest({ token: "not-a-jwt" }), "UNAUTHENTICATED", 401); + }); + + it("never derives identity from a spoofed plaintext email header", async () => { + await expectRejection( + buildRequest({ spoofEmail: "admin@example.com" }), + "UNAUTHENTICATED", + 401, + ); + }); +}); + +describe("guardRead — role gate", () => { + it("rejects an edge-authenticated identity that maps to no role", async () => { + await expectRejection(buildRequest({ token: noRoleToken }), "FORBIDDEN_ROLE", 403); + }); + + it("admits a reviewer", async () => { + const identity = await guardRead(buildRequest({ token: reviewerToken }), deps(), { + minRole: "reviewer", + }); + expect(identity).toMatchObject({ kind: "human", email: "reviewer@example.com" }); + }); + + it("admits an admin by inheritance", async () => { + const identity = await guardRead(buildRequest({ token: adminToken }), deps(), { + minRole: "reviewer", + }); + expect(identity.roles).toContain("admin"); + }); +}); diff --git a/apps/labeler/worker-configuration.d.ts b/apps/labeler/worker-configuration.d.ts index f573e45d75..70a1cd945e 100644 --- a/apps/labeler/worker-configuration.d.ts +++ b/apps/labeler/worker-configuration.d.ts @@ -6,11 +6,13 @@ interface __BaseEnv_Env { DB: D1Database; DISCOVERY_QUEUE: Queue; AI: Ai; + ASSETS: Fetcher; LABELER_DID: "did:web:labels.emdashcms.com"; LABELER_SERVICE_URL: "https://labels.emdashcms.com"; LABEL_SIGNING_KEY_VERSION: "v1"; LABEL_SIGNING_PUBLIC_KEY: "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ"; JETSTREAM_URL: "wss://jetstream2.us-east.bsky.network/subscribe"; + OPERATOR_ACCESS_CONFIG: "{\"teamDomain\":\"https://emdash.cloudflareaccess.com\",\"audience\":\"REPLACE_WITH_ACCESS_APP_AUD_TAG\",\"admins\":[\"emdash-labeler-admins\"],\"reviewers\":[\"emdash-labeler-reviewers\"]}"; LABEL_SIGNING_PRIVATE_KEY: string; LABEL_SUBSCRIPTION: DurableObjectNamespace; LABELER_DISCOVERY_DO: DurableObjectNamespace; @@ -27,7 +29,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/apps/labeler/wrangler.jsonc b/apps/labeler/wrangler.jsonc index 8f575a2396..838bd7c0e4 100644 --- a/apps/labeler/wrangler.jsonc +++ b/apps/labeler/wrangler.jsonc @@ -43,6 +43,17 @@ "ai": { "binding": "AI", }, + // Operator console SPA (apps/labeler/console), built to ./dist/console by + // `console:build`. `run_worker_first: true` keeps the Worker the sole + // router: the SPA fallback only fires where index.ts explicitly calls + // env.ASSETS.fetch (the /admin branch), so the public XRPC/well-known + // surface and the root 404 are unaffected. See W9.3 design. + "assets": { + "directory": "./dist/console", + "binding": "ASSETS", + "not_found_handling": "single-page-application", + "run_worker_first": true, + }, "queues": { "producers": [ { @@ -82,6 +93,10 @@ "LABEL_SIGNING_KEY_VERSION": "v1", "LABEL_SIGNING_PUBLIC_KEY": "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ", "JETSTREAM_URL": "wss://jetstream2.us-east.bsky.network/subscribe", + // Operator console Access config (parsed via parseAccessAuthConfig). + // Group names / audience tag are not secrets. Replace `audience` with the + // Access application's AUD tag and the group names with the deployment's. + "OPERATOR_ACCESS_CONFIG": "{\"teamDomain\":\"https://emdash.cloudflareaccess.com\",\"audience\":\"REPLACE_WITH_ACCESS_APP_AUD_TAG\",\"admins\":[\"emdash-labeler-admins\"],\"reviewers\":[\"emdash-labeler-reviewers\"]}", }, // Required secret, declared in config so `wrangler types` generates the // typed binding and `wrangler deploy` validates the secret is set on the From a70266c2ea213917413e1e6d7a616f31d7b0e921 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 13 Jul 2026 15:41:04 +0100 Subject: [PATCH 049/137] docs(labeler-plan): ratify W9.4 decisions Reviewer issuance on descriptive release labels, server-validated typed confirmation, /whoami identity read, negation-based retraction, server-derived effect preview. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- .../plugin-registry-labelling-service/implementation-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 6b906a00e1..71cf2aaf43 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1125,6 +1125,8 @@ Dependencies: `W2.3`, `W9.1`, static `W8.1` fixtures. - Require exact CID/URI-wide confirmation as appropriate. - Show resulting official-client effect before submit. +Decisions (2026-07-13): the moderation policy grants `reviewer` issuance on descriptive release labels (policy version bump; a reviewer-issued label is human-retract-only per §10). The typed confirmation is server-validated (exact CID for CID-bound actions, rkey for URI-wide) and participates in the idempotency fingerprint. Caller identity/roles for cosmetic UI gating come from a new `/admin/api/whoami` read. Retraction is manual negation through the same issuance path. The effect preview is a server-derived read grounded in the aggregator evaluator — the console holds no policy logic. + Dependencies: `W3.3`, `W5.1`, `W9.2`. ### `W9.5` Implement rerun and false-positive override From d9c458d3dda9e5f965fa44013e6f7968ef363214 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 13 Jul 2026 17:22:29 +0100 Subject: [PATCH 050/137] docs(labeler-plan): fold subject-label visibility into W9.5 Manual labels aren't shown in the console yet; the subject+CID label read override/rerun needs covers it too. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- .../plugin-registry-labelling-service/implementation-plan.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 71cf2aaf43..3ec1608a11 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1135,6 +1135,7 @@ Dependencies: `W3.3`, `W5.1`, `W9.2`. - Unblock is one transaction/action that negates selected automated blocking labels and issues `assessment-passed` plus `assessment-overridden` for exact URI + CID. - New automated findings remain visible but cannot remove the override. - Reviewer/admin can explicitly retract the override. +- Carried from W9.4: manually-issued labels (`assessment_id` NULL, keyed to subject URI+CID) aren't visible in the console — `getAllLabelsForAssessment` is assessment-scoped. Add a subject+CID label read (`GET /admin/api/subjects/:uri/labels?cid=`, wrapping `getActiveLabelState`) and surface manual labels on the subject view and merged into the assessment detail page. Override/rerun needs the same subject-label read, so it lands here. Dependencies: `W6.6`, `W9.4`. From a3e7a3ee99af5de2e6f2850a0101ea85e01e0675 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 13 Jul 2026 17:36:04 +0100 Subject: [PATCH 051/137] feat(labeler): reviewer label issue/retract actions (W9.4) (#2020) * feat(labeler): reviewer label issue/retract actions (W9.4) POST /admin/api/labels/issue and /retract behind the mutation guard, signing a manual label whose prepared statements commit atomically with the operator audit row. Retract is manual negation. Adds server-derived effect preview, /whoami, a server-validated typed confirmation, and the console action dialog. Policy fixture grants reviewers issuance on descriptive release labels. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM * fix(labeler): return 503 when a label issuance is suppressed after the audit commits A signing-key rotation between the pre-check and the batch can commit the operator_actions audit row while both guarded issuance INSERTs match zero rows, yielding a phantom "issued" 200 with no label. Read the label back by action id after commit and surface LABEL_ISSUANCE_UNAVAILABLE (503, retryable) when it's absent. Paused/stale signing on the console path now returns the same retryable error instead of a generic 500. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM * fix(labeler): close the replay path against phantom label-issuance success The post-commit label read-back only ran on the proceed path, so a retry with the same idempotency key replayed the stored success descriptor as a 200 even when the label was suppressed. Both paths now share one assertIssuancePersisted helper. Also gates the retract button to labels the endpoint can actually retract. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- apps/labeler/console/src/api/client.test.ts | 106 ++- apps/labeler/console/src/api/client.ts | 82 ++ apps/labeler/console/src/api/types.ts | 84 ++ .../src/components/LabelActionDialog.tsx | 257 ++++++ apps/labeler/console/src/labels.ts | 55 ++ .../console/src/routes/AssessmentDetail.tsx | 73 +- .../console/src/routes/SubjectHistory.tsx | 57 +- apps/labeler/fixtures/moderation-policy.json | 32 +- apps/labeler/src/console-api.ts | 65 +- apps/labeler/src/console-mutation-api.ts | 327 ++++++++ apps/labeler/src/index.ts | 26 +- apps/labeler/src/label-effect-preview.ts | 157 ++++ apps/labeler/src/service.ts | 178 ++++- .../labeler/test/console-mutation-api.test.ts | 736 ++++++++++++++++++ apps/labeler/test/policy.test.ts | 2 +- apps/labeler/test/service.test.ts | 2 +- apps/labeler/test/xrpc-router.test.ts | 2 +- 17 files changed, 2151 insertions(+), 90 deletions(-) create mode 100644 apps/labeler/console/src/components/LabelActionDialog.tsx create mode 100644 apps/labeler/console/src/labels.ts create mode 100644 apps/labeler/src/console-mutation-api.ts create mode 100644 apps/labeler/src/label-effect-preview.ts create mode 100644 apps/labeler/test/console-mutation-api.test.ts diff --git a/apps/labeler/console/src/api/client.test.ts b/apps/labeler/console/src/api/client.test.ts index 45e54e8aea..468b9b68c8 100644 --- a/apps/labeler/console/src/api/client.test.ts +++ b/apps/labeler/console/src/api/client.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createFixtureClient } from "./client.js"; +import { createFetchClient, createFixtureClient } from "./client.js"; +import type { LabelActionInput } from "./types.js"; const apiClient = createFixtureClient(); @@ -40,3 +41,104 @@ describe("fixture client", () => { expect(history?.assessments.some((a) => a.id === assessment!.id)).toBe(true); }); }); + +describe("fetch client label actions", () => { + const fetchClient = createFetchClient(); + let calls: { url: string; init: RequestInit }[]; + + function stubFetch(response: () => Response) { + calls = []; + vi.stubGlobal( + "fetch", + vi.fn((url: string, init: RequestInit) => { + calls.push({ url, init }); + return Promise.resolve(response()); + }), + ); + } + + const input: LabelActionInput = { + uri: "at://did:plc:x/com.emdashcms.experimental.package.release/rk1", + val: "security-yanked", + confirmation: "rk1", + reason: "withdrawing", + idempotencyKey: "01HZY9K0ULIDXULIDXULIDX00", + }; + + beforeEach(() => { + stubFetch(() => + Response.json({ data: { actionId: "oact_1", val: "security-yanked", neg: false } }), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("POSTs an issue with the CSRF header, JSON content type, and threaded key", async () => { + const result = await fetchClient.issueLabel(input); + expect(result).toMatchObject({ actionId: "oact_1", val: "security-yanked" }); + expect(calls).toHaveLength(1); + const call = calls[0]!; + expect(call.url).toBe("/admin/api/labels/issue"); + expect(call.init.method).toBe("POST"); + const headers = new Headers(call.init.headers); + expect(headers.get("X-EmDash-Request")).toBe("1"); + expect(headers.get("Content-Type")).toBe("application/json"); + expect(JSON.parse(call.init.body as string)).toMatchObject({ + uri: input.uri, + val: input.val, + confirmation: input.confirmation, + reason: input.reason, + idempotencyKey: input.idempotencyKey, + }); + }); + + it("POSTs a retract to the retract route", async () => { + await fetchClient.retractLabel(input); + expect(calls[0]!.url).toBe("/admin/api/labels/retract"); + expect(calls[0]!.init.method).toBe("POST"); + }); + + it("surfaces the server error message on a failed action", async () => { + stubFetch(() => + Response.json( + { error: { code: "CONFIRMATION_MISMATCH", message: "does not match" } }, + { + status: 400, + }, + ), + ); + await expect(fetchClient.issueLabel(input)).rejects.toThrow("does not match"); + }); + + it("surfaces a retryable 503 as an error, not a false success", async () => { + stubFetch(() => + Response.json( + { + error: { + code: "LABEL_ISSUANCE_UNAVAILABLE", + message: "Label issuance is temporarily unavailable; retry.", + }, + }, + { status: 503 }, + ), + ); + await expect(fetchClient.issueLabel(input)).rejects.toThrow( + "Label issuance is temporarily unavailable; retry.", + ); + }); + + it("builds the effect-preview query string", async () => { + stubFetch(() => + Response.json({ data: { labelEffect: "block", scope: "cid-bound", supersedes: [] } }), + ); + await fetchClient.previewEffect({ uri: input.uri, val: "malware", cid: "bafy", neg: true }); + const url = calls[0]!.url; + expect(url).toContain("/admin/api/labels/effect-preview?"); + expect(url).toContain(`uri=${encodeURIComponent(input.uri)}`); + expect(url).toContain("val=malware"); + expect(url).toContain("cid=bafy"); + expect(url).toContain("neg=true"); + }); +}); diff --git a/apps/labeler/console/src/api/client.ts b/apps/labeler/console/src/api/client.ts index 31e5df8206..a42e1fd32f 100644 --- a/apps/labeler/console/src/api/client.ts +++ b/apps/labeler/console/src/api/client.ts @@ -8,7 +8,11 @@ import { } from "../fixtures/index.js"; import type { AssessmentRun, + EffectPreview, + EffectPreviewParams, IssuedLabel, + IssuedLabelDescriptor, + LabelActionInput, ListAssessmentsParams, ListAuditLogParams, OperatorAction, @@ -16,6 +20,7 @@ import type { Page, SubjectHistoryView, SystemStatusSnapshot, + WhoamiIdentity, } from "./types.js"; export const CONSOLE_API_BASE = "/admin/api"; @@ -32,6 +37,10 @@ export interface LabelerConsoleClient { getSubjectHistory(uri: string): Promise; listAuditLog(params?: ListAuditLogParams): Promise>; getSystemStatus(): Promise; + whoami(): Promise; + previewEffect(params: EffectPreviewParams): Promise; + issueLabel(input: LabelActionInput): Promise; + retractLabel(input: LabelActionInput): Promise; } interface ApiErrorBody { @@ -51,6 +60,21 @@ function consoleApiFetch(path: string, init?: RequestInit): Promise { return fetch(`${CONSOLE_API_BASE}${path}`, { ...init, headers, credentials: "same-origin" }); } +/** POST a state-changing label action. `consoleApiFetch` already sets the CSRF + * header and same-origin credentials; this adds the JSON content type the + * mutation guard requires and threads the client-minted idempotency key. */ +async function postLabelAction( + path: string, + input: LabelActionInput, +): Promise { + const response = await consoleApiFetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + return parseJson(response, "Failed to submit label action"); +} + async function parseJson(response: Response, fallback: string): Promise { if (!response.ok) { const body: unknown = await response.json().catch(() => undefined); @@ -109,6 +133,25 @@ export function createFetchClient(): LabelerConsoleClient { const response = await consoleApiFetch("/status"); return parseJson(response, "Failed to load system status"); }, + async whoami() { + const response = await consoleApiFetch("/whoami"); + return parseJson(response, "Failed to load identity"); + }, + async previewEffect(params) { + const search = new URLSearchParams(); + search.set("uri", params.uri); + search.set("val", params.val); + if (params.cid) search.set("cid", params.cid); + if (params.neg) search.set("neg", "true"); + const response = await consoleApiFetch(`/labels/effect-preview?${search.toString()}`); + return parseJson(response, "Failed to preview effect"); + }, + async issueLabel(input) { + return postLabelAction("/labels/issue", input); + }, + async retractLabel(input) { + return postLabelAction("/labels/retract", input); + }, }; } @@ -141,6 +184,45 @@ export function createFixtureClient(): LabelerConsoleClient { async getSystemStatus() { return FIXTURE_SYSTEM_STATUS; }, + async whoami() { + return { + kind: "human", + principal: "reviewer@example.com", + sub: "dev-reviewer", + roles: ["reviewer"], + }; + }, + async previewEffect(params) { + return { + labelEffect: params.val === "security-yanked" ? "block" : "warn", + scope: params.cid ? "cid-bound" : "uri-wide", + supersedes: [], + before: null, + after: null, + }; + }, + async issueLabel(input) { + return { + actionId: "oact_fixture", + val: input.val, + uri: input.uri, + cid: input.cid ?? null, + neg: false, + cts: new Date().toISOString(), + effect: input.val === "security-yanked" ? "block" : "warn", + }; + }, + async retractLabel(input) { + return { + actionId: "oact_fixture", + val: input.val, + uri: input.uri, + cid: input.cid ?? null, + neg: true, + cts: new Date().toISOString(), + effect: input.val === "security-yanked" ? "block" : "warn", + }; + }, }; } diff --git a/apps/labeler/console/src/api/types.ts b/apps/labeler/console/src/api/types.ts index 770a3357b6..d27091b180 100644 --- a/apps/labeler/console/src/api/types.ts +++ b/apps/labeler/console/src/api/types.ts @@ -142,6 +142,90 @@ export interface Page { nextCursor?: string; } +export type OperatorRole = "admin" | "reviewer"; + +/** The caller's verified identity from `/admin/api/whoami`, used only for + * cosmetic button gating — the server (`guardMutation`) is the enforcement + * boundary, so hiding a button never grants anything. */ +export interface WhoamiIdentity { + kind: "human" | "service"; + principal: string; + sub: string; + roles: OperatorRole[]; +} + +export type ReleaseEligibility = "eligible" | "pending" | "error" | "blocked"; + +/** Console mirror of registry-moderation's `ReleaseModeration` (redeclared, not + * imported — the console renders what the effect-preview endpoint returns and + * holds no policy logic). `applicableLabels` is opaque here; the UI shows the + * summarized value lists. */ +export interface ReleaseModeration { + eligibility: ReleaseEligibility; + reasonCodes: string[]; + blockingLabels: string[]; + stateLabels: string[]; + warningLabels: string[]; + suppressedLabels: string[]; + applicableLabels: { val: string; cid?: string; neg?: boolean }[]; + redacted: boolean; +} + +export interface SupersededLabel { + val: string; + cid: string | null; + sequence: number; +} + +/** Server-derived preview of a proposed label action's official-client effect + * (`GET /admin/api/labels/effect-preview`). */ +export interface EffectPreview { + labelEffect: string; + scope: "cid-bound" | "uri-wide"; + supersedes: SupersededLabel[]; + before: ReleaseModeration | null; + after: ReleaseModeration | null; +} + +export interface EffectPreviewParams { + uri: string; + val: string; + cid?: string; + neg?: boolean; +} + +/** A selectable label value plus its ceremony scope, for the action dialog's + * menu. Presentation only — the server is the authority on what a reviewer may + * issue (`guardMutation`), so this menu never gates anything. */ +export interface IssuableLabel { + val: string; + scope: "cid-bound" | "uri-wide"; +} + +/** Body for `POST /admin/api/labels/{issue,retract}`. `idempotencyKey` is minted + * client-side (ULID) per confirm-dialog open and reused across retries so a + * network retry replays rather than double-issues. */ +export interface LabelActionInput { + uri: string; + val: string; + cid?: string; + confirmation: string; + reason: string; + idempotencyKey: string; +} + +/** The deterministic idempotent result returned by an issue/retract — no + * `sequence` (assigned by a DB trigger post-commit; two replays must agree). */ +export interface IssuedLabelDescriptor { + actionId: string; + val: string; + uri: string; + cid: string | null; + neg: boolean; + cts: string; + effect: string; +} + export interface ListAssessmentsParams { state?: PublicAssessmentState; cursor?: string; diff --git a/apps/labeler/console/src/components/LabelActionDialog.tsx b/apps/labeler/console/src/components/LabelActionDialog.tsx new file mode 100644 index 0000000000..a490ccb09b --- /dev/null +++ b/apps/labeler/console/src/components/LabelActionDialog.tsx @@ -0,0 +1,257 @@ +import { Badge, Button, Dialog, Input, InputArea, Loader } from "@cloudflare/kumo"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { ulid } from "ulidx"; + +import { apiClient } from "../api/client.js"; +import type { EffectPreview, IssuableLabel, ReleaseModeration } from "../api/types.js"; + +interface LabelActionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: "issue" | "retract"; + subjectUri: string; + /** The release CID in view, used as the label CID for CID-bound actions. */ + subjectCid?: string; + /** Menu of issuable labels for `mode: "issue"`. */ + issuable?: readonly IssuableLabel[]; + /** The label + scope being negated for `mode: "retract"`. */ + target?: IssuableLabel; + /** TanStack Query keys to invalidate on success so the new state renders. */ + invalidateKeys: readonly (readonly unknown[])[]; +} + +function rkeyOf(uri: string): string { + return uri.split("/").at(-1) ?? ""; +} + +function ModerationSummary({ label, value }: { label: string; value: ReleaseModeration | null }) { + return ( +
+ {label} + {value ? ( +
+ + {value.eligibility} + + {value.blockingLabels.map((v) => ( + + {v} + + ))} + {value.warningLabels.map((v) => ( + + {v} + + ))} +
+ ) : ( + Not a release subject + )} +
+ ); +} + +function EffectPreviewView({ preview }: { preview: EffectPreview }) { + return ( +
+
+ Label effect + {preview.labelEffect} + + {preview.scope === "cid-bound" ? "This exact release CID" : "Whole record, all CIDs"} + +
+ {preview.supersedes.length > 0 && ( +
+ Replaces active label +
+ {preview.supersedes.map((s) => ( + + {s.val} + + ))} +
+
+ )} +
+ + +
+
+ ); +} + +/** + * The §11.4 reviewer action ceremony: shows the subject, the resolved + * official-client effect (server-derived), the CID-bound vs URI-wide scope, a + * required reason, and a server-validated typed confirmation (the exact CID or + * the record rkey). The client mirrors the confirmation check for UX; the server + * is authoritative. The idempotency key is minted per open and reused across + * retries so a network retry replays rather than double-issues. + */ +export function LabelActionDialog({ + open, + onOpenChange, + mode, + subjectUri, + subjectCid, + issuable, + target, + invalidateKeys, +}: LabelActionDialogProps) { + const queryClient = useQueryClient(); + const menu = issuable ?? []; + const [selectedVal, setSelectedVal] = useState(target?.val ?? menu[0]?.val ?? ""); + const [reason, setReason] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [idempotencyKey, setIdempotencyKey] = useState(""); + + // Reset the ceremony each time the dialog opens: a fresh idempotency key, so a + // reopened dialog is a genuinely new action, and cleared inputs. + useEffect(() => { + if (!open) return; + setIdempotencyKey(ulid()); + setReason(""); + setConfirmation(""); + setSelectedVal(target?.val ?? menu[0]?.val ?? ""); + // menu/target are stable per open; re-running on their identity would clobber edits. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const scope: IssuableLabel["scope"] = + mode === "retract" + ? (target?.scope ?? "uri-wide") + : (menu.find((entry) => entry.val === selectedVal)?.scope ?? "cid-bound"); + const cid = scope === "cid-bound" ? subjectCid : undefined; + const expectedConfirmation = cid ?? rkeyOf(subjectUri); + + const previewQuery = useQuery({ + queryKey: ["effect-preview", subjectUri, selectedVal, cid ?? null, mode], + queryFn: () => + apiClient.previewEffect({ + uri: subjectUri, + val: selectedVal, + ...(cid === undefined ? {} : { cid }), + neg: mode === "retract", + }), + enabled: open && selectedVal.length > 0, + }); + + const mutation = useMutation({ + mutationFn: () => { + const input = { + uri: subjectUri, + val: selectedVal, + ...(cid === undefined ? {} : { cid }), + confirmation, + reason, + idempotencyKey, + }; + return mode === "retract" ? apiClient.retractLabel(input) : apiClient.issueLabel(input); + }, + onSuccess: async () => { + await Promise.all( + invalidateKeys.map((queryKey) => queryClient.invalidateQueries({ queryKey })), + ); + onOpenChange(false); + }, + }); + + const confirmationMatches = + confirmation === expectedConfirmation && expectedConfirmation.length > 0; + const canSubmit = reason.trim().length > 0 && confirmationMatches && !mutation.isPending; + const title = mode === "retract" ? "Retract label" : "Issue label"; + + return ( + + + {title} + + {subjectUri} + + + {mode === "issue" && menu.length > 0 && ( + + )} + + {previewQuery.isLoading ? ( +
+ +
+ ) : previewQuery.data ? ( + + ) : previewQuery.isError ? ( +

Could not load the effect preview.

+ ) : null} + + + + { + setConfirmation(event.target.value); + }} + placeholder={expectedConfirmation} + error={ + confirmation.length > 0 && !confirmationMatches + ? "Does not match the subject" + : undefined + } + /> + + {mutation.isError && ( +

+ {mutation.error instanceof Error ? mutation.error.message : "Action failed"} +

+ )} + +
+ +
+
+
+ ); +} diff --git a/apps/labeler/console/src/labels.ts b/apps/labeler/console/src/labels.ts new file mode 100644 index 0000000000..91f8586b94 --- /dev/null +++ b/apps/labeler/console/src/labels.ts @@ -0,0 +1,55 @@ +import type { IssuableLabel } from "./api/types.js"; + +/** + * Presentation menus for the reviewer action dialog. These mirror the ratified + * moderation policy's reviewer-issuable labels so the console can offer a picker, + * but they are NOT the enforcement boundary — the server (`assertW94Issuable` + + * the signing-layer `validateManualProposal`) is authoritative and rejects + * anything outside policy regardless of what the menu offers. A `scope` of + * `cid-bound` targets the exact release CID (policy `cidRule: required`); + * `uri-wide` targets the whole record (`cidRule: forbidden`). + */ + +/** Reviewer-issuable labels on a release subject: the descriptive + * warning/block vocabulary (CID-bound) plus the URI-wide security yank. */ +export const RELEASE_ISSUABLE_LABELS: readonly IssuableLabel[] = [ + { val: "security-yanked", scope: "uri-wide" }, + { val: "malware", scope: "cid-bound" }, + { val: "data-exfiltration", scope: "cid-bound" }, + { val: "credential-harvesting", scope: "cid-bound" }, + { val: "supply-chain-compromise", scope: "cid-bound" }, + { val: "critical-vulnerability", scope: "cid-bound" }, + { val: "artifact-integrity-failure", scope: "cid-bound" }, + { val: "invalid-bundle", scope: "cid-bound" }, + { val: "undeclared-access", scope: "cid-bound" }, + { val: "impersonation", scope: "cid-bound" }, + { val: "suspicious-code", scope: "cid-bound" }, + { val: "obfuscated-code", scope: "cid-bound" }, + { val: "privacy-risk", scope: "cid-bound" }, + { val: "misleading-metadata", scope: "cid-bound" }, + { val: "low-quality", scope: "cid-bound" }, + { val: "broken-release", scope: "cid-bound" }, +]; + +/** Reviewer-issuable labels on a package (profile) subject. */ +export const PACKAGE_ISSUABLE_LABELS: readonly IssuableLabel[] = [ + { val: "package-disputed", scope: "uri-wide" }, +]; + +const RELEASE_SCOPES = new Map(RELEASE_ISSUABLE_LABELS.map((entry) => [entry.val, entry.scope])); + +/** The ceremony scope for a value already active on a release, so a retract + * targets the same scope it was issued at. Defaults to `cid-bound` for the + * descriptive vocabulary. */ +export function releaseLabelScope(val: string): IssuableLabel["scope"] { + return RELEASE_SCOPES.get(val) ?? "cid-bound"; +} + +/** Whether a value is reviewer-issuable on a release subject, and therefore + * retractable through the issue/retract endpoints. Mirrors the server's policy + * allow-set so the retract button renders only where the action can succeed — + * eligibility labels (assessment-passed/overridden/pending/error) are absent + * from the issuable set and are rejected by the server's `assertW94Issuable`. */ +export function isReleaseRetractable(val: string): boolean { + return RELEASE_SCOPES.has(val); +} diff --git a/apps/labeler/console/src/routes/AssessmentDetail.tsx b/apps/labeler/console/src/routes/AssessmentDetail.tsx index 9af3e43135..fc30d4bd4c 100644 --- a/apps/labeler/console/src/routes/AssessmentDetail.tsx +++ b/apps/labeler/console/src/routes/AssessmentDetail.tsx @@ -1,12 +1,15 @@ -import { Badge, LayerCard, Loader } from "@cloudflare/kumo"; +import { Badge, Button, LayerCard, Loader } from "@cloudflare/kumo"; import { useQuery } from "@tanstack/react-query"; import { createRoute, Link } from "@tanstack/react-router"; -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { apiClient } from "../api/client.js"; +import type { IssuableLabel } from "../api/types.js"; import { FindingCard } from "../components/FindingCard.js"; +import { LabelActionDialog } from "../components/LabelActionDialog.js"; import { QueryError } from "../components/QueryError.js"; import { StateBadge } from "../components/StateBadge.js"; +import { isReleaseRetractable, RELEASE_ISSUABLE_LABELS, releaseLabelScope } from "../labels.js"; import { shellRoute } from "./root.js"; function MetaRow({ label, value }: { label: string; value: ReactNode }) { @@ -50,6 +53,11 @@ function AssessmentDetail() { queryFn: () => apiClient.listLabels(id), enabled: !!assessment, }); + const { data: whoami } = useQuery({ queryKey: ["whoami"], queryFn: () => apiClient.whoami() }); + const canAct = whoami?.roles.includes("reviewer") || whoami?.roles.includes("admin") || false; + + const [issueOpen, setIssueOpen] = useState(false); + const [retractTarget, setRetractTarget] = useState(null); if (isAssessmentError) { return ; @@ -120,7 +128,14 @@ function AssessmentDetail() {
-

Labels

+
+

Labels

+ {canAct && ( + + )} +
{isLabelsError ? ( ) : isLoadingLabels || !labels ? ( @@ -130,17 +145,57 @@ function AssessmentDetail() { ) : (
{labels.map((label) => ( - - {label.val} - +
+ {label.val} + {canAct && !label.neg && isReleaseRetractable(label.val) && ( + + )} +
))}
)}
+ {canAct && ( + <> + + { + if (!open) setRetractTarget(null); + }} + mode="retract" + subjectUri={assessment.uri} + subjectCid={assessment.cid} + {...(retractTarget ? { target: retractTarget } : {})} + invalidateKeys={[ + ["assessment", id, "labels"], + ["assessment", id], + ]} + /> + + )} +

Findings

{isFindingsError ? ( diff --git a/apps/labeler/console/src/routes/SubjectHistory.tsx b/apps/labeler/console/src/routes/SubjectHistory.tsx index 69363fc4cb..c4bc291f88 100644 --- a/apps/labeler/console/src/routes/SubjectHistory.tsx +++ b/apps/labeler/console/src/routes/SubjectHistory.tsx @@ -1,12 +1,26 @@ -import { LayerCard, Loader, Table } from "@cloudflare/kumo"; +import { Button, LayerCard, Loader, Table } from "@cloudflare/kumo"; import { useQuery } from "@tanstack/react-query"; import { createRoute, Link } from "@tanstack/react-router"; +import { useState } from "react"; import { apiClient } from "../api/client.js"; +import type { IssuableLabel } from "../api/types.js"; +import { LabelActionDialog } from "../components/LabelActionDialog.js"; import { QueryError } from "../components/QueryError.js"; import { StateBadge } from "../components/StateBadge.js"; +import { PACKAGE_ISSUABLE_LABELS, RELEASE_ISSUABLE_LABELS } from "../labels.js"; import { shellRoute } from "./root.js"; +/** URI-wide (record-level) issuable labels for a subject, chosen by its + * collection: a release record can be security-yanked, a package profile can be + * disputed. Presentation only — the server enforces policy. */ +function uriWideLabelsFor(collection: string): readonly IssuableLabel[] { + if (collection.endsWith(".release")) + return RELEASE_ISSUABLE_LABELS.filter((entry) => entry.scope === "uri-wide"); + if (collection.endsWith(".profile")) return PACKAGE_ISSUABLE_LABELS; + return []; +} + function SubjectHistory() { const { uri } = subjectHistoryRoute.useParams(); @@ -19,6 +33,9 @@ function SubjectHistory() { queryKey: ["subject-history", uri], queryFn: () => apiClient.getSubjectHistory(uri), }); + const { data: whoami } = useQuery({ queryKey: ["whoami"], queryFn: () => apiClient.whoami() }); + const canAct = whoami?.roles.includes("reviewer") || whoami?.roles.includes("admin") || false; + const [issueOpen, setIssueOpen] = useState(false); if (isError) { return ; @@ -39,22 +56,42 @@ function SubjectHistory() { } const { subject, assessments } = history; + const uriWideLabels = uriWideLabelsFor(subject.collection); return (
-
-

Subject history

-

{subject.uri}

-

- Current CID: {subject.cid} -

- {subject.deletedAt && ( -

- Deleted at {new Date(subject.deletedAt).toLocaleString()} +

+
+

Subject history

+

{subject.uri}

+

+ Current CID: {subject.cid}

+ {subject.deletedAt && ( +

+ Deleted at {new Date(subject.deletedAt).toLocaleString()} +

+ )} +
+ {canAct && uriWideLabels.length > 0 && ( + )}
+ {canAct && uriWideLabels.length > 0 && ( + + )} + {assessments.length === 0 ? (
diff --git a/apps/labeler/fixtures/moderation-policy.json b/apps/labeler/fixtures/moderation-policy.json index 5eb7728cc5..671176a06f 100644 --- a/apps/labeler/fixtures/moderation-policy.json +++ b/apps/labeler/fixtures/moderation-policy.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "policyVersion": "2026-07-10.experimental.2", + "policyVersion": "2026-07-10.experimental.3", "effectiveAt": "2026-07-10T00:00:00Z", "labelerDid": "did:web:labels.emdashcms.com", "assessmentSchemaVersion": 1, @@ -117,7 +117,7 @@ "category": "automated-block", "officialEffect": "block", "subjectRules": [ - { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated", "reviewer"] } ], "locales": [ { @@ -132,7 +132,7 @@ "category": "automated-block", "officialEffect": "block", "subjectRules": [ - { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated", "reviewer"] } ], "locales": [ { @@ -147,7 +147,7 @@ "category": "automated-block", "officialEffect": "block", "subjectRules": [ - { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated", "reviewer"] } ], "locales": [ { @@ -162,7 +162,7 @@ "category": "automated-block", "officialEffect": "block", "subjectRules": [ - { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated", "reviewer"] } ], "locales": [ { @@ -177,7 +177,7 @@ "category": "automated-block", "officialEffect": "block", "subjectRules": [ - { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated", "reviewer"] } ], "locales": [ { @@ -192,7 +192,7 @@ "category": "automated-block", "officialEffect": "block", "subjectRules": [ - { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated", "reviewer"] } ], "locales": [ { @@ -207,7 +207,7 @@ "category": "automated-block", "officialEffect": "block", "subjectRules": [ - { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated", "reviewer"] } ], "locales": [ { @@ -222,7 +222,7 @@ "category": "automated-block", "officialEffect": "block", "subjectRules": [ - { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated", "reviewer"] } ], "locales": [ { @@ -237,7 +237,7 @@ "category": "automated-block", "officialEffect": "block", "subjectRules": [ - { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated", "reviewer"] } ], "locales": [ { @@ -252,7 +252,7 @@ "category": "warning", "officialEffect": "warn", "subjectRules": [ - { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated", "reviewer"] } ], "locales": [ { @@ -267,7 +267,7 @@ "category": "warning", "officialEffect": "warn", "subjectRules": [ - { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated", "reviewer"] } ], "locales": [ { @@ -282,7 +282,7 @@ "category": "warning", "officialEffect": "warn", "subjectRules": [ - { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated", "reviewer"] } ], "locales": [ { @@ -297,7 +297,7 @@ "category": "warning", "officialEffect": "warn", "subjectRules": [ - { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated", "reviewer"] } ], "locales": [ { @@ -312,7 +312,7 @@ "category": "warning", "officialEffect": "warn", "subjectRules": [ - { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated", "reviewer"] } ], "locales": [ { @@ -327,7 +327,7 @@ "category": "warning", "officialEffect": "warn", "subjectRules": [ - { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] } + { "subject": "release", "cidRule": "required", "issuanceModes": ["automated", "reviewer"] } ], "locales": [ { diff --git a/apps/labeler/src/console-api.ts b/apps/labeler/src/console-api.ts index 6905d8cfdc..1f96f0fa6b 100644 --- a/apps/labeler/src/console-api.ts +++ b/apps/labeler/src/console-api.ts @@ -13,6 +13,7 @@ * expose the body. No route here sets a CORS header. */ +import type { OperatorIdentity } from "./access-auth.js"; import { computeFilterHash, decodeCursor, @@ -40,6 +41,7 @@ import { type Page, } from "./console-serialize.js"; import { LABELER_DISCOVERY_DO_NAME } from "./discovery-do.js"; +import { computeEffectPreview } from "./label-effect-preview.js"; import { MutationGuardError } from "./mutation-guard.js"; import { getOperatorActionsPage } from "./operator-actions.js"; import { guardRead, ReadGuardError, type ReadGuardDeps } from "./operator-read-guard.js"; @@ -74,11 +76,11 @@ export interface ConsoleApiDeps extends ReadGuardDeps { */ export async function handleConsoleApi(request: Request, deps: ConsoleApiDeps): Promise { try { - await guardRead(request, deps, { minRole: "reviewer" }); + const identity = await guardRead(request, deps, { minRole: "reviewer" }); // `matchRoute` is synchronous, so a rejection from the handler is consumed // by this `await` directly — never adopted by an intermediate async layer, // which would surface a spurious unhandled rejection under workerd. - const handler = matchRoute(request, deps); + const handler = matchRoute(request, deps, identity); const response = await handler(); return response; } catch (error) { @@ -92,7 +94,11 @@ export async function handleConsoleApi(request: Request, deps: ConsoleApiDeps): } } -function matchRoute(request: Request, deps: ConsoleApiDeps): () => Promise { +function matchRoute( + request: Request, + deps: ConsoleApiDeps, + identity: OperatorIdentity, +): () => Promise { const url = new URL(request.url); // pathname keeps percent-encoding; the subject URI segment is decoded per route. const segments = url.pathname.replace(ADMIN_API_PREFIX, "").split("/").filter(Boolean); @@ -113,6 +119,14 @@ function matchRoute(request: Request, deps: ConsoleApiDeps): () => Promise handleListAuditLog(request, url, deps); } else if (segments[0] === "status" && segments.length === 1) { return () => handleGetStatus(request, deps); + } else if (segments[0] === "whoami" && segments.length === 1) { + return () => handleWhoami(request, identity); + } else if ( + segments[0] === "labels" && + segments.length === 2 && + segments[1] === "effect-preview" + ) { + return () => handleEffectPreview(request, url, deps); } throw new ReadGuardError("NOT_FOUND"); @@ -279,6 +293,51 @@ async function handleGetStatus(request: Request, deps: ConsoleApiDeps): Promise< }); } +/** The caller's own verified identity — kind, principal, and roles — for the + * console's cosmetic button gating. The server remains the enforcement boundary + * (`guardMutation`'s role gate); hiding a button never grants anything. */ +async function handleWhoami(request: Request, identity: OperatorIdentity): Promise { + requireGet(request); + const principal = identity.kind === "human" ? identity.email : identity.commonName; + return jsonData({ + kind: identity.kind, + principal, + sub: identity.sub, + roles: identity.roles, + }); +} + +async function handleEffectPreview( + request: Request, + url: URL, + deps: ConsoleApiDeps, +): Promise { + requireGet(request); + const uri = url.searchParams.get("uri"); + const val = url.searchParams.get("val"); + if (uri === null || uri.length === 0 || val === null || val.length === 0) + throw new ReadGuardError("INVALID_REQUEST"); + const cid = url.searchParams.get("cid") ?? undefined; + if (cid !== undefined && cid.length === 0) throw new ReadGuardError("INVALID_REQUEST"); + const neg = parseNeg(url.searchParams.get("neg")); + + const preview = await computeEffectPreview( + deps.db, + deps.labelerDid, + { uri, val, ...(cid === undefined ? {} : { cid }), neg }, + new Date(), + ); + // An unknown label value has no policy definition to preview. + if (!preview) throw new ReadGuardError("INVALID_REQUEST"); + return jsonData(preview); +} + +function parseNeg(raw: string | null): boolean { + if (raw === null || raw === "false") return false; + if (raw === "true") return true; + throw new ReadGuardError("INVALID_REQUEST"); +} + /** Dead-letter backlog — the observable stand-in for discovery-queue depth, * which the Queues API does not expose to the consumer Worker (spec §11.1's * `/admin/system` "queue/DLQ health"). */ diff --git a/apps/labeler/src/console-mutation-api.ts b/apps/labeler/src/console-mutation-api.ts new file mode 100644 index 0000000000..d5d35c5ed6 --- /dev/null +++ b/apps/labeler/src/console-mutation-api.ts @@ -0,0 +1,327 @@ +/** + * Operator console mutation API (plan W9.4). The POST counterpart of + * `console-api.ts`'s read dispatcher: `/admin/api/labels/issue` and + * `/admin/api/labels/retract` sign and persist a reviewer-authorized label + * through the W9.2 guard, committing the signed `issued_labels` INSERTs and the + * `operator_actions` audit row in one atomic `db.batch` via `commitMutation`. + * + * Retraction is a negation (`neg: true`) issuance on the same `(src, uri, val)` + * stream — the ATProto stream has no delete. Authorization, CSRF, idempotency, + * and replay are entirely the guard's; this module adds the W9.4 value allow-set + * (policy-driven, minus the override-coupled pair W9.5 owns) and the + * server-validated typed confirmation. + */ + +import type { LabelSigner } from "@emdash-cms/registry-moderation"; + +import type { AccessAuthConfig, AccessKeyResolver } from "./access-auth.js"; +import type { LabelerIdentityConfig } from "./config.js"; +import { + commitMutation, + guardMutation, + MutationGuardError, + type MutationGuardDeps, + type MutationSpec, +} from "./mutation-guard.js"; +import { ReadGuardError } from "./operator-read-guard.js"; +import { getLabelDefinition } from "./policy.js"; +import { + LabelIssuanceUnavailableError, + parseSubjectKind, + prepareManualLabelIssuance, + readIssuedLabelByActionKey, + type AllowedLabelProposal, + type AuthorizedIssuanceAction, +} from "./service.js"; +import { createLabelPublisher } from "./subscribe-labels.js"; + +const ADMIN_API_PREFIX = /^\/admin\/api\/?/; + +/** Override-coupled labels: the atomic unblock action (W9.5) is the only path + * that issues these, so the standalone issue/retract endpoints reject them. */ +const OVERRIDE_COUPLED: ReadonlySet = new Set([ + "assessment-passed", + "assessment-overridden", +]); + +export type LabelMutationCode = "CONFIRMATION_MISMATCH"; + +/** + * A W9.4-specific rejection kept off `MutationGuardError` so the W9.2 guard's + * code union stays untouched. Same wire shape as the guard error; the message is + * static (never echoes the confirmation value). + */ +export class LabelMutationError extends Error { + override readonly name = "LabelMutationError"; + readonly code: LabelMutationCode; + readonly status = 400; + + constructor(code: LabelMutationCode) { + super("Typed confirmation does not match the action subject"); + this.code = code; + } + + toResponse(): Response { + return Response.json( + { error: { code: this.code, message: this.message } }, + { status: this.status }, + ); + } +} + +export interface LabelActionBody { + uri: string; + val: string; + cid?: string; + /** Server-validated typed confirmation: the exact CID for a CID-bound action, + * the record rkey for a URI-wide one. Participates in the request fingerprint + * (it is part of the parsed body), so a replay must send the same value. */ + confirmation: string; +} + +export interface ConsoleMutationDeps { + db: D1Database; + accessConfig: AccessAuthConfig; + keys: AccessKeyResolver; + config: LabelerIdentityConfig; + createSigner: () => Promise; + now: () => Date; + /** Broadcasts the freshly committed label off the response path (keyed on our + * own action id, so a concurrent-race loser finds nothing and skips). Errors + * are swallowed by the implementation — cursor replay backstops a missed frame. */ + afterCommit: (actionId: string) => Promise; + /** Schedules `afterCommit` without blocking the response (workerd `waitUntil`). */ + defer: (work: Promise) => void; +} + +/** + * The deterministic idempotent result stored by `commitMutation` and returned to + * the client. Excludes `sequence` — that is assigned by a DB trigger at INSERT + * time and is unknowable before the batch commits, so two replays would + * otherwise disagree. The console re-reads the sequence from the label reads. + */ +export interface IssuedLabelDescriptor { + actionId: string; + val: string; + uri: string; + cid: string | null; + neg: boolean; + cts: string; + effect: string; +} + +export async function handleConsoleMutation( + request: Request, + deps: ConsoleMutationDeps, +): Promise { + try { + const url = new URL(request.url); + const segments = url.pathname.replace(ADMIN_API_PREFIX, "").split("/").filter(Boolean); + if (segments[0] !== "labels" || segments.length !== 2) throw new ReadGuardError("NOT_FOUND"); + if (segments[1] === "issue") return await runLabelMutation(request, deps, false); + if (segments[1] === "retract") return await runLabelMutation(request, deps, true); + throw new ReadGuardError("NOT_FOUND"); + } catch (error) { + if ( + error instanceof MutationGuardError || + error instanceof ReadGuardError || + error instanceof LabelMutationError + ) + return error.toResponse(); + // Transient signing-state unavailability (issuance paused, key stale, or the + // in-batch signing guard suppressing the label under a rotation race) is + // retryable — surface a 503 rather than a misleading 500 or a phantom 200. + if (error instanceof LabelIssuanceUnavailableError) + return Response.json( + { + error: { + code: "LABEL_ISSUANCE_UNAVAILABLE", + message: "Label issuance is temporarily unavailable; retry.", + }, + }, + { status: 503 }, + ); + console.error("[console-mutation] unhandled error", error); + return Response.json( + { error: { code: "INTERNAL", message: "Internal error" } }, + { status: 500 }, + ); + } +} + +async function runLabelMutation( + request: Request, + deps: ConsoleMutationDeps, + neg: boolean, +): Promise { + const spec = makeSpec(neg); + const guardDeps: MutationGuardDeps = { + db: deps.db, + config: deps.accessConfig, + keys: deps.keys, + now: deps.now, + }; + const outcome = await guardMutation(request, spec, guardDeps); + if (outcome.outcome === "replay") { + await assertIssuancePersisted(deps.db, outcome.actionId); + return jsonData(outcome.result); + } + + const { ctx } = outcome; + const signer = await deps.createSigner(); + const action: AuthorizedIssuanceAction = { + actor: deps.config.labelerDid, + type: "manual-label", + reason: ctx.reason, + // Keying the signing-layer idempotency on the guard's unique action id + // makes the two idempotency layers consistent: a concurrent-race loser's + // issuance INSERTs (keyed by its own actionId) roll back with its audit row. + idempotencyKey: ctx.actionId, + }; + const proposal: AllowedLabelProposal = { + uri: ctx.body.uri, + val: ctx.body.val, + ...(ctx.body.cid === undefined ? {} : { cid: ctx.body.cid }), + neg, + }; + const { statements } = await prepareManualLabelIssuance( + deps.db, + deps.config, + signer, + action, + proposal, + ctx.now, + ); + const descriptor: IssuedLabelDescriptor = { + actionId: ctx.actionId, + val: proposal.val, + uri: proposal.uri, + cid: proposal.cid ?? null, + neg, + cts: ctx.now.toISOString(), + effect: getLabelDefinition(proposal.val)?.officialEffect ?? "", + }; + const returned = await commitMutation(deps.db, ctx, spec, statements, descriptor); + await assertIssuancePersisted(deps.db, returned.actionId); + deps.defer(deps.afterCommit(ctx.actionId)); + return jsonData(returned); +} + +/** + * Rejects a phantom success. The `operator_actions` audit row commits + * unconditionally, but the `issued_labels` INSERT is guarded by an in-batch + * signing-state condition — a key rotation landing between the signing pre-check + * and the commit suppresses the label as a zero-row INSERT (not an error) while + * the audit row lands, and that audit row stores the success descriptor as its + * result. Both the proceed path and the replay path (which the guard serves from + * that stored descriptor) must verify the label actually persisted for the + * committed action, or a retry would return the suppressed issuance as a 200. + * Keying on the committed action id distinguishes a genuine suppression (no + * label) from a concurrent-race loss (the winner's action id, whose label is + * present). + */ +async function assertIssuancePersisted(db: D1Database, actionId: string): Promise { + if (!(await readIssuedLabelByActionKey(db, actionId))) + throw new LabelIssuanceUnavailableError("label issuance did not persist"); +} + +function makeSpec(neg: boolean): MutationSpec { + return { + action: neg ? "label-retract" : "label-issue", + requiredRole: "reviewer", + parseBody: (raw) => { + const body = parseLabelActionBody(raw); + assertW94Issuable(body); + assertConfirmation(body); + return body; + }, + auditFields: (body) => ({ + subjectUri: body.uri, + subjectCid: body.cid, + labelValue: body.val, + metadata: { neg, effect: getLabelDefinition(body.val)?.officialEffect ?? null }, + }), + }; +} + +function parseLabelActionBody(raw: Record): LabelActionBody { + const uri = requireString(raw.uri); + const val = requireString(raw.val); + const confirmation = requireString(raw.confirmation); + const cid = optionalString(raw.cid); + return { uri, val, ...(cid === undefined ? {} : { cid }), confirmation }; +} + +/** + * The W9.4 value allow-set, policy-driven so a fixture grant lights up a value + * with no code change: the label's `subjectRules` must grant `reviewer` for the + * URI's subject, the matched rule's `cidRule` must be satisfied, and the value + * must not be one of the override-coupled pair (W9.5). The admin-only labels + * (`!takedown`, `publisher-compromised`) are rejected here because their rules + * carry no `reviewer` mode — they are W9.6. Enforcing `cidRule` here (not only in + * the issuer's `validateManualProposal`) makes a scope mismatch a clean 400 + * before signing rather than a 500 mid-issuance. + */ +function assertW94Issuable(body: LabelActionBody): void { + if (OVERRIDE_COUPLED.has(body.val)) throw new MutationGuardError("INVALID_BODY"); + const definition = getLabelDefinition(body.val); + if (!definition) throw new MutationGuardError("INVALID_BODY"); + const subject = parseSubjectKind(body.uri); + if (subject === null) throw new MutationGuardError("INVALID_BODY"); + const rule = definition.subjectRules.find((candidate) => candidate.subject === subject); + if (!rule || !rule.issuanceModes.includes("reviewer")) + throw new MutationGuardError("INVALID_BODY"); + if (rule.cidRule === "forbidden" && body.cid !== undefined) + throw new MutationGuardError("INVALID_BODY"); + if (rule.cidRule === "required" && body.cid === undefined) + throw new MutationGuardError("INVALID_BODY"); +} + +/** + * Server-validated typed confirmation (§11.4, §20.2 "enforcement is code, not + * prompt text"): a CID-bound action must confirm the exact CID; a URI-wide one + * must confirm the record rkey (the final AT-URI path segment). A scripted + * client cannot skip the ceremony. + */ +function assertConfirmation(body: LabelActionBody): void { + const expected = body.cid !== undefined ? body.cid : rkeyOf(body.uri); + if (body.confirmation !== expected) throw new LabelMutationError("CONFIRMATION_MISMATCH"); +} + +function rkeyOf(uri: string): string { + return uri.split("/").at(-1) ?? ""; +} + +function requireString(value: unknown): string { + if (typeof value !== "string" || value.length === 0) throw new MutationGuardError("INVALID_BODY"); + return value; +} + +function optionalString(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string" || value.length === 0) throw new MutationGuardError("INVALID_BODY"); + return value; +} + +function jsonData(data: T): Response { + return Response.json({ data }, { headers: { "cache-control": "no-store" } }); +} + +/** + * Best-effort live broadcast of a freshly committed label, run off the response + * path. Keyed on the caller's own action id: a concurrent-race loser (whose + * batch rolled back) reads nothing and skips, so the winner alone publishes. + * Publication needs the trigger-assigned `sequence`, which only exists + * post-commit — this uses a plain read, not `postCommit()`, so a deliberately + * absent row is `null` (skip) rather than a signing-state diagnosis. A missed + * frame is recoverable: subscribers replay from their cursor on reconnect. + */ +export async function publishAfterCommit(env: Env, actionId: string): Promise { + try { + const issued = await readIssuedLabelByActionKey(env.DB, actionId); + if (!issued) return; + await createLabelPublisher(env).publish(issued); + } catch (error) { + console.error("[console-mutation] publish failed", error); + } +} diff --git a/apps/labeler/src/index.ts b/apps/labeler/src/index.ts index bea3cb6310..a9502600fa 100644 --- a/apps/labeler/src/index.ts +++ b/apps/labeler/src/index.ts @@ -3,8 +3,9 @@ import { parseAccessAuthConfig, type AccessAuthConfig, } from "./access-auth.js"; -import { getLabelerIdentityConfig, type LabelerConfig } from "./config.js"; +import { getLabelerIdentityConfig, type LabelerIdentityConfig } from "./config.js"; import { consoleAssetPath, handleConsoleApi, probeJetstreamConnected } from "./console-api.js"; +import { handleConsoleMutation, publishAfterCommit } from "./console-mutation-api.js"; import { drainDiscoveryDeadLetterBatch, processDiscoveryBatch } from "./discovery-consumer.js"; import { LABELER_DISCOVERY_DO_NAME } from "./discovery-do.js"; import type { DiscoveryJob } from "./env.js"; @@ -29,7 +30,7 @@ const DISCOVERY_QUEUE_NAME = "emdash-labeler-discovery"; const DISCOVERY_DLQ_NAME = "emdash-labeler-discovery-dlq"; export default { - async fetch(request: Request, env: Env): Promise { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { const pathname = new URL(request.url).pathname; let config; try { @@ -57,7 +58,7 @@ export default { // the shell needs no in-Worker auth check — the API re-verifies per // request regardless (see console-api.ts / operator-read-guard.ts). if (pathname === "/admin/api" || pathname.startsWith("/admin/api/")) - return handleConsoleApiRequest(env, request, config); + return handleConsoleApiRequest(env, request, config, ctx); const assetPath = consoleAssetPath(pathname); if (assetPath !== null) { const assetUrl = new URL(request.url); @@ -120,7 +121,8 @@ function getOperatorAccessConfig(env: Env): AccessAuthConfig { async function handleConsoleApiRequest( env: Env, request: Request, - config: LabelerConfig, + config: LabelerIdentityConfig, + ctx: ExecutionContext, ): Promise { let accessConfig: AccessAuthConfig; try { @@ -131,10 +133,24 @@ async function handleConsoleApiRequest( { status: 500 }, ); } + const keys = getAccessKeyResolver(accessConfig.teamDomain); + if (request.method === "POST") { + return handleConsoleMutation(request, { + db: env.DB, + accessConfig, + keys, + config, + createSigner: async () => + (await createRuntimeSigner(config, getRuntimeSigningSecret(env))).signer, + now: () => new Date(), + afterCommit: (actionId) => publishAfterCommit(env, actionId), + defer: (work) => ctx.waitUntil(work), + }); + } return handleConsoleApi(request, { db: env.DB, config: accessConfig, - keys: getAccessKeyResolver(accessConfig.teamDomain), + keys, labelerDid: config.labelerDid, jetstreamConnected: () => probeJetstreamConnected(env), }); diff --git a/apps/labeler/src/label-effect-preview.ts b/apps/labeler/src/label-effect-preview.ts new file mode 100644 index 0000000000..7981744a35 --- /dev/null +++ b/apps/labeler/src/label-effect-preview.ts @@ -0,0 +1,157 @@ +/** + * Server-derived effect preview for a proposed reviewer label action (plan + * W9.4, spec §11.4 "resulting official-client effect"). The console holds zero + * policy logic: it renders whatever this endpoint returns. Grounding is the + * exact machinery official clients use — `getActiveLabelState`'s canonical + * `(src, uri, val)` stream reduction with CID applicability, fed into + * `registry-moderation`'s release evaluator (the aggregator / #1972 source of + * truth) with the proposal overlaid as one more `(uri, val, cid, neg)` event. + */ + +import { + evaluateHydratedReleaseModeration, + type ModerationLabel, + type ReleaseModeration, +} from "@emdash-cms/registry-moderation"; + +import { getActiveLabelState, getCurrentSubjectByUri } from "./assessment-store.js"; +import { getLabelDefinition } from "./policy.js"; +import { parseSubjectKind } from "./service.js"; + +export interface EffectPreviewParams { + uri: string; + val: string; + cid?: string; + neg: boolean; +} + +export interface SupersededLabel { + val: string; + cid: string | null; + sequence: number; +} + +export interface EffectPreview { + /** The label's own official effect from policy (block/warn/redact/pass/…). */ + labelEffect: string; + scope: "cid-bound" | "uri-wide"; + /** The currently-active label in this value's stream that the action would + * replace (a retract negates it; a re-issue supersedes it) — empty when the + * value has no active label yet. */ + supersedes: SupersededLabel[]; + /** Resolved release moderation now, and with the proposal overlaid. Only a + * release subject has a release evaluation; package/publisher subjects (and + * releases with no observed subject row) report `null`. */ + before: ReleaseModeration | null; + after: ReleaseModeration | null; +} + +/** + * Computes the effect preview, or `null` when `val` is not a known policy label + * (the caller maps that to a 400). Every other field is best-effort: an + * unresolvable release context yields `before`/`after` `null` rather than + * failing the whole read. + */ +export async function computeEffectPreview( + db: D1Database, + labelerDid: string, + params: EffectPreviewParams, + now: Date, +): Promise { + const definition = getLabelDefinition(params.val); + if (!definition) return null; + + const scope: EffectPreview["scope"] = params.cid !== undefined ? "cid-bound" : "uri-wide"; + const preview: EffectPreview = { + labelEffect: definition.officialEffect, + scope, + supersedes: [], + before: null, + after: null, + }; + + // A release evaluation needs the release CID being evaluated. For a CID-bound + // action it is the proposal CID; for a URI-wide action (e.g. security-yanked) + // it is the current release CID from the observed subject row. + const subject = await getCurrentSubjectByUri(db, params.uri); + const isRelease = + parseSubjectKind(params.uri) === "release" && subject?.collection.endsWith(".release") === true; + const contextCid = params.cid ?? subject?.cid; + + if (contextCid === undefined) return preview; + + const active = await getActiveLabelState(db, { + src: labelerDid, + uri: params.uri, + cid: contextCid, + now, + }); + const supersededWinner = active.get(params.val); + if (supersededWinner && supersededWinner.active) { + preview.supersedes = [ + { + val: supersededWinner.val, + cid: supersededWinner.cid, + sequence: supersededWinner.sequence, + }, + ]; + } + + if (!isRelease || !subject) return preview; + + // Active winners are non-negated, unexpired, and applicable to `contextCid`, + // so they reconstruct as positive `ModerationLabel`s. The overlay carries a + // `now` cts so it wins its own `(uri, val)` stream in the re-reduction. + const activeLabels: ModerationLabel[] = []; + for (const winner of active.values()) { + if (!winner.active) continue; + activeLabels.push({ + ver: 1, + src: labelerDid, + uri: params.uri, + ...(winner.cid === null ? {} : { cid: winner.cid }), + val: winner.val, + cts: winner.cts, + ...(winner.exp === null ? {} : { exp: winner.exp }), + }); + } + + const overlay: ModerationLabel = { + ver: 1, + src: labelerDid, + uri: params.uri, + ...(params.cid === undefined ? {} : { cid: params.cid }), + val: params.val, + ...(params.neg ? { neg: true } : {}), + cts: now.toISOString(), + }; + + const context = { + publisherDid: subject.did, + package: { uri: params.uri, cid: contextCid }, + release: { uri: params.uri, cid: contextCid }, + }; + const acceptedLabelers = [{ did: labelerDid, redact: false }]; + + try { + preview.before = evaluateHydratedReleaseModeration({ + acceptedLabelers, + context, + evaluatedAt: now, + labels: activeLabels, + }); + preview.after = evaluateHydratedReleaseModeration({ + acceptedLabelers, + context, + evaluatedAt: now, + labels: [...activeLabels, overlay], + }); + } catch { + // A malformed URI/CID the evaluator rejects leaves before/after null; the + // scope + labelEffect + supersedes fields are still useful to the console. + preview.before = null; + preview.after = null; + } + + return preview; +} diff --git a/apps/labeler/src/service.ts b/apps/labeler/src/service.ts index c2d28b95e1..43d43e9e78 100644 --- a/apps/labeler/src/service.ts +++ b/apps/labeler/src/service.ts @@ -3,7 +3,7 @@ import type { LabelSigner, SignedLabel } from "@emdash-cms/registry-moderation"; import { ASSESSMENT_ID } from "./assessment-lifecycle.js"; import type { LabelerConfig } from "./config.js"; import type { FindingSeverity } from "./evidence.js"; -import { getLabelDefinition } from "./policy.js"; +import { getLabelDefinition, type SubjectKind } from "./policy.js"; import { getSigningStatusIfInitialized, recordSigningAlert, @@ -51,7 +51,11 @@ export type IssuanceAction = AuthorizedIssuanceAction | AutomatedIssuanceAction; export interface AllowedLabelProposal { uri: string; - val: ManualLabelValue; + /** Any label whose policy `subjectRules` grant a `reviewer` or `admin` + * issuance mode for the parsed subject — validated in `validateManualProposal` + * against the ratified fixture, not a hardcoded union, so a fixture grant lights + * up a value with no code change. */ + val: string; cid?: string; neg?: boolean; exp?: string; @@ -371,10 +375,35 @@ export async function issueManualLabel( throw new TypeError("signer issuer does not match the configured labeler DID"); validateKeyVersion(config.signingKeyVersion); validateAction(action); - validateProposal(proposal); + validateManualProposal(proposal); return issueLabel(db, config, signer, action, proposal, now, publisher); } +/** + * Prepares (validates + signs) a manual label issuance without executing the + * batch, returning the same `IssuanceStatements` `commitMutation` consumes so + * the operator console can commit the signed label INSERTs in the same atomic + * `db.batch` as the `operator_actions` audit row (plan W9.4). Mirrors the + * validation half of `issueManualLabel`; the caller owns `db.batch` and + * publication. `publicationPending` is `true` — the console publishes after the + * commit via `readIssuedLabelByActionKey`. + */ +export async function prepareManualLabelIssuance( + db: D1Database, + config: LabelerConfig, + signer: LabelSigner, + action: AuthorizedIssuanceAction, + proposal: AllowedLabelProposal, + now: Date, +): Promise { + if (signer.issuerDid !== config.labelerDid) + throw new TypeError("signer issuer does not match the configured labeler DID"); + validateKeyVersion(config.signingKeyVersion); + validateAction(action); + validateManualProposal(proposal); + return buildIssuanceStatements(db, config, signer, action, proposal, now, true); +} + export async function issueAutomatedAssessmentLabel( db: D1Database, config: LabelerConfig, @@ -462,6 +491,56 @@ async function assertAutomatedNegationAllowed( } } +/** + * Reads a persisted issuance as an `IssuedLabel` by its issuance-action + * idempotency key. Unlike `IssuanceStatements.postCommit`, a missing row + * returns `null` rather than re-diagnosing signing state and throwing — the + * console's post-commit publisher keys on its own `actionId`, so the race + * loser (whose batch rolled back) legitimately finds nothing and skips. + */ +export async function readIssuedLabelByActionKey( + db: D1Database, + idempotencyKey: string, +): Promise { + const row = await getIssuedLabel(db, idempotencyKey); + return row ? rowToIssuedLabel(row) : null; +} + +function rowToIssuedLabel(stored: StoredLabelRow): IssuedLabel { + const action: IssuanceAction = + stored.type === "automated-assessment" + ? { + actor: stored.actor, + type: "automated-assessment", + assessmentId: stored.assessment_id ?? "", + reason: stored.reason, + idempotencyKey: stored.idempotency_key, + } + : { + actor: stored.actor, + type: "manual-label", + reason: stored.reason, + idempotencyKey: stored.idempotency_key, + }; + return { + action, + label: { + ver: 1, + src: stored.src, + uri: stored.uri, + ...(stored.cid === null ? {} : { cid: stored.cid }), + val: stored.val, + ...(stored.neg === 1 ? { neg: true } : {}), + cts: stored.cts, + ...(stored.exp === null ? {} : { exp: stored.exp }), + sig: new Uint8Array(stored.sig), + }, + sequence: stored.sequence, + signingKeyId: stored.signing_key_id, + signingKeyVersion: stored.signing_key_version, + }; +} + async function getIssuedLabel( db: D1Database, idempotencyKey: string, @@ -500,23 +579,7 @@ function assertMatches( ) { throw new TypeError("idempotency key is already bound to a different issuance"); } - return { - action, - label: { - ver: 1, - src: stored.src, - uri: stored.uri, - ...(stored.cid === null ? {} : { cid: stored.cid }), - val: stored.val, - ...(stored.neg === 1 ? { neg: true } : {}), - cts: stored.cts, - ...(stored.exp === null ? {} : { exp: stored.exp }), - sig: new Uint8Array(stored.sig), - }, - sequence: stored.sequence, - signingKeyId: stored.signing_key_id, - signingKeyVersion: stored.signing_key_version, - }; + return rowToIssuedLabel(stored); } function validateAction(action: AuthorizedIssuanceAction): void { @@ -528,29 +591,60 @@ function validateAction(action: AuthorizedIssuanceAction): void { throw new TypeError("action.idempotencyKey must be between 1 and 200 characters"); } -function validateProposal(proposal: AllowedLabelProposal): void { - const record = REGISTRY_RECORD.exec(proposal.uri); - const isDidSubject = DID.test(proposal.uri); - switch (proposal.val) { - case "publisher-compromised": - if (!isDidSubject || proposal.cid !== undefined) - throw new TypeError("publisher-compromised must target a DID without a CID"); - return; - case "!takedown": - if (!isDidSubject && !record) - throw new TypeError("!takedown must target a DID, package profile, or release record"); - if (proposal.cid !== undefined) throw new TypeError("!takedown must not include a CID"); - return; - case "package-disputed": - if (!record || !record[2]!.endsWith(".profile")) - throw new TypeError("package-disputed must target a package profile record"); - return; - case "security-yanked": - if (!record || !record[2]!.endsWith(".release")) - throw new TypeError("security-yanked must target a release record"); - if (proposal.cid !== undefined) throw new TypeError("security-yanked must not include a CID"); - return; +/** Parses a label subject URI into its policy `SubjectKind`: a bare DID is a + * publisher, a `…package.profile` record is a package, a `…package.release` + * record is a release; anything else is unrecognized. */ +export function parseSubjectKind(uri: string): SubjectKind | null { + if (DID.test(uri)) return "publisher"; + const record = REGISTRY_RECORD.exec(uri); + if (!record) return null; + const collection = record[2]!; + if (collection.endsWith(".profile")) return "package"; + if (collection.endsWith(".release")) return "release"; + return null; +} + +function describeSubject(subject: SubjectKind): string { + switch (subject) { + case "release": + return "release record"; + case "package": + return "package profile record"; + case "publisher": + return "DID"; + } +} + +/** + * Manual issuance legality driven from the ratified policy fixture, mirroring + * `validateAutomatedProposal`: a value is manually issuable only where its + * `subjectRules` grant a `reviewer` or `admin` mode for the subject parsed from + * the URI, and the matched rule's `cidRule` decides whether a CID is forbidden + * (URI-wide), required (CID-bound), or optional. Keeping the issuer policy-driven + * means a fixture grant lights up a value with no code change, and an + * automated-only value (`assessment-pending`, an ungranted descriptive label) + * is rejected here. The per-endpoint W9.4 scope gate (override-coupled labels, + * role) lives in the console mutation dispatcher, not here. + */ +function validateManualProposal(proposal: AllowedLabelProposal): void { + const definition = getLabelDefinition(proposal.val); + if (!definition) throw new TypeError(`unknown label value: ${proposal.val}`); + const manualRules = definition.subjectRules.filter( + (rule) => rule.issuanceModes.includes("reviewer") || rule.issuanceModes.includes("admin"), + ); + if (manualRules.length === 0) + throw new TypeError(`${proposal.val} cannot be issued through the manual path`); + const subject = parseSubjectKind(proposal.uri); + const rule = + subject === null ? undefined : manualRules.find((candidate) => candidate.subject === subject); + if (!rule) { + const allowed = manualRules.map((candidate) => describeSubject(candidate.subject)).join(", "); + throw new TypeError(`${proposal.val} must target a ${allowed}`); } + if (rule.cidRule === "forbidden" && proposal.cid !== undefined) + throw new TypeError(`${proposal.val} must not include a CID`); + if (rule.cidRule === "required" && proposal.cid === undefined) + throw new TypeError(`${proposal.val} must include a CID`); } function validateAutomatedAction(action: AutomatedIssuanceAction): void { diff --git a/apps/labeler/test/console-mutation-api.test.ts b/apps/labeler/test/console-mutation-api.test.ts new file mode 100644 index 0000000000..beed5f3471 --- /dev/null +++ b/apps/labeler/test/console-mutation-api.test.ts @@ -0,0 +1,736 @@ +import { + createLabelSigner, + evaluateHydratedReleaseModeration, + type LabelDidDocument, + type ModerationLabel, +} from "@emdash-cms/registry-moderation"; +import { applyD1Migrations, env } from "cloudflare:test"; +import { generateKeyPair, SignJWT } from "jose"; +import { beforeAll, describe, expect, it } from "vitest"; + +import type { AccessKeyResolver } from "../src/access-auth.js"; +import { createSubject, getActiveLabelState } from "../src/assessment-store.js"; +import { handleConsoleApi, type ConsoleApiDeps } from "../src/console-api.js"; +import { + handleConsoleMutation, + type ConsoleMutationDeps, + type IssuedLabelDescriptor, +} from "../src/console-mutation-api.js"; +import { OPERATOR_REQUEST_HEADER } from "../src/mutation-guard.js"; +import { issueManualLabel } from "../src/service.js"; + +const TEAM_DOMAIN = "https://example-team.cloudflareaccess.com"; +const AUDIENCE = "test-audience"; +const ORIGIN = "https://labeler.example.com"; +const LABELER_DID = "did:web:labels.emdashcms.com"; +const LABELER_SERVICE_URL = "https://labels.emdashcms.com"; +const PUBLISHER_DID = "did:plc:publisher000000000000000000"; +const PRIVATE_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE"; +const MULTIKEY = "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ"; +const CID = "bafkreif4oaymum54i5qefbwoblrt5zasfjhpyhyvacpseqtehi3queew5m"; +const CID_2 = "bafkreieq5jui4j25lacwomsqgjeswwl3y5zcdrresptwgmfylxo2depppq"; + +const CONFIG = { + labelerDid: LABELER_DID, + signingKeyVersion: "v1", + serviceUrl: LABELER_SERVICE_URL, + signingPublicKeyMultibase: MULTIKEY, +}; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; +} +const testEnv = env as unknown as TestEnv; + +let resolver: AccessKeyResolver; +let signKey: CryptoKey; +let reviewerToken: string; +let adminToken: string; +let noRoleToken: string; +let keySeq = 0; + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); + const pair = await generateKeyPair("RS256"); + signKey = pair.privateKey; + resolver = (async () => pair.publicKey) as AccessKeyResolver; + reviewerToken = await mintToken({ email: "reviewer@example.com" }); + adminToken = await mintToken({ email: "admin@example.com" }); + noRoleToken = await mintToken({ email: "nobody@example.com" }); +}); + +async function mintToken(claims: Record): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ sub: "user-sub-1", ...claims }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer(TEAM_DOMAIN) + .setAudience(AUDIENCE) + .setIssuedAt(now) + .setExpirationTime(now + 3600) + .sign(signKey); +} + +function labelerDidDocument(): LabelDidDocument { + return { + id: LABELER_DID, + verificationMethod: [ + { + id: `${LABELER_DID}#atproto_label`, + type: "Multikey", + controller: LABELER_DID, + publicKeyMultibase: MULTIKEY, + }, + ], + }; +} + +function testSigner() { + return createLabelSigner({ + issuerDid: LABELER_DID, + privateKey: PRIVATE_KEY, + resolveDid: async () => labelerDidDocument(), + }); +} + +function mutationDeps(overrides: Partial = {}): ConsoleMutationDeps { + const published: string[] = []; + return { + db: testEnv.DB, + accessConfig: { + teamDomain: TEAM_DOMAIN, + audience: AUDIENCE, + admins: ["admin@example.com"], + reviewers: ["reviewer@example.com"], + }, + keys: resolver, + config: CONFIG, + createSigner: () => testSigner(), + now: () => new Date(), + afterCommit: async (actionId) => { + published.push(actionId); + }, + defer: (work) => { + void work; + }, + ...overrides, + }; +} + +function readDeps(overrides: Partial = {}): ConsoleApiDeps { + return { + db: testEnv.DB, + config: { + teamDomain: TEAM_DOMAIN, + audience: AUDIENCE, + admins: ["admin@example.com"], + reviewers: ["reviewer@example.com"], + }, + keys: resolver, + expectedOrigin: ORIGIN, + labelerDid: LABELER_DID, + jetstreamConnected: async () => true, + ...overrides, + }; +} + +interface PostOptions { + token?: string | null; + csrf?: string | null; + contentType?: string | null; + origin?: string; +} + +function post(path: string, body: unknown, opts: PostOptions = {}): Request { + const headers = new Headers(); + const csrf = opts.csrf === undefined ? "1" : opts.csrf; + if (csrf !== null) headers.set(OPERATOR_REQUEST_HEADER, csrf); + const contentType = opts.contentType === undefined ? "application/json" : opts.contentType; + if (contentType !== null) headers.set("Content-Type", contentType); + const token = opts.token === undefined ? reviewerToken : opts.token; + if (token !== null) headers.set("Cf-Access-Jwt-Assertion", token); + if (opts.origin) headers.set("Origin", opts.origin); + return new Request(`${ORIGIN}${path}`, { method: "POST", headers, body: JSON.stringify(body) }); +} + +function getReq(path: string, token: string | null = reviewerToken): Request { + const headers = new Headers(); + headers.set(OPERATOR_REQUEST_HEADER, "1"); + if (token !== null) headers.set("Cf-Access-Jwt-Assertion", token); + return new Request(`${ORIGIN}${path}`, { method: "GET", headers }); +} + +function releaseUri(rkey: string): string { + return `at://${PUBLISHER_DID}/com.emdashcms.experimental.package.release/${rkey}`; +} + +function profileUri(rkey: string): string { + return `at://${PUBLISHER_DID}/com.emdashcms.experimental.package.profile/${rkey}`; +} + +async function seedReleaseSubject(rkey: string, cid = CID): Promise { + const uri = releaseUri(rkey); + await createSubject(testEnv.DB, { + uri, + cid, + did: PUBLISHER_DID, + collection: "com.emdashcms.experimental.package.release", + rkey, + now: new Date("2026-07-08T08:00:00.000Z"), + }); + return uri; +} + +function nextKey(): string { + keySeq += 1; + return `idem-key-${keySeq.toString().padStart(6, "0")}`; +} + +/** + * Simulates the in-batch signing-guard suppression a signing-key rotation race + * produces: the unguarded `operator_actions` INSERT commits while the guarded + * `issuance_actions` / `issued_labels` INSERTs match zero rows. Wraps `batch` to + * run only the audit INSERT, leaving the exact "audit row present, label absent" + * phantom state the handler's post-commit verification must reject. Every other + * method (`prepare`, etc.) passes through to the real D1, so no signing_state is + * written and later tests stay isolated. + */ +function suppressIssuanceDb(db: D1Database): D1Database { + return new Proxy(db, { + get(target, prop, receiver) { + if (prop === "batch") + return (statements: D1PreparedStatement[]) => target.batch([statements[0]!]); + const value: unknown = Reflect.get(target, prop, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + +async function countRows(sql: string, ...binds: unknown[]): Promise { + const row = await testEnv.DB.prepare(sql) + .bind(...binds) + .first<{ n: number }>(); + return row?.n ?? 0; +} + +async function bodyData(response: Response): Promise { + const parsed = (await response.json()) as { data: T }; + return parsed.data; +} + +async function bodyError(response: Response): Promise<{ code: string; message: string }> { + const parsed = (await response.json()) as { error: { code: string; message: string } }; + return parsed.error; +} + +describe("console mutation: issue/retract effect + audit atomicity", () => { + it("commits the label, issuance action, and audit row with consistent linkage", async () => { + const uri = await seedReleaseSubject("atomicity-ok"); + const key = nextKey(); + const response = await handleConsoleMutation( + post("/admin/api/labels/issue", { + uri, + val: "security-yanked", + confirmation: "atomicity-ok", + reason: "withdrawing for a disclosed CVE", + idempotencyKey: key, + }), + mutationDeps(), + ); + expect(response.status).toBe(200); + const descriptor = await bodyData(response); + expect(descriptor).toMatchObject({ + val: "security-yanked", + uri, + cid: null, + neg: false, + effect: "block", + }); + expect(descriptor.actionId).toMatch(/^oact_/); + // No sequence in the idempotent result. + expect("sequence" in descriptor).toBe(false); + + const audit = await testEnv.DB.prepare( + `SELECT id, action, actor_type, actor_email, role, subject_uri, subject_cid, label_value + FROM operator_actions WHERE idempotency_key = ?`, + ) + .bind(key) + .first(); + expect(audit).toMatchObject({ + id: descriptor.actionId, + action: "label-issue", + actor_type: "human", + actor_email: "reviewer@example.com", + role: "reviewer", + subject_uri: uri, + subject_cid: null, + label_value: "security-yanked", + }); + + // issuance_actions.idempotency_key === operator_actions.id === actionId. + const linked = await testEnv.DB.prepare( + `SELECT l.val, l.neg FROM issued_labels l + JOIN issuance_actions a ON a.id = l.action_id + WHERE a.idempotency_key = ?`, + ) + .bind(descriptor.actionId) + .first<{ val: string; neg: number }>(); + expect(linked).toEqual({ val: "security-yanked", neg: 0 }); + }); + + it("returns a retryable 503 (not a phantom 200) when the label is suppressed after the audit commits", async () => { + const uri = await seedReleaseSubject("phantom-suppressed"); + const key = nextKey(); + const body = { + uri, + val: "security-yanked", + confirmation: "phantom-suppressed", + reason: "rotation lands mid-issuance", + idempotencyKey: key, + }; + const response = await handleConsoleMutation( + post("/admin/api/labels/issue", body), + mutationDeps({ db: suppressIssuanceDb(testEnv.DB) }), + ); + expect(response.status).toBe(503); + expect((await bodyError(response)).code).toBe("LABEL_ISSUANCE_UNAVAILABLE"); + // The audit row committed (its INSERT is unguarded) but no label persisted — + // the exact phantom state the post-commit verification rejects. + expect( + await countRows(`SELECT COUNT(*) n FROM operator_actions WHERE idempotency_key = ?`, key), + ).toBe(1); + expect(await countRows(`SELECT COUNT(*) n FROM issued_labels WHERE uri = ?`, uri)).toBe(0); + + // A retry with the same idempotency key hits guardMutation's replay branch — + // which would return the stored (success) descriptor. The replay path must run + // the same persistence check and also 503, not a phantom 200. A plain db here: + // the replay short-circuits before any batch, reading the committed audit row. + const retry = await handleConsoleMutation( + post("/admin/api/labels/issue", body), + mutationDeps(), + ); + expect(retry.status).toBe(503); + expect((await bodyError(retry)).code).toBe("LABEL_ISSUANCE_UNAVAILABLE"); + // The replay wrote nothing: still no label, still exactly one audit row. + expect(await countRows(`SELECT COUNT(*) n FROM issued_labels WHERE uri = ?`, uri)).toBe(0); + expect( + await countRows(`SELECT COUNT(*) n FROM operator_actions WHERE idempotency_key = ?`, key), + ).toBe(1); + }); + + it("writes no audit row when signing prep fails (no effect ⇒ no audit)", async () => { + const uri = await seedReleaseSubject("atomicity-fail"); + const key = nextKey(); + const response = await handleConsoleMutation( + post("/admin/api/labels/issue", { + uri, + val: "security-yanked", + confirmation: "atomicity-fail", + reason: "prep should fail", + idempotencyKey: key, + }), + mutationDeps({ + createSigner: () => + Promise.resolve({ + issuerDid: LABELER_DID, + sign: () => Promise.reject(new Error("signer unavailable")), + }), + }), + ); + expect(response.status).toBe(500); + expect( + await countRows(`SELECT COUNT(*) n FROM operator_actions WHERE idempotency_key = ?`, key), + ).toBe(0); + }); +}); + +describe("console mutation: guard integration (representative)", () => { + const body = { + uri: releaseUri("guard"), + val: "security-yanked", + confirmation: "guard", + reason: "guard test", + idempotencyKey: "guard-key-000001", + }; + + it("rejects a missing CSRF header", async () => { + const response = await handleConsoleMutation( + post("/admin/api/labels/issue", body, { csrf: null }), + mutationDeps(), + ); + expect(response.status).toBe(403); + expect((await bodyError(response)).code).toBe("CSRF_HEADER_MISSING"); + }); + + it("rejects a non-JSON content type", async () => { + const response = await handleConsoleMutation( + post("/admin/api/labels/issue", body, { contentType: "text/plain" }), + mutationDeps(), + ); + expect(response.status).toBe(415); + }); + + it("rejects an unauthenticated request", async () => { + const response = await handleConsoleMutation( + post("/admin/api/labels/issue", body, { token: null }), + mutationDeps(), + ); + expect(response.status).toBe(401); + }); + + it("rejects a caller without the reviewer role", async () => { + const response = await handleConsoleMutation( + post("/admin/api/labels/issue", body, { token: noRoleToken }), + mutationDeps(), + ); + expect(response.status).toBe(403); + expect((await bodyError(response)).code).toBe("FORBIDDEN_ROLE"); + }); + + it("lets an admin satisfy the reviewer gate and records the inherited role", async () => { + const uri = await seedReleaseSubject("admin-inherits"); + const response = await handleConsoleMutation( + post( + "/admin/api/labels/issue", + { + uri, + val: "security-yanked", + confirmation: "admin-inherits", + reason: "admin acting as reviewer", + idempotencyKey: nextKey(), + }, + { token: adminToken }, + ), + mutationDeps(), + ); + expect(response.status).toBe(200); + const audit = await testEnv.DB.prepare( + `SELECT role, actor_email FROM operator_actions WHERE subject_uri = ? AND action = 'label-issue'`, + ) + .bind(uri) + .first<{ role: string; actor_email: string }>(); + expect(audit).toMatchObject({ role: "admin", actor_email: "admin@example.com" }); + }); +}); + +describe("console mutation: vocabulary and scope", () => { + async function issue(uri: string, val: string, confirmation: string, cid?: string) { + return handleConsoleMutation( + post("/admin/api/labels/issue", { + uri, + val, + ...(cid === undefined ? {} : { cid }), + confirmation, + reason: `issue ${val}`, + idempotencyKey: nextKey(), + }), + mutationDeps(), + ); + } + + it("rejects an automated-only label (assessment-pending)", async () => { + const response = await issue(releaseUri("vocab-pending"), "assessment-pending", CID, CID); + expect(response.status).toBe(400); + expect((await bodyError(response)).code).toBe("INVALID_BODY"); + }); + + it("rejects the override-coupled pair (assessment-passed)", async () => { + const response = await issue(releaseUri("vocab-passed"), "assessment-passed", CID, CID); + expect(response.status).toBe(400); + expect((await bodyError(response)).code).toBe("INVALID_BODY"); + }); + + it("rejects an admin-only label issued as reviewer (!takedown)", async () => { + const response = await issue(releaseUri("vocab-takedown"), "!takedown", "vocab-takedown"); + expect(response.status).toBe(400); + }); + + it("rejects a CID on a cidRule:forbidden label (security-yanked)", async () => { + const response = await issue(releaseUri("vocab-yank-cid"), "security-yanked", CID, CID); + expect(response.status).toBe(400); + expect((await bodyError(response)).code).toBe("INVALID_BODY"); + }); + + it("accepts security-yanked URI-wide on a release", async () => { + await seedReleaseSubject("vocab-yank-ok"); + const response = await issue(releaseUri("vocab-yank-ok"), "security-yanked", "vocab-yank-ok"); + expect(response.status).toBe(200); + }); + + it("accepts a reviewer-granted descriptive label (malware) CID-bound", async () => { + await seedReleaseSubject("vocab-malware"); + const response = await issue(releaseUri("vocab-malware"), "malware", CID, CID); + expect(response.status).toBe(200); + }); + + it("accepts package-disputed with and without a CID", async () => { + const withCid = await issue(profileUri("vocab-disputed-a"), "package-disputed", CID, CID); + expect(withCid.status).toBe(200); + const withoutCid = await issue( + profileUri("vocab-disputed-b"), + "package-disputed", + "vocab-disputed-b", + ); + expect(withoutCid.status).toBe(200); + }); +}); + +describe("console mutation: confirmation", () => { + it("rejects a CID-bound action whose confirmation is not the CID", async () => { + await seedReleaseSubject("conf-cid"); + const response = await handleConsoleMutation( + post("/admin/api/labels/issue", { + uri: releaseUri("conf-cid"), + val: "malware", + cid: CID, + confirmation: "not-the-cid", + reason: "bad confirmation", + idempotencyKey: nextKey(), + }), + mutationDeps(), + ); + expect(response.status).toBe(400); + expect((await bodyError(response)).code).toBe("CONFIRMATION_MISMATCH"); + }); + + it("rejects a URI-wide action whose confirmation is not the rkey", async () => { + await seedReleaseSubject("conf-rkey"); + const response = await handleConsoleMutation( + post("/admin/api/labels/issue", { + uri: releaseUri("conf-rkey"), + val: "security-yanked", + confirmation: "wrong-rkey", + reason: "bad confirmation", + idempotencyKey: nextKey(), + }), + mutationDeps(), + ); + expect(response.status).toBe(400); + expect((await bodyError(response)).code).toBe("CONFIRMATION_MISMATCH"); + }); +}); + +describe("console mutation: retraction is negation", () => { + it("records a negation and the stream winner becomes inactive", async () => { + const uri = await seedReleaseSubject("retract-flow"); + const issued = await handleConsoleMutation( + post("/admin/api/labels/issue", { + uri, + val: "security-yanked", + confirmation: "retract-flow", + reason: "yank it", + idempotencyKey: nextKey(), + }), + mutationDeps(), + ); + expect(issued.status).toBe(200); + + const retracted = await handleConsoleMutation( + post("/admin/api/labels/retract", { + uri, + val: "security-yanked", + confirmation: "retract-flow", + reason: "false alarm", + idempotencyKey: nextKey(), + }), + mutationDeps(), + ); + expect(retracted.status).toBe(200); + const descriptor = await bodyData(retracted); + expect(descriptor.neg).toBe(true); + + expect( + await countRows( + `SELECT COUNT(*) n FROM issued_labels WHERE uri = ? AND val = 'security-yanked' AND neg = 1`, + uri, + ), + ).toBe(1); + + const winners = await getActiveLabelState(testEnv.DB, { src: LABELER_DID, uri, cid: CID }); + expect(winners.get("security-yanked")?.active).toBe(false); + }); +}); + +describe("console mutation: replay and conflict", () => { + it("returns the byte-identical stored descriptor on replay without a second write", async () => { + const uri = await seedReleaseSubject("replay"); + const key = nextKey(); + const request = () => + post("/admin/api/labels/issue", { + uri, + val: "security-yanked", + confirmation: "replay", + reason: "first and replay", + idempotencyKey: key, + }); + const first = await handleConsoleMutation(request(), mutationDeps()); + const firstBody = await first.text(); + const second = await handleConsoleMutation(request(), mutationDeps()); + const secondBody = await second.text(); + expect(second.status).toBe(200); + expect(secondBody).toBe(firstBody); + expect( + await countRows( + `SELECT COUNT(*) n FROM issued_labels WHERE uri = ? AND val = 'security-yanked'`, + uri, + ), + ).toBe(1); + }); + + it("returns 409 for the same key with a different fingerprint", async () => { + const uri = await seedReleaseSubject("conflict"); + const key = nextKey(); + const first = await handleConsoleMutation( + post("/admin/api/labels/issue", { + uri, + val: "security-yanked", + confirmation: "conflict", + reason: "original reason", + idempotencyKey: key, + }), + mutationDeps(), + ); + expect(first.status).toBe(200); + const second = await handleConsoleMutation( + post("/admin/api/labels/issue", { + uri, + val: "security-yanked", + confirmation: "conflict", + reason: "different reason", + idempotencyKey: key, + }), + mutationDeps(), + ); + expect(second.status).toBe(409); + expect((await bodyError(second)).code).toBe("IDEMPOTENCY_KEY_CONFLICT"); + }); +}); + +describe("console reads: whoami", () => { + it("returns the caller kind, principal, and roles", async () => { + const response = await handleConsoleApi(getReq("/admin/api/whoami"), readDeps()); + expect(response.status).toBe(200); + const data = await bodyData<{ kind: string; principal: string; roles: string[] }>(response); + expect(data).toMatchObject({ + kind: "human", + principal: "reviewer@example.com", + roles: ["reviewer"], + }); + }); + + it("reports admin roles for an admin caller", async () => { + const response = await handleConsoleApi(getReq("/admin/api/whoami", adminToken), readDeps()); + const data = await bodyData<{ roles: string[] }>(response); + expect(data.roles).toEqual(["admin"]); + }); +}); + +describe("console reads: effect preview", () => { + async function preview(uri: string, val: string, cid?: string, neg = false) { + const search = new URLSearchParams({ uri, val }); + if (cid) search.set("cid", cid); + if (neg) search.set("neg", "true"); + const response = await handleConsoleApi( + getReq(`/admin/api/labels/effect-preview?${search.toString()}`), + readDeps(), + ); + return response; + } + + it("grounds before/after in the aggregator evaluator for a blocked release", async () => { + const uri = await seedReleaseSubject("preview-block"); + await issueManualLabel( + testEnv.DB, + CONFIG, + await testSigner(), + { + actor: LABELER_DID, + type: "manual-label", + reason: "seed block", + idempotencyKey: nextKey(), + }, + { uri, val: "malware", cid: CID }, + new Date("2026-07-08T09:00:00.000Z"), + ); + + const response = await preview(uri, "security-yanked"); + expect(response.status).toBe(200); + const data = await bodyData<{ + labelEffect: string; + scope: string; + before: { eligibility: string; blockingLabels: string[] } | null; + after: { eligibility: string; blockingLabels: string[] } | null; + }>(response); + expect(data.labelEffect).toBe("block"); + expect(data.scope).toBe("uri-wide"); + expect(data.before?.eligibility).toBe("blocked"); + expect(data.before?.blockingLabels).toContain("malware"); + expect(data.after?.blockingLabels).toContain("security-yanked"); + + // No drift: the endpoint's `after` matches a direct evaluator call over the + // overlaid label set (comparing effect fields, not cts-bearing labels). + const activeMalware: ModerationLabel = { + ver: 1, + src: LABELER_DID, + uri, + cid: CID, + val: "malware", + cts: "2026-07-08T09:00:00.000Z", + }; + const overlay: ModerationLabel = { + ver: 1, + src: LABELER_DID, + uri, + val: "security-yanked", + cts: "2026-07-10T00:00:00.000Z", + }; + const direct = evaluateHydratedReleaseModeration({ + acceptedLabelers: [{ did: LABELER_DID, redact: false }], + context: { + publisherDid: PUBLISHER_DID, + package: { uri, cid: CID }, + release: { uri, cid: CID }, + }, + evaluatedAt: new Date(), + labels: [activeMalware, overlay], + }); + expect(data.after?.eligibility).toBe(direct.eligibility); + expect(data.after?.blockingLabels).toEqual(direct.blockingLabels); + }); + + it("shows a retract returning the release toward eligible", async () => { + const uri = await seedReleaseSubject("preview-retract", CID_2); + const base = new Date("2026-07-08T09:00:00.000Z"); + await issueManualLabel( + testEnv.DB, + CONFIG, + await testSigner(), + { actor: LABELER_DID, type: "manual-label", reason: "seed pass", idempotencyKey: nextKey() }, + { uri, val: "assessment-passed", cid: CID_2 }, + base, + ); + await issueManualLabel( + testEnv.DB, + CONFIG, + await testSigner(), + { actor: LABELER_DID, type: "manual-label", reason: "seed block", idempotencyKey: nextKey() }, + { uri, val: "malware", cid: CID_2 }, + new Date("2026-07-08T09:05:00.000Z"), + ); + + const blocked = await bodyData<{ before: { eligibility: string } | null }>( + await preview(uri, "malware", CID_2), + ); + expect(blocked.before?.eligibility).toBe("blocked"); + + const retract = await bodyData<{ after: { eligibility: string } | null }>( + await preview(uri, "malware", CID_2, true), + ); + expect(retract.after?.eligibility).toBe("eligible"); + }); + + it("rejects an unknown label value", async () => { + const response = await preview(releaseUri("preview-unknown"), "not-a-label"); + expect(response.status).toBe(400); + }); +}); diff --git a/apps/labeler/test/policy.test.ts b/apps/labeler/test/policy.test.ts index b2d903ea01..086e2fab4e 100644 --- a/apps/labeler/test/policy.test.ts +++ b/apps/labeler/test/policy.test.ts @@ -9,7 +9,7 @@ import { describe("moderation policy fixture", () => { it("loads the ratified fixture and exposes label lookups", () => { - expect(MODERATION_POLICY.policyVersion).toBe("2026-07-10.experimental.2"); + expect(MODERATION_POLICY.policyVersion).toBe("2026-07-10.experimental.3"); expect(getLabelDefinition("malware")).toMatchObject({ value: "malware", category: "automated-block", diff --git a/apps/labeler/test/service.test.ts b/apps/labeler/test/service.test.ts index adbd9dafd7..36c45daf0b 100644 --- a/apps/labeler/test/service.test.ts +++ b/apps/labeler/test/service.test.ts @@ -78,7 +78,7 @@ describe("service identity", () => { expect(response.headers.get("etag")).toMatch(/^"[a-f0-9]{64}"$/); expect(await response.json()).toMatchObject({ schemaVersion: 1, - policyVersion: "2026-07-10.experimental.2", + policyVersion: "2026-07-10.experimental.3", labelerDid: LABELER_DID, assessmentSchemaVersion: 1, }); diff --git a/apps/labeler/test/xrpc-router.test.ts b/apps/labeler/test/xrpc-router.test.ts index 14eaa7935d..7f193f199b 100644 --- a/apps/labeler/test/xrpc-router.test.ts +++ b/apps/labeler/test/xrpc-router.test.ts @@ -597,7 +597,7 @@ describe("getPolicy", () => { expect(response.headers.get("cache-control")).toBe("public, max-age=300"); expect(await response.json()).toMatchObject({ schemaVersion: 1, - policyVersion: "2026-07-10.experimental.2", + policyVersion: "2026-07-10.experimental.3", labelerDid: LABELER_DID, assessmentSchemaVersion: 1, }); From 957eb041a4a23ccf13df41ca306032ab0e7dd881 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 13 Jul 2026 19:11:37 +0100 Subject: [PATCH 052/137] docs(labeler-plan): ratify W9.5 decisions Rerun re-pends (orchestrator wiring stays W7/W8); override negates all live blocks validated; override-retract leaves blocks negated for a safe-blocked resting state; single-batch multi-label composition. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- .../plugin-registry-labelling-service/implementation-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 3ec1608a11..b6cd60072c 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1137,6 +1137,8 @@ Dependencies: `W3.3`, `W5.1`, `W9.2`. - Reviewer/admin can explicitly retract the override. - Carried from W9.4: manually-issued labels (`assessment_id` NULL, keyed to subject URI+CID) aren't visible in the console — `getAllLabelsForAssessment` is assessment-scoped. Add a subject+CID label read (`GET /admin/api/subjects/:uri/labels?cid=`, wrapping `getActiveLabelState`) and surface manual labels on the subject view and merged into the assessment detail page. Override/rerun needs the same subject-label read, so it lands here. +Decisions (2026-07-13): rerun mints the operator trigger + fresh run + re-issues `assessment-pending` (terminal verdict waits on the W7/W8 orchestrator wiring — not pulled forward). Override negates ALL live automated blocks for the exact URI+CID (client submits the observed set, server validates against live state), never an operator-chosen subset. The multi-label override commits as one `commitMutation` batch (audit row + N negations + `assessment-passed` + `assessment-overridden`) under one operator action, each issuance piece keyed `${actionId}:${val}:${neg}`; `assertIssuancePersisted` verifies all N+2 landed. Override-retract negates only `assessment-passed`/`assessment-overridden` and leaves the blocks negated → evaluator resolves to blocked/`missing-assessment-pass` (safe default); re-surfacing real findings is "retract then rerun" (re-exposing blocks would corrupt automated provenance under §10). Override-permanence needs no evaluator change — `evaluateReleaseModerationCore` already suppresses current and future automated blocks when the pass+override pair is active. + Dependencies: `W6.6`, `W9.4`. ### `W9.6` Implement admin-only emergency actions From c6e2c9f1b56c8b14befe257924e1467c944053d1 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 13 Jul 2026 20:33:28 +0100 Subject: [PATCH 053/137] docs(labeler-plan): ratify W9.6 decisions Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- .../plugin-registry-labelling-service/implementation-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index b6cd60072c..4ac0e54fe9 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1150,6 +1150,8 @@ Dependencies: `W6.6`, `W9.4`. - Pause/resume automated issuance. - Dead-letter retry/quarantine controls. +Decisions (2026-07-13): `!takedown` is admin-retractable via the same guarded path (negation + full ceremony); the resting state after retraction is the subject's pre-takedown computed state — the never-negated automated labels re-expose, so a still-dangerous release falls back to its blocks, never to eligible. Publisher/package takedown does NOT cascade to per-release labels: one dominant URI-wide/DID label the evaluator honors globally (eliminates the partial-application surface). The automation kill-switch is global and gates Jetstream ingestion only — enforcement lives in the discovery consumer, never the signing layer, so reruns/takedowns/manual labels stay available mid-incident; the pause read fails closed (unreadable switch → retry, never issue). Enforcement mechanism is consumer-`retry()`: a long pause drains the discovery queue to the DLQ, recovered by resume + the DLQ-retry control (accepted trade at expected volume). Sub-decisions: publisher typed-confirmation identifier is the DID's final segment (no handle resolution); intent phrases are server constants; `operational_events` (append-only) is the alert source of truth and `notification_outbox` the delivery queue W11.3 drains; event/outbox INSERTs are gated on the label's in-batch existence so no alert can fire for a label suppression `assertIssuancePersisted` will 503. + Dependencies: `W9.2`, `W3.3`. ### `W9.7` Add browser/accessibility/localization tests From 93fe8ba1c64adfb15d29a373c696609986ff8774 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 13 Jul 2026 21:01:37 +0100 Subject: [PATCH 054/137] docs(labeler-plan): note the kill-switch does not gate future issuance paths Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- .../plugin-registry-labelling-service/implementation-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 4ac0e54fe9..a93f0d6b15 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1150,7 +1150,7 @@ Dependencies: `W6.6`, `W9.4`. - Pause/resume automated issuance. - Dead-letter retry/quarantine controls. -Decisions (2026-07-13): `!takedown` is admin-retractable via the same guarded path (negation + full ceremony); the resting state after retraction is the subject's pre-takedown computed state — the never-negated automated labels re-expose, so a still-dangerous release falls back to its blocks, never to eligible. Publisher/package takedown does NOT cascade to per-release labels: one dominant URI-wide/DID label the evaluator honors globally (eliminates the partial-application surface). The automation kill-switch is global and gates Jetstream ingestion only — enforcement lives in the discovery consumer, never the signing layer, so reruns/takedowns/manual labels stay available mid-incident; the pause read fails closed (unreadable switch → retry, never issue). Enforcement mechanism is consumer-`retry()`: a long pause drains the discovery queue to the DLQ, recovered by resume + the DLQ-retry control (accepted trade at expected volume). Sub-decisions: publisher typed-confirmation identifier is the DID's final segment (no handle resolution); intent phrases are server constants; `operational_events` (append-only) is the alert source of truth and `notification_outbox` the delivery queue W11.3 drains; event/outbox INSERTs are gated on the label's in-batch existence so no alert can fire for a label suppression `assertIssuancePersisted` will 503. +Decisions (2026-07-13): `!takedown` is admin-retractable via the same guarded path (negation + full ceremony); the resting state after retraction is the subject's pre-takedown computed state — the never-negated automated labels re-expose, so a still-dangerous release falls back to its blocks, never to eligible. Publisher/package takedown does NOT cascade to per-release labels: one dominant URI-wide/DID label the evaluator honors globally (eliminates the partial-application surface). The automation kill-switch is global and gates Jetstream ingestion only — enforcement lives in the discovery consumer, never the signing layer, so reruns/takedowns/manual labels stay available mid-incident; the pause read fails closed (unreadable switch → retry, never issue). Enforcement mechanism is consumer-`retry()`: a long pause drains the discovery queue to the DLQ, recovered by resume + the DLQ-retry control (accepted trade at expected volume). Because the switch is checked in the consumer rather than the signing layer, any future automated issuance path (notably the W7/W8 orchestrator finalization) does NOT inherit the gate and must add its own `isAutomationPaused` check. Sub-decisions: publisher typed-confirmation identifier is the DID's final segment (no handle resolution); intent phrases are server constants; `operational_events` (append-only) is the alert source of truth and `notification_outbox` the delivery queue W11.3 drains; event/outbox INSERTs are gated on the label's in-batch existence so no alert can fire for a label suppression `assertIssuancePersisted` will 503. Dependencies: `W9.2`, `W3.3`. From cf6c14f68f85b43fed09ea0a2523cf1f32a6d34e Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 13 Jul 2026 21:45:39 +0100 Subject: [PATCH 055/137] feat(labeler): operational events, outbox, and automation kill-switch (W9.6 PR-0) (#2024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(labeler): operational events, notification outbox, and automation kill-switch (W9.6 PR-0) Foundation for the admin emergency actions: migration 0005 adds the append-only operational_events alert stream, the notification_outbox delivery queue W11.3 drains, the automation_state singleton, and additive dead_letters status columns. Event/outbox insert builders support gating on an issued label's in-batch existence so a signing-suppressed batch queues no spurious alert. The discovery consumer checks the kill-switch at the top of its create branch — ingestion-only, fail-closed (pause or unreadable switch both retry), manual/admin issuance never gated. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM * fix(labeler): index the 0005 foreign keys Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- .../migrations/0005_operational_events.sql | 87 ++++++ apps/labeler/src/automation-state.ts | 65 ++++ apps/labeler/src/discovery-consumer.ts | 24 ++ apps/labeler/src/operational-events.ts | 267 +++++++++++++++++ apps/labeler/test/automation-state.test.ts | 110 +++++++ apps/labeler/test/discovery-consumer.test.ts | 116 ++++++- apps/labeler/test/operational-events.test.ts | 282 ++++++++++++++++++ 7 files changed, 950 insertions(+), 1 deletion(-) create mode 100644 apps/labeler/migrations/0005_operational_events.sql create mode 100644 apps/labeler/src/automation-state.ts create mode 100644 apps/labeler/src/operational-events.ts create mode 100644 apps/labeler/test/automation-state.test.ts create mode 100644 apps/labeler/test/operational-events.test.ts diff --git a/apps/labeler/migrations/0005_operational_events.sql b/apps/labeler/migrations/0005_operational_events.sql new file mode 100644 index 0000000000..488d277d8d --- /dev/null +++ b/apps/labeler/migrations/0005_operational_events.sql @@ -0,0 +1,87 @@ +-- Operational-alert subsystem (spec §11.3/§18.2/§22). Three concerns land +-- together so later W9.6 PRs are code-only: +-- +-- * `operational_events` — append-only operator/deployment alert stream, +-- the source of truth W11.3 alarms read (severity IN ('critical','high')). +-- Distinct from `operator_actions` (who did what) and the publisher-facing +-- `notifications` table (W10). Written atomically with the operator_actions +-- row + the effect by `commitMutation`; immutable like the other audit logs. +-- * `notification_outbox` — mutable per-(event, channel) delivery state W11.3 +-- drains and flips. Mirrors the operator_actions(immutable)/notifications +-- (mutable) split: a delivery failure never rolls back the event or label. +-- * `automation_state` — the singleton ingestion kill-switch (id = 1, +-- mirroring `signing_state`), checked by the discovery consumer. +-- +-- The `dead_letters` ALTERs (forward-only, additive) give operators retry / +-- quarantine controls without recreating the table. +-- +-- Timestamp columns that queries order on gain an integer `*_epoch_ms` +-- sibling, matching 0003/0004 (RFC 3339 strings compare incorrectly across +-- timezone offsets in SQL). + +CREATE TABLE operational_events ( + id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + severity TEXT NOT NULL CHECK (severity IN ('critical', 'high', 'info')), + action_id TEXT REFERENCES operator_actions(id), + subject_uri TEXT, + label_value TEXT, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + created_at_epoch_ms INTEGER NOT NULL +); + +CREATE TRIGGER operational_events_immutable_update +BEFORE UPDATE ON operational_events +BEGIN + SELECT RAISE(ABORT, 'operational events are immutable'); +END; + +CREATE TRIGGER operational_events_immutable_delete +BEFORE DELETE ON operational_events +BEGIN + SELECT RAISE(ABORT, 'operational events are immutable'); +END; + +CREATE INDEX idx_operational_events_created ON operational_events(created_at_epoch_ms DESC); +CREATE INDEX idx_operational_events_severity + ON operational_events(severity, created_at_epoch_ms DESC); +CREATE INDEX idx_operational_events_action ON operational_events(action_id) + WHERE action_id IS NOT NULL; + +CREATE TABLE notification_outbox ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL REFERENCES operational_events(id), + channel TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'sent', 'failed')), + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + created_at TEXT NOT NULL, + created_at_epoch_ms INTEGER NOT NULL, + sent_at TEXT +); + +CREATE INDEX idx_notification_outbox_pending + ON notification_outbox(state, created_at_epoch_ms) WHERE state = 'pending'; +CREATE INDEX idx_notification_outbox_event ON notification_outbox(event_id); + +CREATE TABLE automation_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + paused INTEGER NOT NULL DEFAULT 0 CHECK (paused IN (0, 1)), + paused_reason TEXT, + paused_by_action_id TEXT REFERENCES operator_actions(id), + updated_at TEXT NOT NULL, + updated_at_epoch_ms INTEGER NOT NULL +); + +INSERT INTO automation_state (id, paused, updated_at, updated_at_epoch_ms) + VALUES (1, 0, '1970-01-01T00:00:00.000Z', 0); + +ALTER TABLE dead_letters ADD COLUMN status TEXT NOT NULL DEFAULT 'new' + CHECK (status IN ('new', 'retried', 'quarantined')); +ALTER TABLE dead_letters ADD COLUMN resolved_at TEXT; +ALTER TABLE dead_letters ADD COLUMN resolved_by_action_id TEXT REFERENCES operator_actions(id); + +CREATE INDEX idx_dead_letters_status ON dead_letters(status, received_at); +CREATE INDEX idx_dead_letters_resolved_action ON dead_letters(resolved_by_action_id) + WHERE resolved_by_action_id IS NOT NULL; diff --git a/apps/labeler/src/automation-state.ts b/apps/labeler/src/automation-state.ts new file mode 100644 index 0000000000..c57438e2b3 --- /dev/null +++ b/apps/labeler/src/automation-state.ts @@ -0,0 +1,65 @@ +/** + * Global automation kill-switch (spec §11.3). A single `automation_state` row + * (`id = 1`, seeded by migration 0005) gates the discovery consumer's ingestion + * path; manual/admin issuance is never gated by it, so an emergency `!takedown` + * stays issuable during an incident. + */ + +/** + * Raised when the pause flag cannot be read (missing singleton row or a D1 + * error). The discovery consumer maps this to retry — automation must never + * issue past an unreadable switch, so the read fails closed. + */ +export class AutomationStateUnavailableError extends Error { + override readonly name = "AutomationStateUnavailableError"; +} + +export interface AutomationPauseUpdate { + paused: boolean; + reason: string | null; + actionId: string; + now: Date; +} + +/** + * Reads the kill-switch. Fails closed: a missing singleton row or a read error + * throws rather than reporting "not paused", so the ingestion path can only + * proceed on a positive, successful read of `paused = 0`. + */ +export async function isAutomationPaused(db: D1Database): Promise { + let row: { paused: number } | null; + try { + row = await db + .prepare(`SELECT paused FROM automation_state WHERE id = 1`) + .first<{ paused: number }>(); + } catch (error) { + throw new AutomationStateUnavailableError("automation_state is unreadable", { cause: error }); + } + if (!row) throw new AutomationStateUnavailableError("automation_state singleton row is missing"); + return row.paused === 1; +} + +/** + * Effect statement for a pause or resume. Idempotent — pausing an already-paused + * switch is a harmless re-write. Batched with the operator_actions row (and, for + * a pause, an `automation-paused` operational event) by `commitMutation`. + */ +export function buildAutomationPauseUpdate( + db: D1Database, + input: AutomationPauseUpdate, +): D1PreparedStatement { + return db + .prepare( + `UPDATE automation_state + SET paused = ?, paused_reason = ?, paused_by_action_id = ?, + updated_at = ?, updated_at_epoch_ms = ? + WHERE id = 1`, + ) + .bind( + input.paused ? 1 : 0, + input.reason, + input.actionId, + input.now.toISOString(), + input.now.getTime(), + ); +} diff --git a/apps/labeler/src/discovery-consumer.ts b/apps/labeler/src/discovery-consumer.ts index 1b07637e80..5a14d0124f 100644 --- a/apps/labeler/src/discovery-consumer.ts +++ b/apps/labeler/src/discovery-consumer.ts @@ -45,6 +45,7 @@ import { transitionAssessmentState, type Assessment, } from "./assessment-store.js"; +import { AutomationStateUnavailableError, isAutomationPaused } from "./automation-state.js"; import { getLabelerIdentityConfig, type LabelerConfig } from "./config.js"; import type { DiscoveryJob } from "./env.js"; import { @@ -223,6 +224,13 @@ export async function processDiscoveryMessage( } try { + if (await readAutomationPaused(deps.db)) { + // Ingestion is paused (spec §11.3): retry so the event isn't lost — + // resume re-drives it. Manual/admin issuance stays available because + // the switch is checked here, not in the signing layer. + controller.retry(); + return; + } await verifyAndCreateRun(uri, job, deps, now()); controller.ack(); } catch (err) { @@ -230,6 +238,22 @@ export async function processDiscoveryMessage( } } +/** + * Reads the automation kill-switch, failing closed: an unreadable switch + * becomes a `LabelIssuanceUnavailableError` so `classifyDiscoveryError` retries + * rather than letting ingestion issue past a switch it could not read. + */ +async function readAutomationPaused(db: D1Database): Promise { + try { + return await isAutomationPaused(db); + } catch (err) { + if (err instanceof AutomationStateUnavailableError) { + throw new LabelIssuanceUnavailableError("automation pause state unreadable", { cause: err }); + } + throw err; + } +} + /** * One classification for both the create and delete paths so they can't * diverge (spec §9.1): a transient failure retries; a permanent diff --git a/apps/labeler/src/operational-events.ts b/apps/labeler/src/operational-events.ts new file mode 100644 index 0000000000..08dcd834b5 --- /dev/null +++ b/apps/labeler/src/operational-events.ts @@ -0,0 +1,267 @@ +import { ulid } from "ulidx"; + +/** + * The operational-alert vocabulary, grown by W9.6. Validated in app code (the + * mutation handlers are the only writers), so the `operational_events.event_type` + * column carries no SQL CHECK and each workstream stays purely additive. + */ +export type OperationalEventType = + | "emergency-takedown" + | "publisher-compromised" + | "automation-paused" + | "automation-resumed" + | "dead-letter-retried" + | "dead-letter-quarantined"; + +export type OperationalEventSeverity = "critical" | "high" | "info"; + +/** + * The alert deliverable's public-safe body (spec §18.2): subject URI and label + * value live in dedicated columns; this carries the operator reason only. It has + * no field for findings, private detail, or evidence refs — the type is the + * enforcement, so an event can never smuggle exploit detail into an outbound + * notification. + */ +export interface OperationalEventPayload { + reason?: string; +} + +export interface OperationalEventInsert { + id: string; + eventType: OperationalEventType; + severity: OperationalEventSeverity; + actionId?: string | null; + subjectUri?: string | null; + labelValue?: string | null; + payload: OperationalEventPayload; + now: Date; + /** + * When set, the INSERT becomes an `INSERT ... SELECT ... WHERE EXISTS` gated + * on an `issued_labels` row carrying this `action_id`. Batched after the + * label's issuance statements, the EXISTS sees the just-inserted label; if + * that label was suppressed in-batch (a signing-state race), the event does + * not insert either — so no alert fires for a label that never landed. + */ + gateOnIssuedLabelActionId?: number; +} + +export interface OutboxInsert { + eventId: string; + channel: string; + now: Date; + /** Same in-batch label gating as {@link OperationalEventInsert}. */ + gateOnIssuedLabelActionId?: number; +} + +export interface StoredOperationalEvent { + id: string; + eventType: string; + severity: string; + actionId: string | null; + subjectUri: string | null; + labelValue: string | null; + payloadJson: string; + createdAt: string; + createdAtEpochMs: number; +} + +export interface StoredOutboxEntry { + id: string; + eventId: string; + channel: string; + state: string; + attempts: number; + lastError: string | null; + createdAt: string; + createdAtEpochMs: number; + sentAt: string | null; +} + +interface OperationalEventRow { + id: string; + event_type: string; + severity: string; + action_id: string | null; + subject_uri: string | null; + label_value: string | null; + payload_json: string; + created_at: string; + created_at_epoch_ms: number; +} + +interface OutboxRow { + id: string; + event_id: string; + channel: string; + state: string; + attempts: number; + last_error: string | null; + created_at: string; + created_at_epoch_ms: number; + sent_at: string | null; +} + +export function newOperationalEventId(): string { + return `oev_${ulid()}`; +} + +export function newNotificationOutboxId(): string { + return `nob_${ulid()}`; +} + +/** + * Insert for one operational event. Batched with the operator_actions row + the + * effect statements by `commitMutation`; never run alone for an issuance event. + * When `gateOnIssuedLabelActionId` is set the insert is gated on the label's + * in-batch existence (see {@link OperationalEventInsert}); otherwise it is a + * plain INSERT for events with no issued label (pause/resume, DLQ controls). + */ +export function buildOperationalEventInsert( + db: D1Database, + input: OperationalEventInsert, +): D1PreparedStatement { + const columns = `id, event_type, severity, action_id, subject_uri, label_value, + payload_json, created_at, created_at_epoch_ms`; + const values: (string | number | null)[] = [ + input.id, + input.eventType, + input.severity, + input.actionId ?? null, + input.subjectUri ?? null, + input.labelValue ?? null, + JSON.stringify(input.payload), + input.now.toISOString(), + input.now.getTime(), + ]; + + if (input.gateOnIssuedLabelActionId !== undefined) { + return db + .prepare( + `INSERT INTO operational_events (${columns}) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ? + WHERE EXISTS (SELECT 1 FROM issued_labels WHERE action_id = ?)`, + ) + .bind(...values, input.gateOnIssuedLabelActionId); + } + + return db + .prepare( + `INSERT INTO operational_events (${columns}) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind(...values); +} + +/** + * Insert for one delivery-queue row (one per event + channel). Gated identically + * to {@link buildOperationalEventInsert}: a signing-suppressed batch queues no + * notification. `state` and `attempts` fall to their column defaults. + */ +export function buildOutboxInsert(db: D1Database, input: OutboxInsert): D1PreparedStatement { + const values: (string | number)[] = [ + newNotificationOutboxId(), + input.eventId, + input.channel, + input.now.toISOString(), + input.now.getTime(), + ]; + + if (input.gateOnIssuedLabelActionId !== undefined) { + return db + .prepare( + `INSERT INTO notification_outbox (id, event_id, channel, created_at, created_at_epoch_ms) + SELECT ?, ?, ?, ?, ? + WHERE EXISTS (SELECT 1 FROM issued_labels WHERE action_id = ?)`, + ) + .bind(...values, input.gateOnIssuedLabelActionId); + } + + return db + .prepare( + `INSERT INTO notification_outbox (id, event_id, channel, created_at, created_at_epoch_ms) + VALUES (?, ?, ?, ?, ?)`, + ) + .bind(...values); +} + +/** Operational-event page, newest first over `idx_operational_events_created`. */ +export async function getOperationalEvents( + db: D1Database, + options: { limit: number }, +): Promise { + const rows = await db + .prepare( + `SELECT id, event_type, severity, action_id, subject_uri, label_value, + payload_json, created_at, created_at_epoch_ms + FROM operational_events + ORDER BY created_at_epoch_ms DESC, id DESC + LIMIT ?`, + ) + .bind(options.limit) + .all(); + return (rows.results ?? []).map(rowToStoredEvent); +} + +/** Events emitted by a single operator action, newest first. */ +export async function getOperationalEventsByActionId( + db: D1Database, + actionId: string, +): Promise { + const rows = await db + .prepare( + `SELECT id, event_type, severity, action_id, subject_uri, label_value, + payload_json, created_at, created_at_epoch_ms + FROM operational_events + WHERE action_id = ? + ORDER BY created_at_epoch_ms DESC, id DESC`, + ) + .bind(actionId) + .all(); + return (rows.results ?? []).map(rowToStoredEvent); +} + +/** Outbox rows for one event, oldest first. */ +export async function getOutboxForEvent( + db: D1Database, + eventId: string, +): Promise { + const rows = await db + .prepare( + `SELECT id, event_id, channel, state, attempts, last_error, + created_at, created_at_epoch_ms, sent_at + FROM notification_outbox + WHERE event_id = ? + ORDER BY created_at_epoch_ms ASC, id ASC`, + ) + .bind(eventId) + .all(); + return (rows.results ?? []).map(rowToStoredOutbox); +} + +function rowToStoredEvent(row: OperationalEventRow): StoredOperationalEvent { + return { + id: row.id, + eventType: row.event_type, + severity: row.severity, + actionId: row.action_id, + subjectUri: row.subject_uri, + labelValue: row.label_value, + payloadJson: row.payload_json, + createdAt: row.created_at, + createdAtEpochMs: row.created_at_epoch_ms, + }; +} + +function rowToStoredOutbox(row: OutboxRow): StoredOutboxEntry { + return { + id: row.id, + eventId: row.event_id, + channel: row.channel, + state: row.state, + attempts: row.attempts, + lastError: row.last_error, + createdAt: row.created_at, + createdAtEpochMs: row.created_at_epoch_ms, + sentAt: row.sent_at, + }; +} diff --git a/apps/labeler/test/automation-state.test.ts b/apps/labeler/test/automation-state.test.ts new file mode 100644 index 0000000000..d676dae782 --- /dev/null +++ b/apps/labeler/test/automation-state.test.ts @@ -0,0 +1,110 @@ +import { applyD1Migrations, env } from "cloudflare:test"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { + AutomationStateUnavailableError, + buildAutomationPauseUpdate, + isAutomationPaused, +} from "../src/automation-state.js"; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; +} + +const testEnv = env as unknown as TestEnv; +const NOW = new Date("2026-07-13T00:00:00.000Z"); +const ACTION_ID = "oact_automationtest"; + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); + await insertOperatorAction(ACTION_ID); +}); + +afterEach(async () => { + await buildAutomationPauseUpdate(testEnv.DB, { + paused: false, + reason: null, + actionId: ACTION_ID, + now: NOW, + }).run(); +}); + +/** Seeds an `operator_actions` row so pause/resume FK references resolve. */ +async function insertOperatorAction(id: string): Promise { + await testEnv.DB.prepare( + `INSERT INTO operator_actions + (id, actor_type, actor_id, role, action, reason, idempotency_key, + request_fingerprint, created_at, created_at_epoch_ms) + VALUES (?, 'human', 'u', 'admin', 'pause-issuance', 'r', ?, 'fp', + '2026-07-13T00:00:00.000Z', 0)`, + ) + .bind(id, `key-${id}`) + .run(); +} + +describe("isAutomationPaused", () => { + it("reads false for the seeded (unpaused) singleton row", async () => { + expect(await isAutomationPaused(testEnv.DB)).toBe(false); + }); + + it("round-trips a pause and a resume through the update builder", async () => { + await buildAutomationPauseUpdate(testEnv.DB, { + paused: true, + reason: "incident-42", + actionId: ACTION_ID, + now: NOW, + }).run(); + expect(await isAutomationPaused(testEnv.DB)).toBe(true); + + const paused = await testEnv.DB.prepare( + `SELECT paused, paused_reason, paused_by_action_id FROM automation_state WHERE id = 1`, + ).first<{ paused: number; paused_reason: string; paused_by_action_id: string }>(); + expect(paused).toEqual({ + paused: 1, + paused_reason: "incident-42", + paused_by_action_id: ACTION_ID, + }); + + await buildAutomationPauseUpdate(testEnv.DB, { + paused: false, + reason: null, + actionId: ACTION_ID, + now: NOW, + }).run(); + expect(await isAutomationPaused(testEnv.DB)).toBe(false); + }); + + it("fails closed when the singleton row is missing", async () => { + await testEnv.DB.prepare(`DELETE FROM automation_state WHERE id = 1`).run(); + try { + await expect(isAutomationPaused(testEnv.DB)).rejects.toBeInstanceOf( + AutomationStateUnavailableError, + ); + } finally { + await testEnv.DB.prepare( + `INSERT INTO automation_state (id, paused, updated_at, updated_at_epoch_ms) + VALUES (1, 0, '1970-01-01T00:00:00.000Z', 0)`, + ).run(); + } + }); + + it("fails closed when the read throws", async () => { + const brokenDb = { + prepare() { + return { + bind() { + return this; + }, + first(): Promise { + return Promise.reject(new Error("D1_ERROR: connection lost")); + }, + }; + }, + } as unknown as D1Database; + + await expect(isAutomationPaused(brokenDb)).rejects.toBeInstanceOf( + AutomationStateUnavailableError, + ); + }); +}); diff --git a/apps/labeler/test/discovery-consumer.test.ts b/apps/labeler/test/discovery-consumer.test.ts index af9356e695..e0e140e7d7 100644 --- a/apps/labeler/test/discovery-consumer.test.ts +++ b/apps/labeler/test/discovery-consumer.test.ts @@ -6,7 +6,7 @@ import { type LabelSigner, } from "@emdash-cms/registry-moderation"; import { applyD1Migrations, env } from "cloudflare:test"; -import { beforeAll, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { automatedIdempotencyKey, @@ -14,6 +14,7 @@ import { initialTriggerId, } from "../src/assessment-lifecycle.js"; import { getAssessmentByRunKey, getCurrentAssessment } from "../src/assessment-store.js"; +import { buildAutomationPauseUpdate } from "../src/automation-state.js"; import { type DiscoveryConsumerDeps, type MessageController, @@ -569,3 +570,116 @@ describe("getCurrentAssessment", () => { expect(pointer).toBeNull(); }); }); + +describe("processDiscoveryMessage: automation kill-switch", () => { + const NOW = new Date("2026-07-13T00:00:00.000Z"); + const ACTION_ID = "oact_pausetest"; + + beforeAll(async () => { + await testEnv.DB.prepare( + `INSERT INTO operator_actions + (id, actor_type, actor_id, role, action, reason, idempotency_key, + request_fingerprint, created_at, created_at_epoch_ms) + VALUES (?, 'human', 'u', 'admin', 'pause-issuance', 'r', ?, 'fp', + '2026-07-13T00:00:00.000Z', 0)`, + ) + .bind(ACTION_ID, `key-${ACTION_ID}`) + .run(); + }); + + async function setPaused(paused: boolean): Promise { + await buildAutomationPauseUpdate(testEnv.DB, { + paused, + reason: paused ? "incident" : null, + actionId: ACTION_ID, + now: NOW, + }).run(); + } + + afterEach(async () => { + await setPaused(false); + }); + + it("retries and creates no run while ingestion is paused", async () => { + const job = await jobFor({ rkey: rkey() }); + const deps = await buildDeps(); + const msg = new FakeMessage(); + await setPaused(true); + + await processDiscoveryMessage(job, msg, { ...deps, verify: verifiedFor(job) }); + + expect(msg.retried).toBe(1); + expect(msg.acked).toBe(0); + const subject = await testEnv.DB.prepare(`SELECT COUNT(*) AS n FROM subjects WHERE uri = ?`) + .bind(uriFor(job)) + .first<{ n: number }>(); + expect(subject?.n).toBe(0); + }); + + it("processes normally once resumed", async () => { + const job = await jobFor({ rkey: rkey() }); + const deps = await buildDeps(); + + await setPaused(true); + const first = new FakeMessage(); + await processDiscoveryMessage(job, first, { ...deps, verify: verifiedFor(job) }); + expect(first.retried).toBe(1); + + await setPaused(false); + const second = new FakeMessage(); + await processDiscoveryMessage(job, second, { ...deps, verify: verifiedFor(job) }); + + expect(second.acked).toBe(1); + expect(second.retried).toBe(0); + const assessment = await getAssessmentByRunKey(testEnv.DB, await runKeyFor(job)); + expect(assessment?.state).toBe("pending"); + }); + + it("retries when the switch is unreadable (fails closed)", async () => { + const job = await jobFor({ rkey: rkey() }); + const deps = await buildDeps(); + const msg = new FakeMessage(); + await testEnv.DB.prepare(`DELETE FROM automation_state WHERE id = 1`).run(); + + try { + await processDiscoveryMessage(job, msg, { ...deps, verify: verifiedFor(job) }); + expect(msg.retried).toBe(1); + expect(msg.acked).toBe(0); + const subject = await testEnv.DB.prepare(`SELECT COUNT(*) AS n FROM subjects WHERE uri = ?`) + .bind(uriFor(job)) + .first<{ n: number }>(); + expect(subject?.n).toBe(0); + } finally { + await testEnv.DB.prepare( + `INSERT INTO automation_state (id, paused, updated_at, updated_at_epoch_ms) + VALUES (1, 0, '1970-01-01T00:00:00.000Z', 0)`, + ).run(); + } + }); + + it("does not gate the delete branch while ingestion is paused", async () => { + const job = await jobFor({ rkey: rkey() }); + const deps = await buildDeps(); + await processDiscoveryMessage(job, new FakeMessage(), { ...deps, verify: verifiedFor(job) }); + + await setPaused(true); + const deleteJob: DiscoveryJob = { ...job, operation: "delete", cid: "" }; + const msg = new FakeMessage(); + await processDiscoveryMessage(deleteJob, msg, { + ...deps, + confirmDeleted: () => Promise.resolve(true), + }); + + expect(msg.acked).toBe(1); + expect(msg.retried).toBe(0); + const after = await getAssessmentByRunKey(testEnv.DB, await runKeyFor(job)); + expect(after?.state).toBe("cancelled"); + const negated = await testEnv.DB.prepare( + `SELECT neg FROM issued_labels + WHERE uri = ? AND val = 'assessment-pending' ORDER BY sequence DESC LIMIT 1`, + ) + .bind(uriFor(job)) + .first<{ neg: number }>(); + expect(negated?.neg).toBe(1); + }); +}); diff --git a/apps/labeler/test/operational-events.test.ts b/apps/labeler/test/operational-events.test.ts new file mode 100644 index 0000000000..560dc080a1 --- /dev/null +++ b/apps/labeler/test/operational-events.test.ts @@ -0,0 +1,282 @@ +import { applyD1Migrations, env } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { + buildOperationalEventInsert, + buildOutboxInsert, + getOperationalEvents, + getOperationalEventsByActionId, + getOutboxForEvent, + newOperationalEventId, + type OperationalEventInsert, +} from "../src/operational-events.js"; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; +} + +const testEnv = env as unknown as TestEnv; + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); +}); + +let counter = 0; + +function eventInput(overrides: Partial = {}): OperationalEventInsert { + counter++; + return { + id: newOperationalEventId(), + eventType: "emergency-takedown", + severity: "critical", + actionId: null, + subjectUri: `did:plc:publisher${counter}`, + labelValue: "!takedown", + payload: { reason: `incident ${counter}` }, + now: new Date("2026-07-13T00:00:00.000Z"), + ...overrides, + }; +} + +/** Seeds an `operator_actions` row so an event's `action_id` FK resolves. */ +async function insertOperatorAction(id: string): Promise { + await testEnv.DB.prepare( + `INSERT INTO operator_actions + (id, actor_type, actor_id, role, action, reason, idempotency_key, + request_fingerprint, created_at, created_at_epoch_ms) + VALUES (?, 'human', 'u', 'admin', 'takedown', 'r', ?, 'fp', + '2026-07-13T00:00:00.000Z', 0)`, + ) + .bind(id, `key-${id}`) + .run(); +} + +/** + * Inserts an `issued_labels` row (via its `issuance_actions` parent) carrying + * the given `action_id`, so the gated builders' `WHERE EXISTS` sees a label. + */ +async function insertIssuedLabel(actionId: number): Promise { + await testEnv.DB.prepare( + `INSERT INTO issuance_actions (id, actor, type, reason, idempotency_key, created_at) + VALUES (?, ?, 'manual-label', 'takedown', ?, '2026-07-13T00:00:00.000Z')`, + ) + .bind(actionId, "did:web:labels.emdashcms.com", `gate-key-${actionId}`) + .run(); + await testEnv.DB.prepare( + `INSERT INTO issued_labels (action_id, ver, src, uri, val, cts, sig, signing_key_id) + VALUES (?, 1, 'did:web:labels.emdashcms.com', 'did:plc:sub', '!takedown', + '2026-07-13T00:00:00.000Z', X'00', 'did:web:labels.emdashcms.com#atproto_label')`, + ) + .bind(actionId) + .run(); +} + +describe("operational_events store", () => { + it("inserts and reads back an event newest-first", async () => { + const input = eventInput({ severity: "high", eventType: "automation-paused" }); + await buildOperationalEventInsert(testEnv.DB, input).run(); + + const [stored] = await getOperationalEvents(testEnv.DB, { limit: 1 }); + expect(stored).toMatchObject({ + id: input.id, + eventType: "automation-paused", + severity: "high", + subjectUri: input.subjectUri, + labelValue: "!takedown", + payloadJson: JSON.stringify(input.payload), + }); + }); + + it("stores a null action_id, subject, and label for a system event", async () => { + const input = eventInput({ + eventType: "automation-resumed", + severity: "info", + actionId: null, + subjectUri: null, + labelValue: null, + payload: {}, + }); + await buildOperationalEventInsert(testEnv.DB, input).run(); + + const [stored] = await getOperationalEvents(testEnv.DB, { limit: 1 }); + expect(stored).toMatchObject({ + actionId: null, + subjectUri: null, + labelValue: null, + payloadJson: "{}", + }); + }); + + it("reads events by action id", async () => { + const actionId = `oact_events${counter}`; + const otherId = `oact_other${counter}`; + await insertOperatorAction(actionId); + await insertOperatorAction(otherId); + await buildOperationalEventInsert(testEnv.DB, eventInput({ actionId })).run(); + await buildOperationalEventInsert(testEnv.DB, eventInput({ actionId })).run(); + await buildOperationalEventInsert(testEnv.DB, eventInput({ actionId: otherId })).run(); + + const rows = await getOperationalEventsByActionId(testEnv.DB, actionId); + expect(rows).toHaveLength(2); + expect(rows.every((r) => r.actionId === actionId)).toBe(true); + }); + + it("rejects UPDATE on a recorded event (immutable log)", async () => { + const input = eventInput(); + await buildOperationalEventInsert(testEnv.DB, input).run(); + + await expect( + testEnv.DB.prepare("UPDATE operational_events SET severity = 'info' WHERE id = ?") + .bind(input.id) + .run(), + ).rejects.toThrow(/immutable/); + }); + + it("rejects DELETE on a recorded event (immutable log)", async () => { + const input = eventInput(); + await buildOperationalEventInsert(testEnv.DB, input).run(); + + await expect( + testEnv.DB.prepare("DELETE FROM operational_events WHERE id = ?").bind(input.id).run(), + ).rejects.toThrow(/immutable/); + }); +}); + +describe("label-gated event + outbox inserts", () => { + it("inserts the event and outbox row when the gated label exists", async () => { + const gateActionId = 8001; + await insertIssuedLabel(gateActionId); + + const input = eventInput({ gateOnIssuedLabelActionId: gateActionId }); + await buildOperationalEventInsert(testEnv.DB, input).run(); + await buildOutboxInsert(testEnv.DB, { + eventId: input.id, + channel: "deployment-alert", + now: input.now, + gateOnIssuedLabelActionId: gateActionId, + }).run(); + + const eventCount = await testEnv.DB.prepare( + `SELECT COUNT(*) AS n FROM operational_events WHERE id = ?`, + ) + .bind(input.id) + .first<{ n: number }>(); + expect(eventCount?.n).toBe(1); + + const outbox = await getOutboxForEvent(testEnv.DB, input.id); + expect(outbox).toHaveLength(1); + expect(outbox[0]).toMatchObject({ + eventId: input.id, + channel: "deployment-alert", + state: "pending", + attempts: 0, + }); + }); + + it("inserts neither event nor outbox row when the gated label is absent", async () => { + const gateActionId = 9999; // no issued_labels row references this + const input = eventInput({ gateOnIssuedLabelActionId: gateActionId }); + + await buildOperationalEventInsert(testEnv.DB, input).run(); + await buildOutboxInsert(testEnv.DB, { + eventId: input.id, + channel: "deployment-alert", + now: input.now, + gateOnIssuedLabelActionId: gateActionId, + }).run(); + + const eventCount = await testEnv.DB.prepare( + `SELECT COUNT(*) AS n FROM operational_events WHERE id = ?`, + ) + .bind(input.id) + .first<{ n: number }>(); + expect(eventCount?.n).toBe(0); + + const outbox = await getOutboxForEvent(testEnv.DB, input.id); + expect(outbox).toHaveLength(0); + }); + + it("sees a label inserted earlier in the same db.batch", async () => { + const gateActionId = 8100; + // Parent issuance_actions row is committed first; the issued_labels insert + // itself rides in the same batch as the gated event + outbox, so the + // EXISTS must observe an in-batch (uncommitted) sibling insert. + await testEnv.DB.prepare( + `INSERT INTO issuance_actions (id, actor, type, reason, idempotency_key, created_at) + VALUES (?, ?, 'manual-label', 'takedown', ?, '2026-07-13T00:00:00.000Z')`, + ) + .bind(gateActionId, "did:web:labels.emdashcms.com", `batch-key-${gateActionId}`) + .run(); + + const input = eventInput({ gateOnIssuedLabelActionId: gateActionId }); + await testEnv.DB.batch([ + testEnv.DB.prepare( + `INSERT INTO issued_labels (action_id, ver, src, uri, val, cts, sig, signing_key_id) + VALUES (?, 1, 'did:web:labels.emdashcms.com', 'did:plc:sub', '!takedown', + '2026-07-13T00:00:00.000Z', X'00', 'did:web:labels.emdashcms.com#atproto_label')`, + ).bind(gateActionId), + buildOperationalEventInsert(testEnv.DB, input), + buildOutboxInsert(testEnv.DB, { + eventId: input.id, + channel: "deployment-alert", + now: input.now, + gateOnIssuedLabelActionId: gateActionId, + }), + ]); + + const eventCount = await testEnv.DB.prepare( + `SELECT COUNT(*) AS n FROM operational_events WHERE id = ?`, + ) + .bind(input.id) + .first<{ n: number }>(); + expect(eventCount?.n).toBe(1); + expect(await getOutboxForEvent(testEnv.DB, input.id)).toHaveLength(1); + }); + + it("inserts neither row when the label is suppressed within the same db.batch", async () => { + const gateActionId = 8200; // no issued_labels insert rides in the batch + const input = eventInput({ gateOnIssuedLabelActionId: gateActionId }); + await testEnv.DB.batch([ + buildOperationalEventInsert(testEnv.DB, input), + buildOutboxInsert(testEnv.DB, { + eventId: input.id, + channel: "deployment-alert", + now: input.now, + gateOnIssuedLabelActionId: gateActionId, + }), + ]); + + const eventCount = await testEnv.DB.prepare( + `SELECT COUNT(*) AS n FROM operational_events WHERE id = ?`, + ) + .bind(input.id) + .first<{ n: number }>(); + expect(eventCount?.n).toBe(0); + expect(await getOutboxForEvent(testEnv.DB, input.id)).toHaveLength(0); + }); +}); + +describe("dead_letters operational columns (0005)", () => { + it("defaults status to 'new' with null resolution columns", async () => { + await testEnv.DB.prepare( + `INSERT INTO dead_letters (did, collection, rkey, reason, payload, received_at) + VALUES ('did:plc:x', 'com.example', 'r1', 'UNEXPECTED_ERROR', X'00', '2026-07-13T00:00:00.000Z')`, + ).run(); + + const row = await testEnv.DB.prepare( + `SELECT status, resolved_at, resolved_by_action_id FROM dead_letters + WHERE reason = 'UNEXPECTED_ERROR' AND rkey = 'r1'`, + ).first<{ status: string; resolved_at: string | null; resolved_by_action_id: string | null }>(); + expect(row).toEqual({ status: "new", resolved_at: null, resolved_by_action_id: null }); + }); + + it("rejects an out-of-domain status", async () => { + await expect( + testEnv.DB.prepare( + `INSERT INTO dead_letters (did, collection, rkey, reason, payload, received_at, status) + VALUES ('did:plc:x', 'com.example', 'r2', 'UNEXPECTED_ERROR', X'00', '2026-07-13T00:00:00.000Z', 'bogus')`, + ).run(), + ).rejects.toThrow(); + }); +}); From 19dea735124fb5e412613d13592c4b105aa4c540 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 13 Jul 2026 22:56:34 +0100 Subject: [PATCH 056/137] feat(labeler): reviewer rerun and false-positive override (W9.5) (#2023) * feat(labeler): reviewer rerun and false-positive override (W9.5) Rerun mints an operator trigger and re-pends the release. Override negates all live automated blocks and issues assessment-passed plus assessment-overridden as one atomic batch under a single audit row; override-retract negates only the eligibility pair, leaving blocks negated for a safe-blocked resting state. Adds the subject+CID label read and surfaces manual/override labels in the console. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM * fix(labeler): derive console block vocabulary from the policy fixture The console mirrored the automated-block set as a hand-maintained literal, so a future policy grant would silently break the override dialog (stale set -> server 400). Derive it with the server's own automatedBlockCategories(MODERATION_POLICY) instead. Also closes the reviewer-noted test gaps: rerun mid-commit suppression 503s on both paths without creating a run, and override-retract rejects a wrong confirmation and 409s idempotency-key reuse. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM * fix(labeler): align the console's override offer with the negatable set The subject-label read now carries the stream head's provenance (automated vs manual), and the console only counts automated heads as override-negatable. A manually-headed block-category label previously showed the override button and an eligible preview that the server then rejected with 400; its unblock path is the label retract. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM * fix(labeler): validate the override preview against the live negatable set The preview overlaid whatever negate set the caller supplied, so a direct API call could render an eligible outcome the submit endpoint would reject with 400. The preview now runs the same assertNegatableBlockSet check as submit and 400s on mismatch. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- apps/labeler/console/src/api/client.test.ts | 85 ++ apps/labeler/console/src/api/client.ts | 98 +- apps/labeler/console/src/api/types.ts | 67 ++ .../src/components/AssessmentActionDialog.tsx | 139 +++ .../console/src/components/OverrideDialog.tsx | 191 ++++ apps/labeler/console/src/labels.ts | 13 + .../console/src/routes/AssessmentDetail.tsx | 111 ++- apps/labeler/src/assessment-store.ts | 92 +- apps/labeler/src/console-api.ts | 87 +- apps/labeler/src/console-mutation-api.ts | 460 ++++++++- apps/labeler/src/console-serialize.ts | 28 + apps/labeler/src/label-effect-preview.ts | 107 +++ apps/labeler/src/service.ts | 151 +++ .../test/console-assessment-mutations.test.ts | 890 ++++++++++++++++++ 14 files changed, 2470 insertions(+), 49 deletions(-) create mode 100644 apps/labeler/console/src/components/AssessmentActionDialog.tsx create mode 100644 apps/labeler/console/src/components/OverrideDialog.tsx create mode 100644 apps/labeler/test/console-assessment-mutations.test.ts diff --git a/apps/labeler/console/src/api/client.test.ts b/apps/labeler/console/src/api/client.test.ts index 468b9b68c8..a08ac338e2 100644 --- a/apps/labeler/console/src/api/client.test.ts +++ b/apps/labeler/console/src/api/client.test.ts @@ -141,4 +141,89 @@ describe("fetch client label actions", () => { expect(url).toContain("cid=bafy"); expect(url).toContain("neg=true"); }); + + it("POSTs a rerun with the CSRF header, JSON content type, and threaded key", async () => { + stubFetch(() => Response.json({ data: { actionId: "oact_1", runId: "asmt_1" } })); + const action = { + confirmation: "bafy", + reason: "reassess", + idempotencyKey: input.idempotencyKey, + }; + const result = await fetchClient.rerunAssessment("asmt_target", action); + expect(result).toMatchObject({ actionId: "oact_1", runId: "asmt_1" }); + const call = calls[0]!; + expect(call.url).toBe("/admin/api/assessments/asmt_target/rerun"); + expect(call.init.method).toBe("POST"); + const headers = new Headers(call.init.headers); + expect(headers.get("X-EmDash-Request")).toBe("1"); + expect(headers.get("Content-Type")).toBe("application/json"); + expect(JSON.parse(call.init.body as string)).toMatchObject(action); + }); + + it("POSTs an override to the override route with the negate set", async () => { + stubFetch(() => Response.json({ data: { actionId: "oact_1", negated: ["malware"] } })); + await fetchClient.overrideAssessment("asmt_target", { + confirmation: "bafy", + reason: "false positive", + idempotencyKey: input.idempotencyKey, + negate: ["malware", "data-exfiltration"], + }); + expect(calls[0]!.url).toBe("/admin/api/assessments/asmt_target/override"); + expect(JSON.parse(calls[0]!.init.body as string).negate).toEqual([ + "malware", + "data-exfiltration", + ]); + }); + + it("POSTs an override-retract to the override-retract route", async () => { + stubFetch(() => Response.json({ data: { actionId: "oact_1" } })); + await fetchClient.retractOverride("asmt_target", { + confirmation: "bafy", + reason: "override was wrong", + idempotencyKey: input.idempotencyKey, + }); + expect(calls[0]!.url).toBe("/admin/api/assessments/asmt_target/override-retract"); + expect(calls[0]!.init.method).toBe("POST"); + }); + + it("GETs subject labels with the CID", async () => { + stubFetch(() => Response.json({ data: [{ val: "assessment-overridden", active: true }] })); + const labels = await fetchClient.getSubjectLabels(input.uri, "bafy"); + expect(labels).toEqual([{ val: "assessment-overridden", active: true }]); + const url = calls[0]!.url; + expect(url).toContain(`/admin/api/subjects/${encodeURIComponent(input.uri)}/labels?`); + expect(url).toContain("cid=bafy"); + }); + + it("builds the override-effect-preview query with repeated negate params", async () => { + stubFetch(() => + Response.json({ data: { labelEffect: "pass", scope: "cid-bound", supersedes: [] } }), + ); + await fetchClient.previewOverrideEffect({ + uri: input.uri, + cid: "bafy", + negate: ["malware", "impersonation"], + }); + const url = calls[0]!.url; + expect(url).toContain("/admin/api/labels/override-effect-preview?"); + expect(url).toContain("negate=malware"); + expect(url).toContain("negate=impersonation"); + }); + + it("surfaces a 409 idempotency conflict message", async () => { + stubFetch(() => + Response.json( + { error: { code: "IDEMPOTENCY_KEY_CONFLICT", message: "key already used" } }, + { status: 409 }, + ), + ); + await expect( + fetchClient.overrideAssessment("asmt_target", { + confirmation: "bafy", + reason: "x", + idempotencyKey: input.idempotencyKey, + negate: ["malware"], + }), + ).rejects.toThrow("key already used"); + }); }); diff --git a/apps/labeler/console/src/api/client.ts b/apps/labeler/console/src/api/client.ts index a42e1fd32f..7be67f5744 100644 --- a/apps/labeler/console/src/api/client.ts +++ b/apps/labeler/console/src/api/client.ts @@ -7,6 +7,7 @@ import { FIXTURE_SYSTEM_STATUS, } from "../fixtures/index.js"; import type { + AssessmentActionInput, AssessmentRun, EffectPreview, EffectPreviewParams, @@ -16,9 +17,15 @@ import type { ListAssessmentsParams, ListAuditLogParams, OperatorAction, + OverrideActionInput, + OverrideEffectPreviewParams, + OverrideResult, + OverrideRetractResult, OperatorFinding, Page, + RerunResult, SubjectHistoryView, + SubjectLabel, SystemStatusSnapshot, WhoamiIdentity, } from "./types.js"; @@ -35,12 +42,17 @@ export interface LabelerConsoleClient { listFindings(assessmentId: string): Promise; listLabels(assessmentId: string): Promise; getSubjectHistory(uri: string): Promise; + getSubjectLabels(uri: string, cid?: string): Promise; listAuditLog(params?: ListAuditLogParams): Promise>; getSystemStatus(): Promise; whoami(): Promise; previewEffect(params: EffectPreviewParams): Promise; + previewOverrideEffect(params: OverrideEffectPreviewParams): Promise; issueLabel(input: LabelActionInput): Promise; retractLabel(input: LabelActionInput): Promise; + rerunAssessment(id: string, input: AssessmentActionInput): Promise; + overrideAssessment(id: string, input: OverrideActionInput): Promise; + retractOverride(id: string, input: AssessmentActionInput): Promise; } interface ApiErrorBody { @@ -63,16 +75,13 @@ function consoleApiFetch(path: string, init?: RequestInit): Promise { /** POST a state-changing label action. `consoleApiFetch` already sets the CSRF * header and same-origin credentials; this adds the JSON content type the * mutation guard requires and threads the client-minted idempotency key. */ -async function postLabelAction( - path: string, - input: LabelActionInput, -): Promise { +async function postAction(path: string, input: unknown, fallback: string): Promise { const response = await consoleApiFetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), }); - return parseJson(response, "Failed to submit label action"); + return parseJson(response, fallback); } async function parseJson(response: Response, fallback: string): Promise { @@ -122,6 +131,15 @@ export function createFetchClient(): LabelerConsoleClient { if (response.status === 404) return null; return parseJson(response, "Failed to load subject history"); }, + async getSubjectLabels(uri, cid) { + const search = new URLSearchParams(); + if (cid) search.set("cid", cid); + const query = search.toString(); + const response = await consoleApiFetch( + `/subjects/${encodeURIComponent(uri)}/labels${query ? `?${query}` : ""}`, + ); + return parseJson(response, "Failed to load subject labels"); + }, async listAuditLog(params = {}) { const search = new URLSearchParams(); if (params.cursor) search.set("cursor", params.cursor); @@ -146,11 +164,42 @@ export function createFetchClient(): LabelerConsoleClient { const response = await consoleApiFetch(`/labels/effect-preview?${search.toString()}`); return parseJson(response, "Failed to preview effect"); }, + async previewOverrideEffect(params) { + const search = new URLSearchParams(); + search.set("uri", params.uri); + search.set("cid", params.cid); + for (const val of params.negate) search.append("negate", val); + const response = await consoleApiFetch( + `/labels/override-effect-preview?${search.toString()}`, + ); + return parseJson(response, "Failed to preview override effect"); + }, async issueLabel(input) { - return postLabelAction("/labels/issue", input); + return postAction("/labels/issue", input, "Failed to submit label action"); }, async retractLabel(input) { - return postLabelAction("/labels/retract", input); + return postAction("/labels/retract", input, "Failed to submit label action"); + }, + async rerunAssessment(id, input) { + return postAction( + `/assessments/${encodeURIComponent(id)}/rerun`, + input, + "Failed to rerun assessment", + ); + }, + async overrideAssessment(id, input) { + return postAction( + `/assessments/${encodeURIComponent(id)}/override`, + input, + "Failed to override assessment", + ); + }, + async retractOverride(id, input) { + return postAction( + `/assessments/${encodeURIComponent(id)}/override-retract`, + input, + "Failed to retract override", + ); }, }; } @@ -178,6 +227,9 @@ export function createFixtureClient(): LabelerConsoleClient { async getSubjectHistory(uri) { return FIXTURE_SUBJECT_HISTORY[uri] ?? null; }, + async getSubjectLabels() { + return []; + }, async listAuditLog() { return { items: [...FIXTURE_OPERATOR_ACTIONS] }; }, @@ -201,6 +253,9 @@ export function createFixtureClient(): LabelerConsoleClient { after: null, }; }, + async previewOverrideEffect() { + return { labelEffect: "pass", scope: "cid-bound", supersedes: [], before: null, after: null }; + }, async issueLabel(input) { return { actionId: "oact_fixture", @@ -223,6 +278,35 @@ export function createFixtureClient(): LabelerConsoleClient { effect: input.val === "security-yanked" ? "block" : "warn", }; }, + async rerunAssessment() { + return { + actionId: "oact_fixture", + runId: "asmt_fixture", + triggerId: "operator:oact_fixture", + uri: "at://did:plc:x/com.emdashcms.experimental.package.release/rk1", + cid: "bafyfixture", + cts: new Date().toISOString(), + }; + }, + async overrideAssessment(_id, input) { + return { + actionId: "oact_fixture", + uri: "at://did:plc:x/com.emdashcms.experimental.package.release/rk1", + cid: "bafyfixture", + negated: input.negate, + issued: ["assessment-passed", "assessment-overridden"], + cts: new Date().toISOString(), + }; + }, + async retractOverride() { + return { + actionId: "oact_fixture", + uri: "at://did:plc:x/com.emdashcms.experimental.package.release/rk1", + cid: "bafyfixture", + negated: ["assessment-passed", "assessment-overridden"], + cts: new Date().toISOString(), + }; + }, }; } diff --git a/apps/labeler/console/src/api/types.ts b/apps/labeler/console/src/api/types.ts index d27091b180..6520bdb0c8 100644 --- a/apps/labeler/console/src/api/types.ts +++ b/apps/labeler/console/src/api/types.ts @@ -226,6 +226,73 @@ export interface IssuedLabelDescriptor { effect: string; } +/** + * The active label state for a subject `(src, uri)` at a CID from + * `GET /admin/api/subjects/:uri/labels?cid=` — the current stream winner per + * value, including the manual/override labels that carry no `assessment_id` and + * so never appear in the assessment-scoped label list. `active` already encodes + * non-negated + unexpired + CID-applicable. */ +export interface SubjectLabel { + val: string; + cid: string | null; + active: boolean; + neg: boolean; + /** Whether the stream head was issued by automation — only automated heads + * are in the override's negatable set; a manual head unblocks via retract. */ + automated: boolean; + cts: string; + exp: string | null; + sequence: number; +} + +/** Body shared by the rerun and override-retract actions: a required reason, a + * server-validated typed CID confirmation, and a client-minted idempotency key + * (ULID) reused across retries so a network retry replays rather than repeats. */ +export interface AssessmentActionInput { + confirmation: string; + reason: string; + idempotencyKey: string; +} + +/** Override body: adds the observed active automated block set, validated + * server-side against live label state (a stale set is rejected). */ +export interface OverrideActionInput extends AssessmentActionInput { + negate: string[]; +} + +/** Idempotent rerun result — the new run + its immutable operator trigger. */ +export interface RerunResult { + actionId: string; + runId: string; + triggerId: string; + uri: string; + cid: string; + cts: string; +} + +export interface OverrideResult { + actionId: string; + uri: string; + cid: string; + negated: string[]; + issued: string[]; + cts: string; +} + +export interface OverrideRetractResult { + actionId: string; + uri: string; + cid: string; + negated: string[]; + cts: string; +} + +export interface OverrideEffectPreviewParams { + uri: string; + cid: string; + negate: string[]; +} + export interface ListAssessmentsParams { state?: PublicAssessmentState; cursor?: string; diff --git a/apps/labeler/console/src/components/AssessmentActionDialog.tsx b/apps/labeler/console/src/components/AssessmentActionDialog.tsx new file mode 100644 index 0000000000..8aef8aa53a --- /dev/null +++ b/apps/labeler/console/src/components/AssessmentActionDialog.tsx @@ -0,0 +1,139 @@ +import { Button, Dialog, Input, InputArea } from "@cloudflare/kumo"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { ulid } from "ulidx"; + +import { apiClient } from "../api/client.js"; +import type { OverrideRetractResult, RerunResult } from "../api/types.js"; + +interface AssessmentActionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: "rerun" | "override-retract"; + assessmentId: string; + subjectUri: string; + /** The exact release CID — the typed confirmation the reviewer must match. */ + subjectCid: string; + /** TanStack Query keys to invalidate on success so the new state renders. */ + invalidateKeys: readonly (readonly unknown[])[]; +} + +const COPY = { + rerun: { + title: "Rerun assessment", + description: + "Creates a fresh assessment run for this exact release and re-issues assessment-pending. The release becomes ineligible until the new run completes.", + submit: "Rerun", + variant: "primary" as const, + }, + "override-retract": { + title: "Retract override", + description: + "Negates assessment-passed and assessment-overridden. The release returns to blocked (missing assessment pass). The original automated blocks stay negated — rerun to re-surface real findings.", + submit: "Retract override", + variant: "destructive" as const, + }, +}; + +/** + * The §11.4 ceremony for the two single-purpose assessment actions (rerun, + * override-retract): a required reason and a server-validated typed CID + * confirmation, no effect preview (the effect is stated in the description). The + * idempotency key is minted per open and reused across retries so a network + * retry replays rather than repeats. + */ +export function AssessmentActionDialog({ + open, + onOpenChange, + mode, + assessmentId, + subjectUri, + subjectCid, + invalidateKeys, +}: AssessmentActionDialogProps) { + const queryClient = useQueryClient(); + const copy = COPY[mode]; + const [reason, setReason] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [idempotencyKey, setIdempotencyKey] = useState(""); + + useEffect(() => { + if (!open) return; + setIdempotencyKey(ulid()); + setReason(""); + setConfirmation(""); + }, [open]); + + const mutation = useMutation({ + mutationFn: () => { + const input = { confirmation, reason, idempotencyKey }; + return mode === "rerun" + ? apiClient.rerunAssessment(assessmentId, input) + : apiClient.retractOverride(assessmentId, input); + }, + onSuccess: async () => { + await Promise.all( + invalidateKeys.map((queryKey) => queryClient.invalidateQueries({ queryKey })), + ); + onOpenChange(false); + }, + }); + + const confirmationMatches = confirmation === subjectCid && subjectCid.length > 0; + const canSubmit = reason.trim().length > 0 && confirmationMatches && !mutation.isPending; + + return ( + + + {copy.title} + + {subjectUri} + + +

{copy.description}

+ + + + { + setConfirmation(event.target.value); + }} + placeholder={subjectCid} + error={ + confirmation.length > 0 && !confirmationMatches + ? "Does not match the release CID" + : undefined + } + /> + + {mutation.isError && ( +

+ {mutation.error instanceof Error ? mutation.error.message : "Action failed"} +

+ )} + +
+ +
+
+
+ ); +} diff --git a/apps/labeler/console/src/components/OverrideDialog.tsx b/apps/labeler/console/src/components/OverrideDialog.tsx new file mode 100644 index 0000000000..a7c665acff --- /dev/null +++ b/apps/labeler/console/src/components/OverrideDialog.tsx @@ -0,0 +1,191 @@ +import { Badge, Button, Dialog, Input, InputArea, Loader } from "@cloudflare/kumo"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { ulid } from "ulidx"; + +import { apiClient } from "../api/client.js"; +import type { ReleaseModeration } from "../api/types.js"; + +interface OverrideDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + assessmentId: string; + subjectUri: string; + subjectCid: string; + /** The active automated blocking labels to negate — the full observed set; + * the server validates it equals live state. */ + blocks: readonly string[]; + invalidateKeys: readonly (readonly unknown[])[]; +} + +function ModerationSummary({ label, value }: { label: string; value: ReleaseModeration | null }) { + return ( +
+ {label} + {value ? ( +
+ + {value.eligibility} + + {value.blockingLabels.map((v) => ( + + {v} + + ))} + {value.suppressedLabels.map((v) => ( + + {v} (suppressed) + + ))} +
+ ) : ( + Not a release subject + )} +
+ ); +} + +/** + * The false-positive override ceremony (spec §7.1/§11.4): shows the exact release, + * the full set of active automated blocks that will be negated, the server-derived + * before→after effect (blocked → eligible-manual-override with the blocks + * suppressed), and a warning that a later admin takedown still blocks. A required + * reason and a typed CID confirmation gate submit; the idempotency key is minted + * per open and reused across retries. + */ +export function OverrideDialog({ + open, + onOpenChange, + assessmentId, + subjectUri, + subjectCid, + blocks, + invalidateKeys, +}: OverrideDialogProps) { + const queryClient = useQueryClient(); + const [reason, setReason] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [idempotencyKey, setIdempotencyKey] = useState(""); + + useEffect(() => { + if (!open) return; + setIdempotencyKey(ulid()); + setReason(""); + setConfirmation(""); + }, [open]); + + const previewQuery = useQuery({ + queryKey: ["override-effect-preview", subjectUri, subjectCid, [...blocks].toSorted()], + queryFn: () => + apiClient.previewOverrideEffect({ uri: subjectUri, cid: subjectCid, negate: [...blocks] }), + enabled: open, + }); + + const mutation = useMutation({ + mutationFn: () => + apiClient.overrideAssessment(assessmentId, { + confirmation, + reason, + idempotencyKey, + negate: [...blocks], + }), + onSuccess: async () => { + await Promise.all( + invalidateKeys.map((queryKey) => queryClient.invalidateQueries({ queryKey })), + ); + onOpenChange(false); + }, + }); + + const confirmationMatches = confirmation === subjectCid && subjectCid.length > 0; + const canSubmit = reason.trim().length > 0 && confirmationMatches && !mutation.isPending; + + return ( + + + Override (unblock) release + + {subjectUri} + +

CID: {subjectCid}

+ +
+ + Automated blocks to negate as false positives + + {blocks.length > 0 ? ( +
+ {blocks.map((val) => ( + + {val} + + ))} +
+ ) : ( + No active automated blocks. + )} +
+ + {previewQuery.isLoading ? ( +
+ +
+ ) : previewQuery.data ? ( +
+ + +
+ ) : previewQuery.isError ? ( +

Could not load the override effect preview.

+ ) : null} + +

+ An override does not shield against a later administrator takedown or security-yank — a + manual block still blocks this release. +

+ + + + { + setConfirmation(event.target.value); + }} + placeholder={subjectCid} + error={ + confirmation.length > 0 && !confirmationMatches + ? "Does not match the release CID" + : undefined + } + /> + + {mutation.isError && ( +

+ {mutation.error instanceof Error ? mutation.error.message : "Override failed"} +

+ )} + +
+ +
+
+
+ ); +} diff --git a/apps/labeler/console/src/labels.ts b/apps/labeler/console/src/labels.ts index 91f8586b94..1c951a8c62 100644 --- a/apps/labeler/console/src/labels.ts +++ b/apps/labeler/console/src/labels.ts @@ -1,3 +1,4 @@ +import { automatedBlockCategories, MODERATION_POLICY } from "../../src/policy.js"; import type { IssuableLabel } from "./api/types.js"; /** @@ -36,6 +37,18 @@ export const PACKAGE_ISSUABLE_LABELS: readonly IssuableLabel[] = [ { val: "package-disputed", scope: "uri-wide" }, ]; +/** The automated blocking label vocabulary (policy `category: automated-block`), + * derived from the bundled policy fixture with the server's own helper so a + * fixture change reaches the override ceremony without a code edit. Presentation + * only — the server re-derives the authoritative set from live label state and + * rejects a stale submission. */ +export const AUTOMATED_BLOCK_LABELS: ReadonlySet = + automatedBlockCategories(MODERATION_POLICY); + +export function isAutomatedBlock(val: string): boolean { + return AUTOMATED_BLOCK_LABELS.has(val); +} + const RELEASE_SCOPES = new Map(RELEASE_ISSUABLE_LABELS.map((entry) => [entry.val, entry.scope])); /** The ceremony scope for a value already active on a release, so a retract diff --git a/apps/labeler/console/src/routes/AssessmentDetail.tsx b/apps/labeler/console/src/routes/AssessmentDetail.tsx index fc30d4bd4c..8a5f90321f 100644 --- a/apps/labeler/console/src/routes/AssessmentDetail.tsx +++ b/apps/labeler/console/src/routes/AssessmentDetail.tsx @@ -5,11 +5,18 @@ import { useState, type ReactNode } from "react"; import { apiClient } from "../api/client.js"; import type { IssuableLabel } from "../api/types.js"; +import { AssessmentActionDialog } from "../components/AssessmentActionDialog.js"; import { FindingCard } from "../components/FindingCard.js"; import { LabelActionDialog } from "../components/LabelActionDialog.js"; +import { OverrideDialog } from "../components/OverrideDialog.js"; import { QueryError } from "../components/QueryError.js"; import { StateBadge } from "../components/StateBadge.js"; -import { isReleaseRetractable, RELEASE_ISSUABLE_LABELS, releaseLabelScope } from "../labels.js"; +import { + isAutomatedBlock, + isReleaseRetractable, + RELEASE_ISSUABLE_LABELS, + releaseLabelScope, +} from "../labels.js"; import { shellRoute } from "./root.js"; function MetaRow({ label, value }: { label: string; value: ReactNode }) { @@ -56,8 +63,19 @@ function AssessmentDetail() { const { data: whoami } = useQuery({ queryKey: ["whoami"], queryFn: () => apiClient.whoami() }); const canAct = whoami?.roles.includes("reviewer") || whoami?.roles.includes("admin") || false; + const subjectUri = assessment?.uri; + const subjectCid = assessment?.cid; + const { data: subjectLabels } = useQuery({ + queryKey: ["subject-labels", subjectUri, subjectCid], + queryFn: () => apiClient.getSubjectLabels(subjectUri!, subjectCid), + enabled: !!subjectUri, + }); + const [issueOpen, setIssueOpen] = useState(false); const [retractTarget, setRetractTarget] = useState(null); + const [rerunOpen, setRerunOpen] = useState(false); + const [overrideOpen, setOverrideOpen] = useState(false); + const [overrideRetractOpen, setOverrideRetractOpen] = useState(false); if (isAssessmentError) { return ; @@ -77,6 +95,21 @@ function AssessmentDetail() { return
Assessment not found.
; } + const activeSubjectLabels = subjectLabels ?? []; + // `automated` matches the server's negatable-set semantics: a manually-headed + // block is not override-negatable (its unblock path is the label retract). + const activeBlocks = activeSubjectLabels + .filter((label) => label.active && label.automated && isAutomatedBlock(label.val)) + .map((label) => label.val); + const hasActiveOverride = activeSubjectLabels.some( + (label) => label.val === "assessment-overridden" && label.active, + ); + const actionInvalidateKeys: readonly (readonly unknown[])[] = [ + ["assessment", id, "labels"], + ["assessment", id], + ["subject-labels", assessment.uri, assessment.cid], + ]; + return (
@@ -127,9 +160,46 @@ function AssessmentDetail() { )} + {canAct && ( +
+ + {activeBlocks.length > 0 && ( + + )} + {hasActiveOverride && ( + + )} +
+ )} + +
+

Active labels for this release

+ {activeSubjectLabels.length === 0 ? ( +

No active labels for this release.

+ ) : ( +
+ {activeSubjectLabels.map((label) => ( + + {label.val} + {!label.active && (label.neg ? " (retracted)" : " (inactive)")} + + ))} +
+ )} +
+
-

Labels

+

Labels issued by this run

{canAct && ( +
+ + + ); +} diff --git a/apps/labeler/console/src/routes/SubjectHistory.tsx b/apps/labeler/console/src/routes/SubjectHistory.tsx index c4bc291f88..95497f0fff 100644 --- a/apps/labeler/console/src/routes/SubjectHistory.tsx +++ b/apps/labeler/console/src/routes/SubjectHistory.tsx @@ -4,13 +4,30 @@ import { createRoute, Link } from "@tanstack/react-router"; import { useState } from "react"; import { apiClient } from "../api/client.js"; -import type { IssuableLabel } from "../api/types.js"; +import type { EmergencyActionKind, IssuableLabel, SubjectLabel } from "../api/types.js"; +import { EmergencyActionDialog } from "../components/EmergencyActionDialog.js"; import { LabelActionDialog } from "../components/LabelActionDialog.js"; import { QueryError } from "../components/QueryError.js"; import { StateBadge } from "../components/StateBadge.js"; import { PACKAGE_ISSUABLE_LABELS, RELEASE_ISSUABLE_LABELS } from "../labels.js"; import { shellRoute } from "./root.js"; +/** A publisher's typed confirmation is its DID's final `:`-segment. */ +function didFinalSegment(did: string): string { + return did.split(":").at(-1) ?? ""; +} + +function isLabelActive(labels: readonly SubjectLabel[] | undefined, val: string): boolean { + return (labels ?? []).some((label) => label.val === val && label.active); +} + +interface EmergencyTarget { + kind: EmergencyActionKind; + mode: "issue" | "retract"; + uri: string; + confirm: string; +} + /** URI-wide (record-level) issuable labels for a subject, chosen by its * collection: a release record can be security-yanked, a package profile can be * disputed. Presentation only — the server enforces policy. */ @@ -35,7 +52,22 @@ function SubjectHistory() { }); const { data: whoami } = useQuery({ queryKey: ["whoami"], queryFn: () => apiClient.whoami() }); const canAct = whoami?.roles.includes("reviewer") || whoami?.roles.includes("admin") || false; + const isAdmin = whoami?.roles.includes("admin") ?? false; + + const subjectRecord = history?.subject; + const { data: recordLabels } = useQuery({ + queryKey: ["subject-labels", subjectRecord?.uri, subjectRecord?.cid], + queryFn: () => apiClient.getSubjectLabels(subjectRecord!.uri, subjectRecord!.cid), + enabled: isAdmin && !!subjectRecord, + }); + const { data: publisherLabels } = useQuery({ + queryKey: ["publisher-labels", subjectRecord?.did], + queryFn: () => apiClient.getSubjectLabels(subjectRecord!.did), + enabled: isAdmin && !!subjectRecord, + }); + const [issueOpen, setIssueOpen] = useState(false); + const [emergency, setEmergency] = useState(null); if (isError) { return ; @@ -57,6 +89,15 @@ function SubjectHistory() { const { subject, assessments } = history; const uriWideLabels = uriWideLabelsFor(subject.collection); + const publisherSegment = didFinalSegment(subject.did); + const recordTakenDown = isLabelActive(recordLabels, "!takedown"); + const publisherTakenDown = isLabelActive(publisherLabels, "!takedown"); + const publisherCompromised = isLabelActive(publisherLabels, "publisher-compromised"); + const emergencyInvalidateKeys: readonly (readonly unknown[])[] = [ + ["subject-history", uri], + ["subject-labels", subject.uri, subject.cid], + ["publisher-labels", subject.did], + ]; return (
@@ -80,6 +121,74 @@ function SubjectHistory() { )}
+ {isAdmin && ( + +
+

Emergency actions

+ {recordTakenDown && ( + This record is taken down. + )} +
+
+ + + +
+
+ )} + + {emergency && ( + { + if (!open) setEmergency(null); + }} + kind={emergency.kind} + mode={emergency.mode} + subjectUri={emergency.uri} + subjectConfirmationExpected={emergency.confirm} + invalidateKeys={emergencyInvalidateKeys} + /> + )} + {canAct && uriWideLabels.length > 0 && ( = { + takedown: "!takedown", + "publisher-compromised": "publisher-compromised", +}; + +const EMERGENCY_EVENT_TYPE: Record = { + takedown: "emergency-takedown", + "publisher-compromised": "publisher-compromised", +}; + +/** The second typed ceremony field: a server constant the operator must retype + * verbatim, distinct per action and per direction so a scripted client cannot + * skip the ceremony and a retract cannot be replayed as an issuance. */ +const EMERGENCY_INTENT: Record = { + takedown: { issue: "CONFIRM TAKEDOWN", retract: "CONFIRM RETRACT" }, + "publisher-compromised": { issue: "CONFIRM COMPROMISE", retract: "CONFIRM RETRACT" }, +}; + +const EMERGENCY_CHANNEL = "deployment-alert"; + +/** + * The two-field emergency ceremony body (design §3). `val` and `neg` are fixed + * by the endpoint, not read from the client, so the wire body carries only the + * subject and the two typed confirmations. Both `subjectConfirmation` and + * `intent` are part of the parsed body, so both fold into the request + * fingerprint (`computeRequestFingerprint` hashes the whole normalized body) — + * a replay must resend identical values. + */ +interface EmergencyActionBody { + uri: string; + val: string; + neg: boolean; + subjectConfirmation: string; + intent: string; +} + +/** The idempotent emergency result — the same shape as a label descriptor + * (`cid` is always null: every emergency label is URI-wide / DID-subject). */ +type EmergencyDescriptor = IssuedLabelDescriptor; + +/** + * `POST /admin/api/emergency/{takedown,publisher-compromised}` and their + * `-retract` siblings — the admin-only crown-jewel actions (spec §11.3/§18.2, + * design §2–§4). Issuance flows through `prepareManualLabelIssuance` unchanged; + * the delta from the reviewer issue endpoint is the `admin` role, the + * admin-issuable policy gate, the two-field ceremony, and the label-gated + * operational event + notification outbox committed in the same atomic batch. + * Retraction is the same path with `neg: true` and a `CONFIRM RETRACT` intent; + * its resting state is the subject's pre-takedown computed state (the automated + * labels, never negated, re-expose — nothing is re-issued). + */ +async function runEmergencyAction( + request: Request, + deps: ConsoleMutationDeps, + action: EmergencyAction, + neg: boolean, +): Promise { + const val = EMERGENCY_VALUE[action]; + const spec: MutationSpec = { + action, + requiredRole: "admin", + parseBody: (raw) => { + const body: EmergencyActionBody = { + uri: requireString(raw.uri), + val, + neg, + subjectConfirmation: requireString(raw.subjectConfirmation), + intent: requireString(raw.intent), + }; + assertAdminIssuable(body); + assertEmergencyConfirmation(body, action, neg); + return body; + }, + auditFields: (body) => ({ + subjectUri: body.uri, + labelValue: val, + metadata: { neg, effect: getLabelDefinition(val)?.officialEffect ?? null }, + }), + }; + + const outcome = await guardMutation(request, spec, guardDepsOf(deps)); + if (outcome.outcome === "replay") { + const stored = storedDescriptor(outcome.result); + await assertIssuancePersisted(deps.db, [stored.actionId]); + return jsonData(stored); + } + + const { ctx } = outcome; + const signer = await deps.createSigner(); + const issuance = await prepareManualLabelIssuance( + deps.db, + deps.config, + signer, + { + actor: deps.config.labelerDid, + type: "manual-label", + reason: ctx.reason, + idempotencyKey: ctx.actionId, + }, + { uri: ctx.body.uri, val, neg }, + ctx.now, + ); + + // The event + outbox are gated on the label's in-batch existence keyed by the + // issuance idempotency key (= this action id): a signing-state race that + // suppresses the label INSERT drops the event and outbox with it, so no + // "takedown issued" alert fires for a takedown that never landed (T1). + const eventId = newOperationalEventId(); + const eventInsert = buildOperationalEventInsert(deps.db, { + id: eventId, + eventType: EMERGENCY_EVENT_TYPE[action], + severity: neg ? "high" : "critical", + actionId: ctx.actionId, + subjectUri: ctx.body.uri, + labelValue: val, + payload: { reason: ctx.reason }, + now: ctx.now, + gateOnIssuedLabelActionKey: ctx.actionId, + }); + const outboxInsert = buildOutboxInsert(deps.db, { + eventId, + channel: EMERGENCY_CHANNEL, + now: ctx.now, + gateOnIssuedLabelActionKey: ctx.actionId, + }); + + const descriptor: EmergencyDescriptor = { + actionId: ctx.actionId, + val, + uri: ctx.body.uri, + cid: null, + neg, + cts: ctx.now.toISOString(), + effect: getLabelDefinition(val)?.officialEffect ?? "", + }; + const returned = await commitMutation( + deps.db, + ctx, + spec, + [...issuance.statements, eventInsert, outboxInsert], + descriptor, + ); + await assertIssuancePersisted(deps.db, [returned.actionId]); + deps.defer(deps.afterCommit(returned.actionId)); + return jsonData(returned); +} + +/** + * The admin issuance gate, policy-driven like `assertW94Issuable` but requiring + * the matched `subjectRule` to grant `admin`: `!takedown` on a release, package, + * or publisher; `publisher-compromised` on a publisher. Every emergency label is + * `cidRule: forbidden`, and the wire body carries no CID, so scope is satisfied + * structurally. A subject the value does not grant `admin` for (e.g. + * `publisher-compromised` on a release) is a clean 400 before signing. + */ +function assertAdminIssuable(body: EmergencyActionBody): void { + const definition = getLabelDefinition(body.val); + if (!definition) throw new MutationGuardError("INVALID_BODY"); + const subject = parseSubjectKind(body.uri); + if (subject === null) throw new MutationGuardError("INVALID_BODY"); + const rule = definition.subjectRules.find((candidate) => candidate.subject === subject); + if (!rule || !rule.issuanceModes.includes("admin")) throw new MutationGuardError("INVALID_BODY"); +} + +/** + * The two-field ceremony (design §3), enforced in code, not prompt text. The + * typed subject identifier is the record rkey for a release/package and the + * DID's final `:`-segment for a publisher, tying the confirmation to the parsed + * subject kind — a URI whose kind mismatches the typed identifier fails (T7). + * The typed intent must equal the server constant for this action + direction. + * Both errors are the static `CONFIRMATION_MISMATCH`, which never echoes the + * typed value. + */ +function assertEmergencyConfirmation( + body: EmergencyActionBody, + action: EmergencyAction, + neg: boolean, +): void { + const subject = parseSubjectKind(body.uri); + const expectedSubject = subject === "publisher" ? didFinalSegment(body.uri) : rkeyOf(body.uri); + if (body.subjectConfirmation !== expectedSubject) + throw new LabelMutationError("CONFIRMATION_MISMATCH"); + const expectedIntent = EMERGENCY_INTENT[action][neg ? "retract" : "issue"]; + if (body.intent !== expectedIntent) throw new LabelMutationError("CONFIRMATION_MISMATCH"); +} + +/** A publisher subject's typed confirmation is its DID's final `:`-segment (the + * PLC/web identifier), not a resolved handle. */ +function didFinalSegment(uri: string): string { + return uri.split(":").at(-1) ?? ""; +} diff --git a/apps/labeler/src/operational-events.ts b/apps/labeler/src/operational-events.ts index 08dcd834b5..a26ad00471 100644 --- a/apps/labeler/src/operational-events.ts +++ b/apps/labeler/src/operational-events.ts @@ -43,6 +43,16 @@ export interface OperationalEventInsert { * not insert either — so no alert fires for a label that never landed. */ gateOnIssuedLabelActionId?: number; + /** + * The same in-batch label gate keyed on the issuance action's idempotency + * key rather than the numeric `issuance_actions.id`. The real issuance path + * (`prepareManualLabelIssuance`) lets D1 autoincrement `issuance_actions.id`, + * so the numeric id is unknown before the batch commits — but the idempotency + * key is the caller-minted operator action id. The gate joins through + * `issuance_actions` to reach the label, so a signing-suppressed batch (no + * `issued_labels` row) inserts neither the event nor its outbox row. + */ + gateOnIssuedLabelActionKey?: string; } export interface OutboxInsert { @@ -51,6 +61,8 @@ export interface OutboxInsert { now: Date; /** Same in-batch label gating as {@link OperationalEventInsert}. */ gateOnIssuedLabelActionId?: number; + /** Same in-batch label gating as {@link OperationalEventInsert.gateOnIssuedLabelActionKey}. */ + gateOnIssuedLabelActionKey?: string; } export interface StoredOperationalEvent { @@ -134,6 +146,20 @@ export function buildOperationalEventInsert( input.now.getTime(), ]; + if (input.gateOnIssuedLabelActionKey !== undefined) { + return db + .prepare( + `INSERT INTO operational_events (${columns}) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ? + WHERE EXISTS ( + SELECT 1 FROM issued_labels l + JOIN issuance_actions a ON a.id = l.action_id + WHERE a.idempotency_key = ? + )`, + ) + .bind(...values, input.gateOnIssuedLabelActionKey); + } + if (input.gateOnIssuedLabelActionId !== undefined) { return db .prepare( @@ -166,6 +192,20 @@ export function buildOutboxInsert(db: D1Database, input: OutboxInsert): D1Prepar input.now.getTime(), ]; + if (input.gateOnIssuedLabelActionKey !== undefined) { + return db + .prepare( + `INSERT INTO notification_outbox (id, event_id, channel, created_at, created_at_epoch_ms) + SELECT ?, ?, ?, ?, ? + WHERE EXISTS ( + SELECT 1 FROM issued_labels l + JOIN issuance_actions a ON a.id = l.action_id + WHERE a.idempotency_key = ? + )`, + ) + .bind(...values, input.gateOnIssuedLabelActionKey); + } + if (input.gateOnIssuedLabelActionId !== undefined) { return db .prepare( diff --git a/apps/labeler/test/console-api.test.ts b/apps/labeler/test/console-api.test.ts index ba3fe52213..423f6abb55 100644 --- a/apps/labeler/test/console-api.test.ts +++ b/apps/labeler/test/console-api.test.ts @@ -500,6 +500,32 @@ describe("handleConsoleApi — findings and labels", () => { expect(labels.length).toBe(3); expect(labels.some((l) => l.neg === true)).toBe(true); }); + + it("resolves URI-wide labels for a bare-DID publisher subject with no cid", async () => { + const publisherDid = "did:plc:pppppppppppppppppppppppp"; + await testEnv.DB.prepare( + `INSERT INTO issuance_actions (id, actor, type, reason, idempotency_key, created_at) + VALUES (2001, ?, 'manual-label', 'r', 'idem-pub-2001', '2026-07-09T00:00:00.000Z')`, + ) + .bind(LABELER_DID) + .run(); + await testEnv.DB.prepare( + `INSERT INTO issued_labels (action_id, ver, src, uri, cid, val, neg, cts, sig, signing_key_id) + VALUES (2001, 1, ?, ?, NULL, 'publisher-compromised', 0, '2026-07-09T00:00:00.000Z', ?, 'v1')`, + ) + .bind(LABELER_DID, publisherDid, new Uint8Array([0])) + .run(); + + const res = await handleConsoleApi( + req(`/admin/api/subjects/${encodeURIComponent(publisherDid)}/labels`, { + token: reviewerToken, + }), + deps(), + ); + expect(res.status).toBe(200); + const labels = (await body(res)).data as { val: string; active: boolean }[]; + expect(labels.find((l) => l.val === "publisher-compromised")?.active).toBe(true); + }); }); describe("handleConsoleApi — subject history", () => { diff --git a/apps/labeler/test/console-mutation-api.test.ts b/apps/labeler/test/console-mutation-api.test.ts index beed5f3471..495be5a8d7 100644 --- a/apps/labeler/test/console-mutation-api.test.ts +++ b/apps/labeler/test/console-mutation-api.test.ts @@ -9,7 +9,12 @@ import { generateKeyPair, SignJWT } from "jose"; import { beforeAll, describe, expect, it } from "vitest"; import type { AccessKeyResolver } from "../src/access-auth.js"; -import { createSubject, getActiveLabelState } from "../src/assessment-store.js"; +import { computeRunKey, initialTriggerId } from "../src/assessment-lifecycle.js"; +import { + createAssessmentRun, + createSubject, + getActiveLabelState, +} from "../src/assessment-store.js"; import { handleConsoleApi, type ConsoleApiDeps } from "../src/console-api.js"; import { handleConsoleMutation, @@ -17,7 +22,7 @@ import { type IssuedLabelDescriptor, } from "../src/console-mutation-api.js"; import { OPERATOR_REQUEST_HEADER } from "../src/mutation-guard.js"; -import { issueManualLabel } from "../src/service.js"; +import { issueAutomatedAssessmentLabel, issueManualLabel } from "../src/service.js"; const TEAM_DOMAIN = "https://example-team.cloudflareaccess.com"; const AUDIENCE = "test-audience"; @@ -734,3 +739,556 @@ describe("console reads: effect preview", () => { expect(response.status).toBe(400); }); }); + +// ─── W9.6: admin-only emergency actions ───────────────────────────────────── + +const PUBLISHER_SEGMENT = PUBLISHER_DID.split(":").at(-1)!; + +/** Seeds a real automated `malware` block (via an assessment, satisfying the + * `issuance_actions.assessment_id` FK) so a takedown/retract test rests on a + * genuine automated block, not a hand-inserted row. */ +async function seedAutomatedBlock(uri: string, cid: string): Promise { + await createSubject(testEnv.DB, { + uri, + cid, + did: PUBLISHER_DID, + collection: "com.emdashcms.experimental.package.release", + rkey: uri.split("/").at(-1)!, + now: new Date("2026-07-08T08:00:00.000Z"), + }); + const triggerId = initialTriggerId(cid); + const runKey = await computeRunKey({ + uri, + cid, + policyVersion: "v1", + modelId: "m", + promptHash: "p", + scannerSetVersion: "v1", + triggerId, + }); + const { assessment } = await createAssessmentRun(testEnv.DB, { + runKey, + uri, + cid, + trigger: "initial", + triggerId, + policyVersion: "v1", + coverageJson: "{}", + }); + await issueAutomatedAssessmentLabel( + testEnv.DB, + CONFIG, + await testSigner(), + { + actor: LABELER_DID, + type: "automated-assessment", + assessmentId: assessment.id, + reason: "automated block", + idempotencyKey: nextKey(), + }, + { uri, cid, val: "malware", findingCategory: "malware", severity: "critical" }, + new Date("2026-07-08T09:00:00.000Z"), + ); +} + +/** Reads the issued labels for a URI as evaluator input, newest last — the + * grounded label set the aggregator evaluator reduces (sigs are irrelevant to + * label semantics). */ +async function moderationLabelsFor(uri: string): Promise { + const rows = await testEnv.DB.prepare( + `SELECT src, uri, cid, val, neg, cts FROM issued_labels WHERE uri = ? ORDER BY sequence ASC`, + ) + .bind(uri) + .all<{ src: string; uri: string; cid: string | null; val: string; neg: number; cts: string }>(); + return (rows.results ?? []).map((row) => ({ + ver: 1, + src: row.src, + uri: row.uri, + ...(row.cid === null ? {} : { cid: row.cid }), + val: row.val, + ...(row.neg === 1 ? { neg: true } : {}), + cts: row.cts, + })); +} + +async function eventCount(actionId: string): Promise { + return countRows(`SELECT COUNT(*) n FROM operational_events WHERE action_id = ?`, actionId); +} + +async function outboxCount(actionId: string): Promise { + return countRows( + `SELECT COUNT(*) n FROM notification_outbox nob + JOIN operational_events oe ON oe.id = nob.event_id + WHERE oe.action_id = ?`, + actionId, + ); +} + +function emergency( + path: string, + body: Record, + token: string | null = adminToken, +): Request { + return post(path, { reason: "incident response", idempotencyKey: nextKey(), ...body }, { token }); +} + +describe("console mutation: emergency issuance (admin)", () => { + it("issues !takedown URI-wide on a release with the atomic event + outbox", async () => { + const uri = await seedReleaseSubject("emrg-td-release"); + const response = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown", { + uri, + subjectConfirmation: "emrg-td-release", + intent: "CONFIRM TAKEDOWN", + }), + mutationDeps(), + ); + expect(response.status).toBe(200); + const descriptor = await bodyData(response); + expect(descriptor).toMatchObject({ + val: "!takedown", + uri, + cid: null, + neg: false, + effect: "redact", + }); + + expect( + await countRows( + `SELECT COUNT(*) n FROM issued_labels WHERE uri = ? AND val = '!takedown' AND neg = 0`, + uri, + ), + ).toBe(1); + expect( + await countRows( + `SELECT COUNT(*) n FROM operational_events + WHERE action_id = ? AND event_type = 'emergency-takedown' AND severity = 'critical'`, + descriptor.actionId, + ), + ).toBe(1); + expect(await outboxCount(descriptor.actionId)).toBe(1); + + // T8: the alert payload carries the operator reason only — no evidence fields. + const event = await testEnv.DB.prepare( + `SELECT payload_json, subject_uri, label_value FROM operational_events WHERE action_id = ?`, + ) + .bind(descriptor.actionId) + .first<{ payload_json: string; subject_uri: string; label_value: string }>(); + expect(event).toMatchObject({ subject_uri: uri, label_value: "!takedown" }); + expect(JSON.parse(event!.payload_json)).toEqual({ reason: "incident response" }); + }); + + it("issues !takedown on a package profile (rkey confirmation)", async () => { + const response = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown", { + uri: profileUri("emrg-td-pkg"), + subjectConfirmation: "emrg-td-pkg", + intent: "CONFIRM TAKEDOWN", + }), + mutationDeps(), + ); + expect(response.status).toBe(200); + expect((await bodyData(response)).val).toBe("!takedown"); + }); + + it("issues !takedown on a publisher DID (final-segment confirmation)", async () => { + const response = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown", { + uri: PUBLISHER_DID, + subjectConfirmation: PUBLISHER_SEGMENT, + intent: "CONFIRM TAKEDOWN", + }), + mutationDeps(), + ); + expect(response.status).toBe(200); + expect((await bodyData(response)).uri).toBe(PUBLISHER_DID); + }); + + it("issues publisher-compromised on a publisher DID", async () => { + const response = await handleConsoleMutation( + emergency("/admin/api/emergency/publisher-compromised", { + uri: PUBLISHER_DID, + subjectConfirmation: PUBLISHER_SEGMENT, + intent: "CONFIRM COMPROMISE", + }), + mutationDeps(), + ); + expect(response.status).toBe(200); + const descriptor = await bodyData(response); + expect(descriptor).toMatchObject({ + val: "publisher-compromised", + uri: PUBLISHER_DID, + neg: false, + }); + expect( + await countRows( + `SELECT COUNT(*) n FROM operational_events + WHERE action_id = ? AND event_type = 'publisher-compromised' AND severity = 'critical'`, + descriptor.actionId, + ), + ).toBe(1); + }); + + it("rejects publisher-compromised targeting a release (no admin rule for that subject)", async () => { + const response = await handleConsoleMutation( + emergency("/admin/api/emergency/publisher-compromised", { + uri: releaseUri("emrg-pc-release"), + subjectConfirmation: "emrg-pc-release", + intent: "CONFIRM COMPROMISE", + }), + mutationDeps(), + ); + expect(response.status).toBe(400); + expect((await bodyError(response)).code).toBe("INVALID_BODY"); + }); +}); + +describe("console mutation: emergency authorization (per-endpoint, pre-effect)", () => { + const cases = [ + { + path: "/admin/api/emergency/takedown", + uri: () => PUBLISHER_DID, + conf: PUBLISHER_SEGMENT, + intent: "CONFIRM TAKEDOWN", + }, + { + path: "/admin/api/emergency/takedown-retract", + uri: () => PUBLISHER_DID, + conf: PUBLISHER_SEGMENT, + intent: "CONFIRM RETRACT", + }, + { + path: "/admin/api/emergency/publisher-compromised", + uri: () => PUBLISHER_DID, + conf: PUBLISHER_SEGMENT, + intent: "CONFIRM COMPROMISE", + }, + { + path: "/admin/api/emergency/publisher-compromised-retract", + uri: () => PUBLISHER_DID, + conf: PUBLISHER_SEGMENT, + intent: "CONFIRM RETRACT", + }, + ]; + + it.each(cases)( + "rejects a reviewer with zero side effects: $path", + async ({ path, uri, conf, intent }) => { + const before = { + actions: await countRows(`SELECT COUNT(*) n FROM operator_actions`), + labels: await countRows(`SELECT COUNT(*) n FROM issued_labels`), + events: await countRows(`SELECT COUNT(*) n FROM operational_events`), + outbox: await countRows(`SELECT COUNT(*) n FROM notification_outbox`), + }; + const response = await handleConsoleMutation( + emergency(path, { uri: uri(), subjectConfirmation: conf, intent }, reviewerToken), + mutationDeps(), + ); + expect(response.status).toBe(403); + expect((await bodyError(response)).code).toBe("FORBIDDEN_ROLE"); + expect(await countRows(`SELECT COUNT(*) n FROM operator_actions`)).toBe(before.actions); + expect(await countRows(`SELECT COUNT(*) n FROM issued_labels`)).toBe(before.labels); + expect(await countRows(`SELECT COUNT(*) n FROM operational_events`)).toBe(before.events); + expect(await countRows(`SELECT COUNT(*) n FROM notification_outbox`)).toBe(before.outbox); + }, + ); + + it.each(cases)( + "accepts an admin (the 403 is the role, not a broken endpoint): $path", + async ({ path, uri, conf, intent }) => { + const response = await handleConsoleMutation( + emergency(path, { uri: uri(), subjectConfirmation: conf, intent }, adminToken), + mutationDeps(), + ); + expect(response.status).toBe(200); + }, + ); + + it("rejects an unauthenticated caller with zero side effects (401)", async () => { + const before = { + actions: await countRows(`SELECT COUNT(*) n FROM operator_actions`), + labels: await countRows(`SELECT COUNT(*) n FROM issued_labels`), + events: await countRows(`SELECT COUNT(*) n FROM operational_events`), + outbox: await countRows(`SELECT COUNT(*) n FROM notification_outbox`), + }; + const response = await handleConsoleMutation( + emergency( + "/admin/api/emergency/takedown", + { uri: PUBLISHER_DID, subjectConfirmation: PUBLISHER_SEGMENT, intent: "CONFIRM TAKEDOWN" }, + null, + ), + mutationDeps(), + ); + expect(response.status).toBe(401); + expect(await countRows(`SELECT COUNT(*) n FROM operator_actions`)).toBe(before.actions); + expect(await countRows(`SELECT COUNT(*) n FROM issued_labels`)).toBe(before.labels); + expect(await countRows(`SELECT COUNT(*) n FROM operational_events`)).toBe(before.events); + expect(await countRows(`SELECT COUNT(*) n FROM notification_outbox`)).toBe(before.outbox); + }); +}); + +describe("console mutation: emergency ceremony", () => { + it("rejects a wrong subject confirmation without echoing the typed value (400)", async () => { + const response = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown", { + uri: releaseUri("emrg-conf"), + subjectConfirmation: "not-the-rkey", + intent: "CONFIRM TAKEDOWN", + }), + mutationDeps(), + ); + expect(response.status).toBe(400); + const error = await bodyError(response); + expect(error.code).toBe("CONFIRMATION_MISMATCH"); + expect(error.message).not.toContain("not-the-rkey"); + }); + + it("rejects a wrong intent phrase (400)", async () => { + const response = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown", { + uri: releaseUri("emrg-intent"), + subjectConfirmation: "emrg-intent", + intent: "CONFIRM COMPROMISE", + }), + mutationDeps(), + ); + expect(response.status).toBe(400); + expect((await bodyError(response)).code).toBe("CONFIRMATION_MISMATCH"); + }); + + it("rejects a missing intent (400)", async () => { + const response = await handleConsoleMutation( + post( + "/admin/api/emergency/takedown", + { + uri: releaseUri("emrg-nointent"), + subjectConfirmation: "emrg-nointent", + reason: "no intent", + idempotencyKey: nextKey(), + }, + { token: adminToken }, + ), + mutationDeps(), + ); + expect(response.status).toBe(400); + }); + + it("rejects the issue intent on a retract endpoint (400)", async () => { + const response = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown-retract", { + uri: PUBLISHER_DID, + subjectConfirmation: PUBLISHER_SEGMENT, + intent: "CONFIRM TAKEDOWN", + }), + mutationDeps(), + ); + expect(response.status).toBe(400); + expect((await bodyError(response)).code).toBe("CONFIRMATION_MISMATCH"); + }); + + it("T7: a release URI confirmed with a DID segment fails (subject-kind confusion)", async () => { + const response = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown", { + uri: releaseUri("emrg-t7"), + subjectConfirmation: PUBLISHER_SEGMENT, + intent: "CONFIRM TAKEDOWN", + }), + mutationDeps(), + ); + expect(response.status).toBe(400); + expect((await bodyError(response)).code).toBe("CONFIRMATION_MISMATCH"); + }); +}); + +describe("console mutation: emergency replay and phantom success", () => { + it("emits exactly one operational event + outbox row across a replay", async () => { + const uri = await seedReleaseSubject("emrg-replay"); + const key = nextKey(); + const request = () => + post( + "/admin/api/emergency/takedown", + { + uri, + subjectConfirmation: "emrg-replay", + intent: "CONFIRM TAKEDOWN", + reason: "replay incident", + idempotencyKey: key, + }, + { token: adminToken }, + ); + const first = await handleConsoleMutation(request(), mutationDeps()); + const firstBody = await first.text(); + const second = await handleConsoleMutation(request(), mutationDeps()); + expect(second.status).toBe(200); + expect(await second.text()).toBe(firstBody); + + const descriptor = JSON.parse(firstBody).data as IssuedLabelDescriptor; + expect(await eventCount(descriptor.actionId)).toBe(1); + expect(await outboxCount(descriptor.actionId)).toBe(1); + expect( + await countRows( + `SELECT COUNT(*) n FROM issued_labels WHERE uri = ? AND val = '!takedown'`, + uri, + ), + ).toBe(1); + }); + + it("503s with no label, event, or outbox when the batch is suppressed, and re-503s on replay (T1)", async () => { + const uri = await seedReleaseSubject("emrg-phantom"); + const key = nextKey(); + const body = { + uri, + subjectConfirmation: "emrg-phantom", + intent: "CONFIRM TAKEDOWN", + reason: "rotation mid-issuance", + idempotencyKey: key, + }; + const response = await handleConsoleMutation( + post("/admin/api/emergency/takedown", body, { token: adminToken }), + mutationDeps({ db: suppressIssuanceDb(testEnv.DB) }), + ); + expect(response.status).toBe(503); + expect((await bodyError(response)).code).toBe("LABEL_ISSUANCE_UNAVAILABLE"); + expect( + await countRows(`SELECT COUNT(*) n FROM operator_actions WHERE idempotency_key = ?`, key), + ).toBe(1); + expect(await countRows(`SELECT COUNT(*) n FROM issued_labels WHERE uri = ?`, uri)).toBe(0); + expect( + await countRows(`SELECT COUNT(*) n FROM operational_events WHERE subject_uri = ?`, uri), + ).toBe(0); + + const outboxBefore = await countRows(`SELECT COUNT(*) n FROM notification_outbox`); + const retry = await handleConsoleMutation( + post("/admin/api/emergency/takedown", body, { token: adminToken }), + mutationDeps(), + ); + expect(retry.status).toBe(503); + expect((await bodyError(retry)).code).toBe("LABEL_ISSUANCE_UNAVAILABLE"); + expect(await countRows(`SELECT COUNT(*) n FROM issued_labels WHERE uri = ?`, uri)).toBe(0); + expect( + await countRows(`SELECT COUNT(*) n FROM operational_events WHERE subject_uri = ?`, uri), + ).toBe(0); + expect(await countRows(`SELECT COUNT(*) n FROM notification_outbox`)).toBe(outboxBefore); + }); +}); + +describe("console mutation: emergency retract resting state", () => { + it("re-exposes pre-takedown automated blocks and re-issues nothing (evaluator agrees)", async () => { + const uri = releaseUri("emrg-rest"); + await seedAutomatedBlock(uri, CID); + + const takedown = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown", { + uri, + subjectConfirmation: "emrg-rest", + intent: "CONFIRM TAKEDOWN", + }), + mutationDeps(), + ); + expect(takedown.status).toBe(200); + + const redacted = evaluateHydratedReleaseModeration({ + acceptedLabelers: [{ did: LABELER_DID, redact: true }], + context: { + publisherDid: PUBLISHER_DID, + package: { uri, cid: CID }, + release: { uri, cid: CID }, + }, + evaluatedAt: new Date(), + labels: await moderationLabelsFor(uri), + }); + expect(redacted.redacted).toBe(true); + + const retract = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown-retract", { + uri, + subjectConfirmation: "emrg-rest", + intent: "CONFIRM RETRACT", + }), + mutationDeps(), + ); + expect(retract.status).toBe(200); + expect((await bodyData(retract)).neg).toBe(true); + + // Resting state: the takedown is inactive, the automated malware block is + // active again (it was never negated), and nothing was re-issued for it. + const winners = await getActiveLabelState(testEnv.DB, { src: LABELER_DID, uri, cid: CID }); + expect(winners.get("!takedown")?.active).toBe(false); + expect(winners.get("malware")?.active).toBe(true); + expect( + await countRows( + `SELECT COUNT(*) n FROM issued_labels WHERE uri = ? AND val = 'malware'`, + uri, + ), + ).toBe(1); + + const atRest = evaluateHydratedReleaseModeration({ + acceptedLabelers: [{ did: LABELER_DID, redact: true }], + context: { + publisherDid: PUBLISHER_DID, + package: { uri, cid: CID }, + release: { uri, cid: CID }, + }, + evaluatedAt: new Date(), + labels: await moderationLabelsFor(uri), + }); + expect(atRest.redacted).toBe(false); + expect(atRest.eligibility).toBe("blocked"); + expect(atRest.blockingLabels).toContain("malware"); + }); + + it("emits a high-severity operational event for a retract", async () => { + const response = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown-retract", { + uri: PUBLISHER_DID, + subjectConfirmation: PUBLISHER_SEGMENT, + intent: "CONFIRM RETRACT", + }), + mutationDeps(), + ); + expect(response.status).toBe(200); + const descriptor = await bodyData(response); + expect( + await countRows( + `SELECT COUNT(*) n FROM operational_events WHERE action_id = ? AND severity = 'high'`, + descriptor.actionId, + ), + ).toBe(1); + }); +}); + +describe("console mutation: emergency §10 automation interaction", () => { + it("automation cannot negate an admin-issued !takedown", async () => { + const uri = await seedReleaseSubject("emrg-s10"); + const takedown = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown", { + uri, + subjectConfirmation: "emrg-s10", + intent: "CONFIRM TAKEDOWN", + }), + mutationDeps(), + ); + expect(takedown.status).toBe(200); + + // The automated issuance path refuses the emergency vocabulary outright, so + // automation can never reach — let alone negate — a manually-headed takedown. + await expect( + issueAutomatedAssessmentLabel( + testEnv.DB, + CONFIG, + await testSigner(), + { + actor: LABELER_DID, + type: "automated-assessment", + assessmentId: `asmt_${"0".repeat(26)}`, + reason: "automated negation attempt", + idempotencyKey: nextKey(), + }, + { uri, cid: CID, val: "!takedown", neg: true }, + ), + ).rejects.toThrow(); + + const winners = await getActiveLabelState(testEnv.DB, { src: LABELER_DID, uri, cid: CID }); + expect(winners.get("!takedown")?.active).toBe(true); + }); +}); From 05afa5978b09bb7c8ec6e374366fea62f90a1011 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 14 Jul 2026 00:18:59 +0100 Subject: [PATCH 058/137] feat(labeler): admin pause/resume of automated issuance (W9.6 PR-2) (#2027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two admin-only endpoints toggle the global ingestion kill-switch through the mutation guard: the effect flips automation_state.paused and emits an ungated operational event (automation-paused high / automation-resumed info) with the audit row in one batch. System status now reports the paused state, reason, and since; the console dashboard gains an admin-gated pause/resume control. Enforcement already lives in the discovery consumer (PR-0) — this wires the operator surface that drives it, proven end to end (pause endpoint -> consumer retries -> resume -> processes). Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- apps/labeler/console/src/api/client.ts | 26 ++ apps/labeler/console/src/api/types.ts | 22 ++ .../src/components/AutomationControl.test.tsx | 98 ++++++ .../src/components/AutomationControl.tsx | 112 +++++++ .../console/src/fixtures/system-status.ts | 3 + apps/labeler/console/src/routes/Dashboard.tsx | 8 + apps/labeler/src/console-api.ts | 18 +- apps/labeler/src/console-mutation-api.ts | 74 +++++ apps/labeler/test/console-api.test.ts | 30 ++ .../labeler/test/console-mutation-api.test.ts | 287 +++++++++++++++++- 10 files changed, 676 insertions(+), 2 deletions(-) create mode 100644 apps/labeler/console/src/components/AutomationControl.test.tsx create mode 100644 apps/labeler/console/src/components/AutomationControl.tsx diff --git a/apps/labeler/console/src/api/client.ts b/apps/labeler/console/src/api/client.ts index 716ad0fa7e..51ed7eaa12 100644 --- a/apps/labeler/console/src/api/client.ts +++ b/apps/labeler/console/src/api/client.ts @@ -9,6 +9,8 @@ import { import type { AssessmentActionInput, AssessmentRun, + AutomationToggleInput, + AutomationToggleResult, EffectPreview, EffectPreviewParams, EmergencyActionInput, @@ -60,6 +62,8 @@ export interface LabelerConsoleClient { mode: "issue" | "retract", input: EmergencyActionInput, ): Promise; + pauseAutomation(input: AutomationToggleInput): Promise; + resumeAutomation(input: AutomationToggleInput): Promise; } /** The admin-only emergency endpoints, keyed by action and direction. */ @@ -220,6 +224,12 @@ export function createFetchClient(): LabelerConsoleClient { async emergencyAction(kind, mode, input) { return postAction(EMERGENCY_PATHS[kind][mode], input, "Failed to submit emergency action"); }, + async pauseAutomation(input) { + return postAction("/automation/pause", input, "Failed to pause automation"); + }, + async resumeAutomation(input) { + return postAction("/automation/resume", input, "Failed to resume automation"); + }, }; } @@ -337,6 +347,22 @@ export function createFixtureClient(): LabelerConsoleClient { effect: kind === "takedown" ? "redact" : "block", }; }, + async pauseAutomation(input) { + return { + actionId: "oact_fixture", + paused: true, + reason: input.reason, + cts: new Date().toISOString(), + }; + }, + async resumeAutomation(input) { + return { + actionId: "oact_fixture", + paused: false, + reason: input.reason, + cts: new Date().toISOString(), + }; + }, }; } diff --git a/apps/labeler/console/src/api/types.ts b/apps/labeler/console/src/api/types.ts index 01db8a652b..29f00ec0db 100644 --- a/apps/labeler/console/src/api/types.ts +++ b/apps/labeler/console/src/api/types.ts @@ -135,6 +135,11 @@ export interface SystemStatusSnapshot { /** Dead-letter backlog — the observable stand-in for discovery-queue depth, * which the Queues API does not expose to the consumer Worker. */ deadLetterDepth: number; + /** The global ingestion kill-switch (spec §11.3). `pausedReason`/`pausedSince` + * are non-null only while paused. */ + automationPaused: boolean; + pausedReason: string | null; + pausedSince: string | null; } export interface Page { @@ -313,6 +318,23 @@ export interface EmergencyActionInput { idempotencyKey: string; } +/** Body for the admin-only pause/resume endpoints (`POST /admin/api/automation/*`). + * A required reason (folded into the audit row, the operational event, and — for + * a pause — `automation_state.paused_reason`) and a client-minted idempotency key + * reused across retries so a network retry replays rather than double-toggles. */ +export interface AutomationToggleInput { + reason: string; + idempotencyKey: string; +} + +/** Idempotent pause/resume result — the kill-switch's post-action state. */ +export interface AutomationToggleResult { + actionId: string; + paused: boolean; + reason: string; + cts: string; +} + export interface ListAssessmentsParams { state?: PublicAssessmentState; cursor?: string; diff --git a/apps/labeler/console/src/components/AutomationControl.test.tsx b/apps/labeler/console/src/components/AutomationControl.test.tsx new file mode 100644 index 0000000000..dd50547dcc --- /dev/null +++ b/apps/labeler/console/src/components/AutomationControl.test.tsx @@ -0,0 +1,98 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { apiClient } from "../api/client.js"; +import { AutomationControl } from "./AutomationControl.js"; + +vi.mock("../api/client.js", () => ({ + apiClient: { pauseAutomation: vi.fn(), resumeAutomation: vi.fn() }, +})); + +const pauseAutomation = vi.mocked(apiClient.pauseAutomation); +const resumeAutomation = vi.mocked(apiClient.resumeAutomation); + +interface RenderOverrides { + paused?: boolean; + pausedReason?: string | null; + isAdmin?: boolean; +} + +function renderControl(overrides: RenderOverrides = {}) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); +} + +afterEach(() => { + pauseAutomation.mockReset(); + resumeAutomation.mockReset(); +}); + +describe("AutomationControl", () => { + it("shows Active and pauses through the required-reason dialog for an admin", async () => { + pauseAutomation.mockResolvedValue({ + actionId: "oact_1", + paused: true, + reason: "incident", + cts: "2026-07-13T00:00:00.000Z", + }); + renderControl({ paused: false }); + expect(screen.getByText("Active")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Pause ingestion" })); + const submit = screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement; + expect(submit.disabled).toBe(true); + + fireEvent.change(screen.getByPlaceholderText("Why ingestion is being paused"), { + target: { value: "incident" }, + }); + expect(submit.disabled).toBe(false); + fireEvent.click(submit); + + await waitFor(() => { + expect(pauseAutomation).toHaveBeenCalledWith( + expect.objectContaining({ reason: "incident" }), + ); + }); + expect(resumeAutomation).not.toHaveBeenCalled(); + }); + + it("shows Paused with the reason and resumes when paused", async () => { + resumeAutomation.mockResolvedValue({ + actionId: "oact_2", + paused: false, + reason: "cleared", + cts: "2026-07-13T00:00:00.000Z", + }); + renderControl({ paused: true, pausedReason: "incident-42" }); + expect(screen.getByText("Paused")).toBeTruthy(); + expect(screen.getByText("incident-42")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Resume ingestion" })); + fireEvent.change(screen.getByPlaceholderText("Why ingestion is being resumed"), { + target: { value: "cleared" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Resume" })); + + await waitFor(() => { + expect(resumeAutomation).toHaveBeenCalledWith( + expect.objectContaining({ reason: "cleared" }), + ); + }); + expect(pauseAutomation).not.toHaveBeenCalled(); + }); + + it("hides the toggle for a non-admin (server stays authoritative)", () => { + renderControl({ paused: false, isAdmin: false }); + expect(screen.getByText("Active")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Pause ingestion" })).toBeNull(); + }); +}); diff --git a/apps/labeler/console/src/components/AutomationControl.tsx b/apps/labeler/console/src/components/AutomationControl.tsx new file mode 100644 index 0000000000..22b6a9f01d --- /dev/null +++ b/apps/labeler/console/src/components/AutomationControl.tsx @@ -0,0 +1,112 @@ +import { Button, Dialog, InputArea, LayerCard } from "@cloudflare/kumo"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { ulid } from "ulidx"; + +import { apiClient } from "../api/client.js"; + +interface AutomationControlProps { + paused: boolean; + pausedReason: string | null; + /** Cosmetic gating from `/whoami` — the server (`guardMutation`) is the + * enforcement boundary, so hiding the toggle never grants anything. */ + isAdmin: boolean; +} + +/** + * The admin-only global ingestion kill-switch control (spec §11.3, design §5). + * Shows whether automated issuance is live or paused and, for an admin, toggles + * it behind a required reason. Pausing halts Jetstream ingestion only — in-flight + * work and manual/emergency issuance stay available. The idempotency key is + * minted per dialog open and reused across retries so a network retry replays + * rather than double-toggling. + */ +export function AutomationControl({ paused, pausedReason, isAdmin }: AutomationControlProps) { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [reason, setReason] = useState(""); + const [idempotencyKey, setIdempotencyKey] = useState(""); + + useEffect(() => { + if (!open) return; + setIdempotencyKey(ulid()); + setReason(""); + }, [open]); + + const mutation = useMutation({ + mutationFn: () => + paused + ? apiClient.resumeAutomation({ reason, idempotencyKey }) + : apiClient.pauseAutomation({ reason, idempotencyKey }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["system-status"] }); + setOpen(false); + }, + }); + + const canSubmit = reason.trim().length > 0 && !mutation.isPending; + + return ( + +
+
+ Automated issuance + + {paused ? "Paused" : "Active"} + + {paused && pausedReason && ( + {pausedReason} + )} +
+ {isAdmin && ( + + )} +
+ + + + + {paused ? "Resume automated issuance" : "Pause automated issuance"} + + + {paused + ? "Resumes Jetstream ingestion. New releases are discovered and assessed automatically again." + : "Halts Jetstream ingestion globally. Manual and emergency issuance are unaffected; queued releases retry until you resume."} + + + + + {mutation.isError && ( +

+ {mutation.error instanceof Error ? mutation.error.message : "Action failed"} +

+ )} + +
+ +
+
+
+
+ ); +} diff --git a/apps/labeler/console/src/fixtures/system-status.ts b/apps/labeler/console/src/fixtures/system-status.ts index 02ccd62a72..9e4e6eb992 100644 --- a/apps/labeler/console/src/fixtures/system-status.ts +++ b/apps/labeler/console/src/fixtures/system-status.ts @@ -6,4 +6,7 @@ export const FIXTURE_SYSTEM_STATUS: SystemStatusSnapshot = { jetstreamConnected: true, pendingAssessments: 1, deadLetterDepth: 2, + automationPaused: false, + pausedReason: null, + pausedSince: null, }; diff --git a/apps/labeler/console/src/routes/Dashboard.tsx b/apps/labeler/console/src/routes/Dashboard.tsx index 872ec2b07f..303fb2592e 100644 --- a/apps/labeler/console/src/routes/Dashboard.tsx +++ b/apps/labeler/console/src/routes/Dashboard.tsx @@ -4,6 +4,7 @@ import { createRoute } from "@tanstack/react-router"; import * as React from "react"; import { apiClient } from "../api/client.js"; +import { AutomationControl } from "../components/AutomationControl.js"; import { QueryError } from "../components/QueryError.js"; import { shellRoute } from "./root.js"; @@ -26,6 +27,8 @@ function Dashboard() { queryKey: ["system-status"], queryFn: () => apiClient.getSystemStatus(), }); + const { data: whoami } = useQuery({ queryKey: ["whoami"], queryFn: () => apiClient.whoami() }); + const isAdmin = whoami?.roles.includes("admin") ?? false; if (isError) { return ( @@ -59,6 +62,11 @@ function Dashboard() { + Labeler DID: {status.labelerDid} diff --git a/apps/labeler/src/console-api.ts b/apps/labeler/src/console-api.ts index be9df8292d..b35c507b09 100644 --- a/apps/labeler/src/console-api.ts +++ b/apps/labeler/src/console-api.ts @@ -321,9 +321,10 @@ async function handleListAuditLog( async function handleGetStatus(request: Request, deps: ConsoleApiDeps): Promise { requireGet(request); - const [pendingAssessments, deadLetterDepth, jetstreamConnected] = await Promise.all([ + const [pendingAssessments, deadLetterDepth, automation, jetstreamConnected] = await Promise.all([ countInFlightAssessments(deps.db), countDeadLetters(deps.db), + readAutomationState(deps.db), deps.jetstreamConnected(), ]); return jsonData({ @@ -331,9 +332,24 @@ async function handleGetStatus(request: Request, deps: ConsoleApiDeps): Promise< jetstreamConnected, pendingAssessments, deadLetterDepth, + automationPaused: automation.paused, + pausedReason: automation.reason, + pausedSince: automation.since, }); } +/** The ingestion kill-switch state for the operator dashboard. `reason`/`since` + * are meaningful only while paused; both read null when ingestion is live. */ +async function readAutomationState( + db: D1Database, +): Promise<{ paused: boolean; reason: string | null; since: string | null }> { + const row = await db + .prepare(`SELECT paused, paused_reason, updated_at FROM automation_state WHERE id = 1`) + .first<{ paused: number; paused_reason: string | null; updated_at: string }>(); + if (!row || row.paused !== 1) return { paused: false, reason: null, since: null }; + return { paused: true, reason: row.paused_reason, since: row.updated_at }; +} + /** The caller's own verified identity — kind, principal, and roles — for the * console's cosmetic button gating. The server remains the enforcement boundary * (`guardMutation`'s role gate); hiding a button never grants anything. */ diff --git a/apps/labeler/src/console-mutation-api.ts b/apps/labeler/src/console-mutation-api.ts index 6aa1c87845..0905910f90 100644 --- a/apps/labeler/src/console-mutation-api.ts +++ b/apps/labeler/src/console-mutation-api.ts @@ -27,6 +27,7 @@ import { getAssessment, type Assessment, } from "./assessment-store.js"; +import { buildAutomationPauseUpdate } from "./automation-state.js"; import type { LabelerIdentityConfig } from "./config.js"; import { commitMutation, @@ -168,6 +169,10 @@ export async function handleConsoleMutation( if (segments[1] === "publisher-compromised-retract") return await runEmergencyAction(request, deps, "publisher-compromised", true); } + if (segments[0] === "automation" && segments.length === 2) { + if (segments[1] === "pause") return await runAutomationToggle(request, deps, true); + if (segments[1] === "resume") return await runAutomationToggle(request, deps, false); + } throw new ReadGuardError("NOT_FOUND"); } catch (error) { if ( @@ -979,3 +984,72 @@ function assertEmergencyConfirmation( function didFinalSegment(uri: string): string { return uri.split(":").at(-1) ?? ""; } + +// ─── W9.6: admin-only automation kill-switch (pause/resume ingestion) ──────── + +/** The idempotent pause/resume result. `paused` is the switch's post-action + * state; `reason` echoes the operator reason folded into the audit row and the + * operational event. */ +interface AutomationToggleDescriptor { + actionId: string; + paused: boolean; + reason: string; + cts: string; +} + +/** + * `POST /admin/api/automation/{pause,resume}` — the admin-only global ingestion + * kill-switch (spec §11.3, design §5). Flips `automation_state.paused` and emits + * an ungated operational event in one atomic `commitMutation` batch with the + * audit row. Unlike the emergency actions it issues no label: the effect is a + * state UPDATE plus an unconditional event insert, so there is no confirmation + * ceremony and no phantom-label verification. A required `reason` is the only + * body field and folds into the request fingerprint, so a replay must resend it. + * + * The switch gates only the discovery consumer's ingestion path — manual/admin + * issuance (including an emergency `!takedown`) stays available while paused. + * Pausing an already-paused switch is a harmless idempotent re-write; each + * distinct action is independently audited and emits its own event. + */ +async function runAutomationToggle( + request: Request, + deps: ConsoleMutationDeps, + paused: boolean, +): Promise { + const spec: MutationSpec> = { + action: paused ? "pause-issuance" : "resume-issuance", + requiredRole: "admin", + parseBody: () => ({}), + auditFields: () => ({ metadata: { paused } }), + }; + + const outcome = await guardMutation(request, spec, guardDepsOf(deps)); + if (outcome.outcome === "replay") + return jsonData(storedDescriptor(outcome.result)); + + const { ctx } = outcome; + const stateUpdate = buildAutomationPauseUpdate(deps.db, { + paused, + reason: paused ? ctx.reason : null, + actionId: ctx.actionId, + now: ctx.now, + }); + const eventInsert = buildOperationalEventInsert(deps.db, { + id: newOperationalEventId(), + eventType: paused ? "automation-paused" : "automation-resumed", + severity: paused ? "high" : "info", + actionId: ctx.actionId, + payload: { reason: ctx.reason }, + now: ctx.now, + }); + + const descriptor: AutomationToggleDescriptor = { + actionId: ctx.actionId, + paused, + reason: ctx.reason, + cts: ctx.now.toISOString(), + }; + return jsonData( + await commitMutation(deps.db, ctx, spec, [stateUpdate, eventInsert], descriptor), + ); +} diff --git a/apps/labeler/test/console-api.test.ts b/apps/labeler/test/console-api.test.ts index 423f6abb55..c5d807771a 100644 --- a/apps/labeler/test/console-api.test.ts +++ b/apps/labeler/test/console-api.test.ts @@ -637,14 +637,44 @@ describe("handleConsoleApi — system status", () => { jetstreamConnected: boolean; pendingAssessments: number; deadLetterDepth: number; + automationPaused: boolean; + pausedReason: string | null; + pausedSince: string | null; }; expect(status.labelerDid).toBe(LABELER_DID); expect(status.jetstreamConnected).toBe(false); // asmt_run (running) + asmt_pending (pending). expect(status.pendingAssessments).toBe(2); expect(status.deadLetterDepth).toBe(3); + // Ingestion runs by default: the seeded kill-switch reads unpaused. + expect(status.automationPaused).toBe(false); + expect(status.pausedReason).toBeNull(); + expect(status.pausedSince).toBeNull(); expect(status).not.toHaveProperty("lastReconciliationAt"); }); + + it("reports the paused state, reason, and since when ingestion is paused", async () => { + await testEnv.DB.prepare( + `UPDATE automation_state + SET paused = 1, paused_reason = 'incident-77', updated_at = '2026-07-13T12:00:00.000Z' + WHERE id = 1`, + ).run(); + try { + const res = await handleConsoleApi(req("/admin/api/status", { token: reviewerToken }), deps()); + const status = (await body(res)).data as { + automationPaused: boolean; + pausedReason: string | null; + pausedSince: string | null; + }; + expect(status.automationPaused).toBe(true); + expect(status.pausedReason).toBe("incident-77"); + expect(status.pausedSince).toBe("2026-07-13T12:00:00.000Z"); + } finally { + await testEnv.DB.prepare( + `UPDATE automation_state SET paused = 0, paused_reason = NULL WHERE id = 1`, + ).run(); + } + }); }); describe("consoleAssetPath — asset prefix rewrite", () => { diff --git a/apps/labeler/test/console-mutation-api.test.ts b/apps/labeler/test/console-mutation-api.test.ts index 495be5a8d7..46785f03f1 100644 --- a/apps/labeler/test/console-mutation-api.test.ts +++ b/apps/labeler/test/console-mutation-api.test.ts @@ -6,7 +6,7 @@ import { } from "@emdash-cms/registry-moderation"; import { applyD1Migrations, env } from "cloudflare:test"; import { generateKeyPair, SignJWT } from "jose"; -import { beforeAll, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; import type { AccessKeyResolver } from "../src/access-auth.js"; import { computeRunKey, initialTriggerId } from "../src/assessment-lifecycle.js"; @@ -21,7 +21,14 @@ import { type ConsoleMutationDeps, type IssuedLabelDescriptor, } from "../src/console-mutation-api.js"; +import { + processDiscoveryMessage, + type DiscoveryConsumerDeps, + type MessageController, +} from "../src/discovery-consumer.js"; +import type { DiscoveryJob } from "../src/env.js"; import { OPERATOR_REQUEST_HEADER } from "../src/mutation-guard.js"; +import type { DidDocumentResolverLike } from "../src/record-verification.js"; import { issueAutomatedAssessmentLabel, issueManualLabel } from "../src/service.js"; const TEAM_DOMAIN = "https://example-team.cloudflareaccess.com"; @@ -1292,3 +1299,281 @@ describe("console mutation: emergency §10 automation interaction", () => { expect(winners.get("!takedown")?.active).toBe(true); }); }); + +// ─── W9.6: admin-only automation kill-switch (pause/resume) ────────────────── + +function automation( + path: string, + token: string | null = adminToken, + body: Record = {}, +): Request { + return post(path, { reason: "incident response", idempotencyKey: nextKey(), ...body }, { token }); +} + +async function automationRow(): Promise<{ paused: number; reason: string | null } | null> { + return testEnv.DB.prepare( + `SELECT paused, paused_reason AS reason FROM automation_state WHERE id = 1`, + ).first<{ paused: number; reason: string | null }>(); +} + +async function resetAutomation(): Promise { + await testEnv.DB.prepare( + `UPDATE automation_state SET paused = 0, paused_reason = NULL WHERE id = 1`, + ).run(); +} + +interface AutomationToggleDescriptor { + actionId: string; + paused: boolean; + reason: string; + cts: string; +} + +describe("console mutation: automation pause/resume (admin)", () => { + afterEach(resetAutomation); + + it("pause flips the switch and emits one automation-paused event + audit row", async () => { + const response = await handleConsoleMutation( + automation("/admin/api/automation/pause"), + mutationDeps(), + ); + expect(response.status).toBe(200); + const descriptor = await bodyData(response); + expect(descriptor).toMatchObject({ paused: true, reason: "incident response" }); + + const row = await automationRow(); + expect(row?.paused).toBe(1); + expect(row?.reason).toBe("incident response"); + expect( + await countRows( + `SELECT COUNT(*) n FROM operator_actions WHERE id = ? AND action = 'pause-issuance'`, + descriptor.actionId, + ), + ).toBe(1); + expect( + await countRows( + `SELECT COUNT(*) n FROM operational_events + WHERE action_id = ? AND event_type = 'automation-paused' AND severity = 'high'`, + descriptor.actionId, + ), + ).toBe(1); + // Pause issues no label and queues no delivery row. + expect(await outboxCount(descriptor.actionId)).toBe(0); + + // T8: the event carries the operator reason only, with no subject or label. + const event = await testEnv.DB.prepare( + `SELECT payload_json, subject_uri, label_value FROM operational_events WHERE action_id = ?`, + ) + .bind(descriptor.actionId) + .first<{ payload_json: string; subject_uri: string | null; label_value: string | null }>(); + expect(event).toMatchObject({ subject_uri: null, label_value: null }); + expect(JSON.parse(event!.payload_json)).toEqual({ reason: "incident response" }); + }); + + it("resume flips the switch back and emits an automation-resumed event", async () => { + await handleConsoleMutation(automation("/admin/api/automation/pause"), mutationDeps()); + expect((await automationRow())?.paused).toBe(1); + + const response = await handleConsoleMutation( + automation("/admin/api/automation/resume"), + mutationDeps(), + ); + expect(response.status).toBe(200); + const descriptor = await bodyData(response); + expect(descriptor.paused).toBe(false); + + const row = await automationRow(); + expect(row?.paused).toBe(0); + expect(row?.reason).toBeNull(); + expect( + await countRows( + `SELECT COUNT(*) n FROM operational_events + WHERE action_id = ? AND event_type = 'automation-resumed' AND severity = 'info'`, + descriptor.actionId, + ), + ).toBe(1); + }); +}); + +describe("console mutation: automation authorization (per-endpoint, pre-effect)", () => { + afterEach(resetAutomation); + + it.each(["/admin/api/automation/pause", "/admin/api/automation/resume"])( + "rejects a reviewer with zero side effects: %s", + async (path) => { + const before = { + actions: await countRows(`SELECT COUNT(*) n FROM operator_actions`), + events: await countRows(`SELECT COUNT(*) n FROM operational_events`), + paused: (await automationRow())?.paused, + }; + const response = await handleConsoleMutation(automation(path, reviewerToken), mutationDeps()); + expect(response.status).toBe(403); + expect((await bodyError(response)).code).toBe("FORBIDDEN_ROLE"); + expect(await countRows(`SELECT COUNT(*) n FROM operator_actions`)).toBe(before.actions); + expect(await countRows(`SELECT COUNT(*) n FROM operational_events`)).toBe(before.events); + expect((await automationRow())?.paused).toBe(before.paused); + }, + ); + + it.each(["/admin/api/automation/pause", "/admin/api/automation/resume"])( + "accepts an admin (the 403 is the role, not a broken endpoint): %s", + async (path) => { + const response = await handleConsoleMutation(automation(path, adminToken), mutationDeps()); + expect(response.status).toBe(200); + }, + ); + + it("rejects an unauthenticated caller with zero side effects (401)", async () => { + const before = { + actions: await countRows(`SELECT COUNT(*) n FROM operator_actions`), + events: await countRows(`SELECT COUNT(*) n FROM operational_events`), + paused: (await automationRow())?.paused, + }; + const response = await handleConsoleMutation( + automation("/admin/api/automation/pause", null), + mutationDeps(), + ); + expect(response.status).toBe(401); + expect(await countRows(`SELECT COUNT(*) n FROM operator_actions`)).toBe(before.actions); + expect(await countRows(`SELECT COUNT(*) n FROM operational_events`)).toBe(before.events); + expect((await automationRow())?.paused).toBe(before.paused); + }); +}); + +describe("console mutation: automation replay and idempotency", () => { + afterEach(resetAutomation); + + it("replays the stored result for the same key + reason, toggling once", async () => { + const key = nextKey(); + const request = () => + post( + "/admin/api/automation/pause", + { reason: "same reason", idempotencyKey: key }, + { token: adminToken }, + ); + const first = await handleConsoleMutation(request(), mutationDeps()); + const firstBody = await first.text(); + const second = await handleConsoleMutation(request(), mutationDeps()); + expect(second.status).toBe(200); + expect(await second.text()).toBe(firstBody); + + const descriptor = (JSON.parse(firstBody) as { data: AutomationToggleDescriptor }).data; + expect(await eventCount(descriptor.actionId)).toBe(1); + expect((await automationRow())?.paused).toBe(1); + }); + + it("409s a same-key request whose reason differs, with no second event", async () => { + const key = nextKey(); + const first = await handleConsoleMutation( + post( + "/admin/api/automation/pause", + { reason: "first reason", idempotencyKey: key }, + { token: adminToken }, + ), + mutationDeps(), + ); + expect(first.status).toBe(200); + const eventsBefore = await countRows(`SELECT COUNT(*) n FROM operational_events`); + + const second = await handleConsoleMutation( + post( + "/admin/api/automation/pause", + { reason: "different reason", idempotencyKey: key }, + { token: adminToken }, + ), + mutationDeps(), + ); + expect(second.status).toBe(409); + expect((await bodyError(second)).code).toBe("IDEMPOTENCY_KEY_CONFLICT"); + expect(await countRows(`SELECT COUNT(*) n FROM operational_events`)).toBe(eventsBefore); + }); + + it("a second distinct pause while already paused is a harmless success", async () => { + const first = await bodyData( + await handleConsoleMutation(automation("/admin/api/automation/pause"), mutationDeps()), + ); + const secondResponse = await handleConsoleMutation( + automation("/admin/api/automation/pause"), + mutationDeps(), + ); + expect(secondResponse.status).toBe(200); + const second = await bodyData(secondResponse); + expect(second.paused).toBe(true); + expect(second.actionId).not.toBe(first.actionId); + + // Still paused; each distinct action is independently audited and emits its + // own event. + expect((await automationRow())?.paused).toBe(1); + expect(await eventCount(first.actionId)).toBe(1); + expect(await eventCount(second.actionId)).toBe(1); + }); +}); + +class KillSwitchMessage implements MessageController { + acked = 0; + retried = 0; + ack() { + this.acked += 1; + } + retry() { + this.retried += 1; + } +} + +class KillSwitchStubResolver implements DidDocumentResolverLike { + resolve(): never { + throw new Error("resolver should not be called — verify is injected"); + } +} + +describe("console mutation: pause endpoint drives the discovery consumer gate (end-to-end)", () => { + afterEach(resetAutomation); + + it("retries ingestion while paused, processes after resume", async () => { + const rkeyName = "kill-switch-e2e"; + const uri = releaseUri(rkeyName); + const job: DiscoveryJob = { + did: PUBLISHER_DID, + collection: "com.emdashcms.experimental.package.release", + operation: "create", + cid: CID, + rkey: rkeyName, + }; + const consumerDeps: DiscoveryConsumerDeps = { + db: testEnv.DB, + config: CONFIG, + signer: await testSigner(), + didDocumentResolver: new KillSwitchStubResolver(), + verify: () => + Promise.resolve({ + cid: CID, + record: { $type: job.collection, package: rkeyName, version: "1.0.0" }, + carBytes: new Uint8Array([0xde, 0xad, 0xbe, 0xef]), + }), + }; + + const paused = await handleConsoleMutation( + automation("/admin/api/automation/pause"), + mutationDeps(), + ); + expect(paused.status).toBe(200); + + const pausedMsg = new KillSwitchMessage(); + await processDiscoveryMessage(job, pausedMsg, consumerDeps); + expect(pausedMsg.retried).toBe(1); + expect(pausedMsg.acked).toBe(0); + expect(await countRows(`SELECT COUNT(*) n FROM subjects WHERE uri = ?`, uri)).toBe(0); + + const resumed = await handleConsoleMutation( + automation("/admin/api/automation/resume"), + mutationDeps(), + ); + expect(resumed.status).toBe(200); + + const resumedMsg = new KillSwitchMessage(); + await processDiscoveryMessage(job, resumedMsg, consumerDeps); + expect(resumedMsg.acked).toBe(1); + expect(resumedMsg.retried).toBe(0); + expect(await countRows(`SELECT COUNT(*) n FROM subjects WHERE uri = ?`, uri)).toBe(1); + }); +}); From ea31d766785b40ae378f04bbcc729199400d16a8 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 14 Jul 2026 01:01:48 +0100 Subject: [PATCH 059/137] feat(labeler): admin dead-letter retry and quarantine controls (W9.6 PR-3) (#2028) Two admin-only endpoints act on the dead_letters status columns from PR-0: retry flips a new letter to retried, writes the resolving action, emits a dead-letter-retried event, and re-enqueues the job to the discovery queue on a deferred tail (the consumer's runKey dedup makes a double-drive converge); quarantine is a terminal reviewed state with no re-enqueue. Both guard on status='new' so a second concurrent action 409s rather than double-processing. The status health count now excludes resolved letters, a reviewer-visible list route paginates the queue, and the console gains a dead-letter view with admin-gated actions. Dead letters now persist the full discovery job so a retry can re-verify with the original cid. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- apps/labeler/console/src/api/client.test.ts | 53 +++ apps/labeler/console/src/api/client.ts | 48 +++ apps/labeler/console/src/api/types.ts | 40 +++ .../src/components/DeadLetterActions.test.tsx | 81 +++++ .../src/components/DeadLetterActions.tsx | 134 +++++++ .../console/src/components/Sidebar.tsx | 3 +- .../console/src/fixtures/dead-letters.ts | 42 +++ apps/labeler/console/src/fixtures/index.ts | 1 + apps/labeler/console/src/router.tsx | 2 + .../console/src/routes/DeadLetterQueue.tsx | 107 ++++++ apps/labeler/src/console-api.ts | 50 ++- apps/labeler/src/console-mutation-api.ts | 185 +++++++++- apps/labeler/src/console-serialize.ts | 32 ++ apps/labeler/src/dead-letters.ts | 155 ++++++++ apps/labeler/src/discovery-consumer.ts | 5 +- apps/labeler/src/index.ts | 3 + apps/labeler/test/console-api.test.ts | 72 +++- .../test/console-assessment-mutations.test.ts | 1 + .../labeler/test/console-mutation-api.test.ts | 333 ++++++++++++++++++ 19 files changed, 1339 insertions(+), 8 deletions(-) create mode 100644 apps/labeler/console/src/components/DeadLetterActions.test.tsx create mode 100644 apps/labeler/console/src/components/DeadLetterActions.tsx create mode 100644 apps/labeler/console/src/fixtures/dead-letters.ts create mode 100644 apps/labeler/console/src/routes/DeadLetterQueue.tsx create mode 100644 apps/labeler/src/dead-letters.ts diff --git a/apps/labeler/console/src/api/client.test.ts b/apps/labeler/console/src/api/client.test.ts index a08ac338e2..182e52ae70 100644 --- a/apps/labeler/console/src/api/client.test.ts +++ b/apps/labeler/console/src/api/client.test.ts @@ -40,6 +40,14 @@ describe("fixture client", () => { expect(history?.subject.uri).toBe(assessment!.uri); expect(history?.assessments.some((a) => a.id === assessment!.id)).toBe(true); }); + + it("lists dead letters newest-first with a new (actionable) row", async () => { + const page = await apiClient.listDeadLetters(); + expect(page.items.length).toBeGreaterThan(0); + const ids = page.items.map((d) => d.id); + expect(ids).toEqual(ids.toSorted((a, b) => b - a)); + expect(page.items.some((d) => d.status === "new")).toBe(true); + }); }); describe("fetch client label actions", () => { @@ -210,6 +218,51 @@ describe("fetch client label actions", () => { expect(url).toContain("negate=impersonation"); }); + it("POSTs a dead-letter retry to the retry route with the threaded key", async () => { + stubFetch(() => + Response.json({ data: { actionId: "oact_1", deadLetterId: 42, status: "retried" } }), + ); + const result = await fetchClient.retryDeadLetter(42, { + reason: "re-drive", + idempotencyKey: input.idempotencyKey, + }); + expect(result).toMatchObject({ deadLetterId: 42, status: "retried" }); + const call = calls[0]!; + expect(call.url).toBe("/admin/api/dead-letters/42/retry"); + expect(call.init.method).toBe("POST"); + const headers = new Headers(call.init.headers); + expect(headers.get("X-EmDash-Request")).toBe("1"); + expect(headers.get("Content-Type")).toBe("application/json"); + expect(JSON.parse(call.init.body as string)).toMatchObject({ + reason: "re-drive", + idempotencyKey: input.idempotencyKey, + }); + }); + + it("POSTs a dead-letter quarantine to the quarantine route", async () => { + stubFetch(() => + Response.json({ data: { actionId: "oact_1", deadLetterId: 42, status: "quarantined" } }), + ); + await fetchClient.quarantineDeadLetter(42, { + reason: "reviewed", + idempotencyKey: input.idempotencyKey, + }); + expect(calls[0]!.url).toBe("/admin/api/dead-letters/42/quarantine"); + expect(calls[0]!.init.method).toBe("POST"); + }); + + it("surfaces the server 409 already-resolved message on a dead-letter retry", async () => { + stubFetch(() => + Response.json( + { error: { code: "DEAD_LETTER_RESOLVED", message: "Dead letter is already resolved" } }, + { status: 409 }, + ), + ); + await expect( + fetchClient.retryDeadLetter(42, { reason: "x", idempotencyKey: input.idempotencyKey }), + ).rejects.toThrow("Dead letter is already resolved"); + }); + it("surfaces a 409 idempotency conflict message", async () => { stubFetch(() => Response.json( diff --git a/apps/labeler/console/src/api/client.ts b/apps/labeler/console/src/api/client.ts index 51ed7eaa12..a0ac5069f1 100644 --- a/apps/labeler/console/src/api/client.ts +++ b/apps/labeler/console/src/api/client.ts @@ -1,5 +1,6 @@ import { FIXTURE_ASSESSMENTS, + FIXTURE_DEAD_LETTERS, FIXTURE_FINDINGS_BY_ASSESSMENT, FIXTURE_LABELS_BY_ASSESSMENT, FIXTURE_OPERATOR_ACTIONS, @@ -11,6 +12,9 @@ import type { AssessmentRun, AutomationToggleInput, AutomationToggleResult, + DeadLetter, + DeadLetterActionInput, + DeadLetterActionResult, EffectPreview, EffectPreviewParams, EmergencyActionInput, @@ -20,6 +24,7 @@ import type { LabelActionInput, ListAssessmentsParams, ListAuditLogParams, + ListDeadLettersParams, OperatorAction, OverrideActionInput, OverrideEffectPreviewParams, @@ -48,6 +53,7 @@ export interface LabelerConsoleClient { getSubjectHistory(uri: string): Promise; getSubjectLabels(uri: string, cid?: string): Promise; listAuditLog(params?: ListAuditLogParams): Promise>; + listDeadLetters(params?: ListDeadLettersParams): Promise>; getSystemStatus(): Promise; whoami(): Promise; previewEffect(params: EffectPreviewParams): Promise; @@ -64,6 +70,8 @@ export interface LabelerConsoleClient { ): Promise; pauseAutomation(input: AutomationToggleInput): Promise; resumeAutomation(input: AutomationToggleInput): Promise; + retryDeadLetter(id: number, input: DeadLetterActionInput): Promise; + quarantineDeadLetter(id: number, input: DeadLetterActionInput): Promise; } /** The admin-only emergency endpoints, keyed by action and direction. */ @@ -167,6 +175,13 @@ export function createFetchClient(): LabelerConsoleClient { const response = await consoleApiFetch(`/audit-log?${search.toString()}`); return parseJson(response, "Failed to load audit log"); }, + async listDeadLetters(params = {}) { + const search = new URLSearchParams(); + if (params.cursor) search.set("cursor", params.cursor); + if (params.limit) search.set("limit", String(params.limit)); + const response = await consoleApiFetch(`/dead-letters?${search.toString()}`); + return parseJson(response, "Failed to load dead letters"); + }, async getSystemStatus() { const response = await consoleApiFetch("/status"); return parseJson(response, "Failed to load system status"); @@ -230,6 +245,20 @@ export function createFetchClient(): LabelerConsoleClient { async resumeAutomation(input) { return postAction("/automation/resume", input, "Failed to resume automation"); }, + async retryDeadLetter(id, input) { + return postAction( + `/dead-letters/${encodeURIComponent(id)}/retry`, + input, + "Failed to retry dead letter", + ); + }, + async quarantineDeadLetter(id, input) { + return postAction( + `/dead-letters/${encodeURIComponent(id)}/quarantine`, + input, + "Failed to quarantine dead letter", + ); + }, }; } @@ -262,6 +291,9 @@ export function createFixtureClient(): LabelerConsoleClient { async listAuditLog() { return { items: [...FIXTURE_OPERATOR_ACTIONS] }; }, + async listDeadLetters() { + return { items: [...FIXTURE_DEAD_LETTERS] }; + }, async getSystemStatus() { return FIXTURE_SYSTEM_STATUS; }, @@ -363,6 +395,22 @@ export function createFixtureClient(): LabelerConsoleClient { cts: new Date().toISOString(), }; }, + async retryDeadLetter(id) { + return { + actionId: "oact_fixture", + deadLetterId: id, + status: "retried", + cts: new Date().toISOString(), + }; + }, + async quarantineDeadLetter(id) { + return { + actionId: "oact_fixture", + deadLetterId: id, + status: "quarantined", + cts: new Date().toISOString(), + }; + }, }; } diff --git a/apps/labeler/console/src/api/types.ts b/apps/labeler/console/src/api/types.ts index 29f00ec0db..7980262558 100644 --- a/apps/labeler/console/src/api/types.ts +++ b/apps/labeler/console/src/api/types.ts @@ -335,6 +335,41 @@ export interface AutomationToggleResult { cts: string; } +export type DeadLetterStatus = "new" | "retried" | "quarantined"; + +/** A `dead_letters` row from `GET /admin/api/dead-letters` — a discovery event + * that failed verification. The raw payload is never sent; the console shows the + * failure reason and resolution state. `new` letters are actionable (admin-only + * retry / quarantine); `retried` / `quarantined` are terminal. */ +export interface DeadLetter { + id: number; + did: string; + collection: string; + rkey: string; + reason: string; + detail: string | null; + status: DeadLetterStatus; + receivedAt: string; + resolvedAt: string | null; + resolvedByActionId: string | null; +} + +/** Body for the admin-only dead-letter endpoints (`POST /admin/api/dead-letters/:id/*`). + * A required reason and a client-minted idempotency key reused across retries so + * a network retry replays rather than re-drives twice. */ +export interface DeadLetterActionInput { + reason: string; + idempotencyKey: string; +} + +/** Idempotent retry / quarantine result — the dead letter's post-action status. */ +export interface DeadLetterActionResult { + actionId: string; + deadLetterId: number; + status: Exclude; + cts: string; +} + export interface ListAssessmentsParams { state?: PublicAssessmentState; cursor?: string; @@ -345,3 +380,8 @@ export interface ListAuditLogParams { cursor?: string; limit?: number; } + +export interface ListDeadLettersParams { + cursor?: string; + limit?: number; +} diff --git a/apps/labeler/console/src/components/DeadLetterActions.test.tsx b/apps/labeler/console/src/components/DeadLetterActions.test.tsx new file mode 100644 index 0000000000..e9f442c915 --- /dev/null +++ b/apps/labeler/console/src/components/DeadLetterActions.test.tsx @@ -0,0 +1,81 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { apiClient } from "../api/client.js"; +import { DeadLetterActions } from "./DeadLetterActions.js"; + +vi.mock("../api/client.js", () => ({ + apiClient: { retryDeadLetter: vi.fn(), quarantineDeadLetter: vi.fn() }, +})); + +const retryDeadLetter = vi.mocked(apiClient.retryDeadLetter); +const quarantineDeadLetter = vi.mocked(apiClient.quarantineDeadLetter); + +function renderActions(id = 7) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); +} + +afterEach(() => { + retryDeadLetter.mockReset(); + quarantineDeadLetter.mockReset(); +}); + +describe("DeadLetterActions", () => { + it("retries through the required-reason dialog, threading id and reason", async () => { + retryDeadLetter.mockResolvedValue({ + actionId: "oact_1", + deadLetterId: 7, + status: "retried", + cts: "2026-07-13T00:00:00.000Z", + }); + renderActions(7); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + const submit = screen.getByRole("button", { name: "Confirm retry" }) as HTMLButtonElement; + expect(submit.disabled).toBe(true); + + fireEvent.change(screen.getByPlaceholderText("Why this event is being re-driven"), { + target: { value: "transient PDS outage cleared" }, + }); + expect(submit.disabled).toBe(false); + fireEvent.click(submit); + + await waitFor(() => { + expect(retryDeadLetter).toHaveBeenCalledWith( + 7, + expect.objectContaining({ reason: "transient PDS outage cleared" }), + ); + }); + expect(quarantineDeadLetter).not.toHaveBeenCalled(); + }); + + it("quarantines through the required-reason dialog", async () => { + quarantineDeadLetter.mockResolvedValue({ + actionId: "oact_2", + deadLetterId: 7, + status: "quarantined", + cts: "2026-07-13T00:00:00.000Z", + }); + renderActions(7); + + fireEvent.click(screen.getByRole("button", { name: "Quarantine" })); + fireEvent.change(screen.getByPlaceholderText("Why this event is being quarantined"), { + target: { value: "not a real release" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Confirm quarantine" })); + + await waitFor(() => { + expect(quarantineDeadLetter).toHaveBeenCalledWith( + 7, + expect.objectContaining({ reason: "not a real release" }), + ); + }); + expect(retryDeadLetter).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/labeler/console/src/components/DeadLetterActions.tsx b/apps/labeler/console/src/components/DeadLetterActions.tsx new file mode 100644 index 0000000000..80005e74d0 --- /dev/null +++ b/apps/labeler/console/src/components/DeadLetterActions.tsx @@ -0,0 +1,134 @@ +import { Button, Dialog, InputArea } from "@cloudflare/kumo"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { ulid } from "ulidx"; + +import { apiClient } from "../api/client.js"; + +type Action = "retry" | "quarantine"; + +interface DialogCopy { + title: string; + description: string; + verb: string; + placeholder: string; + variant: "primary" | "destructive"; +} + +const DIALOG_COPY: Record = { + retry: { + title: "Retry dead letter", + description: + "Re-enqueues the failed discovery event. The consumer re-fetches and re-verifies the record; a duplicate re-drive converges on a single assessment.", + verb: "Confirm retry", + placeholder: "Why this event is being re-driven", + variant: "primary", + }, + quarantine: { + title: "Quarantine dead letter", + description: + "Marks the failed event reviewed and permanently excluded from retry. It leaves the dead-letter backlog and is not re-driven.", + verb: "Confirm quarantine", + placeholder: "Why this event is being quarantined", + variant: "destructive", + }, +}; + +interface DeadLetterActionsProps { + deadLetterId: number; +} + +/** + * The admin-only retry / quarantine controls for one `new` dead letter (design + * §6). Each action runs behind a required-reason dialog; the idempotency key is + * minted per dialog open and reused across retries so a network retry replays + * rather than re-drives twice. The server (`guardMutation` admin gate) is the + * enforcement boundary — the route only renders this for an admin. + */ +export function DeadLetterActions({ deadLetterId }: DeadLetterActionsProps) { + const queryClient = useQueryClient(); + const [action, setAction] = useState(null); + const [reason, setReason] = useState(""); + const [idempotencyKey, setIdempotencyKey] = useState(""); + + useEffect(() => { + if (action === null) return; + setIdempotencyKey(ulid()); + setReason(""); + }, [action]); + + const mutation = useMutation({ + mutationFn: () => + action === "quarantine" + ? apiClient.quarantineDeadLetter(deadLetterId, { reason, idempotencyKey }) + : apiClient.retryDeadLetter(deadLetterId, { reason, idempotencyKey }), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["dead-letters"] }), + queryClient.invalidateQueries({ queryKey: ["system-status"] }), + ]); + setAction(null); + }, + }); + + const copy = action ? DIALOG_COPY[action] : null; + const canSubmit = reason.trim().length > 0 && !mutation.isPending; + + return ( +
+ + + + { + if (!open) setAction(null); + }} + role="alertdialog" + > + + {copy && ( + <> + {copy.title} + + {copy.description} + + + + + {mutation.isError && ( +

+ {mutation.error instanceof Error ? mutation.error.message : "Action failed"} +

+ )} + +
+ +
+ + )} +
+
+
+ ); +} diff --git a/apps/labeler/console/src/components/Sidebar.tsx b/apps/labeler/console/src/components/Sidebar.tsx index 4bd94a4235..ad4cb5d0b5 100644 --- a/apps/labeler/console/src/components/Sidebar.tsx +++ b/apps/labeler/console/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import { Sidebar as KumoSidebar, useSidebar } from "@cloudflare/kumo"; -import { ClockCounterClockwise, ShieldCheck, SquaresFour } from "@phosphor-icons/react"; +import { ClockCounterClockwise, Envelope, ShieldCheck, SquaresFour } from "@phosphor-icons/react"; import { useLocation } from "@tanstack/react-router"; import * as React from "react"; @@ -42,6 +42,7 @@ export function SidebarNav() { const items: NavItem[] = [ { to: "/", label: "Dashboard", icon: SquaresFour }, { to: "/assessments", label: "Assessments", icon: ShieldCheck }, + { to: "/dead-letters", label: "Dead letters", icon: Envelope }, { to: "/audit", label: "Audit log", icon: ClockCounterClockwise }, ]; diff --git a/apps/labeler/console/src/fixtures/dead-letters.ts b/apps/labeler/console/src/fixtures/dead-letters.ts new file mode 100644 index 0000000000..7ec8d29b1d --- /dev/null +++ b/apps/labeler/console/src/fixtures/dead-letters.ts @@ -0,0 +1,42 @@ +import type { DeadLetter } from "../api/types.js"; + +/** Sample `dead_letters` rows for offline UI work and tests — a new (actionable) + * letter, a retried one, and a quarantined one, newest first. */ +export const FIXTURE_DEAD_LETTERS: DeadLetter[] = [ + { + id: 3, + did: "did:plc:publisher000000000000000003", + collection: "com.emdashcms.experimental.package.release", + rkey: "rk-new", + reason: "INVALID_PROOF", + detail: "MST proof did not verify", + status: "new", + receivedAt: "2026-07-13 09:15:00", + resolvedAt: null, + resolvedByActionId: null, + }, + { + id: 2, + did: "did:plc:publisher000000000000000002", + collection: "com.emdashcms.experimental.package.release", + rkey: "rk-retried", + reason: "PDS_HTTP_ERROR", + detail: "502 from PDS", + status: "retried", + receivedAt: "2026-07-13 08:40:00", + resolvedAt: "2026-07-13 09:00:00", + resolvedByActionId: "oact_fixture_retry", + }, + { + id: 1, + did: "did:plc:publisher000000000000000001", + collection: "com.emdashcms.experimental.package.release", + rkey: "rk-quarantined", + reason: "RECORD_NOT_FOUND", + detail: null, + status: "quarantined", + receivedAt: "2026-07-13 08:00:00", + resolvedAt: "2026-07-13 08:20:00", + resolvedByActionId: "oact_fixture_quarantine", + }, +]; diff --git a/apps/labeler/console/src/fixtures/index.ts b/apps/labeler/console/src/fixtures/index.ts index c540b33da9..7a0ef33fa1 100644 --- a/apps/labeler/console/src/fixtures/index.ts +++ b/apps/labeler/console/src/fixtures/index.ts @@ -14,6 +14,7 @@ export { FINDING_GAMMA_OBFUSCATED_CODE, FIXTURE_FINDINGS_BY_ASSESSMENT, } from "./findings.js"; +export { FIXTURE_DEAD_LETTERS } from "./dead-letters.js"; export { FIXTURE_LABELS_BY_ASSESSMENT } from "./labels.js"; export { FIXTURE_OPERATOR_ACTIONS } from "./operator-actions.js"; export { FIXTURE_SUBJECT_HISTORY } from "./subject-history.js"; diff --git a/apps/labeler/console/src/router.tsx b/apps/labeler/console/src/router.tsx index 9e52b8c81d..bc3e5ebebd 100644 --- a/apps/labeler/console/src/router.tsx +++ b/apps/labeler/console/src/router.tsx @@ -5,6 +5,7 @@ import { assessmentDetailRoute } from "./routes/AssessmentDetail.js"; import { assessmentListRoute } from "./routes/AssessmentList.js"; import { auditLogRoute } from "./routes/AuditLog.js"; import { dashboardRoute } from "./routes/Dashboard.js"; +import { deadLetterQueueRoute } from "./routes/DeadLetterQueue.js"; import { notFoundRoute } from "./routes/NotFound.js"; import { rootRoute, shellRoute } from "./routes/root.js"; import { subjectHistoryRoute } from "./routes/SubjectHistory.js"; @@ -15,6 +16,7 @@ const shellRoutes = shellRoute.addChildren([ assessmentDetailRoute, subjectHistoryRoute, auditLogRoute, + deadLetterQueueRoute, notFoundRoute, ]); diff --git a/apps/labeler/console/src/routes/DeadLetterQueue.tsx b/apps/labeler/console/src/routes/DeadLetterQueue.tsx new file mode 100644 index 0000000000..f4eb216ec5 --- /dev/null +++ b/apps/labeler/console/src/routes/DeadLetterQueue.tsx @@ -0,0 +1,107 @@ +import { Badge, LayerCard, Loader, Table } from "@cloudflare/kumo"; +import { useQuery } from "@tanstack/react-query"; +import { createRoute } from "@tanstack/react-router"; + +import { apiClient } from "../api/client.js"; +import type { DeadLetterStatus } from "../api/types.js"; +import { DeadLetterActions } from "../components/DeadLetterActions.js"; +import { QueryError } from "../components/QueryError.js"; +import { shellRoute } from "./root.js"; + +type BadgeVariant = "neutral" | "info" | "success" | "warning" | "error"; + +const STATUS_VARIANT: Record = { + new: "warning", + retried: "info", + quarantined: "neutral", +}; + +const STATUS_LABEL: Record = { + new: "New", + retried: "Retried", + quarantined: "Quarantined", +}; + +function DeadLetterQueue() { + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["dead-letters"], + queryFn: () => apiClient.listDeadLetters(), + }); + const { data: whoami } = useQuery({ queryKey: ["whoami"], queryFn: () => apiClient.whoami() }); + const isAdmin = whoami?.roles.includes("admin") ?? false; + + return ( +
+

Dead-letter queue

+

+ Discovery events that failed verification. Retry re-drives an event through the consumer; + quarantine marks it reviewed and excludes it from retry. +

+ + {isError ? ( + + ) : ( + + {isLoading || !data ? ( +
+ +
+ ) : data.items.length === 0 ? ( +
No dead letters.
+ ) : ( + + + + Subject + Reason + Status + Received + Actions + + + + {data.items.map((letter) => ( + + + + {letter.rkey} + + + + {letter.reason} + {letter.detail && ( + {letter.detail} + )} + + + + {STATUS_LABEL[letter.status]} + + + {new Date(letter.receivedAt).toLocaleString()} + + {isAdmin && letter.status === "new" ? ( + + ) : ( + + )} + + + ))} + +
+ )} +
+ )} +
+ ); +} + +export const deadLetterQueueRoute = createRoute({ + getParentRoute: () => shellRoute, + path: "/dead-letters", + component: DeadLetterQueue, +}); diff --git a/apps/labeler/src/console-api.ts b/apps/labeler/src/console-api.ts index b35c507b09..dd967f938e 100644 --- a/apps/labeler/src/console-api.ts +++ b/apps/labeler/src/console-api.ts @@ -35,6 +35,7 @@ import { } from "./assessment-store.js"; import { serializeAssessmentRun, + serializeDeadLetter, serializeIssuedLabel, serializeOperatorActionView, serializeOperatorFinding, @@ -42,6 +43,7 @@ import { serializeSubjectRecord, type Page, } from "./console-serialize.js"; +import { getDeadLettersPage } from "./dead-letters.js"; import { LABELER_DISCOVERY_DO_NAME } from "./discovery-do.js"; import { computeEffectPreview, computeOverrideEffectPreview } from "./label-effect-preview.js"; import { MutationGuardError } from "./mutation-guard.js"; @@ -122,6 +124,8 @@ function matchRoute( return () => handleGetSubjectLabels(request, url, deps, uri); } else if (segments[0] === "audit-log" && segments.length === 1) { return () => handleListAuditLog(request, url, deps); + } else if (segments[0] === "dead-letters" && segments.length === 1) { + return () => handleListDeadLetters(request, url, deps); } else if (segments[0] === "status" && segments.length === 1) { return () => handleGetStatus(request, deps); } else if (segments[0] === "whoami" && segments.length === 1) { @@ -319,6 +323,45 @@ async function handleListAuditLog( return jsonData(body); } +/** + * Lists `dead_letters` newest-first for the operator console (design §6). All + * statuses are listed so the operator sees resolved history; the retry / + * quarantine actions are admin-only and gated separately. Keyset pagination is + * on the numeric autoincrement `id` (a total order); the opaque cursor still + * carries `received_at` for the shared cursor's shape/validation. + */ +async function handleListDeadLetters( + request: Request, + url: URL, + deps: ConsoleApiDeps, +): Promise { + requireGet(request); + const limit = parseLimit(url.searchParams); + const filterHash = await computeFilterHash({}); + const keyset = decodeReadCursor(url.searchParams.get("cursor"), filterHash); + const keysetId = keyset === null ? null : parseCursorId(keyset.id); + + const rows = await getDeadLettersPage(deps.db, keysetId, limit); + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + const last = page.at(-1); + const body: Page> = { + items: page.map(serializeDeadLetter), + ...(hasMore && last + ? { + nextCursor: encodeCursor({ createdAt: last.receivedAt, id: String(last.id) }, filterHash), + } + : {}), + }; + return jsonData(body); +} + +function parseCursorId(raw: string): number { + const id = Number(raw); + if (!Number.isSafeInteger(id) || id <= 0) throw new ReadGuardError("INVALID_CURSOR"); + return id; +} + async function handleGetStatus(request: Request, deps: ConsoleApiDeps): Promise { requireGet(request); const [pendingAssessments, deadLetterDepth, automation, jetstreamConnected] = await Promise.all([ @@ -432,9 +475,12 @@ function parseNeg(raw: string | null): boolean { /** Dead-letter backlog — the observable stand-in for discovery-queue depth, * which the Queues API does not expose to the consumer Worker (spec §11.1's - * `/admin/system` "queue/DLQ health"). */ + * `/admin/system` "queue/DLQ health"). Counts only `status = 'new'`: a retried + * or quarantined letter has been actioned and no longer represents backlog. */ async function countDeadLetters(db: D1Database): Promise { - const row = await db.prepare(`SELECT COUNT(*) AS n FROM dead_letters`).first<{ n: number }>(); + const row = await db + .prepare(`SELECT COUNT(*) AS n FROM dead_letters WHERE status = 'new'`) + .first<{ n: number }>(); return row?.n ?? 0; } diff --git a/apps/labeler/src/console-mutation-api.ts b/apps/labeler/src/console-mutation-api.ts index 0905910f90..dd848e2a37 100644 --- a/apps/labeler/src/console-mutation-api.ts +++ b/apps/labeler/src/console-mutation-api.ts @@ -29,12 +29,20 @@ import { } from "./assessment-store.js"; import { buildAutomationPauseUpdate } from "./automation-state.js"; import type { LabelerIdentityConfig } from "./config.js"; +import { + buildDeadLetterResolveUpdate, + getDeadLetter, + readDeadLetterJob, + type DeadLetterStatus, +} from "./dead-letters.js"; +import type { DiscoveryJob } from "./env.js"; import { commitMutation, guardMutation, MutationGuardError, type MutationGuardDeps, type MutationSpec, + type OperatorActionType, } from "./mutation-guard.js"; import { buildOperationalEventInsert, @@ -68,6 +76,7 @@ const RERUN_PROMPT_HASH = "unassigned"; const RERUN_SCANNER_SET_VERSION = "unassigned"; const ADMIN_API_PREFIX = /^\/admin\/api\/?/; +const POSITIVE_INTEGER = /^[1-9]\d*$/; /** Override-coupled labels: the atomic unblock action (W9.5) is the only path * that issues these, so the standalone issue/retract endpoints reject them. */ @@ -124,6 +133,10 @@ export interface ConsoleMutationDeps { afterCommit: (actionId: string) => Promise; /** Schedules `afterCommit` without blocking the response (workerd `waitUntil`). */ defer: (work: Promise) => void; + /** Re-enqueues a dead letter's discovery job on retry (design §6). A queue op + * cannot join the D1 batch, so it runs in the deferred tail; the discovery + * consumer's `runKey` dedup absorbs a duplicate re-drive. */ + sendDiscoveryJob: (job: DiscoveryJob) => Promise; } /** @@ -173,12 +186,19 @@ export async function handleConsoleMutation( if (segments[1] === "pause") return await runAutomationToggle(request, deps, true); if (segments[1] === "resume") return await runAutomationToggle(request, deps, false); } + if (segments[0] === "dead-letters" && segments.length === 3 && segments[1] !== undefined) { + const id = segments[1]; + if (segments[2] === "retry") return await runDeadLetterAction(request, deps, id, "retried"); + if (segments[2] === "quarantine") + return await runDeadLetterAction(request, deps, id, "quarantined"); + } throw new ReadGuardError("NOT_FOUND"); } catch (error) { if ( error instanceof MutationGuardError || error instanceof ReadGuardError || - error instanceof LabelMutationError + error instanceof LabelMutationError || + error instanceof DeadLetterResolvedError ) return error.toResponse(); // A submitted override negation set that isn't exactly the live automated @@ -1049,7 +1069,166 @@ async function runAutomationToggle( reason: ctx.reason, cts: ctx.now.toISOString(), }; - return jsonData( - await commitMutation(deps.db, ctx, spec, [stateUpdate, eventInsert], descriptor), + return jsonData(await commitMutation(deps.db, ctx, spec, [stateUpdate, eventInsert], descriptor)); +} + +// ─── W9.6: admin-only dead-letter retry / quarantine controls ──────────────── + +type DeadLetterResolution = Exclude; + +const DEAD_LETTER_ACTION: Record = { + retried: "dlq-retry", + quarantined: "dlq-quarantine", +}; + +const DEAD_LETTER_EVENT_TYPE: Record = { + retried: "dead-letter-retried", + quarantined: "dead-letter-quarantined", +}; + +/** A dead letter that is no longer actionable (already retried or quarantined). + * Static message; a 409 rather than a spurious success so a second resolve does + * not double-enqueue (T6). */ +class DeadLetterResolvedError extends Error { + override readonly name = "DeadLetterResolvedError"; + readonly code = "DEAD_LETTER_RESOLVED"; + readonly status = 409; + + constructor() { + super("Dead letter is already resolved"); + } + + toResponse(): Response { + return Response.json( + { error: { code: this.code, message: this.message } }, + { status: this.status }, + ); + } +} + +/** The id from the path, folded into the parsed body so it joins the request + * fingerprint — a replayed key must target the same dead letter. */ +interface DeadLetterActionBody { + deadLetterId: number; +} + +/** The idempotent resolve result. */ +interface DeadLetterActionDescriptor { + actionId: string; + deadLetterId: number; + status: DeadLetterResolution; + cts: string; +} + +/** + * `POST /admin/api/dead-letters/:id/{retry,quarantine}` — the admin-only + * operational controls (design §6). Neither issues a label: the effect is a + * `status` UPDATE guarded by `status = 'new'` plus an ungated `info` operational + * event, both committed in one atomic `commitMutation` batch with the audit row. + * Retry additionally re-enqueues the dead letter's discovery job in the deferred + * tail. A row that is absent (404) or already resolved (409) is rejected before + * any commit, so a late request commits nothing and enqueues nothing (T6). + */ +async function runDeadLetterAction( + request: Request, + deps: ConsoleMutationDeps, + rawId: string, + status: DeadLetterResolution, +): Promise { + const spec: MutationSpec = { + action: DEAD_LETTER_ACTION[status], + requiredRole: "admin", + parseBody: () => ({ deadLetterId: parseDeadLetterId(rawId) }), + auditFields: () => ({}), + }; + + const outcome = await guardMutation(request, spec, guardDepsOf(deps)); + if (outcome.outcome === "replay") + return jsonData(storedDescriptor(outcome.result)); + + const { ctx } = outcome; + const id = ctx.body.deadLetterId; + const letter = await getDeadLetter(deps.db, id); + if (!letter) throw new ReadGuardError("NOT_FOUND"); + if (letter.status !== "new") throw new DeadLetterResolvedError(); + + const subjectUri = `at://${letter.did}/${letter.collection}/${letter.rkey}`; + const update = buildDeadLetterResolveUpdate(deps.db, { + id, + status, + actionId: ctx.actionId, + now: ctx.now, + }); + const eventInsert = buildOperationalEventInsert(deps.db, { + id: newOperationalEventId(), + eventType: DEAD_LETTER_EVENT_TYPE[status], + severity: "info", + actionId: ctx.actionId, + subjectUri, + payload: { reason: ctx.reason }, + now: ctx.now, + }); + + const descriptor: DeadLetterActionDescriptor = { + actionId: ctx.actionId, + deadLetterId: id, + status, + cts: ctx.now.toISOString(), + }; + const commitSpec: MutationSpec = { + ...spec, + auditFields: () => ({ subjectUri, metadata: { deadLetterId: id, status } }), + }; + const returned = await commitMutation( + deps.db, + ctx, + commitSpec, + [update, eventInsert], + descriptor, ); + + if (status === "retried") deferDeadLetterReenqueue(deps, id, ctx.actionId); + return jsonData(returned); +} + +/** + * Re-enqueues a retried dead letter's discovery job off the response path, but + * only when this action won the `status = 'new'` race: a concurrent retry that + * committed its audit row yet matched zero rows in the UPDATE reads back a + * `resolved_by_action_id` that is not ours and skips, so the job enqueues once + * even under a double retry. The consumer's `runKey` dedup is the backstop; a + * lost enqueue is recoverable by re-driving `status = 'retried'` rows. + */ +function deferDeadLetterReenqueue(deps: ConsoleMutationDeps, id: number, actionId: string): void { + deps.defer( + (async () => { + try { + const letter = await getDeadLetter(deps.db, id); + if (!letter || letter.resolvedByActionId !== actionId) return; + const job = await readDeadLetterJob(deps.db, id); + if (!job) { + // The row flipped to 'retried' but its payload didn't decode to a + // valid job (e.g. a row written before the full-job payload shape), + // so nothing re-drives it and a fresh retry is barred by status. + console.error("[console-mutation] dead-letter payload undecodable, re-enqueue skipped", { + id, + }); + return; + } + await deps.sendDiscoveryJob(job); + } catch (error) { + console.error("[console-mutation] dead-letter re-enqueue failed", error); + } + })(), + ); +} + +/** A non-numeric or non-positive path segment names no dead letter — a 404, + * matching the absent-row case. Runs after the guard's role gate, so a non-admin + * is rejected before this. */ +function parseDeadLetterId(raw: string): number { + if (!POSITIVE_INTEGER.test(raw)) throw new ReadGuardError("NOT_FOUND"); + const id = Number(raw); + if (!Number.isSafeInteger(id)) throw new ReadGuardError("NOT_FOUND"); + return id; } diff --git a/apps/labeler/src/console-serialize.ts b/apps/labeler/src/console-serialize.ts index e684c34004..779c8029d7 100644 --- a/apps/labeler/src/console-serialize.ts +++ b/apps/labeler/src/console-serialize.ts @@ -25,6 +25,7 @@ import type { ListedAssessment, Subject, } from "./assessment-store.js"; +import type { StoredDeadLetter } from "./dead-letters.js"; import type { FindingSeverity } from "./evidence.js"; import type { FindingSource } from "./findings.js"; import type { StoredOperatorAction } from "./operator-actions.js"; @@ -125,6 +126,22 @@ export interface SystemStatusSnapshot { deadLetterDepth: number; } +/** A `dead_letters` row for the operator console. The raw `payload` (unverified + * Jetstream bytes) is deliberately excluded — the console displays the failure + * reason and resolution state, never the untrusted record. */ +export interface DeadLetter { + id: number; + did: string; + collection: string; + rkey: string; + reason: string; + detail: string | null; + status: string; + receivedAt: string; + resolvedAt: string | null; + resolvedByActionId: string | null; +} + export interface Page { items: T[]; nextCursor?: string; @@ -224,3 +241,18 @@ export function serializeOperatorActionView(action: StoredOperatorAction): Opera createdAt: action.createdAt, }; } + +export function serializeDeadLetter(row: StoredDeadLetter): DeadLetter { + return { + id: row.id, + did: row.did, + collection: row.collection, + rkey: row.rkey, + reason: row.reason, + detail: row.detail, + status: row.status, + receivedAt: row.receivedAt, + resolvedAt: row.resolvedAt, + resolvedByActionId: row.resolvedByActionId, + }; +} diff --git a/apps/labeler/src/dead-letters.ts b/apps/labeler/src/dead-letters.ts new file mode 100644 index 0000000000..6fc90d3778 --- /dev/null +++ b/apps/labeler/src/dead-letters.ts @@ -0,0 +1,155 @@ +/** + * Dead-letter operational controls (plan W9.6, design §6). The read side of the + * `dead_letters` table for the console and the effect statement the admin-only + * retry / quarantine mutations commit. `writeDeadLetter` (discovery-consumer.ts) + * is the only writer of new rows; those rows land with `status = 'new'` and are + * resolved here to `retried` or `quarantined`. + */ + +import type { DiscoveryJob } from "./env.js"; + +export type DeadLetterStatus = "new" | "retried" | "quarantined"; + +export interface StoredDeadLetter { + id: number; + did: string; + collection: string; + rkey: string; + reason: string; + detail: string | null; + status: string; + receivedAt: string; + resolvedAt: string | null; + resolvedByActionId: string | null; +} + +interface DeadLetterRow { + id: number; + did: string; + collection: string; + rkey: string; + reason: string; + detail: string | null; + status: string; + received_at: string; + resolved_at: string | null; + resolved_by_action_id: string | null; +} + +const READ_COLUMNS = `id, did, collection, rkey, reason, detail, status, + received_at, resolved_at, resolved_by_action_id`; + +/** + * Dead-letter page, newest first. `id` is a monotonic autoincrement, so keyset + * pagination on `id DESC` alone is a total order — no timestamp tiebreaker (the + * `received_at` string has no epoch sibling and can collide). Fetches `limit + 1` + * so the caller detects a next page without a trailing COUNT. + */ +export async function getDeadLettersPage( + db: D1Database, + keysetId: number | null, + limit: number, +): Promise { + const bindings: number[] = []; + let where = ""; + if (keysetId !== null) { + where = `WHERE id < ?`; + bindings.push(keysetId); + } + bindings.push(limit + 1); + const rows = await db + .prepare(`SELECT ${READ_COLUMNS} FROM dead_letters ${where} ORDER BY id DESC LIMIT ?`) + .bind(...bindings) + .all(); + return (rows.results ?? []).map(rowToStored); +} + +/** One dead letter by id, or null when absent. Carries the status the retry / + * quarantine handlers gate on and the `resolved_by_action_id` they read back + * post-commit to tell the winner of a concurrent resolve from the loser. */ +export async function getDeadLetter(db: D1Database, id: number): Promise { + const row = await db + .prepare(`SELECT ${READ_COLUMNS} FROM dead_letters WHERE id = ?`) + .bind(id) + .first(); + return row ? rowToStored(row) : null; +} + +/** + * Effect statement for a retry / quarantine resolve. The `status = 'new'` + * predicate makes a second concurrent resolve a zero-row UPDATE (T6): the row + * flips exactly once and `resolved_by_action_id` records the winner, so a loser + * that also committed can be detected post-commit and skip its re-enqueue. + * Batched with the operator_actions row + the operational event by `commitMutation`. + */ +export function buildDeadLetterResolveUpdate( + db: D1Database, + input: { id: number; status: Exclude; actionId: string; now: Date }, +): D1PreparedStatement { + return db + .prepare( + `UPDATE dead_letters + SET status = ?, resolved_at = ?, resolved_by_action_id = ? + WHERE id = ? AND status = 'new'`, + ) + .bind(input.status, input.now.toISOString(), input.actionId, input.id); +} + +/** + * Reconstructs the discovery job stored in `dead_letters.payload` for a re-drive. + * `writeDeadLetter` persists the full {@link DiscoveryJob} as JSON, so the retry + * re-enqueues an identical job — the discovery consumer re-fetches and verifies + * (uri, cid) from the PDS, and its `runKey` dedup absorbs a duplicate re-drive. + * Returns null when the payload is missing or not a well-formed job. + */ +export async function readDeadLetterJob(db: D1Database, id: number): Promise { + const row = await db + .prepare(`SELECT payload FROM dead_letters WHERE id = ?`) + .bind(id) + .first<{ payload: ArrayBuffer | Uint8Array | number[] | null }>(); + if (!row || row.payload === null) return null; + return decodeDiscoveryJob(row.payload); +} + +function decodeDiscoveryJob(payload: ArrayBuffer | Uint8Array | number[]): DiscoveryJob | null { + const bytes = + payload instanceof Uint8Array + ? payload + : payload instanceof ArrayBuffer + ? new Uint8Array(payload) + : Uint8Array.from(payload); + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return null; + } + return isDiscoveryJob(parsed) ? parsed : null; +} + +function isDiscoveryJob(value: unknown): value is DiscoveryJob { + if (typeof value !== "object" || value === null) return false; + const job = value as Record; + return ( + typeof job.did === "string" && + typeof job.collection === "string" && + typeof job.rkey === "string" && + typeof job.cid === "string" && + (job.operation === "create" || job.operation === "update" || job.operation === "delete") + ); +} + +function rowToStored(row: DeadLetterRow): StoredDeadLetter { + return { + id: row.id, + did: row.did, + collection: row.collection, + rkey: row.rkey, + reason: row.reason, + detail: row.detail, + status: row.status, + receivedAt: row.received_at, + resolvedAt: row.resolved_at, + resolvedByActionId: row.resolved_by_action_id, + }; +} diff --git a/apps/labeler/src/discovery-consumer.ts b/apps/labeler/src/discovery-consumer.ts index 5a14d0124f..9318e761d6 100644 --- a/apps/labeler/src/discovery-consumer.ts +++ b/apps/labeler/src/discovery-consumer.ts @@ -536,7 +536,10 @@ async function writeDeadLetter( detail: string | null, now: Date, ): Promise { - const payload = JSON.stringify(job.jetstreamRecord ?? { operation: job.operation, cid: job.cid }); + // Persist the whole discovery job (identity + operation + cid + the unverified + // Jetstream record) so an operator retry can re-enqueue an identical job for a + // re-drive (design §6); the record alone would lose the cid a re-verify needs. + const payload = JSON.stringify(job); const payloadBytes = new TextEncoder().encode(payload); await db .prepare( diff --git a/apps/labeler/src/index.ts b/apps/labeler/src/index.ts index a9502600fa..d5d766fbbd 100644 --- a/apps/labeler/src/index.ts +++ b/apps/labeler/src/index.ts @@ -145,6 +145,9 @@ async function handleConsoleApiRequest( now: () => new Date(), afterCommit: (actionId) => publishAfterCommit(env, actionId), defer: (work) => ctx.waitUntil(work), + sendDiscoveryJob: async (job) => { + await env.DISCOVERY_QUEUE.send(job); + }, }); } return handleConsoleApi(request, { diff --git a/apps/labeler/test/console-api.test.ts b/apps/labeler/test/console-api.test.ts index c5d807771a..57adfd7d7f 100644 --- a/apps/labeler/test/console-api.test.ts +++ b/apps/labeler/test/console-api.test.ts @@ -660,7 +660,10 @@ describe("handleConsoleApi — system status", () => { WHERE id = 1`, ).run(); try { - const res = await handleConsoleApi(req("/admin/api/status", { token: reviewerToken }), deps()); + const res = await handleConsoleApi( + req("/admin/api/status", { token: reviewerToken }), + deps(), + ); const status = (await body(res)).data as { automationPaused: boolean; pausedReason: string | null; @@ -677,6 +680,73 @@ describe("handleConsoleApi — system status", () => { }); }); +async function insertResolvedDeadLetter(status: "retried" | "quarantined"): Promise { + await testEnv.DB.prepare( + `INSERT INTO dead_letters (did, collection, rkey, reason, payload, received_at, status, resolved_at) + VALUES (?, 'col', ?, 'verify-failed', ?, datetime('now'), ?, datetime('now'))`, + ) + .bind(`did:plc:resolved${status}`, `rk-${status}`, new Uint8Array([0]), status) + .run(); +} + +describe("handleConsoleApi — dead letters", () => { + it("lists dead letters newest-first for a reviewer", async () => { + const res = await handleConsoleApi( + req("/admin/api/dead-letters", { token: reviewerToken }), + deps(), + ); + expect(res.status).toBe(200); + const page = (await body(res)).data as { items: { id: number; status: string }[] }; + expect(page.items.length).toBeGreaterThan(0); + const ids = page.items.map((d) => d.id); + expect(ids).toEqual(ids.toSorted((a, b) => b - a)); + }); + + it("round-trips the cursor across pages, newest-first", async () => { + const first = await handleConsoleApi( + req("/admin/api/dead-letters?limit=1", { token: reviewerToken }), + deps(), + ); + const page1 = (await body(first)).data as { items: { id: number }[]; nextCursor?: string }; + expect(page1.items).toHaveLength(1); + expect(page1.nextCursor).toBeDefined(); + const second = await handleConsoleApi( + req(`/admin/api/dead-letters?limit=1&cursor=${encodeURIComponent(page1.nextCursor!)}`, { + token: reviewerToken, + }), + deps(), + ); + const page2 = (await body(second)).data as { items: { id: number }[] }; + expect(page2.items).toHaveLength(1); + expect(page2.items[0]!.id).toBeLessThan(page1.items[0]!.id); + }); + + it("rejects an identity with no role (403) and an unauthenticated caller (401)", async () => { + expect( + (await handleConsoleApi(req("/admin/api/dead-letters", { token: noRoleToken }), deps())) + .status, + ).toBe(403); + expect((await handleConsoleApi(req("/admin/api/dead-letters"), deps())).status).toBe(401); + }); + + it("400s a malformed cursor", async () => { + const res = await handleConsoleApi( + req("/admin/api/dead-letters?cursor=not-a-cursor", { token: reviewerToken }), + deps(), + ); + expect(res.status).toBe(400); + }); + + it("counts only new dead letters in the health figure", async () => { + await insertResolvedDeadLetter("retried"); + await insertResolvedDeadLetter("quarantined"); + const res = await handleConsoleApi(req("/admin/api/status", { token: reviewerToken }), deps()); + const status = (await body(res)).data as { deadLetterDepth: number }; + // The three seeded rows are 'new'; the retried + quarantined rows are excluded. + expect(status.deadLetterDepth).toBe(3); + }); +}); + describe("consoleAssetPath — asset prefix rewrite", () => { // The SPA is built with base "/admin/" but the asset binding serves // ./dist/console one-to-one, so the /admin prefix must be stripped before diff --git a/apps/labeler/test/console-assessment-mutations.test.ts b/apps/labeler/test/console-assessment-mutations.test.ts index b157badcb0..7614ee80d1 100644 --- a/apps/labeler/test/console-assessment-mutations.test.ts +++ b/apps/labeler/test/console-assessment-mutations.test.ts @@ -108,6 +108,7 @@ function mutationDeps(overrides: Partial = {}): ConsoleMuta defer: (work) => { void work; }, + sendDiscoveryJob: async () => {}, ...overrides, }; } diff --git a/apps/labeler/test/console-mutation-api.test.ts b/apps/labeler/test/console-mutation-api.test.ts index 46785f03f1..137f78d5f2 100644 --- a/apps/labeler/test/console-mutation-api.test.ts +++ b/apps/labeler/test/console-mutation-api.test.ts @@ -125,6 +125,7 @@ function mutationDeps(overrides: Partial = {}): ConsoleMuta defer: (work) => { void work; }, + sendDiscoveryJob: async () => {}, ...overrides, }; } @@ -1577,3 +1578,335 @@ describe("console mutation: pause endpoint drives the discovery consumer gate (e expect(await countRows(`SELECT COUNT(*) n FROM subjects WHERE uri = ?`, uri)).toBe(1); }); }); + +// ─── W9.6: admin-only dead-letter retry / quarantine controls ──────────────── + +interface DeadLetterActionDescriptor { + actionId: string; + deadLetterId: number; + status: string; + cts: string; +} + +let dlSeq = 0; + +async function seedDeadLetter( + overrides: { status?: string; job?: Partial } = {}, +): Promise<{ id: number; job: DiscoveryJob }> { + dlSeq += 1; + const rkey = `dl-${dlSeq.toString().padStart(4, "0")}`; + const job: DiscoveryJob = { + did: PUBLISHER_DID, + collection: "com.emdashcms.experimental.package.release", + rkey, + operation: "create", + cid: CID, + jetstreamRecord: { $type: "com.emdashcms.experimental.package.release", package: rkey }, + ...overrides.job, + }; + const payload = new TextEncoder().encode(JSON.stringify(job)); + const result = await testEnv.DB.prepare( + `INSERT INTO dead_letters (did, collection, rkey, reason, detail, payload, received_at, status) + VALUES (?, ?, ?, 'verify-failed', 'detail', ?, datetime('now'), ?)`, + ) + .bind(job.did, job.collection, job.rkey, payload, overrides.status ?? "new") + .run(); + return { id: Number(result.meta.last_row_id), job }; +} + +async function deadLetterRow( + id: number, +): Promise<{ + status: string; + resolved_at: string | null; + resolved_by_action_id: string | null; +} | null> { + return testEnv.DB.prepare( + `SELECT status, resolved_at, resolved_by_action_id FROM dead_letters WHERE id = ?`, + ) + .bind(id) + .first(); +} + +function deadLetterReq( + path: string, + token: string | null = adminToken, + body: Record = {}, +): Request { + return post(path, { reason: "re-drive", idempotencyKey: nextKey(), ...body }, { token }); +} + +/** Captures the deferred re-enqueue tail so a test can settle it and observe the + * queue sends. `defer` collects the work rather than dropping it (the default + * `mutationDeps` swallows deferred work). */ +function captureReenqueue(): { + deps: ConsoleMutationDeps; + sent: DiscoveryJob[]; + settle: () => Promise; +} { + const sent: DiscoveryJob[] = []; + const deferred: Promise[] = []; + const deps = mutationDeps({ + sendDiscoveryJob: async (job) => { + sent.push(job); + }, + defer: (work) => { + deferred.push(work); + }, + }); + return { deps, sent, settle: () => Promise.all(deferred.splice(0)) }; +} + +describe("console mutation: dead-letter retry (admin)", () => { + it("flips new→retried, emits one info event + audit row, enqueues one job", async () => { + const { id, job } = await seedDeadLetter(); + const { deps, sent, settle } = captureReenqueue(); + const response = await handleConsoleMutation( + deadLetterReq(`/admin/api/dead-letters/${id}/retry`), + deps, + ); + expect(response.status).toBe(200); + const descriptor = await bodyData(response); + expect(descriptor).toMatchObject({ deadLetterId: id, status: "retried" }); + + const row = await deadLetterRow(id); + expect(row?.status).toBe("retried"); + expect(row?.resolved_at).not.toBeNull(); + expect(row?.resolved_by_action_id).toBe(descriptor.actionId); + + expect( + await countRows( + `SELECT COUNT(*) n FROM operator_actions WHERE id = ? AND action = 'dlq-retry'`, + descriptor.actionId, + ), + ).toBe(1); + expect( + await countRows( + `SELECT COUNT(*) n FROM operational_events + WHERE action_id = ? AND event_type = 'dead-letter-retried' AND severity = 'info'`, + descriptor.actionId, + ), + ).toBe(1); + // No label issued, so no delivery outbox row. + expect(await outboxCount(descriptor.actionId)).toBe(0); + + await settle(); + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ + did: job.did, + collection: job.collection, + rkey: job.rkey, + cid: job.cid, + operation: "create", + }); + }); +}); + +describe("console mutation: dead-letter quarantine (admin)", () => { + it("flips new→quarantined, emits one info event, enqueues nothing", async () => { + const { id } = await seedDeadLetter(); + const { deps, sent, settle } = captureReenqueue(); + const response = await handleConsoleMutation( + deadLetterReq(`/admin/api/dead-letters/${id}/quarantine`), + deps, + ); + expect(response.status).toBe(200); + const descriptor = await bodyData(response); + expect(descriptor).toMatchObject({ deadLetterId: id, status: "quarantined" }); + + const row = await deadLetterRow(id); + expect(row?.status).toBe("quarantined"); + expect(row?.resolved_by_action_id).toBe(descriptor.actionId); + expect( + await countRows( + `SELECT COUNT(*) n FROM operational_events + WHERE action_id = ? AND event_type = 'dead-letter-quarantined' AND severity = 'info'`, + descriptor.actionId, + ), + ).toBe(1); + + await settle(); + expect(sent).toHaveLength(0); + }); +}); + +describe("console mutation: dead-letter authorization (per-endpoint, pre-effect)", () => { + it.each(["retry", "quarantine"])( + "rejects a reviewer with zero side effects: %s", + async (verb) => { + const { id } = await seedDeadLetter(); + const before = { + actions: await countRows(`SELECT COUNT(*) n FROM operator_actions`), + events: await countRows(`SELECT COUNT(*) n FROM operational_events`), + }; + const { deps, sent, settle } = captureReenqueue(); + const response = await handleConsoleMutation( + deadLetterReq(`/admin/api/dead-letters/${id}/${verb}`, reviewerToken), + deps, + ); + expect(response.status).toBe(403); + expect((await bodyError(response)).code).toBe("FORBIDDEN_ROLE"); + expect(await countRows(`SELECT COUNT(*) n FROM operator_actions`)).toBe(before.actions); + expect(await countRows(`SELECT COUNT(*) n FROM operational_events`)).toBe(before.events); + expect((await deadLetterRow(id))?.status).toBe("new"); + await settle(); + expect(sent).toHaveLength(0); + }, + ); + + it.each(["retry", "quarantine"])( + "accepts an admin (the 403 is the role, not a broken endpoint): %s", + async (verb) => { + const { id } = await seedDeadLetter(); + const response = await handleConsoleMutation( + deadLetterReq(`/admin/api/dead-letters/${id}/${verb}`, adminToken), + mutationDeps(), + ); + expect(response.status).toBe(200); + }, + ); + + it("rejects an unauthenticated caller with zero side effects (401)", async () => { + const { id } = await seedDeadLetter(); + const before = { + actions: await countRows(`SELECT COUNT(*) n FROM operator_actions`), + events: await countRows(`SELECT COUNT(*) n FROM operational_events`), + }; + const { deps, sent, settle } = captureReenqueue(); + const response = await handleConsoleMutation( + deadLetterReq(`/admin/api/dead-letters/${id}/retry`, null), + deps, + ); + expect(response.status).toBe(401); + expect(await countRows(`SELECT COUNT(*) n FROM operator_actions`)).toBe(before.actions); + expect(await countRows(`SELECT COUNT(*) n FROM operational_events`)).toBe(before.events); + expect((await deadLetterRow(id))?.status).toBe("new"); + await settle(); + expect(sent).toHaveLength(0); + }); +}); + +describe("console mutation: dead-letter double-processing (T6)", () => { + it("404s a retry on an absent dead letter", async () => { + const response = await handleConsoleMutation( + deadLetterReq(`/admin/api/dead-letters/99999999/retry`), + mutationDeps(), + ); + expect(response.status).toBe(404); + }); + + it("404s a malformed dead-letter id", async () => { + const response = await handleConsoleMutation( + deadLetterReq(`/admin/api/dead-letters/not-an-id/retry`), + mutationDeps(), + ); + expect(response.status).toBe(404); + }); + + it("409s a second retry on an already-resolved letter with no second enqueue", async () => { + const { id } = await seedDeadLetter(); + const { deps, sent, settle } = captureReenqueue(); + const first = await handleConsoleMutation( + deadLetterReq(`/admin/api/dead-letters/${id}/retry`), + deps, + ); + expect(first.status).toBe(200); + await settle(); + expect(sent).toHaveLength(1); + + const actionsBefore = await countRows(`SELECT COUNT(*) n FROM operator_actions`); + const eventsBefore = await countRows(`SELECT COUNT(*) n FROM operational_events`); + const second = await handleConsoleMutation( + deadLetterReq(`/admin/api/dead-letters/${id}/retry`), + deps, + ); + expect(second.status).toBe(409); + expect((await bodyError(second)).code).toBe("DEAD_LETTER_RESOLVED"); + expect(await countRows(`SELECT COUNT(*) n FROM operator_actions`)).toBe(actionsBefore); + expect(await countRows(`SELECT COUNT(*) n FROM operational_events`)).toBe(eventsBefore); + await settle(); + expect(sent).toHaveLength(1); + }); + + it("409s a quarantine after a retry, and a retry after a quarantine", async () => { + const a = await seedDeadLetter(); + expect( + ( + await handleConsoleMutation( + deadLetterReq(`/admin/api/dead-letters/${a.id}/retry`), + mutationDeps(), + ) + ).status, + ).toBe(200); + const quarantineAfterRetry = await handleConsoleMutation( + deadLetterReq(`/admin/api/dead-letters/${a.id}/quarantine`), + mutationDeps(), + ); + expect(quarantineAfterRetry.status).toBe(409); + + const b = await seedDeadLetter(); + expect( + ( + await handleConsoleMutation( + deadLetterReq(`/admin/api/dead-letters/${b.id}/quarantine`), + mutationDeps(), + ) + ).status, + ).toBe(200); + const retryAfterQuarantine = await handleConsoleMutation( + deadLetterReq(`/admin/api/dead-letters/${b.id}/retry`), + mutationDeps(), + ); + expect(retryAfterQuarantine.status).toBe(409); + }); +}); + +describe("console mutation: dead-letter replay and idempotency", () => { + it("replays the stored result for the same key, enqueuing exactly once", async () => { + const { id } = await seedDeadLetter(); + const key = nextKey(); + const { deps, sent, settle } = captureReenqueue(); + const request = () => + post( + `/admin/api/dead-letters/${id}/retry`, + { reason: "re-drive", idempotencyKey: key }, + { token: adminToken }, + ); + const first = await handleConsoleMutation(request(), deps); + const firstBody = await first.text(); + await settle(); + const second = await handleConsoleMutation(request(), deps); + expect(second.status).toBe(200); + expect(await second.text()).toBe(firstBody); + await settle(); + // The replay returns the stored descriptor without re-running the effect. + expect(sent).toHaveLength(1); + const actionId = (JSON.parse(firstBody) as { data: DeadLetterActionDescriptor }).data.actionId; + expect(await eventCount(actionId)).toBe(1); + }); + + it("409s a same-key request targeting a different dead letter", async () => { + const a = await seedDeadLetter(); + const b = await seedDeadLetter(); + const key = nextKey(); + const first = await handleConsoleMutation( + post( + `/admin/api/dead-letters/${a.id}/retry`, + { reason: "re-drive", idempotencyKey: key }, + { token: adminToken }, + ), + mutationDeps(), + ); + expect(first.status).toBe(200); + const second = await handleConsoleMutation( + post( + `/admin/api/dead-letters/${b.id}/retry`, + { reason: "re-drive", idempotencyKey: key }, + { token: adminToken }, + ), + mutationDeps(), + ); + expect(second.status).toBe(409); + expect((await bodyError(second)).code).toBe("IDEMPOTENCY_KEY_CONFLICT"); + }); +}); From 6274b8e22a5d249eef2ef5404e9df37dfc3b5b84 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 14 Jul 2026 06:17:57 +0100 Subject: [PATCH 060/137] docs(labeler-plan): record W9.6 post-review decisions Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- .../plugin-registry-labelling-service/implementation-plan.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index a93f0d6b15..f6f972aa39 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1152,6 +1152,10 @@ Dependencies: `W6.6`, `W9.4`. Decisions (2026-07-13): `!takedown` is admin-retractable via the same guarded path (negation + full ceremony); the resting state after retraction is the subject's pre-takedown computed state — the never-negated automated labels re-expose, so a still-dangerous release falls back to its blocks, never to eligible. Publisher/package takedown does NOT cascade to per-release labels: one dominant URI-wide/DID label the evaluator honors globally (eliminates the partial-application surface). The automation kill-switch is global and gates Jetstream ingestion only — enforcement lives in the discovery consumer, never the signing layer, so reruns/takedowns/manual labels stay available mid-incident; the pause read fails closed (unreadable switch → retry, never issue). Enforcement mechanism is consumer-`retry()`: a long pause drains the discovery queue to the DLQ, recovered by resume + the DLQ-retry control (accepted trade at expected volume). Because the switch is checked in the consumer rather than the signing layer, any future automated issuance path (notably the W7/W8 orchestrator finalization) does NOT inherit the gate and must add its own `isAutomationPaused` check. Sub-decisions: publisher typed-confirmation identifier is the DID's final segment (no handle resolution); intent phrases are server constants; `operational_events` (append-only) is the alert source of truth and `notification_outbox` the delivery queue W11.3 drains; event/outbox INSERTs are gated on the label's in-batch existence so no alert can fire for a label suppression `assertIssuancePersisted` will 503. +Post-review decisions (2026-07-14, after PR-1..PR-3 merged): an emergency retract with no matching active label is rejected (404/409), not accepted as a no-op that emits a spurious high-severity event — the retract endpoints pre-check that the positive label is live before signing. Left as-is: `operator_actions.action` stays shared across issue/retract with direction in `metadata.neg` (no new union members); a pause records its high-severity operational event only, no notification-outbox delivery row (a pause is alarm-visible via the event but is not itself a page-worthy incident); the concurrent two-admin same-dead-letter race keeps its current behavior (at-most-once enqueue holds; the loser's misleading 200 + info event is an accepted rare wrinkle, not worth a post-commit read or a frozen-file gate change). + +W9.6 delivered across PR-0 (#2024 operational-event/outbox subsystem + kill-switch mechanism), PR-1 (#2026 takedown/publisher-compromised + ceremony), PR-2 (#2027 pause/resume), PR-3 (#2028 DLQ retry/quarantine). + Dependencies: `W9.2`, `W3.3`. ### `W9.7` Add browser/accessibility/localization tests From 3792edf46969cf0fd744bb9fe4c249d566164372 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 14 Jul 2026 06:52:47 +0100 Subject: [PATCH 061/137] feat(labeler): reject an emergency retract with no active label (#2029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A takedown/publisher-compromised retract now pre-checks that the target positive label is currently in effect (getActiveLabelState winner exists and active) before signing. An absent, already-negated, or expired label is a 404 NO_ACTIVE_LABEL — no no-op negation is signed and no operational event fires for a label that was never live. Pure read after the role and ceremony gates, before any commit. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- apps/labeler/src/console-mutation-api.ts | 49 +++- .../labeler/test/console-mutation-api.test.ts | 233 +++++++++++++++++- 2 files changed, 276 insertions(+), 6 deletions(-) diff --git a/apps/labeler/src/console-mutation-api.ts b/apps/labeler/src/console-mutation-api.ts index dd848e2a37..a47b916b39 100644 --- a/apps/labeler/src/console-mutation-api.ts +++ b/apps/labeler/src/console-mutation-api.ts @@ -24,6 +24,7 @@ import { import { advanceAssessmentToPending, buildAssessmentRunStatement, + getActiveLabelState, getAssessment, type Assessment, } from "./assessment-store.js"; @@ -198,7 +199,8 @@ export async function handleConsoleMutation( error instanceof MutationGuardError || error instanceof ReadGuardError || error instanceof LabelMutationError || - error instanceof DeadLetterResolvedError + error instanceof DeadLetterResolvedError || + error instanceof NoActiveLabelError ) return error.toResponse(); // A submitted override negation set that isn't exactly the live automated @@ -853,6 +855,27 @@ interface EmergencyActionBody { * (`cid` is always null: every emergency label is URI-wide / DID-subject). */ type EmergencyDescriptor = IssuedLabelDescriptor; +/** A retract whose target positive label is not currently in effect (never + * issued, already negated, or expired) — there is nothing to retract. Static + * message; a 404 mirroring the absent-subject case so a no-op negation is never + * signed and no operational event fires for a takedown that was never live. */ +class NoActiveLabelError extends Error { + override readonly name = "NoActiveLabelError"; + readonly code = "NO_ACTIVE_LABEL"; + readonly status = 404; + + constructor() { + super("No active label to retract"); + } + + toResponse(): Response { + return Response.json( + { error: { code: this.code, message: this.message } }, + { status: this.status }, + ); + } +} + /** * `POST /admin/api/emergency/{takedown,publisher-compromised}` and their * `-retract` siblings — the admin-only crown-jewel actions (spec §11.3/§18.2, @@ -901,6 +924,12 @@ async function runEmergencyAction( } const { ctx } = outcome; + // A retract only makes sense against a currently-in-effect positive label. A + // pure read after the role + ceremony gates and before any signing/commit: an + // absent, already-negated, or expired winner is rejected here, so no no-op + // negation is signed and no operational event or alert fires. + if (neg) await assertRetractableLabel(deps.db, deps.config.labelerDid, ctx.body.uri, val); + const signer = await deps.createSigner(); const issuance = await prepareManualLabelIssuance( deps.db, @@ -1005,6 +1034,24 @@ function didFinalSegment(uri: string): string { return uri.split(":").at(-1) ?? ""; } +/** + * Rejects a retract with no positive label to retract. Reads the same stream + * winner official clients see (`getActiveLabelState`); the label is retractable + * iff its winner exists and is `active` (non-negated, unexpired). Every emergency + * label is URI-wide, so `cid: ""` matches the CID-forbidden winner the same way + * the console's publisher subject-label read does. + */ +async function assertRetractableLabel( + db: D1Database, + src: string, + uri: string, + val: string, +): Promise { + const winners = await getActiveLabelState(db, { src, uri, cid: "" }); + const winner = winners.get(val); + if (!winner || !winner.active) throw new NoActiveLabelError(); +} + // ─── W9.6: admin-only automation kill-switch (pause/resume ingestion) ──────── /** The idempotent pause/resume result. `paused` is the switch's post-action diff --git a/apps/labeler/test/console-mutation-api.test.ts b/apps/labeler/test/console-mutation-api.test.ts index 137f78d5f2..25b2c7e17a 100644 --- a/apps/labeler/test/console-mutation-api.test.ts +++ b/apps/labeler/test/console-mutation-api.test.ts @@ -832,6 +832,15 @@ async function outboxCount(actionId: string): Promise { ); } +async function globalCounts(): Promise> { + return { + actions: await countRows(`SELECT COUNT(*) n FROM operator_actions`), + labels: await countRows(`SELECT COUNT(*) n FROM issued_labels`), + events: await countRows(`SELECT COUNT(*) n FROM operational_events`), + outbox: await countRows(`SELECT COUNT(*) n FROM notification_outbox`), + }; +} + function emergency( path: string, body: Record, @@ -1246,10 +1255,21 @@ describe("console mutation: emergency retract resting state", () => { }); it("emits a high-severity operational event for a retract", async () => { + const uri = await seedReleaseSubject("emrg-retract-event"); + const issued = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown", { + uri, + subjectConfirmation: "emrg-retract-event", + intent: "CONFIRM TAKEDOWN", + }), + mutationDeps(), + ); + expect(issued.status).toBe(200); + const response = await handleConsoleMutation( emergency("/admin/api/emergency/takedown-retract", { - uri: PUBLISHER_DID, - subjectConfirmation: PUBLISHER_SEGMENT, + uri, + subjectConfirmation: "emrg-retract-event", intent: "CONFIRM RETRACT", }), mutationDeps(), @@ -1265,6 +1285,211 @@ describe("console mutation: emergency retract resting state", () => { }); }); +describe("console mutation: emergency retract guard", () => { + const FRESH_DID = "did:plc:nocompromise00000000000000"; + const FRESH_DID_SEGMENT = FRESH_DID.split(":").at(-1)!; + + it("retracts an active takedown: negation lands, event emitted (200)", async () => { + const uri = await seedReleaseSubject("guard-active-td"); + const issued = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown", { + uri, + subjectConfirmation: "guard-active-td", + intent: "CONFIRM TAKEDOWN", + }), + mutationDeps(), + ); + expect(issued.status).toBe(200); + + const retract = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown-retract", { + uri, + subjectConfirmation: "guard-active-td", + intent: "CONFIRM RETRACT", + }), + mutationDeps(), + ); + expect(retract.status).toBe(200); + const descriptor = await bodyData(retract); + expect(descriptor.neg).toBe(true); + + const winners = await getActiveLabelState(testEnv.DB, { src: LABELER_DID, uri, cid: CID }); + expect(winners.get("!takedown")?.active).toBe(false); + expect(await eventCount(descriptor.actionId)).toBe(1); + }); + + it("retracts an active takedown on a package subject (200)", async () => { + const uri = profileUri("guard-active-td-pkg"); + const issued = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown", { + uri, + subjectConfirmation: "guard-active-td-pkg", + intent: "CONFIRM TAKEDOWN", + }), + mutationDeps(), + ); + expect(issued.status).toBe(200); + + const retract = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown-retract", { + uri, + subjectConfirmation: "guard-active-td-pkg", + intent: "CONFIRM RETRACT", + }), + mutationDeps(), + ); + expect(retract.status).toBe(200); + expect((await bodyData(retract)).neg).toBe(true); + const winners = await getActiveLabelState(testEnv.DB, { src: LABELER_DID, uri, cid: "" }); + expect(winners.get("!takedown")?.active).toBe(false); + }); + + it("retracts an active takedown on a publisher subject (200)", async () => { + const uri = "did:plc:guardtdpublisher00000000000"; + const issued = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown", { + uri, + subjectConfirmation: uri.split(":").at(-1)!, + intent: "CONFIRM TAKEDOWN", + }), + mutationDeps(), + ); + expect(issued.status).toBe(200); + + const retract = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown-retract", { + uri, + subjectConfirmation: uri.split(":").at(-1)!, + intent: "CONFIRM RETRACT", + }), + mutationDeps(), + ); + expect(retract.status).toBe(200); + expect((await bodyData(retract)).neg).toBe(true); + const winners = await getActiveLabelState(testEnv.DB, { src: LABELER_DID, uri, cid: "" }); + expect(winners.get("!takedown")?.active).toBe(false); + }); + + it("retracts an active publisher-compromised on a publisher (200)", async () => { + const uri = "did:plc:guardpccompromised000000000"; + const issued = await handleConsoleMutation( + emergency("/admin/api/emergency/publisher-compromised", { + uri, + subjectConfirmation: uri.split(":").at(-1)!, + intent: "CONFIRM COMPROMISE", + }), + mutationDeps(), + ); + expect(issued.status).toBe(200); + + const retract = await handleConsoleMutation( + emergency("/admin/api/emergency/publisher-compromised-retract", { + uri, + subjectConfirmation: uri.split(":").at(-1)!, + intent: "CONFIRM RETRACT", + }), + mutationDeps(), + ); + expect(retract.status).toBe(200); + expect((await bodyData(retract)).neg).toBe(true); + const winners = await getActiveLabelState(testEnv.DB, { src: LABELER_DID, uri, cid: "" }); + expect(winners.get("publisher-compromised")?.active).toBe(false); + }); + + it("rejects a takedown-retract with no active takedown (404, zero side effects)", async () => { + const uri = await seedReleaseSubject("guard-no-td"); + const before = await globalCounts(); + const response = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown-retract", { + uri, + subjectConfirmation: "guard-no-td", + intent: "CONFIRM RETRACT", + }), + mutationDeps(), + ); + expect(response.status).toBe(404); + expect((await bodyError(response)).code).toBe("NO_ACTIVE_LABEL"); + expect(await globalCounts()).toEqual(before); + }); + + it("rejects a publisher-compromised-retract with no active label (404, zero side effects)", async () => { + const before = await globalCounts(); + const response = await handleConsoleMutation( + emergency("/admin/api/emergency/publisher-compromised-retract", { + uri: FRESH_DID, + subjectConfirmation: FRESH_DID_SEGMENT, + intent: "CONFIRM RETRACT", + }), + mutationDeps(), + ); + expect(response.status).toBe(404); + expect((await bodyError(response)).code).toBe("NO_ACTIVE_LABEL"); + expect(await globalCounts()).toEqual(before); + }); + + it("rejects retracting an already-retracted takedown (404, zero side effects)", async () => { + const uri = await seedReleaseSubject("guard-double-retract"); + const issued = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown", { + uri, + subjectConfirmation: "guard-double-retract", + intent: "CONFIRM TAKEDOWN", + }), + mutationDeps(), + ); + expect(issued.status).toBe(200); + const firstRetract = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown-retract", { + uri, + subjectConfirmation: "guard-double-retract", + intent: "CONFIRM RETRACT", + }), + mutationDeps(), + ); + expect(firstRetract.status).toBe(200); + + const before = await globalCounts(); + const secondRetract = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown-retract", { + uri, + subjectConfirmation: "guard-double-retract", + intent: "CONFIRM RETRACT", + }), + mutationDeps(), + ); + expect(secondRetract.status).toBe(404); + expect((await bodyError(secondRetract)).code).toBe("NO_ACTIVE_LABEL"); + expect(await globalCounts()).toEqual(before); + }); + + it("keeps the role and ceremony rejections ahead of the retract guard", async () => { + const uri = await seedReleaseSubject("guard-order"); + // Reviewer role gate (403) precedes the retract guard even with nothing active. + const roleDenied = await handleConsoleMutation( + emergency( + "/admin/api/emergency/takedown-retract", + { uri, subjectConfirmation: "guard-order", intent: "CONFIRM RETRACT" }, + reviewerToken, + ), + mutationDeps(), + ); + expect(roleDenied.status).toBe(403); + expect((await bodyError(roleDenied)).code).toBe("FORBIDDEN_ROLE"); + + // Admin with a wrong intent: the ceremony gate (400) precedes the retract guard. + const ceremonyDenied = await handleConsoleMutation( + emergency("/admin/api/emergency/takedown-retract", { + uri, + subjectConfirmation: "guard-order", + intent: "CONFIRM TAKEDOWN", + }), + mutationDeps(), + ); + expect(ceremonyDenied.status).toBe(400); + expect((await bodyError(ceremonyDenied)).code).toBe("CONFIRMATION_MISMATCH"); + }); +}); + describe("console mutation: emergency §10 automation interaction", () => { it("automation cannot negate an admin-issued !takedown", async () => { const uri = await seedReleaseSubject("emrg-s10"); @@ -1614,9 +1839,7 @@ async function seedDeadLetter( return { id: Number(result.meta.last_row_id), job }; } -async function deadLetterRow( - id: number, -): Promise<{ +async function deadLetterRow(id: number): Promise<{ status: string; resolved_at: string | null; resolved_by_action_id: string | null; From 850085c5d72b8d2800eedb49808e2ce0e614fff6 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 14 Jul 2026 08:06:12 +0100 Subject: [PATCH 062/137] docs(labeler-plan): ratify W8.4 decisions Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- .../plugin-registry-labelling-service/implementation-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index f6f972aa39..8b82ba06fc 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1045,6 +1045,8 @@ History may recommend operator review but cannot automatically produce package/p Dependencies: `W2.3`, registry publisher/profile data. +Decisions (2026-07-14): history is not a new contract surface — `FindingSource` already includes `"history"` and the resolver already drops it (`policy-resolver.ts` excludes `source: "history"` before any category→label mapping; `isBlockingFinding` returns false for it), so "never auto-label" is structural, not a new guard. "Recommend review" is passive: a history finding surfaces through the existing assessment-detail projection an operator already opens; no `operational_events` push and no new "flag-for-review" operator action (D3). Two of the five inputs — recent handle/profile changes and verification state — live only in the aggregator's D1, which the labeler has no binding to reach (and `constants.ts` deliberately avoids ingesting profiles); they are deferred to a later slice gated on a ratified read-only labeler→aggregator read path (service-binding preferred over a second D1 binding), not built now (D1). Repeated-checksum detection is global (cross-publisher): a checksum reused under a different DID is the sharper signal, needing a new `idx_assessments_artifact_checksum` and a cross-DID lookup (D2). Prior releases, active manual labels (reuse `getActiveLabelState`, filter `!automated && active`), and repeated checksums/findings come from the labeler's own D1. The finding contract is amended (touches W8.1): `source: "history"` findings cite a dedicated history/review category set exempt from the automated-block ∪ warning constraint that `validateFinding` enforces, rather than borrowing a warn category (D4). Neutral display facts (prior-release count, verification badge) are assembled at console read-time in the existing subject-history endpoint, not persisted as a pipeline `ContextBundle` (D5). The stage is DB-bound (`analyzeHistory(db, assessment, opts) → NormalizedFinding[]`), wired into `OrchestratorStages`/`STAGE_ORDER`/`stubStages` and run last, wrapping D1 failures as `StageTransientError`. Sliced: (1) own-D1 history findings + the W8.1 amendment + the checksum index — unblocked; (2) console neutral-context surfacing — unblocked, optional; (3) the two aggregator-sourced inputs — blocked on D1's read path. + ### `W8.5` Implement versioned policy resolver Resolve normalized findings under an immutable policy version: From 2881e373a92766e71a9f1d87539ac8befe6dd526 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 14 Jul 2026 21:15:51 +0100 Subject: [PATCH 063/137] docs(labeler): operator guide, ATProto reference, and keygen script (#2031) * docs(labeler): operator guide, ATProto reference, and keygen script Add developer and operator documentation for the labeler service: README (overview, local dev, deploy checklist, config reference), docs/atproto.md (DIDs, did:web, the DID document, labels, signing, XRPC surface), docs/operating.md (console roles and workflows), and docs/moderation-model.md (label vocabulary and eligibility evaluation). Add a `keygen` script that generates a P-256 signing keypair in the exact formats the Worker validates on boot: the private key as unpadded base64url of the raw 32-byte scalar and the public key as a canonical P-256 Multikey. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM * docs(labeler): correct the signing keypair validation timing The keypair mismatch check runs lazily when the signer is first constructed (the first signing operation), not at boot or deploy. `wrangler deploy` only enforces that the private-key secret exists. Reword the README, atproto doc, and keygen comment so operators know a mismatched pair deploys cleanly and fails on the first sign, and to exercise a signing path after setting or rotating the key. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- apps/labeler/README.md | 83 ++++++++++++++++++ apps/labeler/docs/atproto.md | 118 ++++++++++++++++++++++++++ apps/labeler/docs/moderation-model.md | 69 +++++++++++++++ apps/labeler/docs/operating.md | 87 +++++++++++++++++++ apps/labeler/package.json | 4 +- apps/labeler/scripts/keygen.mjs | 38 +++++++++ pnpm-lock.yaml | 3 + 7 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 apps/labeler/README.md create mode 100644 apps/labeler/docs/atproto.md create mode 100644 apps/labeler/docs/moderation-model.md create mode 100644 apps/labeler/docs/operating.md create mode 100644 apps/labeler/scripts/keygen.mjs diff --git a/apps/labeler/README.md b/apps/labeler/README.md new file mode 100644 index 0000000000..333d30862a --- /dev/null +++ b/apps/labeler/README.md @@ -0,0 +1,83 @@ +# @emdash-cms/labeler + +An ATProto labeler service for the emdash plugin registry, deployed as a single Cloudflare Worker. It ingests package-registry records from the ATProto firehose, runs automated moderation assessments on package releases, and emits signed ATProto labels describing each release's eligibility (passed, blocked, warned, pending, error). Consumers such as the emdash aggregator subscribe to those labels and decide what to surface and serve. A React operator console under `/admin` lets human reviewers and admins inspect assessments and act on them. + +## How it fits together + +``` +registry records ──▶ Jetstream firehose ──▶ discovery queue ──▶ automated assessment + │ + ▼ +consumers ◀── subscribeLabels / queryLabels ◀── signed labels ◀── label store +``` + +The Worker consumes the registry's records from the ATProto firehose (Jetstream), enqueues discovered packages and releases, and runs an assessment pipeline over each release. Assessments produce signed labels. Consumers resolve the labeler's DID, discover its endpoint, subscribe to the label stream, verify each label's signature against the public key published in the DID document, and apply the resulting eligibility decisions. + +The concepts behind all of this (DIDs, `did:web`, the DID document, labels, signing, and the XRPC surface) are explained in [docs/atproto.md](docs/atproto.md). The label vocabulary and how labels overlay into an eligibility state are in [docs/moderation-model.md](docs/moderation-model.md). Running the operator console day to day is covered in [docs/operating.md](docs/operating.md). + +## Local development + +Scripts are run through pnpm, filtered to this package: + +```bash +pnpm --filter @emdash-cms/labeler dev # Worker + console dev server (vite dev) +pnpm --filter @emdash-cms/labeler console:dev # console SPA on its own vite config +pnpm --filter @emdash-cms/labeler test # Worker + console test suites +pnpm --filter @emdash-cms/labeler typecheck # tsgo over Worker and console +pnpm --filter @emdash-cms/labeler db:migrate:local # apply D1 migrations to the local database +``` + +The console is not localized (plain English) and there is no dev auth bypass, so operator flows are exercised through real Cloudflare Access in a deployed environment. + +## Deploying + +The Worker checks that its signing keypair matches the public key published in its DID document the first time it signs a label — not at deploy time. `wrangler deploy` only enforces that the `LABEL_SIGNING_PRIVATE_KEY` secret exists, so a mismatched pair deploys cleanly and then fails on the first signing operation. After setting or rotating the key, exercise a signing path (a console mutation, an assessment, or a `queryLabels` re-sign) to confirm the pair is consistent. Work through the checklist in order. + +1. **Create a Cloudflare Access application** covering `/admin/*` on the labeler's domain, with two Access groups (one for admins, one for reviewers) mapped to your identity provider. This produces the application's **AUD tag**. + +2. **Set the operator Access config.** Put the AUD tag, your team domain, and the group names into `OPERATOR_ACCESS_CONFIG` in `wrangler.jsonc` (it ships with a `REPLACE_WITH_ACCESS_APP_AUD_TAG` placeholder): + + ```jsonc + "OPERATOR_ACCESS_CONFIG": "{\"teamDomain\":\"https://your-team.cloudflareaccess.com\",\"audience\":\"\",\"admins\":[\"emdash-labeler-admins\"],\"reviewers\":[\"emdash-labeler-reviewers\"]}" + ``` + +3. **Generate the signing keypair** and install it. The keygen script prints both values in the exact formats the Worker expects; nothing is written to disk. + + ```bash + pnpm --filter @emdash-cms/labeler keygen + echo -n '' | wrangler secret put LABEL_SIGNING_PRIVATE_KEY + # then set LABEL_SIGNING_PUBLIC_KEY in wrangler.jsonc to the printed public key + ``` + + Bump `LABEL_SIGNING_KEY_VERSION` if you are rotating an existing key. + +4. **Run migrations** against the remote D1 database: + + ```bash + pnpm --filter @emdash-cms/labeler db:migrate + ``` + +5. **Deploy.** This builds the console and the Worker, then publishes: + + ```bash + pnpm --filter @emdash-cms/labeler deploy + ``` + +## Configuration reference + +| Key | Kind | Description | +| --------------------------- | ------ | --------------------------------------------------------------------------------- | +| `LABELER_DID` | var | The labeler's DID, `did:web:labels.emdashcms.com`. | +| `LABELER_SERVICE_URL` | var | Service endpoint published in the DID document, `https://labels.emdashcms.com`. | +| `LABEL_SIGNING_PUBLIC_KEY` | var | P-256 public key as a Multikey; published in the DID document's `#atproto_label` method for signature verification. | +| `LABEL_SIGNING_PRIVATE_KEY` | secret | P-256 private key (unpadded base64url of the raw 32-byte scalar) used to sign labels server-side. | +| `LABEL_SIGNING_KEY_VERSION` | var | Signing key version (`v1`); bump when rotating. | +| `OPERATOR_ACCESS_CONFIG` | var | JSON: `teamDomain`, `audience` (Access AUD tag), and `admins` / `reviewers` group names. | + +The private key is declared as a required secret in `wrangler.jsonc`, so `wrangler deploy` refuses to publish if it has not been set on the target Worker. + +## Documents + +- [docs/atproto.md](docs/atproto.md) — ATProto foundations: DIDs, `did:web`, the DID document, labels, signing, and the XRPC surface. +- [docs/operating.md](docs/operating.md) — operator console guide: sign-in, roles, and the review and emergency workflows. +- [docs/moderation-model.md](docs/moderation-model.md) — label vocabulary and how labels evaluate into an eligibility state. diff --git a/apps/labeler/docs/atproto.md b/apps/labeler/docs/atproto.md new file mode 100644 index 0000000000..7d80d35d58 --- /dev/null +++ b/apps/labeler/docs/atproto.md @@ -0,0 +1,118 @@ +# ATProto foundations + +This document explains the ATProto concepts the labeler is built on, and the concrete choices this service makes. Read it if you are consuming the labeler's labels, verifying its signatures, or working on the service itself. For the label vocabulary see [moderation-model.md](moderation-model.md); for the operator console see [operating.md](operating.md). + +## Decentralized identity and DIDs + +ATProto identifies every account and service by a **DID** (Decentralized Identifier): a stable, method-scoped string that does not change even when the underlying host, handle, or key rotates. A DID resolves to a **DID document**, a JSON object describing how to interact with that identity. Two parts of the document matter here: + +- **Verification methods** — public keys bound to the identity. Anything the identity signs can be verified against these keys. +- **Services** — named endpoints. A client that knows a DID can resolve it, read the service list, and find where to talk to it. + +The DID string encodes a **method** that says how to resolve it. Two methods are relevant to a labeler: + +- **`did:plc`** — the common ATProto method. The identifier is an opaque string (`did:plc:abc123…`) hosted by the PLC directory, a separate service that stores and serves the DID document. Rotating keys or moving hosts means updating the record in the directory. +- **`did:web`** — the identifier *is* a domain. `did:web:labels.emdashcms.com` resolves by fetching `https://labels.emdashcms.com/.well-known/did.json`. No external directory is involved; whoever controls the domain controls the document. + +### Why this labeler uses `did:web` + +A labeler already owns and operates a domain and an HTTP server. `did:web` lets it be its own source of truth: the Worker serves its own DID document at `/.well-known/did.json`, so there is no registration step, no external directory to keep in sync, and no third party in the resolution path. The tradeoff — that identity is tied to continued control of the domain — is acceptable for an infrastructure service whose whole purpose is to serve that domain. + +## This labeler's identity + +The labeler's DID is `did:web:labels.emdashcms.com` (config var `LABELER_DID`), and its service URL is `https://labels.emdashcms.com` (`LABELER_SERVICE_URL`). Resolving the DID means fetching `https://labels.emdashcms.com/.well-known/did.json` — which the Worker serves itself, with content type `application/did+ld+json`. + +The document (built by `serviceDidDocument` in `src/identity.ts`) has this shape: + +```json +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/multikey/v1" + ], + "id": "did:web:labels.emdashcms.com", + "verificationMethod": [ + { + "id": "did:web:labels.emdashcms.com#atproto_label", + "type": "Multikey", + "controller": "did:web:labels.emdashcms.com", + "publicKeyMultibase": "zDnae..." + } + ], + "service": [ + { + "id": "did:web:labels.emdashcms.com#atproto_labeler", + "type": "AtprotoLabeler", + "serviceEndpoint": "https://labels.emdashcms.com" + } + ] +} +``` + +Two entries carry the whole contract: + +- **`#atproto_label`** — a single `Multikey` verification method holding the P-256 public key (`publicKeyMultibase`, from `LABEL_SIGNING_PUBLIC_KEY`). Consumers use this key to verify the signature on every label the service emits. This is the anchor of trust: a label is only as trustworthy as the DID document that publishes the key it was signed with. +- **`#atproto_labeler`** — a single service entry of type `AtprotoLabeler` whose `serviceEndpoint` is the labeler's base URL. This is how an ATProto client, given only the DID, discovers where to query and subscribe. + +## Labels + +A **label** in ATProto is a small, signed assertion that attaches a value (a short string like `malware` or `assessment-passed`) to a subject. The subject is either a whole repository (identified by a DID) or a specific record (identified by its AT-URI, optionally pinned to a content hash, the **CID**). Labels can be self-authored (an account labeling its own content) or third-party: a **labeler** is a service that publishes labels about subjects it does not own. Clients choose which labelers they trust and subscribe to them; a label from an untrusted labeler carries no weight. + +This service labels three kinds of subject: + +- **Publisher** — a DID. The identity that publishes packages. +- **Package** — a record URI (the package profile record). +- **Release** — a specific release, identified by its record URI *and* CID, so a label applies to one exact version's bytes. + +The **`cidRule`** on each label encodes which of these it can target: + +- `required` — the label must pin a CID, so it targets one specific release version. +- `forbidden` — the label must not pin a CID, so it applies URI-wide (a whole package or publisher). +- `optional` — either form is valid. + +Release-eligibility labels are `required` (they are about one version's bytes); publisher and package actions are `forbidden` (they apply to everything under that subject). The full rules per label are in [moderation-model.md](moderation-model.md). + +## Signing + +Labels are cryptographically signed so a consumer can trust their provenance without trusting the transport. The signing key is a **P-256** (ECDSA / ES256) keypair: + +- The **public key** lives in the DID document's `#atproto_label` method, as a canonical P-256 Multikey (`LABEL_SIGNING_PUBLIC_KEY`). Anyone resolving the DID can read it. +- The **private key** stays on the server as a secret (`LABEL_SIGNING_PRIVATE_KEY`, the unpadded base64url of the raw 32-byte scalar). It signs every label the service emits and is never exposed. + +A consumer verifies a label by resolving the labeler's DID, reading the public key from `#atproto_label`, and checking the label's signature against it. The Worker checks that the configured private key derives the public key published in the document when it constructs its signer — which happens lazily, on the first signing operation, not at boot or deploy. A mismatched pair therefore deploys cleanly and fails the first time the Worker tries to sign (a discovery assessment, a `queryLabels` re-sign, or a console mutation), rather than being caught up front. + +Key rotation is tracked by `LABEL_SIGNING_KEY_VERSION` (currently `v1`). Generate a fresh keypair with `pnpm --filter @emdash-cms/labeler keygen`, install the new secret and public key, and bump the version. + +## XRPC surface + +ATProto's HTTP-RPC convention is **XRPC**: each method is a namespaced identifier (an NSID like `com.atproto.label.queryLabels`) called at `/xrpc/`. The labeler exposes: + +| Path | Purpose | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `/xrpc/com.atproto.label.queryLabels` | Query the current labels for a set of subjects (a point-in-time read). | +| `/xrpc/com.atproto.label.subscribeLabels` | The streaming label firehose. A WebSocket subscription consumers follow to receive labels as they are issued; accepts a numeric `cursor` to resume. | +| `/xrpc/com.atproto.moderation.createReport` | **Rejected.** This labeler does not accept user moderation reports; the endpoint returns a `NotSupported` error. | +| `/xrpc/com.emdashcms.experimental.labeler.*` | The experimental assessment API (`getAssessment`, `getCurrentAssessment`, `listAssessments`, `getPolicy`). | + +Alongside the XRPC methods, two documents are served under `/.well-known`: + +| Path | Purpose | +| --------------------------------------------- | --------------------------------------------------------------- | +| `/.well-known/did.json` | The DID document (above). | +| `/.well-known/emdash-labeler-policy.json` | The moderation policy document (label vocabulary and rules), cached for 300s. | + +## Jetstream and ingestion + +The labeler does not wait for anyone to submit content. It consumes the registry's records directly from the ATProto firehose via **Jetstream** (a JSON-streaming view of the network's commit stream), which is how it discovers new packages and releases to assess. Discovered work is enqueued and processed by the assessment pipeline, which is what ultimately produces the labels. + +## How it fits the wider network + +A consumer — the emdash aggregator, or any client that trusts this labeler — puts the pieces together like this: + +1. Resolve `did:web:labels.emdashcms.com` by fetching `/.well-known/did.json`. +2. Read the `#atproto_labeler` service entry to find the endpoint. +3. Subscribe to labels via `subscribeLabels` (or query current state with `queryLabels`). +4. Verify each label's signature against the P-256 key in `#atproto_label`. +5. Overlay the verified labels into an eligibility decision (see [moderation-model.md](moderation-model.md)) and act on it — showing, gating, or withholding the release. + +The trust chain is end to end: control of the domain backs the DID, the DID publishes the key, the key signs the labels, and the labels drive the decision. diff --git a/apps/labeler/docs/moderation-model.md b/apps/labeler/docs/moderation-model.md new file mode 100644 index 0000000000..c5ff52c631 --- /dev/null +++ b/apps/labeler/docs/moderation-model.md @@ -0,0 +1,69 @@ +# Moderation model + +This is the reference for the labeler's label vocabulary and how active labels evaluate into an eligibility decision. It is what a `queryLabels` consumer needs to act on the labels correctly, and what an operator needs to understand which action produces which effect. For the console workflows that issue these labels, see [operating.md](operating.md); for the ATProto label and subject concepts, see [atproto.md](atproto.md). + +## Structure + +Labels are grouped into categories. Each label carries: + +- an **effect** — `pass`, `block`, `warn`, `pending`, `error`, or `redact`; +- a **subject scope** — release, package, or publisher; +- a **`cidRule`** — `required` (targets one exact release CID), `forbidden` (URI-wide, whole record or publisher), or `optional` (either); and +- **issuance modes** — who may issue it: `automated`, `reviewer`, or `admin`. + +The policy document is served publicly at `/.well-known/emdash-labeler-policy.json`. The current `policyVersion` is `2026-07-10.experimental.3`. + +## Vocabulary + +| Label | Category | Effect | Subject → cidRule | Who issues | +| ---------------------------- | --------------- | ------- | ------------------------------------------------------------ | --------------------- | +| `assessment-passed` | eligibility | pass | release → required | automated, reviewer | +| `assessment-overridden` | eligibility | pass | release → required | reviewer | +| `assessment-pending` | eligibility | pending | release → required | automated | +| `assessment-error` | eligibility | error | release → required | automated | +| `malware` | automated-block | block | release → required | automated, reviewer | +| `data-exfiltration` | automated-block | block | release → required | automated, reviewer | +| `credential-harvesting` | automated-block | block | release → required | automated, reviewer | +| `supply-chain-compromise` | automated-block | block | release → required | automated, reviewer | +| `critical-vulnerability` | automated-block | block | release → required | automated, reviewer | +| `artifact-integrity-failure` | automated-block | block | release → required | automated, reviewer | +| `invalid-bundle` | automated-block | block | release → required | automated, reviewer | +| `undeclared-access` | automated-block | block | release → required | automated, reviewer | +| `impersonation` | automated-block | block | release → required | automated, reviewer | +| `suspicious-code` | warning | warn | release → required | automated, reviewer | +| `obfuscated-code` | warning | warn | release → required | automated, reviewer | +| `privacy-risk` | warning | warn | release → required | automated, reviewer | +| `misleading-metadata` | warning | warn | release → required | automated, reviewer | +| `low-quality` | warning | warn | release → required | automated, reviewer | +| `broken-release` | warning | warn | release → required | automated, reviewer | +| `!takedown` | manual-system | redact | release / package / publisher → forbidden | admin | +| `security-yanked` | manual-system | block | release → forbidden | reviewer | +| `publisher-compromised` | manual-system | block | publisher → forbidden | admin | +| `package-disputed` | manual-system | warn | package → optional | reviewer | + +Notes: + +- The **eligibility** labels are automation-issued, except the override pair (`assessment-overridden`, and the reviewer half of `assessment-passed`), which is reviewer-only. `assessment-pending` gates a release until it resolves; `assessment-error` marks a pipeline failure after bounded retries. +- The **automated-block** and **warning** labels each carry *both* `automated` and `reviewer` issuance modes, so a reviewer may also apply any of them by hand — always CID-bound to one release. +- **`!takedown`** is the strongest action: it redacts the subject, and applies to a release, package, or publisher. + +## Eligibility evaluation + +A consumer overlays a release's active labels into one overall state: **eligible**, **pending**, **error**, or **blocked** (plus redaction). This is what `queryLabels` consumers act on. The rules: + +- Any active **block** (automated or manual) makes the release **blocked**. +- An active **pending** makes it **pending**. +- An active **error** surfaces as **error**. +- The **pass + override** pair suppresses the current automated blocks *and* future ones for that release — override permanence — so a re-assessment cannot silently re-block an overridden release. +- **Warnings never block.** A release can be eligible and still carry warning labels; they are advisory. +- A **`!takedown`** redacts the subject regardless of its other labels. + +The policy's precedence order (highest first) is: manual block, label-state collision, assessment error, assessment pending, automated block, missing assessment pass, then eligible. + +## The §10 rule + +Automation can never negate a human decision. Any manually issued label — a takedown, an override, a reviewer label — is **human-retract-only**. Re-assessment and the automated pipeline cannot pull a human's label; only an operator can. This is why an override is permanent in effect: once a reviewer has passed a release, automation is not allowed to re-block it. + +## No cascade + +A takedown (or block) on a publisher or package is a single URI-wide or DID-wide label that the evaluator honors for everything beneath it. It is **not** fanned out into per-release labels. One subject, one label; the evaluation walks up to the covering subject rather than expecting a copy on every release underneath. diff --git a/apps/labeler/docs/operating.md b/apps/labeler/docs/operating.md new file mode 100644 index 0000000000..b65839314b --- /dev/null +++ b/apps/labeler/docs/operating.md @@ -0,0 +1,87 @@ +# Operating the labeler console + +The operator console is a React SPA served under `/admin` on the labeler's domain. It lets reviewers and admins inspect automated assessments and act on them: issuing and retracting labels, overriding false positives, taking down abusive content, pausing automation, and clearing the dead-letter queue. This guide covers signing in, the two roles, and each workflow. + +For the label vocabulary the actions manipulate, see [moderation-model.md](moderation-model.md). For the identity and signing concepts underneath, see [atproto.md](atproto.md). + +## Signing in + +Authentication is **Cloudflare Access** — there is no separate password and no dev bypass. You authenticate at the Cloudflare edge against your identity provider; Access issues a signed JWT and injects it (as `Cf-Access-Jwt-Assertion`) on every request to `/admin/api/*`. The Worker verifies that JWT against Access's JWKS (RS256, checking issuer, audience, and expiry) on every request. The edge policy on `/admin/*` also redirects an unauthenticated browser before it ever reaches the Worker, so you will be sent through your IdP the first time you open the console. + +Your role comes from the Access group membership in the verified JWT, matched against the `admins` and `reviewers` group names in `OPERATOR_ACCESS_CONFIG`. `GET /admin/api/whoami` returns your roles; the console uses it only to show or hide admin controls. The server re-checks authorization on every mutation regardless of what the UI shows, so the button visibility is cosmetic — the server is always authoritative. + +## Roles + +There are two roles, **reviewer** and **admin**. An admin inherits every reviewer capability; there is no path the other way. + +| Capability | Reviewer | Admin | +| ----------------------------------------------------------------- | :------: | :---: | +| Read assessments, findings, labels, subject history, audit log, system status, dead-letter list, effect previews | ✓ | ✓ | +| Issue and retract a reviewer label | ✓ | ✓ | +| Re-run an assessment | ✓ | ✓ | +| False-positive override and override-retract | ✓ | ✓ | +| Emergency takedown / takedown-retract | | ✓ | +| Publisher-compromised / retract | | ✓ | +| Automation pause / resume | | ✓ | +| Dead-letter retry / quarantine | | ✓ | + +A reviewer who calls an admin-only endpoint gets a `403`. + +Reviewers can read operator-only detail on findings (the `privateDetail` field), not just the public assessment. + +## Reviewing an assessment + +Open an assessment from the list to see its findings, the labels currently live on the subject, and the operator-only detail. From there a reviewer can attach a descriptive label to a subject with **issue** (`POST /admin/api/labels/issue`) and pull one back with **retract** (`POST /admin/api/labels/retract`), which negates a reviewer-issued label. Which labels a reviewer may issue, and against which subject and CID, is governed by the policy — see [moderation-model.md](moderation-model.md). + +## Re-running an assessment + +**Re-run** (`POST /admin/api/assessments/:id/rerun`) re-queues the assessment: it mints a fresh run and re-issues `assessment-pending` so the release is gated again until the new run resolves. Because a re-run changes the live state of a specific release, you confirm the action by typing the release **CID** — a deliberate check that you are acting on the version you think you are. + +## False-positive override + +When an automated block is wrong, **override** (`POST /admin/api/assessments/:id/override`) clears it. In one atomic action it negates *all* live automated blocks on the exact URI + CID and issues the reviewer pair `assessment-passed` + `assessment-overridden`. The negate-set you submit must equal the live automated-block set exactly; if automation has issued a block you did not include, the action is rejected rather than partially applied. Like a re-run, it requires CID confirmation. + +An override is permanent in effect: the pass pair suppresses the negated blocks *and* future automated blocks for that release, so re-assessment cannot silently re-block it. This is the §10 rule in action — automation may not overturn a human decision. + +**Override-retract** (`POST /admin/api/assessments/:id/override-retract`) pulls the override pair back. It does **not** restore the original blocks — those stay negated — so the release resolves to a "safe-blocked" state: blocked, but for a missing assessment pass rather than a live finding. To re-surface the real findings, retract the override and then re-run. + +## Emergency actions (admin) + +Emergency actions carry a two-field ceremony to prevent misfires. Every one requires you to type the **subject identifier** and an exact **intent phrase**; the action is rejected unless both match. + +- The subject identifier is the record `rkey` for a release or package subject, or the DID's final `:`-segment for a publisher. +- The intent phrase is fixed per action. + +| Action | Endpoint | Intent phrase | +| ---------------------------------------------------------- | ----------------------------------------------- | ------------------- | +| Takedown (`!takedown` redaction on release/package/publisher) | `POST /admin/api/emergency/takedown` | `CONFIRM TAKEDOWN` | +| Takedown retract | `POST /admin/api/emergency/takedown-retract` | `CONFIRM RETRACT` | +| Publisher-compromised | `POST /admin/api/emergency/publisher-compromised` | `CONFIRM COMPROMISE` | +| Publisher-compromised retract | `POST /admin/api/emergency/publisher-compromised-retract` | `CONFIRM RETRACT` | + +A takedown of a publisher or package is a single URI-wide (or DID-wide) label the evaluator honors for everything beneath it — it is not fanned out into per-release labels. Retracting a takedown restores the state that was computed before it: the automated blocks that were live re-expose, because they were never negated and nothing is re-issued. A retract with no active label to pull returns `404 NO_ACTIVE_LABEL`. + +## The automation kill-switch (admin) + +**Pause** (`POST /admin/api/automation/pause`) and **resume** (`POST /admin/api/automation/resume`) are the global switch on automated ingestion. Pausing halts Jetstream ingestion only: the discovery consumer holds and retries paused events, so nothing is lost, and manual issuance and reruns stay fully available. A `reason` is required; there is no confirmation ceremony. The switch fails closed — if its state cannot be determined, automation does not run. + +## Dead-letter queue (admin) + +Discovery jobs that exhaust their retries land in the dead-letter list. Each letter can be: + +- **Retried** (`POST /admin/api/dead-letters/:id/retry`) — re-enqueues the discovery job (at-most-once). +- **Quarantined** (`POST /admin/api/dead-letters/:id/quarantine`) — a terminal "will not retry" state. + +Both reject an already-resolved letter with `409`, so two operators acting on the same letter cannot double-resolve it. + +## Audit log + +Every console mutation writes an append-only audit row recording who acted (the Access `sub`), the action, the reason, and an idempotency key. The table is immutable — rows are never updated or deleted. + +Idempotency: replaying a request with the same key and an identical body returns the stored result (a safe retry). The same key with a *different* body is a `409` conflict, so a key cannot be reused to smuggle a different action through. + +The audit view you see in the console omits internal columns — idempotency key, request fingerprint, raw result, metadata, and epoch timestamps — and shows the human-facing record. + +## CSRF + +Every state-changing request also needs the header `X-EmDash-Request: 1`, plus same-origin and JSON content-type checks. The console sends this automatically; operators do nothing. It is noted here only so that anyone scripting against `/admin/api/*` directly knows it is required. diff --git a/apps/labeler/package.json b/apps/labeler/package.json index 2c9fdb227a..7fbd36f50e 100644 --- a/apps/labeler/package.json +++ b/apps/labeler/package.json @@ -16,7 +16,8 @@ "console:dev": "vite dev --config console/vite.config.ts", "console:build": "vite build --config console/vite.config.ts", "console:typecheck": "tsgo --noEmit -p console/tsconfig.json", - "console:test": "vitest run --config console/vitest.config.ts" + "console:test": "vitest run --config console/vitest.config.ts", + "keygen": "node scripts/keygen.mjs" }, "dependencies": { "@atcute/cbor": "catalog:", @@ -34,6 +35,7 @@ }, "devDependencies": { "@atcute/cid": "catalog:", + "@atcute/multibase": "catalog:", "@cloudflare/kumo": "catalog:", "@cloudflare/vite-plugin": "catalog:", "@cloudflare/vitest-pool-workers": "catalog:", diff --git a/apps/labeler/scripts/keygen.mjs b/apps/labeler/scripts/keygen.mjs new file mode 100644 index 0000000000..26c89b1d14 --- /dev/null +++ b/apps/labeler/scripts/keygen.mjs @@ -0,0 +1,38 @@ +// Generates a P-256 signing keypair for the labeler in the exact formats the +// Worker validates: the private key as unpadded base64url of the raw +// 32-byte scalar (LABEL_SIGNING_PRIVATE_KEY secret) and the public key as a +// canonical P-256 Multikey (LABEL_SIGNING_PUBLIC_KEY var, published in the DID +// document's #atproto_label verification method). +// +// Run: pnpm --filter @emdash-cms/labeler keygen +// +// The two values are printed to stdout; nothing is written to disk. Set the +// private key with `wrangler secret put LABEL_SIGNING_PRIVATE_KEY` and paste the +// public key into wrangler.jsonc. The Worker verifies the pair the first time it +// signs (not at deploy), so a mismatch surfaces as a failed signing operation +// rather than at boot -- exercise a signing path after setting or rotating it. + +import { P256PrivateKeyExportable } from "@atcute/crypto"; +import { toBase64Url } from "@atcute/multibase"; + +const key = await P256PrivateKeyExportable.createKeypair(); +const privateKey = toBase64Url(await key.exportPrivateKey("raw")); +const publicKey = await key.exportPublicKey("multikey"); + +process.stdout.write( + [ + "P-256 labeler signing keypair", + "", + "LABEL_SIGNING_PRIVATE_KEY (secret — never commit):", + ` ${privateKey}`, + "", + "LABEL_SIGNING_PUBLIC_KEY (wrangler.jsonc var):", + ` ${publicKey}`, + "", + "Next:", + " 1. echo -n '' | wrangler secret put LABEL_SIGNING_PRIVATE_KEY", + " 2. set LABEL_SIGNING_PUBLIC_KEY in wrangler.jsonc to the value above", + " 3. bump LABEL_SIGNING_KEY_VERSION if you are rotating an existing key", + "", + ].join("\n"), +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d20c7d532..28e8045f4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -457,6 +457,9 @@ importers: '@atcute/cid': specifier: 'catalog:' version: 2.4.1 + '@atcute/multibase': + specifier: 'catalog:' + version: 1.2.0 '@cloudflare/kumo': specifier: 'catalog:' version: 2.6.0(@date-fns/tz@1.4.1)(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(date-fns@4.1.0)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1) From 4b350ae9da298df6f43f8bb22fbba4f6685579a5 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 14 Jul 2026 21:32:55 +0100 Subject: [PATCH 064/137] test(labeler): component a11y, keyboard, and RTL coverage for the operator console (#2032) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(labeler): component a11y, keyboard, and RTL coverage for the operator console Add component-level accessibility and layout regression tests for the labeler operator console: - axe checks (jsdom) on every route container and every dialog panel - keyboard/focus behaviour for all six dialogs: focus moves into the panel on open, Escape closes and restores focus to the trigger - an RTL-safety source scan asserting console source uses logical Tailwind classes, never physical-direction ones - cosmetic admin-gating checks confirming reviewer sessions do not see admin-only controls (the server stays authoritative) Adds a vitest setup file registering the vitest-axe matcher and the jsdom polyfills Kumo needs to render (matchMedia, ResizeObserver, Element.prototype.getAnimations), plus vitest-axe and @testing-library/user-event as devDependencies. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM * fix(labeler): render the console NotFound page for unknown paths The console catch-all route declared `path: "*"`, but TanStack Router's splat segment is `$` — so `*` matched literally and unknown URLs fell through to the router's default "Not Found" instead of the console's styled NotFoundPage inside the shell. Switch to the splat token and cover it with a routing test plus an axe check. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- apps/labeler/console/src/routes/NotFound.tsx | 2 +- apps/labeler/console/src/test/axe.test.tsx | 163 ++++++++++++++++ apps/labeler/console/src/test/harness.tsx | 70 +++++++ .../console/src/test/keyboard.test.tsx | 179 ++++++++++++++++++ .../labeler/console/src/test/routing.test.tsx | 16 ++ .../console/src/test/rtl-safety.test.ts | 61 ++++++ .../console/src/test/visibility.test.tsx | 78 ++++++++ apps/labeler/console/tsconfig.json | 2 +- apps/labeler/console/vitest.config.ts | 1 + apps/labeler/console/vitest.setup.ts | 36 ++++ apps/labeler/package.json | 2 + pnpm-lock.yaml | 65 +++++++ 12 files changed, 673 insertions(+), 2 deletions(-) create mode 100644 apps/labeler/console/src/test/axe.test.tsx create mode 100644 apps/labeler/console/src/test/harness.tsx create mode 100644 apps/labeler/console/src/test/keyboard.test.tsx create mode 100644 apps/labeler/console/src/test/routing.test.tsx create mode 100644 apps/labeler/console/src/test/rtl-safety.test.ts create mode 100644 apps/labeler/console/src/test/visibility.test.tsx create mode 100644 apps/labeler/console/vitest.setup.ts diff --git a/apps/labeler/console/src/routes/NotFound.tsx b/apps/labeler/console/src/routes/NotFound.tsx index 5cbdc577c4..d4cf2d3eea 100644 --- a/apps/labeler/console/src/routes/NotFound.tsx +++ b/apps/labeler/console/src/routes/NotFound.tsx @@ -15,6 +15,6 @@ function NotFoundPage() { export const notFoundRoute = createRoute({ getParentRoute: () => shellRoute, - path: "*", + path: "$", component: NotFoundPage, }); diff --git a/apps/labeler/console/src/test/axe.test.tsx b/apps/labeler/console/src/test/axe.test.tsx new file mode 100644 index 0000000000..4065568cd4 --- /dev/null +++ b/apps/labeler/console/src/test/axe.test.tsx @@ -0,0 +1,163 @@ +import { fireEvent, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { axe } from "vitest-axe"; + +import { apiClient } from "../api/client.js"; +import { AssessmentActionDialog } from "../components/AssessmentActionDialog.js"; +import { AutomationControl } from "../components/AutomationControl.js"; +import { DeadLetterActions } from "../components/DeadLetterActions.js"; +import { EmergencyActionDialog } from "../components/EmergencyActionDialog.js"; +import { LabelActionDialog } from "../components/LabelActionDialog.js"; +import { OverrideDialog } from "../components/OverrideDialog.js"; +import { ASSESSMENT_GAMMA, SUBJECT_ALPHA } from "../fixtures/index.js"; +import { RELEASE_ISSUABLE_LABELS } from "../labels.js"; +import { ADMIN_IDENTITY, renderRoute, renderWithClient } from "./harness.js"; + +vi.mock("../api/client.js", async () => { + const actual = await vi.importActual("../api/client.js"); + return { ...actual, apiClient: actual.createFixtureClient() }; +}); + +beforeEach(() => { + vi.spyOn(apiClient, "whoami").mockResolvedValue(ADMIN_IDENTITY); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const RELEASE_URI = "at://did:plc:x/com.emdashcms.experimental.package.release/rk1"; + +/** Kumo dialogs portal a Base UI focus-guard sentinel (role="button", no name) + * into the body around the panel; scoping axe to the panel scans the console's + * own dialog markup and not that framework artifact. */ +async function expectDialogClean(role: "dialog" | "alertdialog") { + const panel = await screen.findByRole(role); + expect(await axe(panel)).toHaveNoViolations(); +} + +describe("axe: routes", () => { + it("Dashboard", async () => { + const { container } = renderRoute("/"); + await screen.findByRole("heading", { name: "Dashboard", level: 1 }); + await screen.findByRole("button", { name: "Pause ingestion" }); + expect(await axe(container)).toHaveNoViolations(); + }); + + it("AssessmentList", async () => { + const { container } = renderRoute("/assessments"); + await screen.findByRole("heading", { name: "Assessments", level: 1 }); + await screen.findByRole("table"); + expect(await axe(container)).toHaveNoViolations(); + }); + + it("AssessmentDetail", async () => { + const { container } = renderRoute(`/assessments/${ASSESSMENT_GAMMA.id}`); + await screen.findByRole("heading", { name: "Assessment", level: 1 }); + await screen.findByRole("button", { name: "Rerun" }); + expect(await axe(container)).toHaveNoViolations(); + }); + + it("SubjectHistory", async () => { + const { container } = renderRoute(`/subjects/${encodeURIComponent(SUBJECT_ALPHA.uri)}`); + await screen.findByRole("heading", { name: "Subject history", level: 1 }); + await screen.findByRole("heading", { name: "Emergency actions", level: 2 }); + expect(await axe(container)).toHaveNoViolations(); + }); + + it("AuditLog", async () => { + const { container } = renderRoute("/audit"); + await screen.findByRole("heading", { name: "Audit log", level: 1 }); + await screen.findByText("Audit log not yet available"); + expect(await axe(container)).toHaveNoViolations(); + }); + + it("DeadLetterQueue", async () => { + const { container } = renderRoute("/dead-letters"); + await screen.findByRole("heading", { name: "Dead-letter queue", level: 1 }); + await screen.findByRole("button", { name: "Retry" }); + expect(await axe(container)).toHaveNoViolations(); + }); + + it("NotFound", async () => { + const { container } = renderRoute("/no-such-page"); + await screen.findByRole("heading", { name: "Not found", level: 1 }); + expect(await axe(container)).toHaveNoViolations(); + }); +}); + +describe("axe: dialogs", () => { + it("EmergencyActionDialog", async () => { + renderWithClient( + {}} + kind="takedown" + mode="issue" + subjectUri={RELEASE_URI} + subjectConfirmationExpected="rk1" + invalidateKeys={[]} + />, + ); + await expectDialogClean("alertdialog"); + }); + + it("OverrideDialog", async () => { + renderWithClient( + {}} + assessmentId="asmt_1" + subjectUri={RELEASE_URI} + subjectCid="bafyfixture" + blocks={["security-yanked"]} + invalidateKeys={[]} + />, + ); + await expectDialogClean("alertdialog"); + }); + + it("AssessmentActionDialog", async () => { + renderWithClient( + {}} + mode="rerun" + assessmentId="asmt_1" + subjectUri={RELEASE_URI} + subjectCid="bafyfixture" + invalidateKeys={[]} + />, + ); + await expectDialogClean("alertdialog"); + }); + + it("LabelActionDialog", async () => { + renderWithClient( + {}} + mode="issue" + subjectUri={RELEASE_URI} + subjectCid="bafyfixture" + issuable={RELEASE_ISSUABLE_LABELS} + invalidateKeys={[]} + />, + ); + await expectDialogClean("dialog"); + }); + + it("AutomationControl pause dialog", async () => { + renderWithClient(); + fireEvent.click(screen.getByRole("button", { name: "Pause ingestion" })); + await expectDialogClean("alertdialog"); + }); + + it("DeadLetterActions retry dialog", async () => { + renderWithClient(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + const panel = await screen.findByRole("alertdialog"); + await within(panel).findByText("Retry dead letter"); + expect(await axe(panel)).toHaveNoViolations(); + }); +}); diff --git a/apps/labeler/console/src/test/harness.tsx b/apps/labeler/console/src/test/harness.tsx new file mode 100644 index 0000000000..5a2ac92bd8 --- /dev/null +++ b/apps/labeler/console/src/test/harness.tsx @@ -0,0 +1,70 @@ +import { DirectionProvider } from "@cloudflare/kumo/primitives"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/react-router"; +import { render, type RenderResult } from "@testing-library/react"; +import type { ReactElement } from "react"; + +import type { WhoamiIdentity } from "../api/types.js"; +import { assessmentDetailRoute } from "../routes/AssessmentDetail.js"; +import { assessmentListRoute } from "../routes/AssessmentList.js"; +import { auditLogRoute } from "../routes/AuditLog.js"; +import { dashboardRoute } from "../routes/Dashboard.js"; +import { deadLetterQueueRoute } from "../routes/DeadLetterQueue.js"; +import { notFoundRoute } from "../routes/NotFound.js"; +import { rootRoute, shellRoute } from "../routes/root.js"; +import { subjectHistoryRoute } from "../routes/SubjectHistory.js"; + +const routeTree = rootRoute.addChildren([ + shellRoute.addChildren([ + dashboardRoute, + assessmentListRoute, + assessmentDetailRoute, + subjectHistoryRoute, + auditLogRoute, + deadLetterQueueRoute, + notFoundRoute, + ]), +]); + +export const ADMIN_IDENTITY: WhoamiIdentity = { + kind: "human", + principal: "admin@example.com", + sub: "dev-admin", + roles: ["admin"], +}; + +export const REVIEWER_IDENTITY: WhoamiIdentity = { + kind: "human", + principal: "reviewer@example.com", + sub: "dev-reviewer", + roles: ["reviewer"], +}; + +function newClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +export function renderRoute(path: string): RenderResult { + const queryClient = newClient(); + const router = createRouter({ + routeTree, + context: { queryClient }, + basepath: "/admin", + history: createMemoryHistory({ initialEntries: [`/admin${path}`] }), + }); + return render( + + + + + , + ); +} + +export function renderWithClient(ui: ReactElement): RenderResult { + return render( + + {ui} + , + ); +} diff --git a/apps/labeler/console/src/test/keyboard.test.tsx b/apps/labeler/console/src/test/keyboard.test.tsx new file mode 100644 index 0000000000..28bf871732 --- /dev/null +++ b/apps/labeler/console/src/test/keyboard.test.tsx @@ -0,0 +1,179 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState, type ReactElement } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { apiClient } from "../api/client.js"; +import { AssessmentActionDialog } from "../components/AssessmentActionDialog.js"; +import { AutomationControl } from "../components/AutomationControl.js"; +import { DeadLetterActions } from "../components/DeadLetterActions.js"; +import { EmergencyActionDialog } from "../components/EmergencyActionDialog.js"; +import { LabelActionDialog } from "../components/LabelActionDialog.js"; +import { OverrideDialog } from "../components/OverrideDialog.js"; +import { RELEASE_ISSUABLE_LABELS } from "../labels.js"; +import { renderWithClient } from "./harness.js"; + +vi.mock("../api/client.js", async () => { + const actual = await vi.importActual("../api/client.js"); + return { ...actual, apiClient: actual.createFixtureClient() }; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const RELEASE_URI = "at://did:plc:x/com.emdashcms.experimental.package.release/rk1"; + +interface DialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** Renders a real trigger button alongside a controlled dialog so the tests can + * exercise the full open -> trap -> Escape -> restore cycle Kumo's Dialog owns. */ +function renderTriggered(build: (props: DialogProps) => ReactElement) { + const onOpenChange = vi.fn(); + function Wrapper() { + const [open, setOpen] = useState(false); + return ( + <> + + {build({ + open, + onOpenChange: (next) => { + onOpenChange(next); + setOpen(next); + }, + })} + + ); + } + renderWithClient(); + return { onOpenChange }; +} + +const CASES: { + name: string; + role: "dialog" | "alertdialog"; + build: (props: DialogProps) => ReactElement; +}[] = [ + { + name: "EmergencyActionDialog", + role: "alertdialog", + build: (props) => ( + + ), + }, + { + name: "OverrideDialog", + role: "alertdialog", + build: (props) => ( + + ), + }, + { + name: "AssessmentActionDialog", + role: "alertdialog", + build: (props) => ( + + ), + }, + { + name: "LabelActionDialog", + role: "dialog", + build: (props) => ( + + ), + }, +]; + +describe("keyboard: dialog focus management", () => { + it.each(CASES)( + "$name moves focus in on open and Escape closes it, restoring focus", + async ({ role, build }) => { + const user = userEvent.setup(); + const { onOpenChange } = renderTriggered(build); + + const trigger = screen.getByRole("button", { name: "Open dialog" }); + await user.click(trigger); + + const dialog = await screen.findByRole(role); + await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true)); + + await user.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByRole(role)).toBeNull()); + expect(onOpenChange).toHaveBeenCalledWith(false); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + }, + ); +}); + +describe("keyboard: self-contained action controls", () => { + it("AutomationControl restores focus to its trigger after Escape", async () => { + const user = userEvent.setup(); + renderWithClient(); + + const trigger = screen.getByRole("button", { name: "Pause ingestion" }); + await user.click(trigger); + const dialog = await screen.findByRole("alertdialog"); + await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true)); + + await user.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull()); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + }); + + it("DeadLetterActions runs its primary action from the keyboard", async () => { + const user = userEvent.setup(); + const retry = vi + .spyOn(apiClient, "retryDeadLetter") + .mockResolvedValue({ actionId: "oact_1", deadLetterId: 3, status: "retried", cts: "" }); + renderWithClient(); + + const trigger = screen.getByRole("button", { name: "Retry" }); + await user.click(trigger); + await screen.findByRole("alertdialog"); + + await user.type( + screen.getByPlaceholderText("Why this event is being re-driven"), + "PDS recovered", + ); + const submit = screen.getByRole("button", { name: "Confirm retry" }); + submit.focus(); + await user.keyboard("{Enter}"); + + await waitFor(() => + expect(retry).toHaveBeenCalledWith(3, expect.objectContaining({ reason: "PDS recovered" })), + ); + }); +}); diff --git a/apps/labeler/console/src/test/routing.test.tsx b/apps/labeler/console/src/test/routing.test.tsx new file mode 100644 index 0000000000..7ec0587aac --- /dev/null +++ b/apps/labeler/console/src/test/routing.test.tsx @@ -0,0 +1,16 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { renderRoute } from "./harness.js"; + +vi.mock("../api/client.js", async () => { + const actual = await vi.importActual("../api/client.js"); + return { ...actual, apiClient: actual.createFixtureClient() }; +}); + +describe("console routing", () => { + it("renders the NotFound page for an unknown path", async () => { + renderRoute("/no-such-page"); + expect(await screen.findByRole("heading", { name: "Not found", level: 1 })).toBeTruthy(); + }); +}); diff --git a/apps/labeler/console/src/test/rtl-safety.test.ts b/apps/labeler/console/src/test/rtl-safety.test.ts new file mode 100644 index 0000000000..1489c23a8f --- /dev/null +++ b/apps/labeler/console/src/test/rtl-safety.test.ts @@ -0,0 +1,61 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const SRC_ROOT = dirname(import.meta.dirname); + +/** Tailwind classes that hard-code a physical side. RTL-safe layout uses the + * logical equivalents (ms/me, ps/pe, start/end, border-s/e, rounded-s/e, + * float-start/end, text-start/end). */ +const PHYSICAL = [ + /^-?(ml|mr|pl|pr)-/, + /^-?(left|right)-/, + /^(text-left|text-right)$/, + /^border-(l|r)(-|$)/, + /^rounded-(l|r)(-|$)/, + /^(float-left|float-right)$/, +]; + +const CLASS_VALUE = /className=(?:"([^"]*)"|\{`([^`]*)`\})/g; + +function sourceFiles(dir: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = `${dir}/${entry.name}`; + if (entry.isDirectory()) { + if (entry.name === "test") continue; + files.push(...sourceFiles(path)); + } else if (/\.tsx?$/.test(entry.name) && !entry.name.includes(".test.")) { + files.push(path); + } + } + return files; +} + +function physicalClasses(source: string): string[] { + const hits: string[] = []; + for (const match of source.matchAll(CLASS_VALUE)) { + const value = match[1] ?? match[2] ?? ""; + for (const token of value.split(/\s+/)) { + const base = (token.split(":").at(-1) ?? token).replace(/["'`]/g, ""); + if (PHYSICAL.some((re) => re.test(base))) hits.push(token); + } + } + return hits; +} + +describe("RTL safety: console source uses logical Tailwind classes", () => { + const files = sourceFiles(SRC_ROOT); + + it("scans a non-trivial set of source files", () => { + expect(files.length).toBeGreaterThan(10); + }); + + it.each(files.map((file) => [file.slice(SRC_ROOT.length), file] as const))( + "%s has no physical-direction classes", + (_label, file) => { + expect(physicalClasses(readFileSync(file, "utf8"))).toEqual([]); + }, + ); +}); diff --git a/apps/labeler/console/src/test/visibility.test.tsx b/apps/labeler/console/src/test/visibility.test.tsx new file mode 100644 index 0000000000..37184d9118 --- /dev/null +++ b/apps/labeler/console/src/test/visibility.test.tsx @@ -0,0 +1,78 @@ +import { screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { apiClient } from "../api/client.js"; +import { SUBJECT_ALPHA } from "../fixtures/index.js"; +import { ADMIN_IDENTITY, renderRoute, REVIEWER_IDENTITY } from "./harness.js"; + +vi.mock("../api/client.js", async () => { + const actual = await vi.importActual("../api/client.js"); + return { ...actual, apiClient: actual.createFixtureClient() }; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function asRole(identity: typeof ADMIN_IDENTITY) { + vi.spyOn(apiClient, "whoami").mockResolvedValue(identity); +} + +describe("cosmetic admin gating (server stays authoritative)", () => { + describe("SubjectHistory emergency actions", () => { + const path = `/subjects/${encodeURIComponent(SUBJECT_ALPHA.uri)}`; + + it("renders for an admin", async () => { + asRole(ADMIN_IDENTITY); + renderRoute(path); + await screen.findByRole("heading", { name: "Subject history", level: 1 }); + expect(await screen.findByRole("heading", { name: "Emergency actions" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Take down record" })).toBeTruthy(); + }); + + it("is hidden from a reviewer", async () => { + asRole(REVIEWER_IDENTITY); + renderRoute(path); + await screen.findByRole("heading", { name: "Subject history", level: 1 }); + await screen.findByRole("button", { name: "Issue record label" }); + expect(screen.queryByRole("heading", { name: "Emergency actions" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Take down record" })).toBeNull(); + }); + }); + + describe("Dashboard automation control", () => { + it("shows the pause toggle to an admin", async () => { + asRole(ADMIN_IDENTITY); + renderRoute("/"); + await screen.findByRole("heading", { name: "Dashboard", level: 1 }); + expect(await screen.findByRole("button", { name: "Pause ingestion" })).toBeTruthy(); + }); + + it("hides the pause toggle from a reviewer", async () => { + asRole(REVIEWER_IDENTITY); + renderRoute("/"); + await screen.findByRole("heading", { name: "Dashboard", level: 1 }); + await screen.findByText("Active"); + expect(screen.queryByRole("button", { name: "Pause ingestion" })).toBeNull(); + }); + }); + + describe("DeadLetterQueue actions", () => { + it("renders retry/quarantine for an admin", async () => { + asRole(ADMIN_IDENTITY); + renderRoute("/dead-letters"); + await screen.findByRole("heading", { name: "Dead-letter queue", level: 1 }); + expect(await screen.findByRole("button", { name: "Retry" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Quarantine" })).toBeTruthy(); + }); + + it("hides them from a reviewer", async () => { + asRole(REVIEWER_IDENTITY); + renderRoute("/dead-letters"); + await screen.findByRole("heading", { name: "Dead-letter queue", level: 1 }); + await screen.findByRole("table"); + expect(screen.queryByRole("button", { name: "Retry" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Quarantine" })).toBeNull(); + }); + }); +}); diff --git a/apps/labeler/console/tsconfig.json b/apps/labeler/console/tsconfig.json index 43350f9fb9..0b23937902 100644 --- a/apps/labeler/console/tsconfig.json +++ b/apps/labeler/console/tsconfig.json @@ -6,5 +6,5 @@ "types": ["vite/client"], "noEmit": true }, - "include": ["src/**/*", "vite.config.ts", "vitest.config.ts"] + "include": ["src/**/*", "vite.config.ts", "vitest.config.ts", "vitest.setup.ts"] } diff --git a/apps/labeler/console/vitest.config.ts b/apps/labeler/console/vitest.config.ts index 7f56cf1734..9e66115024 100644 --- a/apps/labeler/console/vitest.config.ts +++ b/apps/labeler/console/vitest.config.ts @@ -10,5 +10,6 @@ export default defineConfig({ globals: true, environment: "jsdom", include: ["src/**/*.test.{ts,tsx}"], + setupFiles: ["./vitest.setup.ts"], }, }); diff --git a/apps/labeler/console/vitest.setup.ts b/apps/labeler/console/vitest.setup.ts new file mode 100644 index 0000000000..e6ef0f451e --- /dev/null +++ b/apps/labeler/console/vitest.setup.ts @@ -0,0 +1,36 @@ +import { expect, vi } from "vitest"; +import type { AxeMatchers } from "vitest-axe"; +import * as matchers from "vitest-axe/matchers"; + +expect.extend(matchers); + +declare module "vitest" { + // eslint-disable-next-line typescript/no-explicit-any -- match vitest's own Assertion. + interface Assertion extends AxeMatchers {} + interface AsymmetricMatchersContaining extends AxeMatchers {} +} + +if (!window.matchMedia) { + window.matchMedia = (query: string): MediaQueryList => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(() => false), + }); +} + +if (!globalThis.ResizeObserver) { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; +} + +if (!Element.prototype.getAnimations) { + Element.prototype.getAnimations = () => []; +} diff --git a/apps/labeler/package.json b/apps/labeler/package.json index 7fbd36f50e..2d9c14e8df 100644 --- a/apps/labeler/package.json +++ b/apps/labeler/package.json @@ -45,6 +45,7 @@ "@tanstack/react-query": "catalog:", "@tanstack/react-router": "catalog:", "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", @@ -56,6 +57,7 @@ "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:", + "vitest-axe": "^0.1.0", "wrangler": "catalog:" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28e8045f4a..3b371155c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -487,6 +487,9 @@ importers: '@testing-library/react': specifier: ^16.3.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/node': specifier: 'catalog:' version: 24.10.13 @@ -520,6 +523,9 @@ importers: vitest: specifier: 'catalog:' version: 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)) + vitest-axe: + specifier: ^0.1.0 + version: 0.1.0(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))) wrangler: specifier: 'catalog:' version: 4.110.0(@cloudflare/workers-types@5.20260712.1) @@ -7066,6 +7072,12 @@ packages: '@types/react-dom': optional: true + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tiptap/core@3.20.0': resolution: {integrity: sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==} peerDependencies: @@ -9081,6 +9093,10 @@ packages: resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==} engines: {node: '>=20.19.0'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -9460,6 +9476,9 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} @@ -9746,6 +9765,10 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + miniflare@4.20260301.1: resolution: {integrity: sha512-fqkHx0QMKswRH9uqQQQOU/RoaS3Wjckxy3CUX3YGJr0ZIMu7ObvI+NovdYi6RIsSPthNtq+3TPmRNxjeRiasog==} engines: {node: '>=18.0.0'} @@ -10622,6 +10645,10 @@ packages: recma-stringify@1.0.0: resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -11027,6 +11054,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -11785,6 +11816,11 @@ packages: vite: optional: true + vitest-axe@0.1.0: + resolution: {integrity: sha512-jvtXxeQPg8R/2ANTY8QicA5pvvdRP4F0FsVUAHANJ46YCDASie/cuhlSzu0DGcLmZvGBSBNsNuK3HqfaeknyvA==} + peerDependencies: + vitest: '>=0.16.0' + vitest-browser-react@2.2.0: resolution: {integrity: sha512-oY3KM6305kwJMa6nHo92vVtkOsih7mjEf12dLKuphaF+9ywWPEc+qanIBd394SZ6m5LadVEaG6dicvvizOzmjA==} peerDependencies: @@ -16881,6 +16917,10 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tiptap/core@3.20.0(@tiptap/pm@3.20.0)': dependencies: '@tiptap/pm': 3.20.0 @@ -19697,6 +19737,8 @@ snapshots: import-without-cache@0.2.5: {} + indent-string@4.0.0: {} + inherits@2.0.4: {} ini@1.3.8: {} @@ -20057,6 +20099,8 @@ snapshots: dependencies: p-locate: 4.1.0 + lodash-es@4.18.1: {} + lodash.startcase@4.4.0: {} lodash@4.17.21: {} @@ -20620,6 +20664,8 @@ snapshots: mimic-response@3.1.0: {} + min-indent@1.0.1: {} + miniflare@4.20260301.1: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -21614,6 +21660,11 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -22241,6 +22292,10 @@ snapshots: strip-bom@3.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@2.0.1: {} strip-json-comments@5.0.3: {} @@ -22820,6 +22875,16 @@ snapshots: optionalDependencies: vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + vitest-axe@0.1.0(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))): + dependencies: + aria-query: 5.3.2 + axe-core: 4.12.1 + chalk: 5.6.2 + dom-accessibility-api: 0.5.16 + lodash-es: 4.18.1 + redent: 3.0.0 + 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)) + vitest-browser-react@2.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.5): dependencies: react: 19.2.4 From e741ce4d644d70826ce610a471ae5c6acbf5d04c Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 15 Jul 2026 08:05:17 +0100 Subject: [PATCH 065/137] feat(labeler): publisher-history context stage (W8.4 slice 1) (#2033) * feat(labeler): publisher-history context stage (W8.4 slice 1) Add a `history`-source assessment stage that produces bounded factual context from the labeler's own D1: prior releases from the publishing DID, the same artifact checksum submitted under other DIDs (global, cross-publisher), and active manual labels on the subject. History findings are operator-only context, never labels. The resolver already drops every `source: "history"` finding before any category to label mapping, and this amends the W8.1 finding contract (D4) so history findings cite a dedicated `HISTORY_FINDING_CATEGORIES` set, disjoint from the automated-block union warning vocabulary; the non-history validation path is unchanged. An orchestrator test proves a history finding flows through validation and resolution but never becomes an issued label. The stage is best-effort: because it can never affect labels, a failure to gather context must never fail the run and discard the other stages' findings, so it swallows its own errors and returns no findings. Sensitive specifics (cross-publisher checksum reuse, active manual-label identities) stay in `privateDetail`, never in the public-facing title/summary. Adds migration 0006 (partial index on `assessments.artifact_checksum`) for the cross-DID checksum lookup. The two aggregator-sourced inputs (handle/profile changes, verification state) are deferred to a later slice gated on a ratified read path. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM * test(labeler): make the history never-auto-label test self-contained The non-vacuousness assertion relied on prior releases seeded by earlier tests under the shared DID, so it was vacuous when run in isolation. Seed an explicit prior release inside the test. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM * test(labeler): assert the specific publisher-history finding in the invariant test Check for the `publisher-history` category the seeded prior release produces rather than any history category, so a regression in the prior-release path can't pass on an unrelated history finding. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- .../implementation-plan.md | 2 +- .../0006_assessment_checksum_index.sql | 9 + apps/labeler/src/assessment-orchestrator.ts | 17 +- apps/labeler/src/assessment-store.ts | 48 +++ apps/labeler/src/findings.ts | 33 +- apps/labeler/src/history-context.ts | 168 ++++++++++ .../test/assessment-orchestrator.test.ts | 47 ++- apps/labeler/test/findings.test.ts | 46 ++- apps/labeler/test/history-context.test.ts | 290 ++++++++++++++++++ 9 files changed, 652 insertions(+), 8 deletions(-) create mode 100644 apps/labeler/migrations/0006_assessment_checksum_index.sql create mode 100644 apps/labeler/src/history-context.ts create mode 100644 apps/labeler/test/history-context.test.ts diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 8b82ba06fc..a96d20b945 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1045,7 +1045,7 @@ History may recommend operator review but cannot automatically produce package/p Dependencies: `W2.3`, registry publisher/profile data. -Decisions (2026-07-14): history is not a new contract surface — `FindingSource` already includes `"history"` and the resolver already drops it (`policy-resolver.ts` excludes `source: "history"` before any category→label mapping; `isBlockingFinding` returns false for it), so "never auto-label" is structural, not a new guard. "Recommend review" is passive: a history finding surfaces through the existing assessment-detail projection an operator already opens; no `operational_events` push and no new "flag-for-review" operator action (D3). Two of the five inputs — recent handle/profile changes and verification state — live only in the aggregator's D1, which the labeler has no binding to reach (and `constants.ts` deliberately avoids ingesting profiles); they are deferred to a later slice gated on a ratified read-only labeler→aggregator read path (service-binding preferred over a second D1 binding), not built now (D1). Repeated-checksum detection is global (cross-publisher): a checksum reused under a different DID is the sharper signal, needing a new `idx_assessments_artifact_checksum` and a cross-DID lookup (D2). Prior releases, active manual labels (reuse `getActiveLabelState`, filter `!automated && active`), and repeated checksums/findings come from the labeler's own D1. The finding contract is amended (touches W8.1): `source: "history"` findings cite a dedicated history/review category set exempt from the automated-block ∪ warning constraint that `validateFinding` enforces, rather than borrowing a warn category (D4). Neutral display facts (prior-release count, verification badge) are assembled at console read-time in the existing subject-history endpoint, not persisted as a pipeline `ContextBundle` (D5). The stage is DB-bound (`analyzeHistory(db, assessment, opts) → NormalizedFinding[]`), wired into `OrchestratorStages`/`STAGE_ORDER`/`stubStages` and run last, wrapping D1 failures as `StageTransientError`. Sliced: (1) own-D1 history findings + the W8.1 amendment + the checksum index — unblocked; (2) console neutral-context surfacing — unblocked, optional; (3) the two aggregator-sourced inputs — blocked on D1's read path. +Decisions (2026-07-14): history is not a new contract surface — `FindingSource` already includes `"history"` and the resolver already drops it (`policy-resolver.ts` excludes `source: "history"` before any category→label mapping; `isBlockingFinding` returns false for it), so "never auto-label" is structural, not a new guard. "Recommend review" is passive: a history finding surfaces through the existing assessment-detail projection an operator already opens; no `operational_events` push and no new "flag-for-review" operator action (D3). Two of the five inputs — recent handle/profile changes and verification state — live only in the aggregator's D1, which the labeler has no binding to reach (and `constants.ts` deliberately avoids ingesting profiles); they are deferred to a later slice gated on a ratified read-only labeler→aggregator read path (service-binding preferred over a second D1 binding), not built now (D1). Repeated-checksum detection is global (cross-publisher): a checksum reused under a different DID is the sharper signal, needing a new `idx_assessments_artifact_checksum` and a cross-DID lookup (D2). Prior releases, active manual labels (reuse `getActiveLabelState`, filter `!automated && active`), and repeated checksums/findings come from the labeler's own D1. The finding contract is amended (touches W8.1): `source: "history"` findings cite a dedicated history/review category set exempt from the automated-block ∪ warning constraint that `validateFinding` enforces, rather than borrowing a warn category (D4). Neutral display facts (prior-release count, verification badge) are assembled at console read-time in the existing subject-history endpoint, not persisted as a pipeline `ContextBundle` (D5). The stage is DB-bound (`analyzeHistory(db, assessment, opts) → NormalizedFinding[]`), wired into `OrchestratorStages`/`STAGE_ORDER`/`stubStages` and run last. Because history is operator-only context that never becomes a label, the stage is best-effort — on any error it logs and returns no findings rather than throwing, so it can never fail the run and discard the decision-relevant findings from the other stages (adversary-flagged 2026-07-14). History findings keep sensitive specifics (cross-publisher checksum reuse, active manual-label identities/existence including redactions) in `privateDetail` only, never in the public-facing `title`/`publicSummary`; slice (2)'s surfacing must additionally render history findings in the operator projection only and exclude them from any public serializer. Sliced: (1) own-D1 history findings + the W8.1 amendment + the checksum index — unblocked; (2) console neutral-context surfacing — unblocked, optional; (3) the two aggregator-sourced inputs — blocked on D1's read path. ### `W8.5` Implement versioned policy resolver diff --git a/apps/labeler/migrations/0006_assessment_checksum_index.sql b/apps/labeler/migrations/0006_assessment_checksum_index.sql new file mode 100644 index 0000000000..0abf5d5b2f --- /dev/null +++ b/apps/labeler/migrations/0006_assessment_checksum_index.sql @@ -0,0 +1,9 @@ +-- Indexes the artifact checksum so the publisher-history stage (plan W8.4) can +-- run its global, cross-publisher checksum-repeat lookup — "this exact artifact +-- was also submitted under another DID" — without scanning the assessments +-- table. Partial (`WHERE artifact_checksum IS NOT NULL`) because pre-artifact +-- lifecycle states leave the column null. + +CREATE INDEX idx_assessments_artifact_checksum + ON assessments(artifact_checksum) + WHERE artifact_checksum IS NOT NULL; diff --git a/apps/labeler/src/assessment-orchestrator.ts b/apps/labeler/src/assessment-orchestrator.ts index cbbccdcb43..3cfc23d663 100644 --- a/apps/labeler/src/assessment-orchestrator.ts +++ b/apps/labeler/src/assessment-orchestrator.ts @@ -60,9 +60,23 @@ export interface OrchestratorStages { dependency: StageAdapter; codeAi: StageAdapter; imageAi: StageAdapter; + /** Publisher-history context (plan W8.4). Runs last: its findings are + * context the resolver drops, never labels, so nothing downstream depends on + * them. Must be best-effort — a history stage that throws would fail the whole + * run and discard every other stage's findings; `analyzeHistory` swallows its + * own errors and returns `[]`. DB-bound — a real wiring passes a closure over + * `analyzeHistory`. */ + history: StageAdapter; } -const STAGE_ORDER = ["acquire", "deterministic", "dependency", "codeAi", "imageAi"] as const; +const STAGE_ORDER = [ + "acquire", + "deterministic", + "dependency", + "codeAi", + "imageAi", + "history", +] as const; export interface AssessmentOrchestratorOptions { db: D1Database; @@ -336,4 +350,5 @@ export const stubStages: OrchestratorStages = { dependency: () => Promise.resolve([]), codeAi: () => Promise.resolve([]), imageAi: () => Promise.resolve([]), + history: () => Promise.resolve([]), }; diff --git a/apps/labeler/src/assessment-store.ts b/apps/labeler/src/assessment-store.ts index 1840118571..e821b32972 100644 --- a/apps/labeler/src/assessment-store.ts +++ b/apps/labeler/src/assessment-store.ts @@ -1128,6 +1128,54 @@ export async function getAllLabelsForAssessment( })); } +/** + * Distinct release URIs previously observed under a publishing DID, newest + * observation first, excluding the URI under assessment — the "prior releases + * from the DID" input to the publisher-history stage (plan W8.4). Capped at + * `limit`; the caller reports the count as bounded rather than exact. + */ +export async function getPriorReleaseUrisForDid( + db: D1Database, + input: { did: string; excludeUri: string; limit: number }, +): Promise { + const rows = await db + .prepare( + `SELECT uri, MAX(observed_at_epoch_ms) AS observed + FROM subjects + WHERE did = ? AND uri != ? + GROUP BY uri + ORDER BY observed DESC + LIMIT ?`, + ) + .bind(input.did, input.excludeUri, input.limit) + .all<{ uri: string; observed: number }>(); + return (rows.results ?? []).map((row) => row.uri); +} + +/** + * Distinct OTHER publishing DIDs that have submitted an assessment for the same + * exact artifact checksum (plan W8.4 D2) — the cross-publisher reuse signal. + * Global: it spans every publisher's assessments, not just the queried + * subject's. Capped at `limit`. + */ +export async function getPublishersSharingChecksum( + db: D1Database, + input: { checksum: string; excludeDid: string; limit: number }, +): Promise { + const rows = await db + .prepare( + `SELECT DISTINCT s.did + FROM assessments a + JOIN subjects s ON s.uri = a.uri AND s.cid = a.cid + WHERE a.artifact_checksum = ? AND s.did != ? + ORDER BY s.did + LIMIT ?`, + ) + .bind(input.checksum, input.excludeDid, input.limit) + .all<{ did: string }>(); + return (rows.results ?? []).map((row) => row.did); +} + function rowToAssessment(row: AssessmentRow): Assessment { return { id: row.id, diff --git a/apps/labeler/src/findings.ts b/apps/labeler/src/findings.ts index 4983ca6483..961c5e9692 100644 --- a/apps/labeler/src/findings.ts +++ b/apps/labeler/src/findings.ts @@ -35,11 +35,30 @@ export interface ModelFindingMetadata { * finding; model/image stages report the model and prompt version. */ export type FindingSourceMetadata = ToolFindingMetadata | ModelFindingMetadata; +/** + * The category vocabulary a `source: "history"` finding may cite (plan W8.4 + * D4). History findings are context, never labels — the resolver drops them + * before any category→label mapping — so they cite this dedicated set rather + * than the policy's automated-block ∪ warning label values, and + * `validateFinding` holds them to it exclusively. + */ +export type HistoryFindingCategory = + | "publisher-history" + | "shared-artifact" + | "active-manual-label"; + +export const HISTORY_FINDING_CATEGORIES: ReadonlySet = new Set([ + "publisher-history", + "shared-artifact", + "active-manual-label", +]); + export interface NormalizedFinding { source: FindingSource; - /** A label value from the policy vocabulary — validated against - * `allowedFindingCategories` (automated-block ∪ warning), never the - * eligibility or manual-system label values. */ + /** For non-history sources, a label value from the policy vocabulary — + * validated against `allowedFindingCategories` (automated-block ∪ warning), + * never the eligibility or manual-system label values. For `source: + * "history"`, one of `HISTORY_FINDING_CATEGORIES` instead (plan W8.4 D4). */ category: string; severity: FindingSeverity; confidence?: number; @@ -77,8 +96,14 @@ export function validateFinding(finding: unknown, opts: ValidateFindingOptions): if (typeof source !== "string" || !isFindingSource(source)) throw new FindingValidationError(`finding.source is invalid: ${String(source)}`); + // History findings are exempt from the automated-block ∪ warning constraint + // (plan W8.4 D4): they cite the dedicated `HISTORY_FINDING_CATEGORIES` set, + // and only that set — a history finding may not borrow a block/warn label + // value, nor a non-history finding a history category. + const allowedCategories = + source === "history" ? HISTORY_FINDING_CATEGORIES : opts.allowedCategories; const category = finding.category; - if (typeof category !== "string" || !opts.allowedCategories.has(category)) + if (typeof category !== "string" || !allowedCategories.has(category)) throw new FindingValidationError( `finding.category is not an allowed finding category: ${String(category)}`, ); diff --git a/apps/labeler/src/history-context.ts b/apps/labeler/src/history-context.ts new file mode 100644 index 0000000000..b8227d9c03 --- /dev/null +++ b/apps/labeler/src/history-context.ts @@ -0,0 +1,168 @@ +/** + * Publisher-history context stage (plan W8.4, slice 1). Produces + * `source: "history"` normalized findings from the labeler's OWN D1 — prior + * releases from the publishing DID, the same artifact checksum submitted under + * other DIDs, and existing active manual labels on the subject. + * + * These findings are bounded, factual context an operator reads through the + * assessment projection. They are structurally never turned into labels: the + * resolver drops every `source: "history"` finding before any category→label + * mapping (`policy-resolver.ts`), and `validateFinding` holds them to the + * dedicated `HISTORY_FINDING_CATEGORIES` set (`findings.ts`), disjoint from the + * policy's label vocabulary. + * + * The two deferred inputs — recent handle/profile changes and verification + * state — live only in the aggregator's D1, which the labeler has no binding to + * reach; they are gated on a ratified read path (plan W8.4 D1) and not built + * here. + */ + +import { + getActiveLabelState, + getCurrentSubjectByUri, + getPriorReleaseUrisForDid, + getPublishersSharingChecksum, + type Assessment, +} from "./assessment-store.js"; +import type { NormalizedFinding } from "./findings.js"; + +const HISTORY_TOOL = "publisher-history"; +const HISTORY_TOOL_VERSION = "1"; + +const DEFAULT_PRIOR_RELEASE_LIMIT = 20; +const DEFAULT_SHARED_PUBLISHER_LIMIT = 20; +/** How many URIs/DIDs to name in a finding's private detail. */ +const SAMPLE_SIZE = 5; + +export interface HistoryContextOptions { + /** The labeler's own DID (`src`) — the stream whose active manual labels + * count as context. */ + src: string; + priorReleaseLimit?: number; + sharedPublisherLimit?: number; + now?: Date; +} + +/** + * DB-bound stage adapter matching the orchestrator's stage contract: returns + * `NormalizedFinding[]`, one per input that has something to report. History is + * operator-only context that never becomes a label, so a failure to gather it + * must never fail the assessment run — that would discard the decision-relevant + * findings from every other stage. The stage is best-effort: on any error it + * logs and returns no findings rather than throwing, so it can never gate the + * run regardless of its position in `STAGE_ORDER`. + */ +export async function analyzeHistory( + db: D1Database, + assessment: Assessment, + opts: HistoryContextOptions, +): Promise { + const priorReleaseLimit = opts.priorReleaseLimit ?? DEFAULT_PRIOR_RELEASE_LIMIT; + const sharedPublisherLimit = opts.sharedPublisherLimit ?? DEFAULT_SHARED_PUBLISHER_LIMIT; + + try { + const findings: NormalizedFinding[] = []; + const subject = await getCurrentSubjectByUri(db, assessment.uri); + + if (subject) { + const priorUris = await getPriorReleaseUrisForDid(db, { + did: subject.did, + excludeUri: assessment.uri, + limit: priorReleaseLimit, + }); + if (priorUris.length > 0) + findings.push(priorReleasesFinding(subject.did, priorUris, priorReleaseLimit)); + + if (assessment.artifactChecksum) { + const otherDids = await getPublishersSharingChecksum(db, { + checksum: assessment.artifactChecksum, + excludeDid: subject.did, + limit: sharedPublisherLimit, + }); + if (otherDids.length > 0) + findings.push( + sharedArtifactFinding(assessment.artifactChecksum, otherDids, sharedPublisherLimit), + ); + } + } + + const labelState = await getActiveLabelState(db, { + src: opts.src, + uri: assessment.uri, + cid: assessment.cid, + ...(opts.now !== undefined ? { now: opts.now } : {}), + }); + const activeManualLabels = [...labelState.values()] + .filter((winner) => !winner.automated && winner.active) + .map((winner) => winner.val); + if (activeManualLabels.length > 0) findings.push(activeManualLabelFinding(activeManualLabels)); + + return findings; + } catch (err) { + console.error( + `[history-context] lookup failed, skipping context for this run: ${err instanceof Error ? err.message : String(err)}`, + ); + return []; + } +} + +function toolMetadata() { + return { kind: "tool", tool: HISTORY_TOOL, version: HISTORY_TOOL_VERSION } as const; +} + +function countText(count: number, capped: boolean): string { + return capped ? `at least ${count}` : `${count}`; +} + +function pluralize(count: number, noun: string): string { + return count === 1 ? noun : `${noun}s`; +} + +function priorReleasesFinding(did: string, uris: string[], limit: number): NormalizedFinding { + const capped = uris.length >= limit; + const count = countText(uris.length, capped); + return { + source: "history", + category: "publisher-history", + severity: "info", + title: `Publisher has ${count} prior ${pluralize(uris.length, "release")}`, + publicSummary: `The publishing account has ${count} other ${pluralize(uris.length, "release")} known to the labeler.`, + privateDetail: `Publisher ${did} has ${count} prior ${pluralize(uris.length, "release")}. Sample: ${uris.slice(0, SAMPLE_SIZE).join(", ")}.`, + evidenceRefs: [], + sourceMetadata: toolMetadata(), + }; +} + +function sharedArtifactFinding(checksum: string, dids: string[], limit: number): NormalizedFinding { + const capped = dids.length >= limit; + const count = countText(dids.length, capped); + // Cross-publisher artifact reuse is a correlation/deanonymization signal, so + // the title and publicSummary stay non-revealing — the specifics live only in + // privateDetail. + return { + source: "history", + category: "shared-artifact", + severity: "low", + title: "Artifact provenance context", + publicSummary: "Operator-only artifact-provenance context is recorded for this release.", + privateDetail: `Artifact checksum ${checksum} also submitted by ${count} other ${pluralize(dids.length, "publisher")}: ${dids.slice(0, SAMPLE_SIZE).join(", ")}.`, + evidenceRefs: [], + sourceMetadata: toolMetadata(), + }; +} + +function activeManualLabelFinding(vals: string[]): NormalizedFinding { + // The existence and identity of manual labels (including redactions) must not + // leak into a public projection, so the title and publicSummary reveal + // nothing — the label values live only in privateDetail. + return { + source: "history", + category: "active-manual-label", + severity: "info", + title: "Operator label context", + publicSummary: "Operator-only label context is recorded for this subject.", + privateDetail: `Active manual labels: ${vals.join(", ")}.`, + evidenceRefs: [], + sourceMetadata: toolMetadata(), + }; +} diff --git a/apps/labeler/test/assessment-orchestrator.test.ts b/apps/labeler/test/assessment-orchestrator.test.ts index 45c1c43dec..4c6955c2bd 100644 --- a/apps/labeler/test/assessment-orchestrator.test.ts +++ b/apps/labeler/test/assessment-orchestrator.test.ts @@ -23,7 +23,8 @@ import { getCurrentAssessment, transitionAssessmentState, } from "../src/assessment-store.js"; -import { FindingValidationError } from "../src/findings.js"; +import { FindingValidationError, HISTORY_FINDING_CATEGORIES } from "../src/findings.js"; +import { analyzeHistory } from "../src/history-context.js"; import { MODERATION_POLICY } from "../src/policy.js"; import { issueManualLabel } from "../src/service.js"; import { initializeSigningState } from "../src/signing-rotation.js"; @@ -699,6 +700,50 @@ describe("AssessmentOrchestrator: invalid findings", () => { }); }); +describe("AssessmentOrchestrator: history stage never auto-labels (W8.4)", () => { + it("runs the history stage, whose finding surfaces but is never turned into an issued label", async () => { + const run = await pendingRun({ + name: "history-invariant", + cidValue: await cid("history-invariant"), + }); + + // Seed an explicit prior release under the same DID so the stage genuinely + // produces a publisher-history finding without relying on other tests' + // state — keeps the non-vacuousness assertion below self-contained. + await createSubject(testEnv.DB, { + uri: releaseUri("history-invariant-prior"), + cid: await cid("history-invariant-prior"), + did: PUBLISHER_DID, + collection: "com.emdashcms.experimental.package.release", + rkey: "history-invariant-prior:1.0.0", + }); + const assessment = await getAssessment(testEnv.DB, run.id); + const produced = await analyzeHistory(testEnv.DB, assessment!, { src: LABELER_DID }); + // Assert the specific finding the seeded prior release produces, not just + // "any history category" — so a regression in the prior-release path can't + // pass on the back of an unrelated history finding. + expect(produced.some((f) => f.category === "publisher-history")).toBe(true); + + const stages: OrchestratorStages = { + ...stubStages, + history: (ctx) => analyzeHistory(testEnv.DB, ctx.assessment, { src: LABELER_DID }), + }; + const result = await (await buildOrchestrator(stages)).runAssessment(run.id); + + // The history finding flowed through validation (it did not abort the run) + // and resolution, yet produced no blocking or warning outcome. + expect(result.state).toBe("passed"); + + // No history-category value is ever issued as a label — the resolver drops + // every history-source finding before any category→label mapping. + const labels = await testEnv.DB.prepare(`SELECT val FROM issued_labels WHERE uri = ?`) + .bind(run.uri) + .all<{ val: string }>(); + const issued = (labels.results ?? []).map((row) => row.val); + for (const category of HISTORY_FINDING_CATEGORIES) expect(issued).not.toContain(category); + }); +}); + describe("AssessmentOrchestrator: supersession negation provenance (decision 6)", () => { it("negates the prior run's automated block label but never a manually-issued label", async () => { const name = "supersede-manual-survives"; diff --git a/apps/labeler/test/findings.test.ts b/apps/labeler/test/findings.test.ts index 4b55ef3574..50ed703482 100644 --- a/apps/labeler/test/findings.test.ts +++ b/apps/labeler/test/findings.test.ts @@ -4,6 +4,7 @@ import type { PublicFindingView } from "../src/evidence.js"; import { allowedFindingCategories, FindingValidationError, + HISTORY_FINDING_CATEGORIES, toPublicFindingView, validateFinding, validateFindings, @@ -62,7 +63,9 @@ describe("allowedFindingCategories", () => { describe("validateFinding", () => { it("passes a valid finding for each FindingSource", () => { for (const source of ["deterministic", "capability", "model", "image", "history"]) { - const finding = validateFinding(baseFinding({ source }), { + // History findings cite their own category set, disjoint from block/warn. + const category = source === "history" ? "publisher-history" : "obfuscated-code"; + const finding = validateFinding(baseFinding({ source, category }), { allowedCategories: ALLOWED_CATEGORIES, resolvableEvidenceIds: NO_EVIDENCE, }); @@ -208,6 +211,47 @@ describe("validateFinding", () => { }); }); +describe("validateFinding: history-source categories (W8.1 D4)", () => { + const opts = { allowedCategories: ALLOWED_CATEGORIES, resolvableEvidenceIds: NO_EVIDENCE }; + + it("accepts a history finding for every history category, none of which are block/warn values", () => { + for (const category of HISTORY_FINDING_CATEGORIES) { + expect(ALLOWED_CATEGORIES.has(category)).toBe(false); + const finding = validateFinding(baseFinding({ source: "history", category }), opts); + expect(finding.source).toBe("history"); + expect(finding.category).toBe(category); + } + }); + + it("rejects a history finding that cites a block/warn category, holding history to its own set", () => { + for (const category of ["malware", "obfuscated-code"]) { + expect(() => validateFinding(baseFinding({ source: "history", category }), opts)).toThrow( + FindingValidationError, + ); + } + }); + + it("rejects a NON-history finding that cites a history category", () => { + for (const source of ["deterministic", "capability", "model", "image"]) { + expect(() => + validateFinding(baseFinding({ source, category: "publisher-history" }), opts), + ).toThrow(FindingValidationError); + } + }); + + it("leaves non-history validation unchanged: a deterministic finding still needs a block/warn category", () => { + expect(() => + validateFinding(baseFinding({ source: "deterministic", category: "malware" }), opts), + ).not.toThrow(); + expect(() => + validateFinding( + baseFinding({ source: "deterministic", category: "active-manual-label" }), + opts, + ), + ).toThrow(FindingValidationError); + }); +}); + describe("validateFindings", () => { it("validates every finding and returns them in order", () => { const findings = validateFindings( diff --git a/apps/labeler/test/history-context.test.ts b/apps/labeler/test/history-context.test.ts new file mode 100644 index 0000000000..1169b58eb5 --- /dev/null +++ b/apps/labeler/test/history-context.test.ts @@ -0,0 +1,290 @@ +import { createLabelSigner, type LabelSigner } from "@emdash-cms/registry-moderation"; +import { applyD1Migrations, env } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { createAssessmentRun, createSubject, type Assessment } from "../src/assessment-store.js"; +import { + allowedFindingCategories, + HISTORY_FINDING_CATEGORIES, + validateFindings, +} from "../src/findings.js"; +import { analyzeHistory } from "../src/history-context.js"; +import { MODERATION_POLICY } from "../src/policy.js"; +import { issueManualLabel } from "../src/service.js"; +import { initializeSigningState } from "../src/signing-rotation.js"; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; +} + +const testEnv = env as unknown as TestEnv; +const LABELER_DID = "did:web:labels.emdashcms.com"; +const PRIVATE_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE"; +const MULTIKEY = "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ"; +const config = { labelerDid: LABELER_DID, signingKeyVersion: "v1" }; + +let counter = 0; + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); + await initializeSigningState(testEnv.DB, { + issuerDid: LABELER_DID, + keyVersion: "v1", + publicKeyMultibase: MULTIKEY, + }); +}); + +function signer(): Promise { + return createLabelSigner({ + issuerDid: LABELER_DID, + privateKey: PRIVATE_KEY, + resolveDid: async () => ({ + id: LABELER_DID, + verificationMethod: [ + { + id: "#atproto_label", + type: "Multikey", + controller: LABELER_DID, + publicKeyMultibase: MULTIKEY, + }, + ], + }), + }); +} + +function uriFor(did: string, name: string): string { + return `at://${did}/com.emdashcms.experimental.package.release/${name}:1.0.0`; +} + +async function seedSubject(did: string, name: string): Promise<{ uri: string; cid: string }> { + const uri = uriFor(did, name); + const cid = `bafkreicid${counter++}00000000000000000000000000000000000000`; + await createSubject(testEnv.DB, { + uri, + cid, + did, + collection: "com.emdashcms.experimental.package.release", + rkey: `${name}:1.0.0`, + }); + return { uri, cid }; +} + +async function seedAssessmentWithChecksum( + subject: { uri: string; cid: string }, + checksum: string, +): Promise { + await createAssessmentRun(testEnv.DB, { + runKey: `rk-${counter++}`, + uri: subject.uri, + cid: subject.cid, + artifactChecksum: checksum, + trigger: "initial", + triggerId: `trigger-${counter++}`, + policyVersion: MODERATION_POLICY.policyVersion, + coverageJson: "{}", + }); +} + +function assessmentFor(input: { + uri: string; + cid: string; + artifactChecksum?: string | null; +}): Assessment { + return { + id: "asmt_under_test", + runKey: "rk_under_test", + uri: input.uri, + cid: input.cid, + artifactId: null, + artifactChecksum: input.artifactChecksum ?? null, + state: "running", + trigger: "initial", + triggerId: "trigger", + policyVersion: MODERATION_POLICY.policyVersion, + modelId: null, + promptHash: null, + publicSummary: null, + coverageJson: "{}", + supersedesAssessmentId: null, + startedAt: null, + completedAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + }; +} + +describe("analyzeHistory", () => { + it("returns no findings for a fresh publisher with no reuse and no manual labels", async () => { + const did = "did:plc:fresh0000000000000000000000"; + const subject = await seedSubject(did, "only"); + const findings = await analyzeHistory( + testEnv.DB, + assessmentFor({ uri: subject.uri, cid: subject.cid }), + { src: LABELER_DID }, + ); + expect(findings).toEqual([]); + }); + + it("reports prior releases from the same publishing DID", async () => { + const did = "did:plc:prior000000000000000000000"; + const current = await seedSubject(did, "current"); + await seedSubject(did, "older-a"); + await seedSubject(did, "older-b"); + + const findings = await analyzeHistory( + testEnv.DB, + assessmentFor({ uri: current.uri, cid: current.cid }), + { src: LABELER_DID }, + ); + + expect(findings).toHaveLength(1); + const finding = findings[0]!; + expect(finding.source).toBe("history"); + expect(finding.category).toBe("publisher-history"); + expect(finding.title).toContain("2 prior releases"); + }); + + it("caps the prior-release count and reports it as bounded", async () => { + const did = "did:plc:manyreleases00000000000000"; + const current = await seedSubject(did, "many-current"); + for (let i = 0; i < 4; i++) await seedSubject(did, `many-${i}`); + + const findings = await analyzeHistory( + testEnv.DB, + assessmentFor({ uri: current.uri, cid: current.cid }), + { src: LABELER_DID, priorReleaseLimit: 2 }, + ); + + expect(findings[0]!.title).toContain("at least 2 prior releases"); + }); + + it("reports the same artifact checksum submitted under a different publisher (global, cross-DID)", async () => { + const checksum = `sha256-shared-${counter++}`; + const subjectA = await seedSubject("did:plc:pubA00000000000000000000000", "reuse-a"); + const subjectB = await seedSubject("did:plc:pubB00000000000000000000000", "reuse-b"); + await seedAssessmentWithChecksum(subjectB, checksum); + + const findings = await analyzeHistory( + testEnv.DB, + assessmentFor({ uri: subjectA.uri, cid: subjectA.cid, artifactChecksum: checksum }), + { src: LABELER_DID }, + ); + + const shared = findings.find((f) => f.category === "shared-artifact"); + expect(shared).toBeDefined(); + expect(shared!.source).toBe("history"); + // Cross-publisher correlation is a deanonymization signal: it must stay out + // of the public-facing title and summary, only in privateDetail. + expect(shared!.title).not.toContain("did:plc:pubB00000000000000000000000"); + expect(shared!.publicSummary).not.toContain("did:plc:pubB00000000000000000000000"); + expect(shared!.publicSummary).not.toContain("1 other"); + expect(shared!.privateDetail).toContain("1 other publisher"); + expect(shared!.privateDetail).toContain("did:plc:pubB00000000000000000000000"); + }); + + it("does not flag the same checksum submitted only under the subject's own DID", async () => { + const did = "did:plc:selfreuse0000000000000000"; + const checksum = `sha256-self-${counter++}`; + const first = await seedSubject(did, "self-a"); + const second = await seedSubject(did, "self-b"); + await seedAssessmentWithChecksum(first, checksum); + + const findings = await analyzeHistory( + testEnv.DB, + assessmentFor({ uri: second.uri, cid: second.cid, artifactChecksum: checksum }), + { src: LABELER_DID }, + ); + + expect(findings.find((f) => f.category === "shared-artifact")).toBeUndefined(); + }); + + it("reports active manual labels on the subject", async () => { + const did = "did:plc:manuallabel00000000000000"; + const subject = await seedSubject(did, "manual"); + await issueManualLabel( + testEnv.DB, + config, + await signer(), + { + actor: LABELER_DID, + type: "manual-label", + reason: "reviewer: confirmed security issue", + idempotencyKey: `manual-${counter++}`, + }, + { uri: subject.uri, val: "security-yanked" }, + ); + + const findings = await analyzeHistory( + testEnv.DB, + assessmentFor({ uri: subject.uri, cid: subject.cid }), + { src: LABELER_DID }, + ); + + const manual = findings.find((f) => f.category === "active-manual-label"); + expect(manual).toBeDefined(); + expect(manual!.source).toBe("history"); + // The existence and identity of manual labels (including redactions) must + // not leak into the public-facing fields. + expect(manual!.title).not.toContain("security-yanked"); + expect(manual!.publicSummary).not.toContain("security-yanked"); + expect(manual!.privateDetail).toContain("security-yanked"); + }); + + it("emits all three context findings together, each valid under the amended finding contract", async () => { + const did = "did:plc:allthree00000000000000000"; + const other = "did:plc:othercombined0000000000000"; + const checksum = `sha256-all-${counter++}`; + const current = await seedSubject(did, "all-current"); + await seedSubject(did, "all-older"); + const otherSubject = await seedSubject(other, "all-other"); + await seedAssessmentWithChecksum(otherSubject, checksum); + await issueManualLabel( + testEnv.DB, + config, + await signer(), + { + actor: LABELER_DID, + type: "manual-label", + reason: "reviewer: disputed", + idempotencyKey: `manual-all-${counter++}`, + }, + { uri: current.uri, val: "security-yanked" }, + ); + + const findings = await analyzeHistory( + testEnv.DB, + assessmentFor({ uri: current.uri, cid: current.cid, artifactChecksum: checksum }), + { src: LABELER_DID }, + ); + + expect(findings.map((f) => f.category).toSorted()).toEqual([ + "active-manual-label", + "publisher-history", + "shared-artifact", + ]); + for (const finding of findings) + expect(HISTORY_FINDING_CATEGORIES.has(finding.category)).toBe(true); + + // The orchestrator validates every stage's output with the block∪warn + // allowed set; the amended validator must still admit these history findings. + const validated = validateFindings(findings, { + allowedCategories: allowedFindingCategories(MODERATION_POLICY), + resolvableEvidenceIds: new Set(), + }); + expect(validated).toHaveLength(3); + }); + + it("is best-effort: a D1 failure yields no findings rather than failing the run", async () => { + const brokenDb = { + prepare() { + throw new Error("D1 unavailable"); + }, + } as unknown as D1Database; + + await expect( + analyzeHistory(brokenDb, assessmentFor({ uri: "at://x/y/z", cid: "cid" }), { + src: LABELER_DID, + }), + ).resolves.toEqual([]); + }); +}); From 1f21880be222bbdd3ef96cd134066e9346372722 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 15 Jul 2026 08:27:50 +0100 Subject: [PATCH 066/137] docs(labeler-plan): ratify W8.6 decisions Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- .../plugin-registry-labelling-service/implementation-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index a96d20b945..21a86ee898 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1073,6 +1073,8 @@ Dependencies: `W1.7`, `W8.1` through `W8.4`. Dependencies: `W8.2`, `W8.3`, `W8.5`. +Decisions (2026-07-15): the harness is runnable locally now — not gated on staging infra. It drives the REAL adapters (`analyzeCode`/`analyzeImages` with their production prompts, response schemas, and the live policy) via a `RestAiBinding` satisfying the adapters' `AiBinding` interface against the Workers AI REST endpoint, authenticated with `wrangler auth token`; the model+prompt+schema combination is the unit under evaluation, never a hand-rolled eval prompt. Model matrix (all verified enabled on the account): code lane `@cf/moonshotai/kimi-k2.7-code` (current default) vs `@cf/moonshotai/kimi-k2.6` vs `@cf/zai-org/glm-5.2` (text-only — excluded from the image lane); image lane kimi-k2.7-code vs kimi-k2.6 vs `@cf/meta/llama-3.2-11b-vision-instruct`, with `@cf/moondream/moondream3.1-9B-A2B` as an experimental caption-only lens (Image-to-Text input shape — cannot flow through the real adapter, results not comparable). The legacy corpus is `packages/marketplace/tests/fixtures/audit/` (13 fixtures), ported into `apps/labeler/calibration/`; its expected.json files were baselined on retired models and are treated as priors for human review, not ground truth. Runs execute via a `calibrate` script outside the `test` CI gate; recorded run artifacts are gitignored review material, and the diff report (fixture × model outcome matrix, newly-blocked/newly-allowed sections, per-model agreement) is the calibration report artifact. Reasoning models spend output budget on `reasoning_content` before `content` (observed live on kimi-k2.6), so runs record finish_reason and token usage to make truncation diagnosable. + ### W8 Completion Gate 4 passes. A complete automated run can propose and issue the expected labels while preserving the signer boundary. From 2a205af7189f067bf2aa4ef1aeb46ac17ae118b3 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 15 Jul 2026 10:08:02 +0100 Subject: [PATCH 067/137] docs(labeler-plan): ratify W8.5 severity/vocabulary amendments and W8.6 first-run outcomes Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- .../plugin-registry-labelling-service/implementation-plan.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 21a86ee898..3970342a36 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1062,6 +1062,8 @@ Return a typed proposal accepted by `W3.2`, not a signed label. Dependencies: `W1.7`, `W8.1` through `W8.4`. +Decisions (2026-07-15, from the first calibration run): the "AI critical" blocking rule is amended — a model/image finding on ANY automated-block category blocks at `high` or `critical` severity, not `critical` only, and a block-category finding below `high` is never dropped: it degrades to a warning label, so any malicious-code finding always produces a visible label (the ratified principle: flag ALL suspected-malicious code; blocking is reserved for high-confidence findings). The first calibration sweep showed two independent models rating a genuine data-exfiltration sample `high`, which the critical-only gate reduced to no label at all (the finding was neither blocked nor warned — dropped entirely); prompt guidance to rate real threats `critical` is added as well, but the resolver gate is the defense because the resolver was built to distrust model severity calibration. Warning-category behavior is unchanged. The moderation-policy vocabulary gains three image-content automated-block categories — `hateful-imagery`, `explicit-imagery`, and `graphic-violence` (three-way split ratified over the legacy two-axis offensive/nsfw grouping) — each release-subject, cid-required, `automated`+`reviewer` issuance, with `en` locales and a `policyVersion` bump; the change is additive and safe for `queryLabels` consumers, and the image adapter's severity-guidance sentence (which steered models away from rating content-policy violations critical) is rewritten to name them as block-worthy. CSAM and other unlawful content stays OUT of the label vocabulary: no automated classification (unreliable and legally fraught); suspected cases route through operator escalation to `!takedown`, and an out-of-band legal reporting process (NCMEC-style) is a separate pre-launch work item owned by the maintainer. + ### `W8.6` Build calibration harness - Port legacy code/image audit fixtures. @@ -1075,7 +1077,7 @@ Dependencies: `W8.2`, `W8.3`, `W8.5`. Decisions (2026-07-15): the harness is runnable locally now — not gated on staging infra. It drives the REAL adapters (`analyzeCode`/`analyzeImages` with their production prompts, response schemas, and the live policy) via a `RestAiBinding` satisfying the adapters' `AiBinding` interface against the Workers AI REST endpoint, authenticated with `wrangler auth token`; the model+prompt+schema combination is the unit under evaluation, never a hand-rolled eval prompt. Model matrix (all verified enabled on the account): code lane `@cf/moonshotai/kimi-k2.7-code` (current default) vs `@cf/moonshotai/kimi-k2.6` vs `@cf/zai-org/glm-5.2` (text-only — excluded from the image lane); image lane kimi-k2.7-code vs kimi-k2.6 vs `@cf/meta/llama-3.2-11b-vision-instruct`, with `@cf/moondream/moondream3.1-9B-A2B` as an experimental caption-only lens (Image-to-Text input shape — cannot flow through the real adapter, results not comparable). The legacy corpus is `packages/marketplace/tests/fixtures/audit/` (13 fixtures), ported into `apps/labeler/calibration/`; its expected.json files were baselined on retired models and are treated as priors for human review, not ground truth. Runs execute via a `calibrate` script outside the `test` CI gate; recorded run artifacts are gitignored review material, and the diff report (fixture × model outcome matrix, newly-blocked/newly-allowed sections, per-model agreement) is the calibration report artifact. Reasoning models spend output budget on `reasoning_content` before `content` (observed live on kimi-k2.6), so runs record finish_reason and token usage to make truncation diagnosable. -### W8 Completion +Outcomes ratified from the first run (2026-07-15): the code-lane default model switches from `kimi-k2.7-code` (weakest measured: two under-blocked threats, 2× glm's latency) to `@cf/zai-org/glm-5.2`, to be re-validated by a follow-up sweep after the W8.5 severity amendment lands; `kimi-k2.6` is excluded from serving (72s mean latency, timeout, invalid structured output on 3 of 16 calls) but retained in the matrix as a detection-quality reference. The production adapters' `parseModelOutput` gains support for the OpenAI-compatible `{choices[].message.content}` envelope the live models actually return (as written it could parse none of them — the run's headline finding), folded into the harness PR; once production parses both envelopes the harness's transport unwrap is REMOVED so the eval exercises the true production parse path (adversary-flagged: the unwrap silently repaired, and never disclosed, the exact defect the harness exists to catch). The report additionally tallies over-warning on clean fixtures (passed→warned), which the FP count alone concealed — the run showed every model warning on every clean fixture, a prompt/threshold problem tracked for the same follow-up sweep. The Meta license for `llama-3.2-11b-vision-instruct` was accepted on the account (2026-07-15), unblocking the image lane. Gate 4 passes. A complete automated run can propose and issue the expected labels while preserving the signer boundary. From 3eefd1257941134566eb7b96397a254299676f5a Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 15 Jul 2026 14:31:19 +0100 Subject: [PATCH 068/137] feat(labeler): model calibration harness and OpenAI envelope support (W8.6) (#2058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(labeler): parse OpenAI chat envelopes and default to glm-5.2 The code/image AI adapters expected only the classic Workers AI `{response}` envelope, but the OpenAI-compatible chat models they target (moonshot/kimi, zai/glm) return `{choices:[{message:{content}}]}` — as written the adapters could parse none of their output. Add a shared `unwrapModelEnvelope` that reduces the chat envelope to the content string (discarding `reasoning_content`) before the existing parse path; non-string content falls through to the existing error handling and `validateFindings` gates everything unchanged. Switch `DEFAULT_MODEL_ID` to `@cf/zai-org/glm-5.2`: in the first calibration sweep it was the only zero-error code model, ~3x faster than kimi-k2.7-code, and blocked more of the malware corpus. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM * feat(labeler): model calibration harness (W8.6) Local harness that runs the production code/image AI adapters — their real prompts, response schemas, and policy — over the ported legacy audit corpus (13 fixtures) against live Workers AI models via a REST `AiBinding`, resolves each result through `resolvePolicyOutcome`, and records per-call artifacts for review. A report entrypoint renders the fixture-by-model outcome matrix, per-model agreement/false-positive/ false-negative/over-warn tallies, and newly-blocked/newly-allowed diffs between runs. Runs execute via `calibrate`/`calibrate:report` scripts outside the `test` CI gate (real inference is non-deterministic and billed); run artifacts are gitignored review material. The 32 calibration unit tests (fixture loading, expectation mapping, report diffing, envelope handling) run in a node-env stage of `pnpm test`. The runner writes its manifest incrementally so an interrupted sweep stays loadable, and orders models slowest-first so a late kill loses the cheapest calls. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM * fix(labeler): dedicated image default model; share parseModelOutput analyzeImages fell back to the code adapter's DEFAULT_MODEL_ID, now the text-only glm-5.2, so an image caller that omitted modelId got a model that cannot see images. Add DEFAULT_IMAGE_MODEL_ID (kimi-k2.7-code, the only reliable image model in the W8.6 sweep) and use it as the fallback. Export parseModelOutput from code-ai-adapter.ts and drop the image adapter's identical local copy so a future envelope change lands in one place. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM * fix(labeler): validate calibration run label against path traversal createRunDir joined the operator-supplied CALIBRATE_LABEL into the run path unsanitized, so a label like '../../tmp/x' escaped runs/. Validate the label against a strict pattern (alphanumeric start; alphanumerics, '.', '-', '_'; 64-char cap) and reject otherwise, plus a containment assertion that the resolved dir stays under RUNS_DIR as defense-in-depth. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM * feat(labeler): record finding evidence lists in calibration artifacts RecordedFinding dropped the validated finding's optional affectedFiles/affectedImages, so a run artifact couldn't show which files or images a finding cited — harder to triage a state change from artifacts alone. Thread both optional lists through recordFindings into the recorded shape. Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- apps/labeler/calibration/.gitignore | 1 + apps/labeler/calibration/README.md | 62 ++++ .../calibration/expectation-mapping.test.ts | 89 ++++++ .../calibration/fixture-loader.test.ts | 101 +++++++ apps/labeler/calibration/fixture-loader.ts | 261 ++++++++++++++++ .../fixtures/benign-network-usage/backend.js | 17 ++ .../benign-network-usage/expected.json | 1 + .../benign-network-usage/manifest.json | 10 + .../fixtures/brand-impersonation/backend.js | 3 + .../brand-impersonation/expected.json | 9 + .../fixtures/brand-impersonation/icon.png | Bin 0 -> 11517 bytes .../brand-impersonation/manifest.json | 10 + .../fixtures/clean-seo-plugin/backend.js | 12 + .../fixtures/clean-seo-plugin/expected.json | 1 + .../fixtures/clean-seo-plugin/manifest.json | 10 + .../fixtures/clean-with-images/backend.js | 11 + .../fixtures/clean-with-images/expected.json | 6 + .../fixtures/clean-with-images/icon.png | Bin 0 -> 1087 bytes .../fixtures/clean-with-images/manifest.json | 10 + .../fixtures/credential-harvester/backend.js | 13 + .../credential-harvester/expected.json | 1 + .../credential-harvester/manifest.json | 21 ++ .../fixtures/crypto-miner/backend.js | 14 + .../fixtures/crypto-miner/expected.json | 8 + .../fixtures/crypto-miner/manifest.json | 10 + .../fixtures/data-exfiltration/backend.js | 20 ++ .../fixtures/data-exfiltration/expected.json | 1 + .../fixtures/data-exfiltration/manifest.json | 10 + .../dynamic-url-construction/backend.js | 12 + .../dynamic-url-construction/expected.json | 8 + .../dynamic-url-construction/manifest.json | 10 + .../fixtures/hate-imagery/backend.js | 3 + .../fixtures/hate-imagery/expected.json | 9 + .../fixtures/hate-imagery/icon.png | Bin 0 -> 259052 bytes .../fixtures/hate-imagery/manifest.json | 10 + .../fixtures/misleading-screenshot/backend.js | 3 + .../misleading-screenshot/expected.json | 9 + .../misleading-screenshot/manifest.json | 10 + .../fixtures/obfuscated-payload/backend.js | 17 ++ .../fixtures/obfuscated-payload/expected.json | 8 + .../fixtures/obfuscated-payload/manifest.json | 10 + .../fixtures/prompt-injection/backend.js | 55 ++++ .../fixtures/prompt-injection/expected.json | 1 + .../fixtures/prompt-injection/manifest.json | 10 + .../fixtures/social-engineering/backend.js | 22 ++ .../fixtures/social-engineering/expected.json | 1 + .../fixtures/social-engineering/manifest.json | 14 + apps/labeler/calibration/io.test.ts | 109 +++++++ apps/labeler/calibration/io.ts | 127 ++++++++ apps/labeler/calibration/models.ts | 39 +++ apps/labeler/calibration/report.calibrate.ts | 32 ++ apps/labeler/calibration/report.test.ts | 173 +++++++++++ apps/labeler/calibration/report.ts | Bin 0 -> 9285 bytes .../calibration/rest-ai-binding.test.ts | 84 ++++++ apps/labeler/calibration/rest-ai-binding.ts | 128 ++++++++ apps/labeler/calibration/run.calibrate.ts | 19 ++ apps/labeler/calibration/run.ts | 282 ++++++++++++++++++ apps/labeler/calibration/tsconfig.json | 9 + apps/labeler/calibration/types.ts | 70 +++++ .../labeler/calibration/unit.vitest.config.ts | 15 + apps/labeler/calibration/vitest.config.ts | 22 ++ apps/labeler/package.json | 6 +- apps/labeler/src/code-ai-adapter.ts | 33 +- apps/labeler/src/image-ai-adapter.ts | 28 +- apps/labeler/test/code-ai-adapter.test.ts | 38 +++ apps/labeler/test/image-ai-adapter.test.ts | 63 +++- apps/labeler/vitest.config.ts | 7 +- 67 files changed, 2172 insertions(+), 36 deletions(-) create mode 100644 apps/labeler/calibration/.gitignore create mode 100644 apps/labeler/calibration/README.md create mode 100644 apps/labeler/calibration/expectation-mapping.test.ts create mode 100644 apps/labeler/calibration/fixture-loader.test.ts create mode 100644 apps/labeler/calibration/fixture-loader.ts create mode 100644 apps/labeler/calibration/fixtures/benign-network-usage/backend.js create mode 100644 apps/labeler/calibration/fixtures/benign-network-usage/expected.json create mode 100644 apps/labeler/calibration/fixtures/benign-network-usage/manifest.json create mode 100644 apps/labeler/calibration/fixtures/brand-impersonation/backend.js create mode 100644 apps/labeler/calibration/fixtures/brand-impersonation/expected.json create mode 100644 apps/labeler/calibration/fixtures/brand-impersonation/icon.png create mode 100644 apps/labeler/calibration/fixtures/brand-impersonation/manifest.json create mode 100644 apps/labeler/calibration/fixtures/clean-seo-plugin/backend.js create mode 100644 apps/labeler/calibration/fixtures/clean-seo-plugin/expected.json create mode 100644 apps/labeler/calibration/fixtures/clean-seo-plugin/manifest.json create mode 100644 apps/labeler/calibration/fixtures/clean-with-images/backend.js create mode 100644 apps/labeler/calibration/fixtures/clean-with-images/expected.json create mode 100644 apps/labeler/calibration/fixtures/clean-with-images/icon.png create mode 100644 apps/labeler/calibration/fixtures/clean-with-images/manifest.json create mode 100644 apps/labeler/calibration/fixtures/credential-harvester/backend.js create mode 100644 apps/labeler/calibration/fixtures/credential-harvester/expected.json create mode 100644 apps/labeler/calibration/fixtures/credential-harvester/manifest.json create mode 100644 apps/labeler/calibration/fixtures/crypto-miner/backend.js create mode 100644 apps/labeler/calibration/fixtures/crypto-miner/expected.json create mode 100644 apps/labeler/calibration/fixtures/crypto-miner/manifest.json create mode 100644 apps/labeler/calibration/fixtures/data-exfiltration/backend.js create mode 100644 apps/labeler/calibration/fixtures/data-exfiltration/expected.json create mode 100644 apps/labeler/calibration/fixtures/data-exfiltration/manifest.json create mode 100644 apps/labeler/calibration/fixtures/dynamic-url-construction/backend.js create mode 100644 apps/labeler/calibration/fixtures/dynamic-url-construction/expected.json create mode 100644 apps/labeler/calibration/fixtures/dynamic-url-construction/manifest.json create mode 100644 apps/labeler/calibration/fixtures/hate-imagery/backend.js create mode 100644 apps/labeler/calibration/fixtures/hate-imagery/expected.json create mode 100644 apps/labeler/calibration/fixtures/hate-imagery/icon.png create mode 100644 apps/labeler/calibration/fixtures/hate-imagery/manifest.json create mode 100644 apps/labeler/calibration/fixtures/misleading-screenshot/backend.js create mode 100644 apps/labeler/calibration/fixtures/misleading-screenshot/expected.json create mode 100644 apps/labeler/calibration/fixtures/misleading-screenshot/manifest.json create mode 100644 apps/labeler/calibration/fixtures/obfuscated-payload/backend.js create mode 100644 apps/labeler/calibration/fixtures/obfuscated-payload/expected.json create mode 100644 apps/labeler/calibration/fixtures/obfuscated-payload/manifest.json create mode 100644 apps/labeler/calibration/fixtures/prompt-injection/backend.js create mode 100644 apps/labeler/calibration/fixtures/prompt-injection/expected.json create mode 100644 apps/labeler/calibration/fixtures/prompt-injection/manifest.json create mode 100644 apps/labeler/calibration/fixtures/social-engineering/backend.js create mode 100644 apps/labeler/calibration/fixtures/social-engineering/expected.json create mode 100644 apps/labeler/calibration/fixtures/social-engineering/manifest.json create mode 100644 apps/labeler/calibration/io.test.ts create mode 100644 apps/labeler/calibration/io.ts create mode 100644 apps/labeler/calibration/models.ts create mode 100644 apps/labeler/calibration/report.calibrate.ts create mode 100644 apps/labeler/calibration/report.test.ts create mode 100644 apps/labeler/calibration/report.ts create mode 100644 apps/labeler/calibration/rest-ai-binding.test.ts create mode 100644 apps/labeler/calibration/rest-ai-binding.ts create mode 100644 apps/labeler/calibration/run.calibrate.ts create mode 100644 apps/labeler/calibration/run.ts create mode 100644 apps/labeler/calibration/tsconfig.json create mode 100644 apps/labeler/calibration/types.ts create mode 100644 apps/labeler/calibration/unit.vitest.config.ts create mode 100644 apps/labeler/calibration/vitest.config.ts diff --git a/apps/labeler/calibration/.gitignore b/apps/labeler/calibration/.gitignore new file mode 100644 index 0000000000..a1e03960fb --- /dev/null +++ b/apps/labeler/calibration/.gitignore @@ -0,0 +1 @@ +runs/ diff --git a/apps/labeler/calibration/README.md b/apps/labeler/calibration/README.md new file mode 100644 index 0000000000..223bc84929 --- /dev/null +++ b/apps/labeler/calibration/README.md @@ -0,0 +1,62 @@ +# Labeler calibration harness (W8.6) + +Runs the **real** moderation adapters (`analyzeCode`, `analyzeImages`) and the +real policy resolver over a fixture corpus against live Workers AI models, so +false positives/negatives can be reviewed before a policy or model change ships. +The model + prompt + schema + policy combination is the unit under evaluation — +nothing here re-implements the eval prompt. + +This is not part of `pnpm test`. It calls real models and costs tokens. + +## Layout + +- `fixtures//` — ported audit fixtures: `manifest.json`, `backend.js`, + optional `icon.png`, and `expected.json` re-expressed in labeler policy terms. +- `models.ts` — the data-driven model matrix (add/remove models here). +- `rest-ai-binding.ts` — `AiBinding` backed by the Workers AI REST endpoint. +- `fixture-loader.ts` — pure conversion (PNG dims, sha256, legacy→labeler map). +- `run.ts` / `run.calibrate.ts` — the sweep; writes artifacts to `runs/`. +- `report.ts` / `report.calibrate.ts` — markdown report + run-vs-run diff. +- `runs/` — recorded artifacts (gitignored; review outputs, not source). + +## Run a sweep + +```bash +CLOUDFLARE_API_TOKEN=$(pnpm --filter @emdash-cms/labeler exec wrangler auth token | tail -1) \ +CLOUDFLARE_ACCOUNT_ID= \ +CALIBRATE_LABEL=baseline \ + pnpm --filter @emdash-cms/labeler calibrate +``` + +The token is read from the environment and never logged or persisted. Artifacts +land in `calibration/runs/-
+ {publisherHistory && } + {isAdmin && (
@@ -243,6 +250,51 @@ function SubjectHistory() { ); } +/** Neutral read-time context (plan W8.4 D5): the publishing account's prior + * releases and the subject's active manual labels. Facts only — no findings, no + * action affordances. */ +function PublisherHistoryCard({ history }: { history: PublisherHistory }) { + const priorReleases = history.priorReleaseCapped + ? `at least ${history.priorReleaseCount}` + : String(history.priorReleaseCount); + return ( + +

Publisher history

+
+ Publishing account + {history.did} +
+
+ Prior releases from this publisher + {priorReleases} + {history.priorReleaseSample.length > 0 && ( +
    + {history.priorReleaseSample.map((uri) => ( +
  • + {uri} +
  • + ))} +
+ )} +
+
+ Active manual labels + {history.activeManualLabels.length > 0 ? ( +
+ {history.activeManualLabels.map((label) => ( + + {label.val} + + ))} +
+ ) : ( + None + )} +
+
+ ); +} + export const subjectHistoryRoute = createRoute({ getParentRoute: () => shellRoute, path: "/subjects/$uri", diff --git a/apps/labeler/console/src/test/subject-history.test.tsx b/apps/labeler/console/src/test/subject-history.test.tsx new file mode 100644 index 0000000000..1171be2811 --- /dev/null +++ b/apps/labeler/console/src/test/subject-history.test.tsx @@ -0,0 +1,42 @@ +import { screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { apiClient } from "../api/client.js"; +import { SUBJECT_ALPHA } from "../fixtures/index.js"; +import { renderRoute, REVIEWER_IDENTITY } from "./harness.js"; + +vi.mock("../api/client.js", async () => { + const actual = await vi.importActual("../api/client.js"); + return { ...actual, apiClient: actual.createFixtureClient() }; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const path = `/subjects/${encodeURIComponent(SUBJECT_ALPHA.uri)}`; + +describe("SubjectHistory publisher-history section", () => { + it("renders prior releases and active manual labels from the fixture", async () => { + vi.spyOn(apiClient, "whoami").mockResolvedValue(REVIEWER_IDENTITY); + renderRoute(path); + await screen.findByRole("heading", { name: "Subject history", level: 1 }); + expect( + await screen.findByRole("heading", { name: "Publisher history", level: 2 }), + ).toBeTruthy(); + expect(screen.getByText(SUBJECT_ALPHA.did)).toBeTruthy(); + expect(screen.getByText("disputed")).toBeTruthy(); + expect(screen.getByText(/3lduzalphaprev1/)).toBeTruthy(); + }); + + it("omits the section when the response carries no publisher-history block", async () => { + vi.spyOn(apiClient, "whoami").mockResolvedValue(REVIEWER_IDENTITY); + vi.spyOn(apiClient, "getSubjectHistory").mockResolvedValue({ + subject: SUBJECT_ALPHA, + assessments: [], + }); + renderRoute(path); + await screen.findByRole("heading", { name: "Subject history", level: 1 }); + expect(screen.queryByRole("heading", { name: "Publisher history" })).toBeNull(); + }); +}); diff --git a/apps/labeler/src/console-api.ts b/apps/labeler/src/console-api.ts index dd967f938e..b4187c1d4a 100644 --- a/apps/labeler/src/console-api.ts +++ b/apps/labeler/src/console-api.ts @@ -29,6 +29,7 @@ import { getAssessmentsPage, getCurrentSubjectByUri, getFindingsForAssessment, + getPriorReleaseUrisForDid, isSuperseded, type ListAssessmentsFilters, type ListedAssessment, @@ -39,6 +40,7 @@ import { serializeIssuedLabel, serializeOperatorActionView, serializeOperatorFinding, + serializePublisherHistory, serializeSubjectLabel, serializeSubjectRecord, type Page, @@ -53,6 +55,10 @@ import { assertNegatableBlockSet, NegatableBlockSetError, parseSubjectKind } fro const DEFAULT_LIMIT = 50; const MAX_LIMIT = 100; +// Matches history-context.ts's DEFAULT_PRIOR_RELEASE_LIMIT / SAMPLE_SIZE so the +// console's read-time count and sample agree with a run's history finding. +const PRIOR_RELEASE_LIMIT = 20; +const PRIOR_RELEASE_SAMPLE_SIZE = 5; const NON_NEGATIVE_INTEGER = /^\d+$/; const ADMIN_API_PREFIX = /^\/admin\/api\/?/; @@ -255,10 +261,33 @@ async function handleGetSubjectHistory( requireGet(request); const subject = await getCurrentSubjectByUri(deps.db, uri); if (!subject) throw new ReadGuardError("NOT_FOUND"); - const assessments = await getAssessmentsForUri(deps.db, uri); + // Three independent reads keyed on the resolved subject — the assessment + // history plus the two neutral publisher-history inputs (plan W8.4 D5). No + // per-row fan-out: the label lookup reduces the whole (src, uri) stream once. + const [assessments, priorReleaseUris, labelWinners] = await Promise.all([ + getAssessmentsForUri(deps.db, uri), + getPriorReleaseUrisForDid(deps.db, { + did: subject.did, + excludeUri: uri, + limit: PRIOR_RELEASE_LIMIT, + }), + getActiveLabelState(deps.db, { + src: deps.labelerDid, + uri, + cid: subject.cid, + now: new Date(), + }), + ]); return jsonData({ subject: serializeSubjectRecord(subject), assessments: assessments.map(serializeAssessmentRun), + publisherHistory: serializePublisherHistory({ + did: subject.did, + priorReleaseUris, + priorReleaseLimit: PRIOR_RELEASE_LIMIT, + priorReleaseSampleSize: PRIOR_RELEASE_SAMPLE_SIZE, + labelWinners: labelWinners.values(), + }), }); } diff --git a/apps/labeler/src/console-serialize.ts b/apps/labeler/src/console-serialize.ts index 779c8029d7..0eb151cece 100644 --- a/apps/labeler/src/console-serialize.ts +++ b/apps/labeler/src/console-serialize.ts @@ -99,9 +99,32 @@ export interface SubjectRecord { deletedAt: string | null; } +/** An active, reviewer/admin-issued label on the subject, surfaced as neutral + * publisher-history context. `cid` is present only for a CID-bound label. */ +export interface PublisherHistoryLabel { + val: string; + cid?: string; +} + +/** + * Neutral publisher-history context assembled at console read-time from the + * labeler's own D1 (plan W8.4 D5): the publishing DID's prior releases and the + * subject's active manual labels. `priorReleaseCount` is bounded by the + * read-time cap — `priorReleaseCapped` true means "at least this many". Never + * part of any public serializer; served only on the reviewer-gated console API. + */ +export interface PublisherHistory { + did: string; + priorReleaseCount: number; + priorReleaseCapped: boolean; + priorReleaseSample: string[]; + activeManualLabels: PublisherHistoryLabel[]; +} + export interface SubjectHistoryView { subject: SubjectRecord; assessments: AssessmentRun[]; + publisherHistory: PublisherHistory; } export interface OperatorActionView { @@ -210,6 +233,37 @@ export function serializeSubjectLabel(winner: LabelStreamWinner): SubjectLabel { }; } +/** + * Assembles the publisher-history block from the slice-1 queries + * (`getPriorReleaseUrisForDid`, `getActiveLabelState`). The prior-release count + * is the number of URIs returned under the cap; `capped` matches the pipeline + * stage's rule (`length >= limit`). Active manual labels are the non-automated, + * currently-active stream winners — the same `!automated && active` filter the + * history stage applies. + */ +export function serializePublisherHistory(input: { + did: string; + priorReleaseUris: readonly string[]; + priorReleaseLimit: number; + priorReleaseSampleSize: number; + labelWinners: Iterable; +}): PublisherHistory { + const activeManualLabels: PublisherHistoryLabel[] = []; + for (const winner of input.labelWinners) { + if (winner.automated || !winner.active) continue; + activeManualLabels.push( + winner.cid === null ? { val: winner.val } : { val: winner.val, cid: winner.cid }, + ); + } + return { + did: input.did, + priorReleaseCount: input.priorReleaseUris.length, + priorReleaseCapped: input.priorReleaseUris.length >= input.priorReleaseLimit, + priorReleaseSample: input.priorReleaseUris.slice(0, input.priorReleaseSampleSize), + activeManualLabels, + }; +} + export function serializeSubjectRecord(subject: Subject): SubjectRecord { return { uri: subject.uri, diff --git a/apps/labeler/test/console-api.test.ts b/apps/labeler/test/console-api.test.ts index 57adfd7d7f..6aa6fffa09 100644 --- a/apps/labeler/test/console-api.test.ts +++ b/apps/labeler/test/console-api.test.ts @@ -29,6 +29,16 @@ const URI_S = const CID_S_OLD = "bafyreisssssssssssssssssssssssssssssssssssssssssssssssold"; const CID_S_NEW = "bafyreisssssssssssssssssssssssssssssssssssssssssssssssnew"; +// Publisher-history fixture: one DID with three releases and, on the first, an +// active manual label plus an automated label (which must not count as manual). +const DID_HIST = "did:plc:hhhhhhhhhhhhhhhhhhhhhhhh"; +const URI_H1 = `at://${DID_HIST}/com.emdashcms.experimental.package.release/rkH1`; +const CID_H1 = "bafyreihhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh1"; +const URI_H2 = `at://${DID_HIST}/com.emdashcms.experimental.package.release/rkH2`; +const CID_H2 = "bafyreihhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh2"; +const URI_H3 = `at://${DID_HIST}/com.emdashcms.experimental.package.release/rkH3`; +const CID_H3 = "bafyreihhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh3"; + // Assessment ids must satisfy assessment-lifecycle's ASSESSMENT_ID (ULID) regex, // which the detail route validates via getAssessment. const ASMT_PASS_A = "asmt_SQCP5CP1X1RBMM0V0TQP2WR9PD"; @@ -61,6 +71,7 @@ beforeAll(async () => { noRoleToken = await mintToken({ email: "nobody@example.com" }); await seedSubjects(); + await seedPublisherHistory(); await seedAssessments(); await seedFindings(); await seedLabels(); @@ -148,6 +159,72 @@ async function seedSubjects(): Promise { }); } +async function seedStreamLabel(input: { + actionId: number; + uri: string; + cid: string; + val: string; + type: string; + neg?: 0 | 1; + cts: string; +}): Promise { + await testEnv.DB.prepare( + `INSERT INTO issuance_actions (id, actor, type, reason, idempotency_key, created_at) + VALUES (?, ?, ?, 'r', ?, ?)`, + ) + .bind(input.actionId, LABELER_DID, input.type, `idem-hist-${input.actionId}`, input.cts) + .run(); + await testEnv.DB.prepare( + `INSERT INTO issued_labels (action_id, ver, src, uri, cid, val, neg, cts, sig, signing_key_id) + VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, 'v1')`, + ) + .bind( + input.actionId, + LABELER_DID, + input.uri, + input.cid, + input.val, + input.neg ?? 0, + input.cts, + new Uint8Array([0]), + ) + .run(); +} + +async function seedPublisherHistory(): Promise { + const releases: [uri: string, cid: string, rkey: string, observedAt: string][] = [ + [URI_H1, CID_H1, "rkH1", "2026-07-01T00:00:00.000Z"], + [URI_H2, CID_H2, "rkH2", "2026-07-02T00:00:00.000Z"], + [URI_H3, CID_H3, "rkH3", "2026-07-03T00:00:00.000Z"], + ]; + for (const [uri, cid, rkey, observedAt] of releases) { + await createSubject(testEnv.DB, { + uri, + cid, + did: DID_HIST, + collection: "com.emdashcms.experimental.package.release", + rkey, + now: new Date(observedAt), + }); + } + await seedStreamLabel({ + actionId: 3001, + uri: URI_H1, + cid: CID_H1, + val: "disputed", + type: "manual-label", + cts: "2026-07-04T00:00:00.000Z", + }); + await seedStreamLabel({ + actionId: 3002, + uri: URI_H1, + cid: CID_H1, + val: "assessment-passed", + type: "automated-assessment", + cts: "2026-07-04T01:00:00.000Z", + }); +} + interface SeedAssessment { id: string; uri: string; @@ -554,6 +631,53 @@ describe("handleConsoleApi — subject history", () => { ); expect(res.status).toBe(404); }); + + interface PublisherHistoryBody { + publisherHistory: { + did: string; + priorReleaseCount: number; + priorReleaseCapped: boolean; + priorReleaseSample: string[]; + activeManualLabels: { val: string; cid?: string }[]; + }; + } + + it("assembles publisher-history context: prior releases and active manual labels", async () => { + const res = await handleConsoleApi( + req(`/admin/api/subjects/${encodeURIComponent(URI_H1)}`, { token: reviewerToken }), + deps(), + ); + expect(res.status).toBe(200); + const view = (await body(res)).data as PublisherHistoryBody; + expect(view.publisherHistory.did).toBe(DID_HIST); + expect(view.publisherHistory.priorReleaseCount).toBe(2); + expect(view.publisherHistory.priorReleaseCapped).toBe(false); + expect(view.publisherHistory.priorReleaseSample).toEqual( + expect.arrayContaining([URI_H2, URI_H3]), + ); + expect(view.publisherHistory.priorReleaseSample).not.toContain(URI_H1); + const vals = view.publisherHistory.activeManualLabels.map((label) => label.val); + expect(vals).toContain("disputed"); + // An automated stream head is not a manual label. + expect(vals).not.toContain("assessment-passed"); + const disputed = view.publisherHistory.activeManualLabels.find( + (label) => label.val === "disputed", + ); + expect(disputed?.cid).toBe(CID_H1); + }); + + it("returns an empty publisher-history block for a subject with no history", async () => { + const res = await handleConsoleApi( + req(`/admin/api/subjects/${encodeURIComponent(URI_A)}`, { token: reviewerToken }), + deps(), + ); + expect(res.status).toBe(200); + const view = (await body(res)).data as PublisherHistoryBody; + expect(view.publisherHistory.did).toBe("did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"); + expect(view.publisherHistory.priorReleaseCount).toBe(0); + expect(view.publisherHistory.priorReleaseSample).toEqual([]); + expect(view.publisherHistory.activeManualLabels).toEqual([]); + }); }); describe("handleConsoleApi — audit log", () => { diff --git a/apps/labeler/test/console-serialize.test.ts b/apps/labeler/test/console-serialize.test.ts index d532e6267d..43a89301a3 100644 --- a/apps/labeler/test/console-serialize.test.ts +++ b/apps/labeler/test/console-serialize.test.ts @@ -4,6 +4,7 @@ import type { AssessmentState } from "../src/assessment-lifecycle.js"; import type { AssessmentFinding, AssessmentIssuedLabel, + LabelStreamWinner, ListedAssessment, Subject, } from "../src/assessment-store.js"; @@ -12,6 +13,7 @@ import { serializeIssuedLabel, serializeOperatorActionView, serializeOperatorFinding, + serializePublisherHistory, serializeSubjectRecord, } from "../src/console-serialize.js"; import type { StoredOperatorAction } from "../src/operator-actions.js"; @@ -140,6 +142,57 @@ describe("serializeSubjectRecord", () => { }); }); +describe("serializePublisherHistory", () => { + function winner(over: Partial & { val: string }): LabelStreamWinner { + return { + val: over.val, + cid: over.cid ?? null, + cts: "2026-07-12T00:00:00.000Z", + exp: over.exp ?? null, + sequence: over.sequence ?? 1, + neg: over.neg ?? false, + automated: over.automated ?? false, + active: over.active ?? true, + }; + } + + it("bounds the prior-release count and samples the URIs", () => { + const uris = Array.from({ length: 20 }, (_, i) => `at://did:plc:x/col/rk${i}`); + const view = serializePublisherHistory({ + did: "did:plc:x", + priorReleaseUris: uris, + priorReleaseLimit: 20, + priorReleaseSampleSize: 5, + labelWinners: [], + }); + expect(view.did).toBe("did:plc:x"); + expect(view.priorReleaseCount).toBe(20); + expect(view.priorReleaseCapped).toBe(true); + expect(view.priorReleaseSample).toEqual(uris.slice(0, 5)); + }); + + it("keeps only active, non-automated labels and omits a null cid", () => { + const view = serializePublisherHistory({ + did: "did:plc:x", + priorReleaseUris: ["at://did:plc:x/col/rk0"], + priorReleaseLimit: 20, + priorReleaseSampleSize: 5, + labelWinners: [ + winner({ val: "disputed" }), + winner({ val: "security-yanked", cid: "bafyreialpha" }), + winner({ val: "assessment-passed", automated: true }), + winner({ val: "retracted-manual", active: false }), + ], + }); + expect(view.priorReleaseCount).toBe(1); + expect(view.priorReleaseCapped).toBe(false); + expect(view.activeManualLabels).toEqual([ + { val: "disputed" }, + { val: "security-yanked", cid: "bafyreialpha" }, + ]); + }); +}); + describe("serializeOperatorActionView", () => { const stored: StoredOperatorAction = { id: "oact_01", From a15bcb3a148414c1e33fddbf016981ba1d904a23 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 07:25:34 +0100 Subject: [PATCH 074/137] docs(labeler-plan): ratify the slice-3 labeler->aggregator service binding Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- .../plugin-registry-labelling-service/implementation-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 45f439a9a5..c09e6847e6 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1045,7 +1045,7 @@ History may recommend operator review but cannot automatically produce package/p Dependencies: `W2.3`, registry publisher/profile data. -Decisions (2026-07-14): history is not a new contract surface — `FindingSource` already includes `"history"` and the resolver already drops it (`policy-resolver.ts` excludes `source: "history"` before any category→label mapping; `isBlockingFinding` returns false for it), so "never auto-label" is structural, not a new guard. "Recommend review" is passive: a history finding surfaces through the existing assessment-detail projection an operator already opens; no `operational_events` push and no new "flag-for-review" operator action (D3). Two of the five inputs — recent handle/profile changes and verification state — live only in the aggregator's D1, which the labeler has no binding to reach (and `constants.ts` deliberately avoids ingesting profiles); they are deferred to a later slice gated on a ratified read-only labeler→aggregator read path (service-binding preferred over a second D1 binding), not built now (D1). Repeated-checksum detection is global (cross-publisher): a checksum reused under a different DID is the sharper signal, needing a new `idx_assessments_artifact_checksum` and a cross-DID lookup (D2). Prior releases, active manual labels (reuse `getActiveLabelState`, filter `!automated && active`), and repeated checksums/findings come from the labeler's own D1. The finding contract is amended (touches W8.1): `source: "history"` findings cite a dedicated history/review category set exempt from the automated-block ∪ warning constraint that `validateFinding` enforces, rather than borrowing a warn category (D4). Neutral display facts (prior-release count, verification badge) are assembled at console read-time in the existing subject-history endpoint, not persisted as a pipeline `ContextBundle` (D5). The stage is DB-bound (`analyzeHistory(db, assessment, opts) → NormalizedFinding[]`), wired into `OrchestratorStages`/`STAGE_ORDER`/`stubStages` and run last. Because history is operator-only context that never becomes a label, the stage is best-effort — on any error it logs and returns no findings rather than throwing, so it can never fail the run and discard the decision-relevant findings from the other stages (adversary-flagged 2026-07-14). History findings keep sensitive specifics (cross-publisher checksum reuse, active manual-label identities/existence including redactions) in `privateDetail` only, never in the public-facing `title`/`publicSummary`; slice (2)'s surfacing must additionally render history findings in the operator projection only and exclude them from any public serializer. Sliced: (1) own-D1 history findings + the W8.1 amendment + the checksum index — unblocked; (2) console neutral-context surfacing — unblocked, optional; (3) the two aggregator-sourced inputs — blocked on D1's read path. +Decisions (2026-07-14): history is not a new contract surface — `FindingSource` already includes `"history"` and the resolver already drops it (`policy-resolver.ts` excludes `source: "history"` before any category→label mapping; `isBlockingFinding` returns false for it), so "never auto-label" is structural, not a new guard. "Recommend review" is passive: a history finding surfaces through the existing assessment-detail projection an operator already opens; no `operational_events` push and no new "flag-for-review" operator action (D3). Two of the five inputs — recent handle/profile changes and verification state — live only in the aggregator's D1, which the labeler has no binding to reach (and `constants.ts` deliberately avoids ingesting profiles); they are deferred to a later slice gated on a ratified read-only labeler→aggregator read path (service-binding preferred over a second D1 binding), not built now (D1). Ratified (2026-07-16): slice 3 uses a read-only SERVICE BINDING to the aggregator — the aggregator exposes the publisher/profile/verification reads as an internal API and the labeler binds to it; no second D1 binding (coupling to the aggregator's physical schema was rejected). Repeated-checksum detection is global (cross-publisher): a checksum reused under a different DID is the sharper signal, needing a new `idx_assessments_artifact_checksum` and a cross-DID lookup (D2). Prior releases, active manual labels (reuse `getActiveLabelState`, filter `!automated && active`), and repeated checksums/findings come from the labeler's own D1. The finding contract is amended (touches W8.1): `source: "history"` findings cite a dedicated history/review category set exempt from the automated-block ∪ warning constraint that `validateFinding` enforces, rather than borrowing a warn category (D4). Neutral display facts (prior-release count, verification badge) are assembled at console read-time in the existing subject-history endpoint, not persisted as a pipeline `ContextBundle` (D5). The stage is DB-bound (`analyzeHistory(db, assessment, opts) → NormalizedFinding[]`), wired into `OrchestratorStages`/`STAGE_ORDER`/`stubStages` and run last. Because history is operator-only context that never becomes a label, the stage is best-effort — on any error it logs and returns no findings rather than throwing, so it can never fail the run and discard the decision-relevant findings from the other stages (adversary-flagged 2026-07-14). History findings keep sensitive specifics (cross-publisher checksum reuse, active manual-label identities/existence including redactions) in `privateDetail` only, never in the public-facing `title`/`publicSummary`; slice (2)'s surfacing must additionally render history findings in the operator projection only and exclude them from any public serializer. Sliced: (1) own-D1 history findings + the W8.1 amendment + the checksum index — unblocked; (2) console neutral-context surfacing — unblocked, optional; (3) the two aggregator-sourced inputs — blocked on D1's read path. ### `W8.5` Implement versioned policy resolver From 22e052307a6de8e8cd4b42033beee43470ef96ea Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 07:28:43 +0100 Subject: [PATCH 075/137] docs(labeler-plan): ratify W0.6 declared-URL-first acquisition Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- .../plugin-registry-labelling-service/implementation-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index c09e6847e6..b09cff6913 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -321,6 +321,8 @@ Output: artifact acquisition contract consumed by `W7`. Dependencies: none. +Decision (2026-07-16): v1 acquisition is DECLARED-URL-FIRST — the aggregator mirror is unbuilt (`records-consumer.ts` TODO; `mirrored_artifacts` never populated), and the release record's checksum/CID pin makes a declared-URL fetch exactly as verifiable as a mirror fetch, so the mirror is an availability/resilience layer, not an integrity requirement. The labeler fetches from the publisher's declared artifact URL under the ratified SSRF controls (HTTPS, DNS/redirect hardening, byte/time budgets, no ambient credentials) with checksum/CID verification; dead or changed URLs classify as the existing transient/permanent acquisition failures. W7.2's acquisition module is written mirror-ready with a source-preference switch: the future mirror interface is a deterministic R2 object key derived from release coordinates, resolved through the aggregator service binding (same read-path pattern ratified for W8.4 slice 3), and mirror-first ordering activates when the aggregator mirror lands on its own timetable (delegated release-service branch) without changing the labeler contract. W7 is no longer gated on aggregator mirror work. + ### `W0.7` Confirm AI model and capability analysis Evaluate representative fixtures through: From 41102f4744c56c45e86ecdc2b55ef1385019f532 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 07:39:00 +0100 Subject: [PATCH 076/137] docs(labeler-plan): ratify the three-package graduation and Workflow-shell orchestration Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- .../plugin-registry-labelling-service/implementation-plan.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index b09cff6913..ad681af607 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -873,6 +873,8 @@ Workflow stages: Each stage stores bounded serializable output and has explicit retry/permanent-failure classification. +Decision (2026-07-16, resolves the #1978 queue-vs-Workflow question): production wiring executes each assessment run as a Cloudflare WORKFLOW instance whose id derives from the subject (URI+CID) — a thin shell over the tested `AssessmentOrchestrator`: steps call the same stage adapters and atomic finalization, gaining per-stage durable resume, and the instance-per-subject serialization IS the §14.1 per-subject lock, closing both races #1978 deferred by construction. The queue consumer's role narrows to verified discovery + Workflow dispatch. The shell stays thin enough that workerd integration tests plus the existing orchestrator suite carry coverage (Workflows' local-test story is the accepted weak point). + Dependencies: `W2.4` stage interfaces, `W6.3`. `W7` and `W8` later supply production adapters without changing orchestration. ### `W6.5` Implement supersession and current projection @@ -928,7 +930,7 @@ Coordinate this work with the delegated release service plan's proposed `package ### `W7.1` Establish shared verification ownership -Decision (2026-07-11): the delegated release service work owns `packages/registry-verification`, already built on its integration branch (checksum, safe fetch, canonical bundle validation, Sigstore provenance, workerd tests) and covering the required shared surface. `W7.2`/`W7.3` extend that package after it reaches main with the labeler's additions: mirror-first acquisition, the SSRF-hardened declared-URL fallback, and mirror-miss/transient/permanent-mismatch classification. If sequencing inverts and W7 starts first, graduate the package from that branch to main in a standalone PR; do not cherry-pick across integration branches or build a competing implementation. +Decision (2026-07-11): the delegated release service work owns `packages/registry-verification`, already built on its integration branch (checksum, safe fetch, canonical bundle validation, Sigstore provenance, workerd tests) and covering the required shared surface. `W7.2`/`W7.3` extend that package after it reaches main with the labeler's additions: mirror-first acquisition, the SSRF-hardened declared-URL fallback, and mirror-miss/transient/permanent-mismatch classification. If sequencing inverts and W7 starts first, graduate the package from that branch to main in a standalone PR; do not cherry-pick across integration branches or build a competing implementation. Ratified (2026-07-16, sequencing has inverted): the graduation unit is a coordinated THREE-package promotion — registry-verification hard-depends on the delegated branch's additive, main-absent slices of `@emdash-cms/plugin-types` (manifest-schema, declared-access) and `@emdash-cms/registry-lexicons` (profileExtension) — lifted verbatim in one main PR with minor changesets for the two published packages; content-identical code reconciles into the delegated branch on its next main sync. Dependencies: `W0.6`, `packages/registry-verification` on main. From 86a7055bf6bc0b36490076fa0c789b84be97c1c3 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 07:45:33 +0100 Subject: [PATCH 077/137] docs(labeler-plan): ratify the sync.getRecord enforcement exclusion Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- .../plugin-registry-labelling-service/implementation-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index ad681af607..c2ba029859 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -857,6 +857,8 @@ For each job: Dependencies: `W3.3`, existing aggregator/PDS verification helpers, `W2.4`. +Decision (2026-07-16): the aggregator's `sync.getRecord` passthrough is RATIFIED as excluded from label enforcement. It serves the publisher's own signed record CAR — metadata, not artifact bytes — which exists identically on the publisher's PDS and the relay firehose, so filtering our cache removes nothing from the network while breaking the sync layer's verifiable-passthrough contract and its public cacheability. Label enforcement lives in the application views, install eligibility, and — bindingly — the future artifact mirror: when the aggregator mirror lands, a `!takedown` MUST stop mirror serving of the artifact bytes (that is where our infrastructure would distribute the actual content). Unlawful content is handled by actual out-of-band removal per the CSAM decision, and a deleted row 404s from `sync.getRecord` automatically — deletion, not label redaction, is the mechanism that binds this route. + ### `W6.4` Implement Workflow stage orchestration Workflow stages: From 0a2eba12ef0c38dc29c1f3e90aba808bf16d2461 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 07:56:04 +0100 Subject: [PATCH 078/137] docs(labeler-plan): ratify W10.4 contact-resolution decisions Claude-Session: https://claude.ai/code/session_014rikrGMh25h1rJczUQyTMM --- .../plugin-registry-labelling-service/implementation-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index c2ba029859..6024113e4f 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1236,6 +1236,8 @@ Do not access private PDS account email. Store delivery addresses only as long a Dependencies: registry profile/package reads. +Decisions (2026-07-16): all three contact sources already exist as lexicon fields (package `security[]` — required; package `authors[]` — contact optional; publisher profile `contact[]` with security/general kinds) — no lexicon changes. Resolution walks the tiers for the first entry carrying a non-empty EMAIL (url-only contacts are skipped, not "resolved"), preferring `kind: security` within tier 3, with a fresh read at notification time via the W8.4 slice-3 aggregator service binding (no caching; no self-fetch/PDS path). Anti-abuse is DOUBLE OPT-IN: unverified publishers receive one content-neutral confirmation mail and substantive notices only after confirming (the public assessment API and the monitored reconsideration inbox remain the channel for unconfirmed publishers); verified publishers skip confirmation; universal suppression honors unsubscribe/"not me"/hard-failure, with per-address and per-DID rate limits on confirmation sends — a third-party victim named in hostile metadata receives at most one neutral mail, ever. No resolvable contact is an expected non-error terminal state (delivery marked undeliverable, kept for audit), with an operator `operational_event` added only for emergency-takedown notices that cannot be delivered. Addresses are keyed as HMAC-SHA256 over the lowercased/trimmed email with a secret pepper binding; plaintext lives only on the delivery row, cleared on successful send, undelivered rows swept after 30 days (versioned constant); audit/dedup query hashes only. The publisher-facing delivery table keeps the spec's `notifications` naming — the existing `notification_outbox` is the OPERATOR alert subsystem and stays untouched; W10.4 owns `notification_contacts` (confirm state, token hash, rate-limit timestamps) and `notification_suppressions`, plus the confirm/unsubscribe/not-me token endpoints. Noted for W10.5: spec §14.4 keys `notifications.action_id` to `operator_actions`, but automated blocks live in `issuance_actions` — the notification trigger needs a source covering automated issuance. + ### `W10.5` Implement notification outbox and email adapter Delivery uses Cloudflare Email Sending through the Workers `send_email` binding; the sending domain is onboarded on the emdashcms.com zone (`wrangler email sending enable`). No provider API keys. Messages include both `html` and `text` bodies. From fe89dad0591372217a9b26dd8642d54f3a9ab343 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Thu, 16 Jul 2026 07:24:17 +0000 Subject: [PATCH 079/137] style: format --- apps/labeler/README.md | 14 ++--- .../src/components/AutomationControl.test.tsx | 8 +-- apps/labeler/docs/atproto.md | 61 +++++++++---------- apps/labeler/docs/operating.md | 38 ++++++------ .../test/console-assessment-mutations.test.ts | 4 +- 5 files changed, 58 insertions(+), 67 deletions(-) diff --git a/apps/labeler/README.md b/apps/labeler/README.md index 333d30862a..1b08d4eac3 100644 --- a/apps/labeler/README.md +++ b/apps/labeler/README.md @@ -65,14 +65,14 @@ The Worker checks that its signing keypair matches the public key published in i ## Configuration reference -| Key | Kind | Description | -| --------------------------- | ------ | --------------------------------------------------------------------------------- | -| `LABELER_DID` | var | The labeler's DID, `did:web:labels.emdashcms.com`. | -| `LABELER_SERVICE_URL` | var | Service endpoint published in the DID document, `https://labels.emdashcms.com`. | +| Key | Kind | Description | +| --------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------- | +| `LABELER_DID` | var | The labeler's DID, `did:web:labels.emdashcms.com`. | +| `LABELER_SERVICE_URL` | var | Service endpoint published in the DID document, `https://labels.emdashcms.com`. | | `LABEL_SIGNING_PUBLIC_KEY` | var | P-256 public key as a Multikey; published in the DID document's `#atproto_label` method for signature verification. | -| `LABEL_SIGNING_PRIVATE_KEY` | secret | P-256 private key (unpadded base64url of the raw 32-byte scalar) used to sign labels server-side. | -| `LABEL_SIGNING_KEY_VERSION` | var | Signing key version (`v1`); bump when rotating. | -| `OPERATOR_ACCESS_CONFIG` | var | JSON: `teamDomain`, `audience` (Access AUD tag), and `admins` / `reviewers` group names. | +| `LABEL_SIGNING_PRIVATE_KEY` | secret | P-256 private key (unpadded base64url of the raw 32-byte scalar) used to sign labels server-side. | +| `LABEL_SIGNING_KEY_VERSION` | var | Signing key version (`v1`); bump when rotating. | +| `OPERATOR_ACCESS_CONFIG` | var | JSON: `teamDomain`, `audience` (Access AUD tag), and `admins` / `reviewers` group names. | The private key is declared as a required secret in `wrangler.jsonc`, so `wrangler deploy` refuses to publish if it has not been set on the target Worker. diff --git a/apps/labeler/console/src/components/AutomationControl.test.tsx b/apps/labeler/console/src/components/AutomationControl.test.tsx index dd50547dcc..6517e1de92 100644 --- a/apps/labeler/console/src/components/AutomationControl.test.tsx +++ b/apps/labeler/console/src/components/AutomationControl.test.tsx @@ -58,9 +58,7 @@ describe("AutomationControl", () => { fireEvent.click(submit); await waitFor(() => { - expect(pauseAutomation).toHaveBeenCalledWith( - expect.objectContaining({ reason: "incident" }), - ); + expect(pauseAutomation).toHaveBeenCalledWith(expect.objectContaining({ reason: "incident" })); }); expect(resumeAutomation).not.toHaveBeenCalled(); }); @@ -83,9 +81,7 @@ describe("AutomationControl", () => { fireEvent.click(screen.getByRole("button", { name: "Resume" })); await waitFor(() => { - expect(resumeAutomation).toHaveBeenCalledWith( - expect.objectContaining({ reason: "cleared" }), - ); + expect(resumeAutomation).toHaveBeenCalledWith(expect.objectContaining({ reason: "cleared" })); }); expect(pauseAutomation).not.toHaveBeenCalled(); }); diff --git a/apps/labeler/docs/atproto.md b/apps/labeler/docs/atproto.md index 7d80d35d58..6852add444 100644 --- a/apps/labeler/docs/atproto.md +++ b/apps/labeler/docs/atproto.md @@ -12,7 +12,7 @@ ATProto identifies every account and service by a **DID** (Decentralized Identif The DID string encodes a **method** that says how to resolve it. Two methods are relevant to a labeler: - **`did:plc`** — the common ATProto method. The identifier is an opaque string (`did:plc:abc123…`) hosted by the PLC directory, a separate service that stores and serves the DID document. Rotating keys or moving hosts means updating the record in the directory. -- **`did:web`** — the identifier *is* a domain. `did:web:labels.emdashcms.com` resolves by fetching `https://labels.emdashcms.com/.well-known/did.json`. No external directory is involved; whoever controls the domain controls the document. +- **`did:web`** — the identifier _is_ a domain. `did:web:labels.emdashcms.com` resolves by fetching `https://labels.emdashcms.com/.well-known/did.json`. No external directory is involved; whoever controls the domain controls the document. ### Why this labeler uses `did:web` @@ -26,26 +26,23 @@ The document (built by `serviceDidDocument` in `src/identity.ts`) has this shape ```json { - "@context": [ - "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/multikey/v1" - ], - "id": "did:web:labels.emdashcms.com", - "verificationMethod": [ - { - "id": "did:web:labels.emdashcms.com#atproto_label", - "type": "Multikey", - "controller": "did:web:labels.emdashcms.com", - "publicKeyMultibase": "zDnae..." - } - ], - "service": [ - { - "id": "did:web:labels.emdashcms.com#atproto_labeler", - "type": "AtprotoLabeler", - "serviceEndpoint": "https://labels.emdashcms.com" - } - ] + "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1"], + "id": "did:web:labels.emdashcms.com", + "verificationMethod": [ + { + "id": "did:web:labels.emdashcms.com#atproto_label", + "type": "Multikey", + "controller": "did:web:labels.emdashcms.com", + "publicKeyMultibase": "zDnae..." + } + ], + "service": [ + { + "id": "did:web:labels.emdashcms.com#atproto_labeler", + "type": "AtprotoLabeler", + "serviceEndpoint": "https://labels.emdashcms.com" + } + ] } ``` @@ -62,7 +59,7 @@ This service labels three kinds of subject: - **Publisher** — a DID. The identity that publishes packages. - **Package** — a record URI (the package profile record). -- **Release** — a specific release, identified by its record URI *and* CID, so a label applies to one exact version's bytes. +- **Release** — a specific release, identified by its record URI _and_ CID, so a label applies to one exact version's bytes. The **`cidRule`** on each label encodes which of these it can target: @@ -87,19 +84,19 @@ Key rotation is tracked by `LABEL_SIGNING_KEY_VERSION` (currently `v1`). Generat ATProto's HTTP-RPC convention is **XRPC**: each method is a namespaced identifier (an NSID like `com.atproto.label.queryLabels`) called at `/xrpc/`. The labeler exposes: -| Path | Purpose | -| ----------------------------------------------- | ------------------------------------------------------------------------------------------- | -| `/xrpc/com.atproto.label.queryLabels` | Query the current labels for a set of subjects (a point-in-time read). | -| `/xrpc/com.atproto.label.subscribeLabels` | The streaming label firehose. A WebSocket subscription consumers follow to receive labels as they are issued; accepts a numeric `cursor` to resume. | -| `/xrpc/com.atproto.moderation.createReport` | **Rejected.** This labeler does not accept user moderation reports; the endpoint returns a `NotSupported` error. | -| `/xrpc/com.emdashcms.experimental.labeler.*` | The experimental assessment API (`getAssessment`, `getCurrentAssessment`, `listAssessments`, `getPolicy`). | +| Path | Purpose | +| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/xrpc/com.atproto.label.queryLabels` | Query the current labels for a set of subjects (a point-in-time read). | +| `/xrpc/com.atproto.label.subscribeLabels` | The streaming label firehose. A WebSocket subscription consumers follow to receive labels as they are issued; accepts a numeric `cursor` to resume. | +| `/xrpc/com.atproto.moderation.createReport` | **Rejected.** This labeler does not accept user moderation reports; the endpoint returns a `NotSupported` error. | +| `/xrpc/com.emdashcms.experimental.labeler.*` | The experimental assessment API (`getAssessment`, `getCurrentAssessment`, `listAssessments`, `getPolicy`). | Alongside the XRPC methods, two documents are served under `/.well-known`: -| Path | Purpose | -| --------------------------------------------- | --------------------------------------------------------------- | -| `/.well-known/did.json` | The DID document (above). | -| `/.well-known/emdash-labeler-policy.json` | The moderation policy document (label vocabulary and rules), cached for 300s. | +| Path | Purpose | +| ----------------------------------------- | ----------------------------------------------------------------------------- | +| `/.well-known/did.json` | The DID document (above). | +| `/.well-known/emdash-labeler-policy.json` | The moderation policy document (label vocabulary and rules), cached for 300s. | ## Jetstream and ingestion diff --git a/apps/labeler/docs/operating.md b/apps/labeler/docs/operating.md index b65839314b..86913cb964 100644 --- a/apps/labeler/docs/operating.md +++ b/apps/labeler/docs/operating.md @@ -14,16 +14,16 @@ Your role comes from the Access group membership in the verified JWT, matched ag There are two roles, **reviewer** and **admin**. An admin inherits every reviewer capability; there is no path the other way. -| Capability | Reviewer | Admin | -| ----------------------------------------------------------------- | :------: | :---: | -| Read assessments, findings, labels, subject history, audit log, system status, dead-letter list, effect previews | ✓ | ✓ | -| Issue and retract a reviewer label | ✓ | ✓ | -| Re-run an assessment | ✓ | ✓ | -| False-positive override and override-retract | ✓ | ✓ | -| Emergency takedown / takedown-retract | | ✓ | -| Publisher-compromised / retract | | ✓ | -| Automation pause / resume | | ✓ | -| Dead-letter retry / quarantine | | ✓ | +| Capability | Reviewer | Admin | +| ---------------------------------------------------------------------------------------------------------------- | :------: | :---: | +| Read assessments, findings, labels, subject history, audit log, system status, dead-letter list, effect previews | ✓ | ✓ | +| Issue and retract a reviewer label | ✓ | ✓ | +| Re-run an assessment | ✓ | ✓ | +| False-positive override and override-retract | ✓ | ✓ | +| Emergency takedown / takedown-retract | | ✓ | +| Publisher-compromised / retract | | ✓ | +| Automation pause / resume | | ✓ | +| Dead-letter retry / quarantine | | ✓ | A reviewer who calls an admin-only endpoint gets a `403`. @@ -39,9 +39,9 @@ Open an assessment from the list to see its findings, the labels currently live ## False-positive override -When an automated block is wrong, **override** (`POST /admin/api/assessments/:id/override`) clears it. In one atomic action it negates *all* live automated blocks on the exact URI + CID and issues the reviewer pair `assessment-passed` + `assessment-overridden`. The negate-set you submit must equal the live automated-block set exactly; if automation has issued a block you did not include, the action is rejected rather than partially applied. Like a re-run, it requires CID confirmation. +When an automated block is wrong, **override** (`POST /admin/api/assessments/:id/override`) clears it. In one atomic action it negates _all_ live automated blocks on the exact URI + CID and issues the reviewer pair `assessment-passed` + `assessment-overridden`. The negate-set you submit must equal the live automated-block set exactly; if automation has issued a block you did not include, the action is rejected rather than partially applied. Like a re-run, it requires CID confirmation. -An override is permanent in effect: the pass pair suppresses the negated blocks *and* future automated blocks for that release, so re-assessment cannot silently re-block it. This is the §10 rule in action — automation may not overturn a human decision. +An override is permanent in effect: the pass pair suppresses the negated blocks _and_ future automated blocks for that release, so re-assessment cannot silently re-block it. This is the §10 rule in action — automation may not overturn a human decision. **Override-retract** (`POST /admin/api/assessments/:id/override-retract`) pulls the override pair back. It does **not** restore the original blocks — those stay negated — so the release resolves to a "safe-blocked" state: blocked, but for a missing assessment pass rather than a live finding. To re-surface the real findings, retract the override and then re-run. @@ -52,12 +52,12 @@ Emergency actions carry a two-field ceremony to prevent misfires. Every one requ - The subject identifier is the record `rkey` for a release or package subject, or the DID's final `:`-segment for a publisher. - The intent phrase is fixed per action. -| Action | Endpoint | Intent phrase | -| ---------------------------------------------------------- | ----------------------------------------------- | ------------------- | -| Takedown (`!takedown` redaction on release/package/publisher) | `POST /admin/api/emergency/takedown` | `CONFIRM TAKEDOWN` | -| Takedown retract | `POST /admin/api/emergency/takedown-retract` | `CONFIRM RETRACT` | -| Publisher-compromised | `POST /admin/api/emergency/publisher-compromised` | `CONFIRM COMPROMISE` | -| Publisher-compromised retract | `POST /admin/api/emergency/publisher-compromised-retract` | `CONFIRM RETRACT` | +| Action | Endpoint | Intent phrase | +| ------------------------------------------------------------- | --------------------------------------------------------- | -------------------- | +| Takedown (`!takedown` redaction on release/package/publisher) | `POST /admin/api/emergency/takedown` | `CONFIRM TAKEDOWN` | +| Takedown retract | `POST /admin/api/emergency/takedown-retract` | `CONFIRM RETRACT` | +| Publisher-compromised | `POST /admin/api/emergency/publisher-compromised` | `CONFIRM COMPROMISE` | +| Publisher-compromised retract | `POST /admin/api/emergency/publisher-compromised-retract` | `CONFIRM RETRACT` | A takedown of a publisher or package is a single URI-wide (or DID-wide) label the evaluator honors for everything beneath it — it is not fanned out into per-release labels. Retracting a takedown restores the state that was computed before it: the automated blocks that were live re-expose, because they were never negated and nothing is re-issued. A retract with no active label to pull returns `404 NO_ACTIVE_LABEL`. @@ -78,7 +78,7 @@ Both reject an already-resolved letter with `409`, so two operators acting on th Every console mutation writes an append-only audit row recording who acted (the Access `sub`), the action, the reason, and an idempotency key. The table is immutable — rows are never updated or deleted. -Idempotency: replaying a request with the same key and an identical body returns the stored result (a safe retry). The same key with a *different* body is a `409` conflict, so a key cannot be reused to smuggle a different action through. +Idempotency: replaying a request with the same key and an identical body returns the stored result (a safe retry). The same key with a _different_ body is a `409` conflict, so a key cannot be reused to smuggle a different action through. The audit view you see in the console omits internal columns — idempotency key, request fingerprint, raw result, metadata, and epoch timestamps — and shows the human-facing record. diff --git a/apps/labeler/test/console-assessment-mutations.test.ts b/apps/labeler/test/console-assessment-mutations.test.ts index 7614ee80d1..f4fb21cb30 100644 --- a/apps/labeler/test/console-assessment-mutations.test.ts +++ b/apps/labeler/test/console-assessment-mutations.test.ts @@ -815,9 +815,7 @@ describe("subject-label read + override effect preview", () => { getReq(`/admin/api/subjects/${encodeURIComponent(uri)}/labels?cid=${CID}`), readDeps(), ); - const labels = await bodyData<{ val: string; active: boolean; automated: boolean }[]>( - response, - ); + const labels = await bodyData<{ val: string; active: boolean; automated: boolean }[]>(response); const byVal = new Map(labels.map((label) => [label.val, label])); expect(byVal.get("malware")).toMatchObject({ active: true, automated: true }); expect(byVal.get("impersonation")).toMatchObject({ active: true, automated: false }); From 467b4d4c9a917ae952066c6784aef8bfa376ae60 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 09:01:52 +0100 Subject: [PATCH 080/137] feat(labeler): notification contact-state foundation (W10.4 slice B) (#2068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(labeler): notification contact-state foundation (W10.4 slice B) Add the opt-in and suppression state for publisher notifications: `notification_contacts` (confirm state machine with single-use, constant-time-compared token hashes and a confirmation rate-limit timestamp) and `notification_suppressions` (bounce/complaint/ unsubscribe/not-me, earliest reason retained), plus the peppered HMAC-SHA256 recipient-hash helper and a typed query layer. Both tables key on the recipient hash only — no plaintext address column exists; plaintext will live solely on the future W10.5 delivery row. Distinct from the operator `notification_outbox` alert subsystem. * fix(labeler): harden notification contact state transitions - recordConfirmSent/confirmContact gate the UPDATE on confirm_state = 'unconfirmed', so a confirmed or declined row is never reopened and a stale token can never flip it; recordConfirmSent returns whether a row was touched. - suppress takes an explicit finite epochMs instead of re-parsing the ISO string. - recipientHash caches the imported HMAC key per pepper on globalThis. * fix(labeler): complete notification contact state guards - declineContact gates on confirm_state = 'unconfirmed' and returns a boolean, so an unsubscribe-path bug can't revoke a confirmed opt-in. - recordConfirmSent rejects a non-finite epochMs (mirrors suppress) to keep NaN out of the rate-gate comparison. - confirmContact always runs the constant-time token compare, against a fixed-length dummy when the row or token is absent, so elapsed time never reveals recipient existence or a pending token. * test(labeler): validate the PRAGMA table identifier before interpolation * test(labeler): allowlist the PRAGMA table identifier --- .../migrations/0007_notification_contacts.sql | 38 ++ apps/labeler/src/notification-contacts.ts | 295 +++++++++++++++ .../test/notification-contacts.test.ts | 347 ++++++++++++++++++ apps/labeler/vitest.config.ts | 1 + apps/labeler/worker-configuration.d.ts | 3 +- apps/labeler/wrangler.jsonc | 11 +- 6 files changed, 689 insertions(+), 6 deletions(-) create mode 100644 apps/labeler/migrations/0007_notification_contacts.sql create mode 100644 apps/labeler/src/notification-contacts.ts create mode 100644 apps/labeler/test/notification-contacts.test.ts diff --git a/apps/labeler/migrations/0007_notification_contacts.sql b/apps/labeler/migrations/0007_notification_contacts.sql new file mode 100644 index 0000000000..b550c7a17c --- /dev/null +++ b/apps/labeler/migrations/0007_notification_contacts.sql @@ -0,0 +1,38 @@ +-- Publisher-notification contact state (spec §18/§19, plan W10.4): the +-- double-opt-in ledger for the publisher-facing `notifications` delivery +-- subsystem. Distinct from `notification_outbox` (0005), which is the OPERATOR +-- alert subsystem draining `operational_events` — these two share a name prefix +-- only. Nothing here references an operator table. +-- +-- * `notification_contacts` — one row per recipient, keyed by the +-- HMAC-SHA256 `recipient_hash` (see src/notification-contacts.ts). Holds the +-- confirmation state machine (unconfirmed -> confirmed | declined), the +-- hash of the outstanding confirm token, and the last-confirm-send timestamp +-- the per-address rate gate reads. Mutable, unlike the audit logs: confirm +-- state flips and the token is set then cleared, so it carries no +-- immutability trigger. Plaintext email never lands here — only the +-- HMAC digest — so audit/dedup queries touch hashes only. +-- * `notification_suppressions` — universal do-not-contact set, keyed by the +-- same `recipient_hash`. A row here suppresses all sends (bounce, complaint, +-- unsubscribe, or "not me"). Idempotent inserts keep the earliest reason. +-- +-- `last_confirm_sent_at_epoch_ms` is stored as an integer epoch because the rate +-- gate compares it numerically (RFC 3339 strings compare incorrectly across +-- timezone offsets in SQL), matching the `*_epoch_ms` convention in 0003-0005. +-- `first_seen_at`/`confirmed_at` are audit-only RFC 3339 strings never ordered on. + +CREATE TABLE notification_contacts ( + recipient_hash TEXT PRIMARY KEY, + confirm_state TEXT NOT NULL CHECK (confirm_state IN ('unconfirmed', 'confirmed', 'declined')), + confirm_token_hash TEXT, + first_seen_at TEXT NOT NULL, + confirmed_at TEXT, + last_confirm_sent_at_epoch_ms INTEGER +); + +CREATE TABLE notification_suppressions ( + recipient_hash TEXT PRIMARY KEY, + reason TEXT NOT NULL CHECK (reason IN ('bounce', 'complaint', 'unsubscribe', 'not_me')), + created_at TEXT NOT NULL, + created_at_epoch_ms INTEGER NOT NULL +); diff --git a/apps/labeler/src/notification-contacts.ts b/apps/labeler/src/notification-contacts.ts new file mode 100644 index 0000000000..8817bb13a2 --- /dev/null +++ b/apps/labeler/src/notification-contacts.ts @@ -0,0 +1,295 @@ +/** + * Contact-state foundation for the publisher-notification subsystem (spec + * §18/§19, plan W10.4). Addresses are keyed exclusively by an HMAC-SHA256 + * `recipient_hash`; plaintext email never enters these tables or this module's + * persisted output. The delivery row that briefly holds plaintext lives in a + * later slice (W10.5) and is out of scope here. + * + * Backs two tables from migration 0007: `notification_contacts` (the + * double-opt-in state machine) and `notification_suppressions` (the universal + * do-not-contact set). Distinct from the operator `notification_outbox`. + */ + +const encoder = new TextEncoder(); + +export type ConfirmState = "unconfirmed" | "confirmed" | "declined"; + +export type SuppressionReason = "bounce" | "complaint" | "unsubscribe" | "not_me"; + +export interface ContactState { + recipientHash: string; + confirmState: ConfirmState; + confirmTokenHash: string | null; + firstSeenAt: string; + confirmedAt: string | null; + lastConfirmSentAtEpochMs: number | null; +} + +interface ContactRow { + recipient_hash: string; + confirm_state: ConfirmState; + confirm_token_hash: string | null; + first_seen_at: string; + confirmed_at: string | null; + last_confirm_sent_at_epoch_ms: number | null; +} + +/** + * The peppered address key. Mirrors {@link import("./signing-runtime.js")}'s + * `LabelSigningSecret`: production supplies a Cloudflare secret binding, tests a + * plain string. + */ +export interface NotificationHashPepper { + get(): Promise; +} + +export function getNotificationHashPepper(env: object): NotificationHashPepper { + const binding: unknown = Reflect.get(env, "NOTIFICATION_HASH_PEPPER"); + if (typeof binding === "string") return { get: async () => binding }; + if (isNotificationHashPepper(binding)) return binding; + throw new TypeError("NOTIFICATION_HASH_PEPPER is not configured"); +} + +function isNotificationHashPepper(value: unknown): value is NotificationHashPepper { + return ( + typeof value === "object" && value !== null && "get" in value && typeof value.get === "function" + ); +} + +/** + * HMAC-SHA256 of the normalized address under `pepper`, as lowercase hex (64 + * chars). Normalization is lowercase + trim only — no provider-specific rules + * (e.g. Gmail dot/plus folding) per the ratified W10.4 decision. WebCrypto only, + * so it runs unchanged on workerd. + */ +export async function recipientHash(pepper: string, email: string): Promise { + const key = await getHmacKey(pepper); + const signature = await crypto.subtle.sign( + "HMAC", + key, + encoder.encode(email.trim().toLowerCase()), + ); + return toHex(signature); +} + +/** + * Cache the imported HMAC key per pepper on `globalThis` (Vite can duplicate a + * module across chunks; a plain module-scope `Map` would become two caches — + * see the xrpc-router precedent). The promise is cached so concurrent first + * callers share one `importKey`. + */ +const HMAC_KEY_CACHE_KEY = Symbol.for("emdash:labeler-notification-hmac-keys"); +const hmacKeyGlobal = globalThis as Record; +function getHmacKey(pepper: string): Promise { + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern (see core request-cache.ts) + let cache = hmacKeyGlobal[HMAC_KEY_CACHE_KEY] as Map> | undefined; + if (!cache) { + cache = new Map(); + hmacKeyGlobal[HMAC_KEY_CACHE_KEY] = cache; + } + let key = cache.get(pepper); + if (!key) { + key = crypto.subtle.importKey( + "raw", + encoder.encode(pepper), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + cache.set(pepper, key); + } + return key; +} + +export async function getContactState( + db: D1Database, + recipientHashValue: string, +): Promise { + const row = await db + .prepare( + `SELECT recipient_hash, confirm_state, confirm_token_hash, + first_seen_at, confirmed_at, last_confirm_sent_at_epoch_ms + FROM notification_contacts + WHERE recipient_hash = ?`, + ) + .bind(recipientHashValue) + .first(); + return row ? mapContact(row) : null; +} + +/** Insert-if-absent as `unconfirmed`; a no-op once the contact exists. */ +export async function ensureContact( + db: D1Database, + recipientHashValue: string, + nowIso: string, +): Promise { + await db + .prepare( + `INSERT INTO notification_contacts (recipient_hash, confirm_state, first_seen_at) + VALUES (?, 'unconfirmed', ?) + ON CONFLICT(recipient_hash) DO NOTHING`, + ) + .bind(recipientHashValue, nowIso) + .run(); +} + +/** + * Record that a confirmation mail was sent: stores the token hash the eventual + * confirm must match and stamps the send time the rate gate reads. Only touches + * an `unconfirmed` contact — a confirmed or declined row is never reopened even + * if a caller skips {@link canSendConfirm}. Returns whether a row was updated. + */ +export async function recordConfirmSent( + db: D1Database, + recipientHashValue: string, + tokenHash: string, + epochMs: number, +): Promise { + if (!Number.isFinite(epochMs)) { + throw new TypeError("recordConfirmSent requires a finite epochMs"); + } + const result = await db + .prepare( + `UPDATE notification_contacts + SET confirm_token_hash = ?, last_confirm_sent_at_epoch_ms = ? + WHERE recipient_hash = ? AND confirm_state = 'unconfirmed'`, + ) + .bind(tokenHash, epochMs, recipientHashValue) + .run(); + return result.meta.changes > 0; +} + +/** + * Flip to `confirmed` iff `tokenHash` matches the stored token hash, clearing + * the token so it cannot be replayed (single-use). Returns whether the flip + * happened. The provided token is compared in constant time against the stored + * hash — or a fixed-length dummy when the row is missing or has no token — so + * elapsed time never reveals whether the recipient or a pending token exists. + * The conditional UPDATE re-checks the stored hash and `unconfirmed` state to + * close the read-then-write race, so a stale token can never flip a + * declined/confirmed row. + */ +export async function confirmContact( + db: D1Database, + recipientHashValue: string, + tokenHash: string, + nowIso: string, +): Promise { + const row = await db + .prepare(`SELECT confirm_token_hash FROM notification_contacts WHERE recipient_hash = ?`) + .bind(recipientHashValue) + .first<{ confirm_token_hash: string | null }>(); + const stored = row?.confirm_token_hash ?? ABSENT_TOKEN_HASH; + const matches = constantTimeEqual(stored, tokenHash); + if (!row || row.confirm_token_hash === null || !matches) return false; + const result = await db + .prepare( + `UPDATE notification_contacts + SET confirm_state = 'confirmed', confirm_token_hash = NULL, confirmed_at = ? + WHERE recipient_hash = ? AND confirm_token_hash = ? AND confirm_state = 'unconfirmed'`, + ) + .bind(nowIso, recipientHashValue, row.confirm_token_hash) + .run(); + return result.meta.changes > 0; +} + +/** + * Decline the pending confirmation, clearing any outstanding token. Only touches + * an `unconfirmed` contact — a confirmed opt-in is never revoked here (that goes + * through {@link suppress}), so an unsubscribe-path bug can't downgrade it. + * Returns whether a row was updated. + */ +export async function declineContact(db: D1Database, recipientHashValue: string): Promise { + const result = await db + .prepare( + `UPDATE notification_contacts + SET confirm_state = 'declined', confirm_token_hash = NULL + WHERE recipient_hash = ? AND confirm_state = 'unconfirmed'`, + ) + .bind(recipientHashValue) + .run(); + return result.meta.changes > 0; +} + +export async function isSuppressed(db: D1Database, recipientHashValue: string): Promise { + const row = await db + .prepare(`SELECT 1 FROM notification_suppressions WHERE recipient_hash = ? LIMIT 1`) + .bind(recipientHashValue) + .first<{ 1: number }>(); + return row !== null; +} + +/** Idempotent: the first reason recorded for an address wins. */ +export async function suppress( + db: D1Database, + recipientHashValue: string, + reason: SuppressionReason, + nowIso: string, + epochMs: number, +): Promise { + if (!Number.isFinite(epochMs)) { + throw new TypeError("suppress requires a finite epochMs"); + } + await db + .prepare( + `INSERT INTO notification_suppressions (recipient_hash, reason, created_at, created_at_epoch_ms) + VALUES (?, ?, ?, ?) + ON CONFLICT(recipient_hash) DO NOTHING`, + ) + .bind(recipientHashValue, reason, nowIso, epochMs) + .run(); +} + +/** + * Pure gate for whether a confirmation mail may be sent to a contact: only + * `unconfirmed` contacts are eligible, and not before `minIntervalMs` has + * elapsed since the last send. A never-seen contact (`null`) is eligible. + * Suppression is checked separately by the caller. + */ +export function canSendConfirm( + row: ContactState | null, + nowEpochMs: number, + minIntervalMs: number, +): boolean { + if (row === null) return true; + if (row.confirmState !== "unconfirmed") return false; + if (row.lastConfirmSentAtEpochMs === null) return true; + return nowEpochMs - row.lastConfirmSentAtEpochMs >= minIntervalMs; +} + +function mapContact(row: ContactRow): ContactState { + return { + recipientHash: row.recipient_hash, + confirmState: row.confirm_state, + confirmTokenHash: row.confirm_token_hash, + firstSeenAt: row.first_seen_at, + confirmedAt: row.confirmed_at, + lastConfirmSentAtEpochMs: row.last_confirm_sent_at_epoch_ms, + }; +} + +function toHex(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let hex = ""; + for (const byte of bytes) { + hex += byte.toString(16).padStart(2, "0"); + } + return hex; +} + +/** + * Stand-in stored hash for `confirmContact` when the row or token is absent, so + * the constant-time compare always runs. 64 hex chars — the SHA-256 token-hash + * length — so the length guard never short-circuits for a well-formed token. + */ +const ABSENT_TOKEN_HASH = "0".repeat(64); + +/** Length-guarded XOR compare for the fixed-length hex token hashes. */ +function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; +} diff --git a/apps/labeler/test/notification-contacts.test.ts b/apps/labeler/test/notification-contacts.test.ts new file mode 100644 index 0000000000..721c1621cb --- /dev/null +++ b/apps/labeler/test/notification-contacts.test.ts @@ -0,0 +1,347 @@ +import { applyD1Migrations, env } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { + canSendConfirm, + confirmContact, + type ContactState, + declineContact, + ensureContact, + getContactState, + isSuppressed, + recipientHash, + recordConfirmSent, + suppress, +} from "../src/notification-contacts.js"; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; +} + +const testEnv = env as unknown as TestEnv; +const db = () => testEnv.DB; + +const PEPPER = "pepper-one"; +let hashCounter = 0; + +/** A distinct recipient hash per test, so rows never collide across cases. */ +async function freshHash(): Promise { + hashCounter++; + return recipientHash(PEPPER, `person-${hashCounter}@example.test`); +} + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); +}); + +describe("recipientHash", () => { + it("emits lowercase hex of the HMAC-SHA256 digest (64 chars)", async () => { + const hash = await recipientHash(PEPPER, "user@example.test"); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it("is deterministic for the same pepper and address", async () => { + const a = await recipientHash(PEPPER, "user@example.test"); + const b = await recipientHash(PEPPER, "user@example.test"); + expect(a).toBe(b); + }); + + it("collapses case and surrounding whitespace", async () => { + const canonical = await recipientHash(PEPPER, "user@example.test"); + expect(await recipientHash(PEPPER, "USER@Example.TEST")).toBe(canonical); + expect(await recipientHash(PEPPER, " user@example.test ")).toBe(canonical); + expect(await recipientHash(PEPPER, "\tUser@Example.Test\n")).toBe(canonical); + }); + + it("does not fold provider-specific address variants", async () => { + const plain = await recipientHash(PEPPER, "user@example.test"); + expect(await recipientHash(PEPPER, "u.s.e.r@example.test")).not.toBe(plain); + expect(await recipientHash(PEPPER, "user+tag@example.test")).not.toBe(plain); + }); + + it("changes with the pepper", async () => { + const a = await recipientHash(PEPPER, "user@example.test"); + const b = await recipientHash("pepper-two", "user@example.test"); + expect(a).not.toBe(b); + }); +}); + +describe("contact lifecycle", () => { + it("ensureContact inserts an unconfirmed row and is idempotent", async () => { + const hash = await freshHash(); + await ensureContact(db(), hash, "2026-07-16T00:00:00.000Z"); + const first = await getContactState(db(), hash); + expect(first).toMatchObject({ + recipientHash: hash, + confirmState: "unconfirmed", + confirmTokenHash: null, + firstSeenAt: "2026-07-16T00:00:00.000Z", + confirmedAt: null, + lastConfirmSentAtEpochMs: null, + }); + + await ensureContact(db(), hash, "2026-07-16T01:00:00.000Z"); + const second = await getContactState(db(), hash); + expect(second?.firstSeenAt).toBe("2026-07-16T00:00:00.000Z"); + }); + + it("getContactState returns null for an unknown recipient", async () => { + expect(await getContactState(db(), await freshHash())).toBeNull(); + }); + + it("recordConfirmSent stores the token hash and send time on an unconfirmed contact", async () => { + const hash = await freshHash(); + await ensureContact(db(), hash, "2026-07-16T00:00:00.000Z"); + expect(await recordConfirmSent(db(), hash, "tokenhash-aaa", 1_000)).toBe(true); + const state = await getContactState(db(), hash); + expect(state?.confirmTokenHash).toBe("tokenhash-aaa"); + expect(state?.lastConfirmSentAtEpochMs).toBe(1_000); + expect(state?.confirmState).toBe("unconfirmed"); + }); + + it("recordConfirmSent is a no-op on a confirmed or declined contact", async () => { + const confirmed = await freshHash(); + await ensureContact(db(), confirmed, "2026-07-16T00:00:00.000Z"); + await recordConfirmSent(db(), confirmed, "tokenhash-c", 1_000); + await confirmContact(db(), confirmed, "tokenhash-c", "2026-07-16T02:00:00.000Z"); + expect(await recordConfirmSent(db(), confirmed, "tokenhash-reopen", 5_000)).toBe(false); + const confirmedState = await getContactState(db(), confirmed); + expect(confirmedState?.confirmState).toBe("confirmed"); + expect(confirmedState?.confirmTokenHash).toBeNull(); + expect(confirmedState?.lastConfirmSentAtEpochMs).toBe(1_000); + + const declined = await freshHash(); + await ensureContact(db(), declined, "2026-07-16T00:00:00.000Z"); + await declineContact(db(), declined); + expect(await recordConfirmSent(db(), declined, "tokenhash-reopen", 5_000)).toBe(false); + const declinedState = await getContactState(db(), declined); + expect(declinedState?.confirmState).toBe("declined"); + expect(declinedState?.confirmTokenHash).toBeNull(); + }); + + it("confirmContact flips to confirmed on a token match and clears the token", async () => { + const hash = await freshHash(); + await ensureContact(db(), hash, "2026-07-16T00:00:00.000Z"); + await recordConfirmSent(db(), hash, "tokenhash-match", 1_000); + + const ok = await confirmContact(db(), hash, "tokenhash-match", "2026-07-16T02:00:00.000Z"); + expect(ok).toBe(true); + + const state = await getContactState(db(), hash); + expect(state?.confirmState).toBe("confirmed"); + expect(state?.confirmTokenHash).toBeNull(); + expect(state?.confirmedAt).toBe("2026-07-16T02:00:00.000Z"); + }); + + it("confirmContact rejects a wrong token and leaves state untouched", async () => { + const hash = await freshHash(); + await ensureContact(db(), hash, "2026-07-16T00:00:00.000Z"); + await recordConfirmSent(db(), hash, "tokenhash-real", 1_000); + + const ok = await confirmContact(db(), hash, "tokenhash-wrong", "2026-07-16T02:00:00.000Z"); + expect(ok).toBe(false); + + const state = await getContactState(db(), hash); + expect(state?.confirmState).toBe("unconfirmed"); + expect(state?.confirmTokenHash).toBe("tokenhash-real"); + expect(state?.confirmedAt).toBeNull(); + }); + + it("confirmContact tokens are single-use", async () => { + const hash = await freshHash(); + await ensureContact(db(), hash, "2026-07-16T00:00:00.000Z"); + await recordConfirmSent(db(), hash, "tokenhash-once", 1_000); + + expect(await confirmContact(db(), hash, "tokenhash-once", "2026-07-16T02:00:00.000Z")).toBe( + true, + ); + expect(await confirmContact(db(), hash, "tokenhash-once", "2026-07-16T03:00:00.000Z")).toBe( + false, + ); + + const state = await getContactState(db(), hash); + expect(state?.confirmedAt).toBe("2026-07-16T02:00:00.000Z"); + }); + + it("confirmContact on an unknown recipient returns false", async () => { + expect( + await confirmContact(db(), await freshHash(), "tokenhash-any", "2026-07-16T02:00:00.000Z"), + ).toBe(false); + }); + + it("a matching token cannot flip a declined or confirmed row (state guard)", async () => { + // A lingering token on a non-unconfirmed row can only arise from a bug or + // race; the UPDATE's state guard must still refuse it. Seed the state + // directly so the guard — not the token-clearing path — is what's tested. + for (const state of ["declined", "confirmed"] as const) { + const hash = await freshHash(); + await db() + .prepare( + `INSERT INTO notification_contacts + (recipient_hash, confirm_state, confirm_token_hash, first_seen_at) + VALUES (?, ?, 'tokenhash-stale', '2026-07-16T00:00:00.000Z')`, + ) + .bind(hash, state) + .run(); + + expect(await confirmContact(db(), hash, "tokenhash-stale", "2026-07-16T02:00:00.000Z")).toBe( + false, + ); + const after = await getContactState(db(), hash); + expect(after?.confirmState).toBe(state); + expect(after?.confirmedAt).toBeNull(); + } + }); + + it("declineContact marks an unconfirmed contact declined and clears the token", async () => { + const hash = await freshHash(); + await ensureContact(db(), hash, "2026-07-16T00:00:00.000Z"); + await recordConfirmSent(db(), hash, "tokenhash-decl", 1_000); + + expect(await declineContact(db(), hash)).toBe(true); + const state = await getContactState(db(), hash); + expect(state?.confirmState).toBe("declined"); + expect(state?.confirmTokenHash).toBeNull(); + }); + + it("declineContact refuses to revoke a confirmed opt-in", async () => { + const hash = await freshHash(); + await ensureContact(db(), hash, "2026-07-16T00:00:00.000Z"); + await recordConfirmSent(db(), hash, "tokenhash-conf", 1_000); + await confirmContact(db(), hash, "tokenhash-conf", "2026-07-16T02:00:00.000Z"); + + expect(await declineContact(db(), hash)).toBe(false); + const state = await getContactState(db(), hash); + expect(state?.confirmState).toBe("confirmed"); + expect(state?.confirmedAt).toBe("2026-07-16T02:00:00.000Z"); + }); + + it("declineContact on an unknown recipient returns false", async () => { + expect(await declineContact(db(), await freshHash())).toBe(false); + }); + + it("recordConfirmSent rejects a non-finite epochMs", async () => { + const hash = await freshHash(); + await ensureContact(db(), hash, "2026-07-16T00:00:00.000Z"); + await expect(recordConfirmSent(db(), hash, "tokenhash-nan", NaN)).rejects.toThrow(TypeError); + const state = await getContactState(db(), hash); + expect(state?.confirmTokenHash).toBeNull(); + expect(state?.lastConfirmSentAtEpochMs).toBeNull(); + }); + + it("confirmContact returns false when the row exists but carries no token", async () => { + const hash = await freshHash(); + await ensureContact(db(), hash, "2026-07-16T00:00:00.000Z"); + expect(await confirmContact(db(), hash, "tokenhash-anything", "2026-07-16T02:00:00.000Z")).toBe( + false, + ); + const state = await getContactState(db(), hash); + expect(state?.confirmState).toBe("unconfirmed"); + }); +}); + +describe("suppressions", () => { + it("isSuppressed is false until an address is suppressed", async () => { + const hash = await freshHash(); + expect(await isSuppressed(db(), hash)).toBe(false); + await suppress(db(), hash, "bounce", "2026-07-16T00:00:00.000Z", 1_752_624_000_000); + expect(await isSuppressed(db(), hash)).toBe(true); + }); + + it("keeps the earliest reason on repeated suppression", async () => { + const hash = await freshHash(); + await suppress(db(), hash, "unsubscribe", "2026-07-16T00:00:00.000Z", 1_752_624_000_000); + await suppress(db(), hash, "complaint", "2026-07-16T05:00:00.000Z", 1_752_642_000_000); + + const row = await db() + .prepare( + `SELECT reason, created_at, created_at_epoch_ms + FROM notification_suppressions WHERE recipient_hash = ?`, + ) + .bind(hash) + .first<{ reason: string; created_at: string; created_at_epoch_ms: number }>(); + expect(row?.reason).toBe("unsubscribe"); + expect(row?.created_at).toBe("2026-07-16T00:00:00.000Z"); + expect(row?.created_at_epoch_ms).toBe(1_752_624_000_000); + }); + + it("rejects a non-finite epochMs", async () => { + const hash = await freshHash(); + await expect(suppress(db(), hash, "bounce", "2026-07-16T00:00:00.000Z", NaN)).rejects.toThrow( + TypeError, + ); + expect(await isSuppressed(db(), hash)).toBe(false); + }); +}); + +describe("canSendConfirm", () => { + const unconfirmed = (lastSent: number | null): ContactState => ({ + recipientHash: "h", + confirmState: "unconfirmed", + confirmTokenHash: null, + firstSeenAt: "2026-07-16T00:00:00.000Z", + confirmedAt: null, + lastConfirmSentAtEpochMs: lastSent, + }); + + it("allows a never-seen contact", () => { + expect(canSendConfirm(null, 10_000, 1_000)).toBe(true); + }); + + it("allows an unconfirmed contact that has never been sent to", () => { + expect(canSendConfirm(unconfirmed(null), 10_000, 1_000)).toBe(true); + }); + + it("blocks before the interval elapses and allows exactly at the boundary", () => { + expect(canSendConfirm(unconfirmed(10_000), 10_999, 1_000)).toBe(false); + expect(canSendConfirm(unconfirmed(10_000), 11_000, 1_000)).toBe(true); + expect(canSendConfirm(unconfirmed(10_000), 11_001, 1_000)).toBe(true); + }); + + it("never sends a confirmation to a confirmed or declined contact", () => { + expect(canSendConfirm({ ...unconfirmed(null), confirmState: "confirmed" }, 10_000, 1_000)).toBe( + false, + ); + expect(canSendConfirm({ ...unconfirmed(null), confirmState: "declined" }, 10_000, 1_000)).toBe( + false, + ); + }); +}); + +describe("schema", () => { + it("stores no plaintext email column on either table", async () => { + const contactColumns = await columnNames("notification_contacts"); + expect(contactColumns).toEqual([ + "recipient_hash", + "confirm_state", + "confirm_token_hash", + "first_seen_at", + "confirmed_at", + "last_confirm_sent_at_epoch_ms", + ]); + + const suppressionColumns = await columnNames("notification_suppressions"); + expect(suppressionColumns).toEqual([ + "recipient_hash", + "reason", + "created_at", + "created_at_epoch_ms", + ]); + + for (const column of [...contactColumns, ...suppressionColumns]) { + expect(column).not.toMatch(/email|address|plaintext/i); + } + }); +}); + +async function columnNames( + table: "notification_contacts" | "notification_suppressions", +): Promise { + // PRAGMA cannot parameterize the table name; allowlist the identifier before + // interpolating. + const allowed = new Set(["notification_contacts", "notification_suppressions"]); + if (!allowed.has(table)) throw new TypeError(`invalid table identifier: ${table}`); + const result = await db().prepare(`PRAGMA table_info(${table})`).all<{ name: string }>(); + return result.results.map((row) => row.name); +} diff --git a/apps/labeler/vitest.config.ts b/apps/labeler/vitest.config.ts index 6028696d10..9cf0d5dc6b 100644 --- a/apps/labeler/vitest.config.ts +++ b/apps/labeler/vitest.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ bindings: { TEST_MIGRATIONS: migrations, LABEL_SIGNING_PRIVATE_KEY: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE", + NOTIFICATION_HASH_PEPPER: "test-notification-hash-pepper", }, }, }), diff --git a/apps/labeler/worker-configuration.d.ts b/apps/labeler/worker-configuration.d.ts index 70a1cd945e..cba90b0aca 100644 --- a/apps/labeler/worker-configuration.d.ts +++ b/apps/labeler/worker-configuration.d.ts @@ -14,6 +14,7 @@ interface __BaseEnv_Env { JETSTREAM_URL: "wss://jetstream2.us-east.bsky.network/subscribe"; OPERATOR_ACCESS_CONFIG: "{\"teamDomain\":\"https://emdash.cloudflareaccess.com\",\"audience\":\"REPLACE_WITH_ACCESS_APP_AUD_TAG\",\"admins\":[\"emdash-labeler-admins\"],\"reviewers\":[\"emdash-labeler-reviewers\"]}"; LABEL_SIGNING_PRIVATE_KEY: string; + NOTIFICATION_HASH_PEPPER: string; LABEL_SUBSCRIPTION: DurableObjectNamespace; LABELER_DISCOVERY_DO: DurableObjectNamespace; } @@ -29,7 +30,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/apps/labeler/wrangler.jsonc b/apps/labeler/wrangler.jsonc index 838bd7c0e4..8daeae21cf 100644 --- a/apps/labeler/wrangler.jsonc +++ b/apps/labeler/wrangler.jsonc @@ -98,13 +98,14 @@ // Access application's AUD tag and the group names with the deployment's. "OPERATOR_ACCESS_CONFIG": "{\"teamDomain\":\"https://emdash.cloudflareaccess.com\",\"audience\":\"REPLACE_WITH_ACCESS_APP_AUD_TAG\",\"admins\":[\"emdash-labeler-admins\"],\"reviewers\":[\"emdash-labeler-reviewers\"]}", }, - // Required secret, declared in config so `wrangler types` generates the - // typed binding and `wrangler deploy` validates the secret is set on the + // Required secrets, declared in config so `wrangler types` generates the + // typed bindings and `wrangler deploy` validates each secret is set on the // target Worker before publishing. Set in production with - // `wrangler secret put LABEL_SIGNING_PRIVATE_KEY`; tests bind a stub via - // miniflare in vitest.config.ts. + // `wrangler secret put `; tests bind stubs via miniflare in + // vitest.config.ts. NOTIFICATION_HASH_PEPPER keys publisher-notification + // recipient hashes (src/notification-contacts.ts). "secrets": { - "required": ["LABEL_SIGNING_PRIVATE_KEY"], + "required": ["LABEL_SIGNING_PRIVATE_KEY", "NOTIFICATION_HASH_PEPPER"], }, "observability": { "enabled": true }, } From 7c28520fc14a24b8cfa90cf2605a045220a37748 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 10:59:35 +0100 Subject: [PATCH 081/137] feat(labeler): verified artifact acquisition (W7.2) (#2069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(labeler): verified artifact acquisition (W7.2) Resolves a verified release's package artifact from the preferred source, fetches it under the shared SSRF-hardened controls, verifies the bytes against the signed checksum, and unpacks the canonical bundle into the analysis file set. Composes @emdash-cms/registry-verification (guarded fetch, multihash verification, bundle validation) and adds the labeler's mirror-first source preference, declared-URL fallback, and acquisition-category classification. Every failure is classified so that transport and capability faults on unverified bytes retry into assessment-error and never a public label, while only checksum/coordinate integrity faults and content-policy bundle rejections become deterministic blocking findings (spec §9.4). Non-UTF-8 code files are rejected rather than silently dropped, keeping the analyzed set a superset of the executable set. Mirror-ready seam: v1 ships no mirror binding, so every acquisition resolves via the publisher's declared URL. Extends validatePluginBundle with a validated file inventory (files) so consumers extract analysis inputs without re-parsing the bundle. * docs(labeler): align acquisition size-cap and mirror-key comments with §9.4 classification * docs(labeler): correct crossCheckCoordinates comment for transient malformed-checksum classification --- .changeset/verified-artifact-inventory.md | 5 + apps/labeler/package.json | 2 + apps/labeler/src/artifact-acquisition.ts | 517 ++++++++++++++++++ .../labeler/test/artifact-acquisition.test.ts | 373 +++++++++++++ .../test/assessment-orchestrator.test.ts | 108 ++++ apps/labeler/test/bundle-fixture.ts | 51 ++ packages/registry-verification/src/bundle.ts | 9 + packages/registry-verification/src/index.ts | 6 +- .../tests/bundle.test.ts | 16 + pnpm-lock.yaml | 33 +- 10 files changed, 1099 insertions(+), 21 deletions(-) create mode 100644 .changeset/verified-artifact-inventory.md create mode 100644 apps/labeler/src/artifact-acquisition.ts create mode 100644 apps/labeler/test/artifact-acquisition.test.ts create mode 100644 apps/labeler/test/bundle-fixture.ts diff --git a/.changeset/verified-artifact-inventory.md b/.changeset/verified-artifact-inventory.md new file mode 100644 index 0000000000..425cd01020 --- /dev/null +++ b/.changeset/verified-artifact-inventory.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-verification": minor +--- + +Adds a validated file inventory to `validatePluginBundle`: the result now carries `files`, every regular archive file (path and bytes) in tar order, so consumers can extract analysis inputs without re-parsing the bundle. diff --git a/apps/labeler/package.json b/apps/labeler/package.json index cfd400b460..e63b4ed135 100644 --- a/apps/labeler/package.json +++ b/apps/labeler/package.json @@ -32,6 +32,7 @@ "@atcute/xrpc-server": "catalog:", "@emdash-cms/registry-lexicons": "workspace:*", "@emdash-cms/registry-moderation": "workspace:*", + "@emdash-cms/registry-verification": "workspace:*", "jose": "^6.1.3", "ulidx": "^2.4.1" }, @@ -53,6 +54,7 @@ "@types/react-dom": "catalog:", "@vitejs/plugin-react": "^5.2.0", "jsdom": "^26.1.0", + "modern-tar": "^0.7.6", "react": "catalog:", "react-dom": "catalog:", "tailwindcss": "^4.3.2", diff --git a/apps/labeler/src/artifact-acquisition.ts b/apps/labeler/src/artifact-acquisition.ts new file mode 100644 index 0000000000..0d830270fe --- /dev/null +++ b/apps/labeler/src/artifact-acquisition.ts @@ -0,0 +1,517 @@ +/** + * Verified artifact acquisition (plan W7.2, spec §9.3/§9.4). Resolves a + * verified release's package artifact from the preferred source, fetches it + * under the shared SSRF-hardened controls, verifies its bytes against the + * signed checksum, and unpacks the canonical bundle into the analysis file + * set. Every failure is classified into the four ratified acquisition + * categories — mirror miss, transient fetch failure, permanent checksum + * mismatch, and policy rejection — each carrying the disposition the + * orchestrator applies (retry as a transient stage error, or a permanent + * deterministic finding). + * + * Composes `@emdash-cms/registry-verification` (W7.1): its guarded fetch, + * multihash verification, and canonical bundle validation are the shared + * foundation. This module adds the labeler's mirror-first source preference, + * declared-URL fallback, and the acquisition-category classification on top — + * it does not reimplement any of them. + * + * Mirror-ready seam: the source preference defaults to mirror-first, but v1 + * ships no mirror binding (`deps.mirror` absent), so the mirror source always + * misses and every acquisition resolves via the publisher's declared URL + * (plan W0.6). Injecting a mirror binding activates mirror-first ordering with + * no change to this contract. + */ + +import { + decodeMultihash, + fetchVerifiedResource, + MAX_BUNDLE_COMPRESSED_BYTES, + validatePluginBundle, + verifyMultihash, + type FetchImplementation, + type HostnameResolver, + type ValidatedPluginBundle, + type VerificationErrorCode, + type VerificationResult, +} from "@emdash-cms/registry-verification"; + +import type { StageAdapter, StageContext } from "./assessment-orchestrator.js"; +import { StageTransientError } from "./assessment-orchestrator.js"; +import type { Assessment } from "./assessment-store.js"; +import type { CodeAnalysisFile } from "./code-ai-adapter.js"; +import type { NormalizedFinding } from "./findings.js"; + +/** Where a release artifact is fetched from. `mirror` prefers the aggregator's + * durable copy; `declared-url` fetches the publisher's signed artifact URL. */ +export type ArtifactSource = "mirror" | "declared-url"; + +/** + * Mirror-ready preference order. v1 has no mirror binding, so the mirror + * source misses and acquisition falls through to the declared URL. Landing a + * mirror binding activates mirror-first without touching this default. + */ +export const DEFAULT_ARTIFACT_SOURCES: readonly ArtifactSource[] = ["mirror", "declared-url"]; + +/** + * Fetch budgets for a declared-URL artifact download. The byte budget equals + * the compressed-bundle cap, so an artifact larger than any valid bundle is + * rejected during streaming (the earliest point), before any checksum. Those + * unverified bytes are classified as a transient failure (retry → + * assessment-error), never a public block label (spec §9.4). Timeouts bound a + * slow or stalled origin. + */ +export const ACQUISITION_FETCH_LIMITS = { + headerTimeoutMs: 10_000, + totalTimeoutMs: 30_000, + maxBytes: MAX_BUNDLE_COMPRESSED_BYTES, + maxRedirects: 3, +} as const; + +/** Tool identifier recorded on the deterministic findings this stage emits. */ +export const ACQUISITION_TOOL = "artifact-acquisition"; +export const ACQUISITION_TOOL_VERSION = "1"; + +/** The four ratified acquisition categories (plan W7.2). */ +export type AcquisitionFailureKind = + | "mirror-miss" + | "transient" + | "permanent-mismatch" + | "policy-rejection"; + +/** Deterministic finding a permanent acquisition failure maps onto (spec §9.4). */ +export type AcquisitionFinding = "artifact-integrity-failure" | "invalid-bundle"; + +/** + * How the orchestrator adapter finalizes a failure: + * - `retry`: a transient stage error — network, timeout, an unfetchable + * declared URL, or an exhausted source. Retries, then finalizes as + * `assessment-error`. Never a public block label: a transport failure is + * not evidence the plugin is malicious (spec §9.4). + * - a `finding`: a permanent, blocking deterministic finding. + */ +export type AcquisitionDisposition = + | { readonly retry: true } + | { readonly retry: false; readonly finding: AcquisitionFinding }; + +export interface AcquisitionFailure { + readonly kind: AcquisitionFailureKind; + /** Underlying verification code, or an acquisition-specific code. */ + readonly code: VerificationErrorCode | AcquisitionCode; + readonly message: string; + /** The source whose attempt produced this terminal failure. */ + readonly source: ArtifactSource; + readonly disposition: AcquisitionDisposition; +} + +/** Acquisition-specific codes with no registry-verification equivalent. */ +export type AcquisitionCode = "MIRROR_MISS" | "COORDINATE_MISMATCH" | "NON_UTF8_CODE_FILE"; + +/** The verified release's package-artifact declaration plus the pinned + * coordinates acquisition cross-checks it against. */ +export interface AcquisitionTarget { + /** Package-artifact download URL from the signed release. */ + readonly url: string; + /** Multibase-multihash checksum from the signed release. */ + readonly checksum: string; + /** Package slug (release.package) — the expected manifest id. */ + readonly slug: string; + /** Release version — the expected manifest version. */ + readonly version: string; + /** Artifact id from the signed release (artifacts.package.id), when present. */ + readonly artifactId?: string; + /** Artifact coordinates pinned on the assessment; cross-checked against the + * declaration when present. */ + readonly pinnedChecksum?: string | null; + readonly pinnedArtifactId?: string | null; +} + +export interface AcquiredArtifact { + readonly source: ArtifactSource; + readonly bundle: ValidatedPluginBundle; + /** UTF-8-decodable bundle files as the code stages' input. Binary files + * (images) are excluded here and remain available on `bundle.files`. */ + readonly files: readonly CodeAnalysisFile[]; + /** The checksum-verified compressed bundle bytes. */ + readonly bytes: Uint8Array; +} + +export type AcquisitionResult = + | { readonly success: true; readonly value: AcquiredArtifact } + | { readonly success: false; readonly error: AcquisitionFailure }; + +/** + * The aggregator artifact mirror. v1 ships no implementation; a future binding + * resolves a deterministic R2 object (see `mirrorObjectKey`) through the + * aggregator service binding (plan W0.6). `fetch` returns the raw compressed + * bundle bytes, or `null` when the mirror holds no object for the release. + */ +export interface ArtifactMirror { + fetch(key: string, target: AcquisitionTarget): Promise; +} + +export interface AcquisitionDeps { + readonly fetch: FetchImplementation; + readonly resolveHostname: HostnameResolver; + /** Future aggregator mirror binding. Absent in v1 — the mirror source misses. */ + readonly mirror?: ArtifactMirror; + /** Source preference order. Defaults to `DEFAULT_ARTIFACT_SOURCES`. */ + readonly sources?: readonly ArtifactSource[]; + /** Fetch budget overrides; defaults to `ACQUISITION_FETCH_LIMITS`. */ + readonly limits?: Partial; +} + +/** + * Deterministic mirror object key derived from release coordinates. The mirror + * stores the exact signed artifact, so the key is stable across acquisitions + * and independent of the declared URL. + */ +export function mirrorObjectKey(target: AcquisitionTarget): string { + return `${target.slug}/${target.version}/${target.artifactId ?? "package"}`; +} + +export async function acquireArtifact( + deps: AcquisitionDeps, + target: AcquisitionTarget, +): Promise { + const coordinate = crossCheckCoordinates(target); + if (coordinate) return { success: false, error: coordinate }; + + const sources = deps.sources ?? DEFAULT_ARTIFACT_SOURCES; + let lastFailure: AcquisitionFailure | undefined; + + for (const source of sources) { + if (source === "mirror") { + const bytes = await loadFromMirror(deps, target); + if (bytes === null) { + lastFailure = mirrorMiss(); + continue; + } + const result = await verifyAndUnpack(bytes, target, "mirror"); + // The mirror is an untrusted transport cache (spec §9.3): bytes that + // fail the signed checksum mean a stale or corrupt copy, so fall + // through to the authoritative declared URL rather than failing. Any + // other outcome is inherent to the checksum-pinned artifact and is + // terminal — the declared URL would yield byte-identical content. + if (!result.success && result.error.code === "CHECKSUM_MISMATCH") { + lastFailure = mirrorMiss(); + continue; + } + return result; + } + + const fetched = await fetchDeclared(deps, target); + if (!fetched.success) { + lastFailure = classifyFailure(fetched.error.code, fetched.error.message, "declared-url"); + continue; + } + return verifyAndUnpack(fetched.value, target, "declared-url"); + } + + return { success: false, error: lastFailure ?? mirrorMiss() }; +} + +/** Cross-checks the pinned coordinates and the declared checksum before any + * network work. A drift between what discovery pinned and what the release + * declares is a permanent integrity fault (`COORDINATE_MISMATCH`). A malformed + * or unsupported declared checksum is not tampering — it is a bad record or a + * labeler capability gap — so it classifies as transient (retry → + * assessment-error), never a public block label. */ +function crossCheckCoordinates(target: AcquisitionTarget): AcquisitionFailure | null { + if ( + typeof target.pinnedChecksum === "string" && + target.pinnedChecksum.length > 0 && + target.pinnedChecksum !== target.checksum + ) { + return classifyFailure( + "COORDINATE_MISMATCH", + "The pinned artifact checksum does not match the signed release declaration.", + "declared-url", + ); + } + if ( + typeof target.pinnedArtifactId === "string" && + target.pinnedArtifactId.length > 0 && + target.pinnedArtifactId !== (target.artifactId ?? null) + ) { + return classifyFailure( + "COORDINATE_MISMATCH", + "The pinned artifact id does not match the signed release declaration.", + "declared-url", + ); + } + const decoded = decodeMultihash(target.checksum); + if (!decoded.success) { + return classifyFailure(decoded.error.code, decoded.error.message, "declared-url"); + } + return null; +} + +async function loadFromMirror( + deps: AcquisitionDeps, + target: AcquisitionTarget, +): Promise { + if (!deps.mirror) return null; + try { + return await deps.mirror.fetch(mirrorObjectKey(target), target); + } catch { + // A mirror binding fault is not authoritative — treat it as a miss and + // fall through to the declared URL. + return null; + } +} + +async function fetchDeclared( + deps: AcquisitionDeps, + target: AcquisitionTarget, +): Promise> { + const limits = { ...ACQUISITION_FETCH_LIMITS, ...deps.limits }; + const result = await fetchVerifiedResource(target.url, { + fetch: deps.fetch, + resolveHostname: deps.resolveHostname, + headerTimeoutMs: limits.headerTimeoutMs, + totalTimeoutMs: limits.totalTimeoutMs, + maxBytes: limits.maxBytes, + maxRedirects: limits.maxRedirects, + }); + if (!result.success) return result; + return { success: true, value: result.value.bytes }; +} + +async function verifyAndUnpack( + bytes: Uint8Array, + target: AcquisitionTarget, + source: ArtifactSource, +): Promise { + const checksum = await verifyMultihash(bytes, target.checksum); + if (!checksum.success) { + return { + success: false, + error: classifyFailure(checksum.error.code, checksum.error.message, source), + }; + } + // The bytes are the exact signed artifact; validation ignores the declared + // content-type and proves the archive shape itself (spec §9.3). + const bundle = await validatePluginBundle(bytes, { + expectedSlug: target.slug, + expectedVersion: target.version, + }); + if (!bundle.success) { + return { + success: false, + error: classifyFailure(bundle.error.code, bundle.error.message, source), + }; + } + const fileSet = buildCodeFileSet(bundle.value); + if (!fileSet.ok) { + return { success: false, error: classifyFailure(fileSet.code, fileSet.message, source) }; + } + return { + success: true, + value: { + source, + bundle: bundle.value, + files: fileSet.files, + bytes, + }, + }; +} + +const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }); + +const CODE_FILE_EXTENSIONS = [ + ".js", + ".mjs", + ".cjs", + ".jsx", + ".ts", + ".mts", + ".cts", + ".tsx", + ".json", +] as const; + +function isCodeFile(path: string): boolean { + const lower = path.toLowerCase(); + return CODE_FILE_EXTENSIONS.some((extension) => lower.endsWith(extension)); +} + +type FileSetResult = + | { readonly ok: true; readonly files: readonly CodeAnalysisFile[] } + | { readonly ok: false; readonly code: AcquisitionCode; readonly message: string }; + +/** + * Decodes the validated inventory into the code stages' file set, in tar order. + * A code file (JS/TS/JSON, including the `backend.js`/`admin.js` entrypoints) + * that is not valid UTF-8 is rejected as `invalid-bundle`: valid source is by + * definition valid Unicode, and a raw invalid byte in an executable is an + * evasion — a lenient runtime still runs it while the code AI, handed only the + * decodable files, would never see it. The analyzed set must be a superset of + * the executable set, so such a file may not be silently dropped. Genuine + * binary assets (images) are the only files that legitimately fail to decode; + * they are skipped here and remain on `bundle.files` for the image path. + */ +function buildCodeFileSet(bundle: ValidatedPluginBundle): FileSetResult { + const files: CodeAnalysisFile[] = []; + for (const file of bundle.files) { + let content: string; + try { + content = utf8.decode(file.bytes); + } catch { + if (isCodeFile(file.path)) { + return { + ok: false, + code: "NON_UTF8_CODE_FILE", + message: `The plugin bundle code file ${file.path} is not valid UTF-8.`, + }; + } + continue; + } + files.push({ path: file.path, content }); + } + return { ok: true, files }; +} + +function mirrorMiss(): AcquisitionFailure { + return classifyFailure( + "MIRROR_MISS", + "The artifact mirror has no object for this release.", + "mirror", + ); +} + +/** + * Retry → `assessment-error`, never a public block. Covers three faults that + * are all "not evidence the artifact is bad": genuine network/origin failures; + * a transport-size cap tripped mid-fetch, before any checksum, so the aborted + * bytes were never verified (mapping them to a public block would let a MITM or + * a misbehaving CDN mislabel a legitimate plugin — spec §9.4); and a + * malformed/unsupported *declared* checksum, which is a bad record or a labeler + * capability gap (e.g. a forward-compat hash), not tampering. A genuine + * decompression bomb is still a permanent block via + * `BUNDLE_DECOMPRESSED_SIZE_EXCEEDED`, which fires on checksum-verified bytes. + */ +const TRANSIENT_CODES: ReadonlySet = new Set([ + "FETCH_FAILED", + "RESOURCE_TIMEOUT", + "RESOURCE_STATUS_ERROR", + "RESOURCE_SIZE_EXCEEDED", + "INVALID_MULTIHASH", + "UNSUPPORTED_MULTIHASH", +]); + +/** URL-safety and redirect refusals: we will not fetch the declared URL. No + * bytes, so no malicious signal — retried, then `assessment-error`. */ +const URL_POLICY_CODES: ReadonlySet = new Set([ + "INVALID_URL", + "HOST_REJECTED", + "REDIRECT_LIMIT_EXCEEDED", + "REDIRECT_LOCATION_MISSING", +]); + +/** Genuine integrity faults on verified/authoritative inputs: the signed bytes + * do not match the pin (`CHECKSUM_MISMATCH`), or the pinned coordinates drift + * from the signed declaration (`COORDINATE_MISMATCH`). A malformed declared + * checksum is NOT here — see `TRANSIENT_CODES` — because it is proven decodable + * pre-fetch, so `verifyMultihash` can only ever return `CHECKSUM_MISMATCH` on + * fetched bytes. */ +const INTEGRITY_CODES: ReadonlySet = new Set([ + "CHECKSUM_MISMATCH", + "COORDINATE_MISMATCH", +]); + +function classifyFailure( + code: VerificationErrorCode | AcquisitionCode, + message: string, + source: ArtifactSource, +): AcquisitionFailure { + if (code === "MIRROR_MISS") { + return { kind: "mirror-miss", code, message, source, disposition: { retry: true } }; + } + if (TRANSIENT_CODES.has(code)) { + return { kind: "transient", code, message, source, disposition: { retry: true } }; + } + if (URL_POLICY_CODES.has(code)) { + return { kind: "policy-rejection", code, message, source, disposition: { retry: true } }; + } + if (INTEGRITY_CODES.has(code)) { + return { + kind: "permanent-mismatch", + code, + message, + source, + disposition: { retry: false, finding: "artifact-integrity-failure" }, + }; + } + // Every remaining code is a checksum-verified bundle rejected on its own + // content: a structurally-invalid archive, or a non-UTF-8 code file. The + // bytes match the signed pin, but the bundle itself is rejected by policy + // (spec §9.4 → invalid-bundle). + return { + kind: "policy-rejection", + code, + message, + source, + disposition: { retry: false, finding: "invalid-bundle" }, + }; +} + +/** Populated by the acquire stage so downstream stages read the acquired + * bundle and file set. The orchestrator's `StageContext` carries only the + * assessment, so acquisition publishes its output through this shared handle — + * the seam the production Workflow wiring will read. */ +export interface AcquisitionHolder { + result?: AcquiredArtifact; +} + +export interface AcquireStageOptions { + readonly deps: AcquisitionDeps; + /** Resolves the acquisition target from the assessment. Future wiring loads + * the verified release record and its pinned coordinates here. */ + readonly resolveTarget: (assessment: Assessment) => Promise; + /** Populated on success for downstream stages to consume. */ + readonly holder: AcquisitionHolder; +} + +/** + * Builds the orchestrator's `acquire` stage adapter. On success it publishes + * the acquired artifact to the holder and reports no findings; a permanent + * failure becomes the mapped deterministic finding; a transient failure throws + * `StageTransientError` for the orchestrator to retry. + */ +export function createAcquireStage(options: AcquireStageOptions): StageAdapter { + return async (ctx: StageContext) => { + const target = await options.resolveTarget(ctx.assessment); + const result = await acquireArtifact(options.deps, target); + if (result.success) { + options.holder.result = result.value; + return []; + } + const { error } = result; + if (error.disposition.retry) { + throw new StageTransientError( + `artifact acquisition failed (${error.kind}/${error.code}): ${error.message}`, + ); + } + return [acquisitionFinding(error, error.disposition.finding)]; + }; +} + +function acquisitionFinding( + error: AcquisitionFailure, + category: AcquisitionFinding, +): NormalizedFinding { + const isIntegrity = category === "artifact-integrity-failure"; + return { + source: "deterministic", + category, + severity: "critical", + confidence: 1, + title: isIntegrity ? "Artifact integrity check failed" : "Plugin bundle rejected", + publicSummary: isIntegrity + ? "The plugin artifact does not match the checksum in its signed release record." + : "The plugin bundle is malformed, unsafe, or missing required installable content.", + privateDetail: `Acquisition failed (${error.kind}/${error.code}) from ${error.source}: ${error.message}`, + evidenceRefs: [], + sourceMetadata: { kind: "tool", tool: ACQUISITION_TOOL, version: ACQUISITION_TOOL_VERSION }, + }; +} diff --git a/apps/labeler/test/artifact-acquisition.test.ts b/apps/labeler/test/artifact-acquisition.test.ts new file mode 100644 index 0000000000..2025ea2db3 --- /dev/null +++ b/apps/labeler/test/artifact-acquisition.test.ts @@ -0,0 +1,373 @@ +import type { FetchImplementation, HostnameResolver } from "@emdash-cms/registry-verification"; +import { describe, expect, it, vi } from "vitest"; + +import { + acquireArtifact, + ACQUISITION_FETCH_LIMITS, + mirrorObjectKey, + type AcquisitionDeps, + type AcquisitionTarget, + type ArtifactMirror, +} from "../src/artifact-acquisition.js"; +import { + bundleBytes, + canonicalBundle, + checksumOf, + file, + FIXTURE_MANIFEST, + gzipBytes, +} from "./bundle-fixture.js"; + +const PUBLIC_ADDRESS = "203.0.113.5"; +const PRIVATE_ADDRESS = "10.0.0.5"; + +const resolvePublic: HostnameResolver = async () => [PUBLIC_ADDRESS]; + +function respondWith(bytes: Uint8Array): FetchImplementation { + return async () => new Response(bytes); +} + +async function target(overrides: Partial = {}): Promise { + const bytes = await canonicalBundle(); + return { + url: "https://cdn.example.test/plugin.tgz", + checksum: await checksumOf(bytes), + slug: "test-plugin", + version: "1.0.0", + ...overrides, + }; +} + +function deps(overrides: Partial): AcquisitionDeps { + return { + fetch: overrides.fetch ?? vi.fn(), + resolveHostname: overrides.resolveHostname ?? resolvePublic, + ...(overrides.mirror ? { mirror: overrides.mirror } : {}), + ...(overrides.sources ? { sources: overrides.sources } : {}), + ...(overrides.limits ? { limits: overrides.limits } : {}), + }; +} + +describe("acquireArtifact: declared-URL success", () => { + it("fetches, checksum-verifies, and unpacks the bundle into the code file set", async () => { + const bytes = await canonicalBundle(); + const result = await acquireArtifact( + deps({ fetch: respondWith(bytes) }), + await target({ checksum: await checksumOf(bytes) }), + ); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.value.source).toBe("declared-url"); + expect(result.value.bundle.manifest.id).toBe("test-plugin"); + expect(result.value.files.map((f) => f.path)).toEqual(["manifest.json", "backend.js"]); + expect(result.value.files.find((f) => f.path === "backend.js")?.content).toBe( + "export default {};", + ); + }); + + it("excludes binary files from the code set but keeps them on the inventory", async () => { + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0xfe, 0x01]); + const bytes = await canonicalBundle([file("icon.png", png)]); + const result = await acquireArtifact( + deps({ fetch: respondWith(bytes) }), + await target({ checksum: await checksumOf(bytes) }), + ); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.value.files.map((f) => f.path)).toEqual(["manifest.json", "backend.js"]); + expect(result.value.bundle.files.map((f) => f.path)).toContain("icon.png"); + }); + + it("defaults to mirror-first sources but resolves the declared URL when no mirror is bound", async () => { + const bytes = await canonicalBundle(); + const fetch = vi.fn(respondWith(bytes)); + const result = await acquireArtifact( + deps({ fetch }), + await target({ checksum: await checksumOf(bytes) }), + ); + + expect(result.success).toBe(true); + if (result.success) expect(result.value.source).toBe("declared-url"); + expect(fetch).toHaveBeenCalledOnce(); + }); +}); + +describe("acquireArtifact: integrity failures (permanent-mismatch)", () => { + it("classifies a declared-URL checksum mismatch as artifact-integrity-failure", async () => { + const served = await canonicalBundle([file("extra.js", "console.log(1);")]); + const result = await acquireArtifact(deps({ fetch: respondWith(served) }), await target()); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "permanent-mismatch", + code: "CHECKSUM_MISMATCH", + source: "declared-url", + disposition: { retry: false, finding: "artifact-integrity-failure" }, + }); + }); + + it("rejects a pinned-coordinate drift before fetching", async () => { + const fetch = vi.fn(); + const result = await acquireArtifact( + deps({ fetch }), + await target({ pinnedChecksum: "bdifferentchecksum" }), + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "permanent-mismatch", + code: "COORDINATE_MISMATCH", + disposition: { retry: false, finding: "artifact-integrity-failure" }, + }); + expect(fetch).not.toHaveBeenCalled(); + }); +}); + +describe("acquireArtifact: bundle rejections (policy-rejection → invalid-bundle)", () => { + it("classifies a checksum-valid but malformed archive as invalid-bundle", async () => { + // A valid gzip envelope whose payload is not a tar archive: it decodes, + // then fails the (synchronous) tar parse — exercising the invalid-archive + // classification without a streaming gzip-decode error. + const notTar = await gzipBytes(new TextEncoder().encode("this is not a tar archive")); + const result = await acquireArtifact( + deps({ fetch: respondWith(notTar) }), + await target({ checksum: await checksumOf(notTar) }), + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "policy-rejection", + code: "BUNDLE_INVALID_ARCHIVE", + disposition: { retry: false, finding: "invalid-bundle" }, + }); + }); + + it("classifies a bundle missing its backend entrypoint as invalid-bundle", async () => { + const noBackend = await bundleBytes([file("manifest.json", JSON.stringify({ id: "x" }))]); + const result = await acquireArtifact( + deps({ fetch: respondWith(noBackend) }), + await target({ checksum: await checksumOf(noBackend) }), + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "policy-rejection", + code: "BUNDLE_MISSING_BACKEND", + disposition: { retry: false, finding: "invalid-bundle" }, + }); + }); + + it("rejects a non-UTF-8 executable rather than silently dropping it from analysis", async () => { + // A valid-JS backend with one invalid UTF-8 byte: structurally accepted by + // the validator (which stores backend.js raw), but it must not vanish from + // the analyzed file set — a lenient runtime still executes it. + const encoder = new TextEncoder(); + const tainted = new Uint8Array([ + ...encoder.encode("export default {"), + 0xff, + ...encoder.encode("};"), + ]); + const bytes = await bundleBytes([ + file("manifest.json", JSON.stringify(FIXTURE_MANIFEST)), + file("backend.js", tainted), + ]); + const result = await acquireArtifact( + deps({ fetch: respondWith(bytes) }), + await target({ checksum: await checksumOf(bytes) }), + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "policy-rejection", + code: "NON_UTF8_CODE_FILE", + disposition: { retry: false, finding: "invalid-bundle" }, + }); + }); +}); + +describe("acquireArtifact: transient failures", () => { + it("classifies a network fault as transient (retryable)", async () => { + // Real fetch rejects asynchronously after I/O; mirror that so the + // rejection is never momentarily unhandled. + const fetch: FetchImplementation = async () => { + await Promise.resolve(); + throw new TypeError("connection reset"); + }; + const result = await acquireArtifact(deps({ fetch }), await target()); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "transient", + code: "FETCH_FAILED", + disposition: { retry: true }, + }); + }); + + it("classifies an origin error status as transient", async () => { + const fetch: FetchImplementation = async () => new Response("nope", { status: 503 }); + const result = await acquireArtifact(deps({ fetch }), await target()); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ kind: "transient", code: "RESOURCE_STATUS_ERROR" }); + expect(result.error.disposition).toEqual({ retry: true }); + }); + + it("treats a transport-oversized response as transient, never a public block", async () => { + // The byte cap aborts the fetch before any checksum runs, so these bytes + // are unverified — a MITM or misbehaving CDN must not turn a legitimate + // plugin into a public invalid-bundle block (spec §9.4). The served bytes + // deliberately do not match the target checksum, proving the abort is + // pre-verification (a wrong checksum still yields RESOURCE_SIZE_EXCEEDED, + // not CHECKSUM_MISMATCH). + const oversized = await canonicalBundle([file("padding.js", "x".repeat(64))]); + const result = await acquireArtifact( + deps({ fetch: respondWith(oversized), limits: { maxBytes: 8 } }), + await target(), + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "transient", + code: "RESOURCE_SIZE_EXCEEDED", + disposition: { retry: true }, + }); + }); + + it("treats a malformed declared checksum as transient, never a public block", async () => { + // An undecodable declared checksum is a bad record or a labeler hash-support + // gap, not tampering — no artifact was fetched or compared, so it must not + // produce a public artifact-integrity-failure. It fails the pre-fetch + // coordinate check. + const fetch = vi.fn(); + const result = await acquireArtifact( + deps({ fetch }), + await target({ checksum: "not-a-multibase-multihash" }), + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "transient", + code: "INVALID_MULTIHASH", + disposition: { retry: true }, + }); + expect(fetch).not.toHaveBeenCalled(); + }); +}); + +describe("acquireArtifact: SSRF/policy rejections", () => { + it("refuses a non-HTTPS declared URL without fetching", async () => { + const fetch = vi.fn(); + const result = await acquireArtifact( + deps({ fetch }), + await target({ url: "http://cdn.example.test/plugin.tgz" }), + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "policy-rejection", + code: "INVALID_URL", + disposition: { retry: true }, + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("refuses an IP-literal declared URL", async () => { + const fetch = vi.fn(); + const result = await acquireArtifact( + deps({ fetch }), + await target({ url: "https://10.0.0.1/plugin.tgz" }), + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ kind: "policy-rejection", code: "HOST_REJECTED" }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("refuses a redirect into a private address range", async () => { + const fetch: FetchImplementation = async (url) => + url.hostname === "cdn.example.test" + ? new Response(null, { status: 302, headers: { location: "https://internal.test/x" } }) + : new Response(await canonicalBundle()); + const resolveHostname: HostnameResolver = async (hostname) => + hostname === "internal.test" ? [PRIVATE_ADDRESS] : [PUBLIC_ADDRESS]; + + const result = await acquireArtifact(deps({ fetch, resolveHostname }), await target()); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "policy-rejection", + code: "HOST_REJECTED", + disposition: { retry: true }, + }); + }); +}); + +describe("acquireArtifact: mirror source", () => { + it("misses when no mirror binding is present and only the mirror is preferred", async () => { + const fetch = vi.fn(); + const result = await acquireArtifact(deps({ fetch, sources: ["mirror"] }), await target()); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "mirror-miss", + code: "MIRROR_MISS", + source: "mirror", + disposition: { retry: true }, + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("serves from the mirror when it holds the object, without fetching the declared URL", async () => { + const bytes = await canonicalBundle(); + const fetch = vi.fn(); + const mirror: ArtifactMirror = { fetch: vi.fn(async () => bytes) }; + const result = await acquireArtifact( + deps({ fetch, mirror }), + await target({ checksum: await checksumOf(bytes) }), + ); + + expect(result.success).toBe(true); + if (result.success) expect(result.value.source).toBe("mirror"); + expect(mirror.fetch).toHaveBeenCalledWith(mirrorObjectKey(await target()), expect.anything()); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("falls back to the declared URL when the mirror serves non-matching bytes", async () => { + const good = await canonicalBundle(); + const stale = await canonicalBundle([file("stale.js", "old();")]); + const mirror: ArtifactMirror = { fetch: async () => stale }; + const result = await acquireArtifact( + deps({ fetch: respondWith(good), mirror }), + await target({ checksum: await checksumOf(good) }), + ); + + expect(result.success).toBe(true); + if (result.success) expect(result.value.source).toBe("declared-url"); + }); +}); + +describe("mirrorObjectKey", () => { + it("derives a deterministic key from release coordinates", async () => { + expect(mirrorObjectKey(await target({ artifactId: "pkg-1" }))).toBe("test-plugin/1.0.0/pkg-1"); + expect(mirrorObjectKey(await target())).toBe("test-plugin/1.0.0/package"); + }); + + it("uses the configured fetch limits by default", () => { + expect(ACQUISITION_FETCH_LIMITS.maxRedirects).toBe(3); + }); +}); diff --git a/apps/labeler/test/assessment-orchestrator.test.ts b/apps/labeler/test/assessment-orchestrator.test.ts index 4c6955c2bd..c38d2c25dd 100644 --- a/apps/labeler/test/assessment-orchestrator.test.ts +++ b/apps/labeler/test/assessment-orchestrator.test.ts @@ -7,12 +7,18 @@ import { import { applyD1Migrations, env } from "cloudflare:test"; import { beforeAll, describe, expect, it } from "vitest"; +import { + createAcquireStage, + type AcquisitionHolder, + type AcquisitionTarget, +} from "../src/artifact-acquisition.js"; import { computeRunKey, initialTriggerId, operatorTriggerId } from "../src/assessment-lifecycle.js"; import { AssessmentOrchestrator, StageTransientError, stubStages, type OrchestratorStages, + type StageAdapter, type StageFinding, } from "../src/assessment-orchestrator.js"; import { @@ -28,6 +34,7 @@ import { analyzeHistory } from "../src/history-context.js"; import { MODERATION_POLICY } from "../src/policy.js"; import { issueManualLabel } from "../src/service.js"; import { initializeSigningState } from "../src/signing-rotation.js"; +import { canonicalBundle, checksumOf, file } from "./bundle-fixture.js"; interface TestEnv { DB: D1Database; @@ -874,3 +881,104 @@ describe("AssessmentOrchestrator: supersession negation provenance (decision 6)" expect(pointer?.assessmentId).toBe(second.id); }); }); + +describe("AssessmentOrchestrator: real acquire stage (W7.2)", () => { + const resolveHostname = async () => ["203.0.113.5"]; + + function targetFor( + checksum: string, + ): (assessment: { uri: string }) => Promise { + return async () => ({ + url: "https://cdn.example.test/plugin.tgz", + checksum, + slug: "test-plugin", + version: "1.0.0", + }); + } + + it("acquires the bundle, feeds its file set to a downstream stage, and finalizes passed", async () => { + const run = await pendingRun({ name: "acquire-ok", cidValue: await cid("acquire-ok") }); + const bytes = await canonicalBundle(); + const holder: AcquisitionHolder = {}; + let downstreamFiles: readonly string[] = []; + + const acquire = createAcquireStage({ + deps: { fetch: async () => new Response(bytes), resolveHostname }, + resolveTarget: targetFor(await checksumOf(bytes)), + holder, + }); + // Stand-in for the code stage: reads the acquired file set the way the + // real codeAi wiring will. + const codeAi: StageAdapter = () => { + downstreamFiles = (holder.result?.files ?? []).map((f) => f.path); + return Promise.resolve([]); + }; + const orchestrator = await buildOrchestrator({ ...stubStages, acquire, codeAi }); + + const result = await orchestrator.runAssessment(run.id); + + expect(result.state).toBe("passed"); + expect(holder.result?.source).toBe("declared-url"); + expect(downstreamFiles).toEqual(["manifest.json", "backend.js"]); + }); + + it("blocks with artifact-integrity-failure when the fetched bytes miss the signed checksum", async () => { + const run = await pendingRun({ + name: "acquire-integrity", + cidValue: await cid("acquire-integrity"), + }); + const served = await canonicalBundle([file("tampered.js", "steal();")]); + const pinned = await checksumOf(await canonicalBundle()); + const acquire = createAcquireStage({ + deps: { fetch: async () => new Response(served), resolveHostname }, + resolveTarget: targetFor(pinned), + holder: {}, + }); + const orchestrator = await buildOrchestrator({ ...stubStages, acquire }); + + const result = await orchestrator.runAssessment(run.id); + + expect(result.state).toBe("blocked"); + const label = await testEnv.DB.prepare( + `SELECT neg FROM issued_labels WHERE uri = ? AND cid = ? AND val = 'artifact-integrity-failure'`, + ) + .bind(run.uri, run.cid) + .first<{ neg: number }>(); + expect(label?.neg).toBe(0); + }); + + it("retries then finalizes error when the declared URL is unfetchable", async () => { + const run = await pendingRun({ + name: "acquire-transient", + cidValue: await cid("acquire-transient"), + }); + let attempts = 0; + const acquire = createAcquireStage({ + deps: { + fetch: async () => { + attempts += 1; + await Promise.resolve(); + throw new TypeError("origin unreachable"); + }, + resolveHostname, + }, + resolveTarget: targetFor(await checksumOf(await canonicalBundle())), + holder: {}, + }); + const orchestrator = await buildOrchestrator( + { ...stubStages, acquire }, + { maxStageRetries: 1 }, + ); + + const result = await orchestrator.runAssessment(run.id); + + expect(result.state).toBe("error"); + expect(attempts).toBe(2); + const error = await testEnv.DB.prepare( + `SELECT neg FROM issued_labels WHERE uri = ? AND cid = ? AND val = 'assessment-error'`, + ) + .bind(run.uri, run.cid) + .first<{ neg: number }>(); + expect(error?.neg).toBe(0); + }); +}); diff --git a/apps/labeler/test/bundle-fixture.ts b/apps/labeler/test/bundle-fixture.ts new file mode 100644 index 0000000000..4535251a24 --- /dev/null +++ b/apps/labeler/test/bundle-fixture.ts @@ -0,0 +1,51 @@ +/** + * Test helpers that build canonical plugin bundles (gzipped USTAR tar) and + * their multibase checksums, entirely in-runtime (workerd `CompressionStream`, + * no `node:zlib`). Shared by the acquisition unit tests and the orchestrator + * acquire-stage test. + */ + +import { computeMultihash } from "@emdash-cms/registry-verification"; +import { packTar, type TarEntry } from "modern-tar"; + +const encoder = new TextEncoder(); + +export const FIXTURE_MANIFEST = { + id: "test-plugin", + version: "1.0.0", + capabilities: ["write:content"], + allowedHosts: [], + storage: {}, + hooks: [], + routes: [], + admin: {}, +} as const; + +export 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 }; +} + +export async function gzipBytes(bytes: Uint8Array): Promise { + const compressed = new Response(bytes).body!.pipeThrough(new CompressionStream("gzip")); + return new Uint8Array(await new Response(compressed).arrayBuffer()); +} + +export async function bundleBytes(entries: TarEntry[]): Promise { + return gzipBytes(await packTar(entries)); +} + +/** A valid bundle: manifest.json + backend.js, plus any extra entries. */ +export async function canonicalBundle(extra: TarEntry[] = []): Promise { + return bundleBytes([ + file("manifest.json", JSON.stringify(FIXTURE_MANIFEST)), + file("backend.js", "export default {};"), + ...extra, + ]); +} + +export async function checksumOf(bytes: Uint8Array): Promise { + const result = await computeMultihash(bytes); + if (!result.success) throw new Error(`could not hash fixture: ${result.error.code}`); + return result.value; +} diff --git a/packages/registry-verification/src/bundle.ts b/packages/registry-verification/src/bundle.ts index cd0e737d4b..d60acbd8e0 100644 --- a/packages/registry-verification/src/bundle.ts +++ b/packages/registry-verification/src/bundle.ts @@ -24,11 +24,19 @@ export interface ValidatePluginBundleOptions { expectedVersion?: string; } +export interface ValidatedBundleFile { + path: string; + bytes: Uint8Array; +} + export interface ValidatedPluginBundle { manifest: PluginManifest; declaredAccess: DeclaredAccess; backend: Uint8Array; admin?: Uint8Array; + /** Every regular file in the archive, in tar order — the validated file + * inventory later stages extract their analysis inputs from. */ + files: readonly ValidatedBundleFile[]; } interface ParsedFile { @@ -98,6 +106,7 @@ export async function validatePluginBundle( manifest, declaredAccess: manifest.declaredAccess ?? {}, backend: backend.data, + files: [...files.value.values()].map((file) => ({ path: file.name, bytes: file.data })), }; const admin = files.value.get("admin.js"); if (admin) result.admin = admin.data; diff --git a/packages/registry-verification/src/index.ts b/packages/registry-verification/src/index.ts index 7e1c09151a..53b03ad61e 100644 --- a/packages/registry-verification/src/index.ts +++ b/packages/registry-verification/src/index.ts @@ -26,7 +26,11 @@ export type { VerifiedResource, } from "./fetch.js"; export type { VerificationError, VerificationErrorCode, VerificationResult } from "./errors.js"; -export type { ValidatePluginBundleOptions, ValidatedPluginBundle } from "./bundle.js"; +export type { + ValidatedBundleFile, + ValidatePluginBundleOptions, + ValidatedPluginBundle, +} from "./bundle.js"; export type { ProvenanceVerificationInput, ProvenanceVerifier, diff --git a/packages/registry-verification/tests/bundle.test.ts b/packages/registry-verification/tests/bundle.test.ts index ecd17412de..ac1a80d308 100644 --- a/packages/registry-verification/tests/bundle.test.ts +++ b/packages/registry-verification/tests/bundle.test.ts @@ -75,6 +75,22 @@ describe("validatePluginBundle", () => { } }); + it("exposes every regular file in the archive as the validated inventory", async () => { + const result = await validatePluginBundle( + await canonicalBundle([file("admin.js", "export const a = 1;"), file("README.md", "hi")]), + ); + expect(result.success).toBe(true); + if (!result.success) return; + const decoder = new TextDecoder(); + const inventory = result.value.files.map((f) => [f.path, decoder.decode(f.bytes)]); + expect(inventory).toEqual([ + ["manifest.json", JSON.stringify(manifest)], + ["backend.js", "export default {};"], + ["admin.js", "export const a = 1;"], + ["README.md", "hi"], + ]); + }); + it("rejects expected slug and version mismatches", async () => { const bytes = await canonicalBundle(); expect(await validatePluginBundle(bytes, { expectedSlug: "other" })).toMatchObject({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2706fa09b7..354987e5bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -450,6 +450,9 @@ importers: '@emdash-cms/registry-moderation': specifier: workspace:* version: link:../../packages/registry-moderation + '@emdash-cms/registry-verification': + specifier: workspace:* + version: link:../../packages/registry-verification jose: specifier: ^6.1.3 version: 6.1.3 @@ -508,6 +511,9 @@ importers: jsdom: specifier: ^26.1.0 version: 26.1.0 + modern-tar: + specifier: ^0.7.6 + version: 0.7.6 react: specifier: 'catalog:' version: 19.2.4 @@ -792,7 +798,7 @@ importers: dependencies: '@astrojs/cloudflare': specifier: ^13.1.7 - version: 13.1.7(@types/node@25.9.1)(astro@6.1.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.55.2)(typescript@6.0.3)(yaml@2.9.0))(jiti@2.7.0)(lightningcss@1.32.0)(wrangler@4.110.0)(yaml@2.9.0) + version: 13.1.7(@types/node@25.9.1)(astro@6.1.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.55.2)(typescript@6.0.3)(yaml@2.9.0))(jiti@2.7.0)(lightningcss@1.32.0)(wrangler@4.110.0(@cloudflare/workers-types@5.20260712.1))(yaml@2.9.0) '@astrojs/starlight': specifier: ^0.38.2 version: 0.38.2(astro@6.1.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.55.2)(typescript@6.0.3)(yaml@2.9.0)) @@ -1147,7 +1153,7 @@ importers: devDependencies: '@flue/cli': specifier: 1.0.0-beta.9 - version: 1.0.0-beta.9(@cfworker/json-schema@4.1.1)(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@types/node@25.9.1)(esbuild@0.28.1)(hono@4.12.23)(jiti@2.7.0)(typebox@1.1.38)(typescript@6.0.3)(wrangler@4.110.0)(ws@8.21.0)(yaml@2.9.0)(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1) + version: 1.0.0-beta.9(@cfworker/json-schema@4.1.1)(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@types/node@25.9.1)(esbuild@0.28.1)(hono@4.12.23)(jiti@2.7.0)(typebox@1.1.38)(typescript@6.0.3)(wrangler@4.110.0(@cloudflare/workers-types@5.20260712.1))(ws@8.21.0)(yaml@2.9.0)(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1) typescript: specifier: 'catalog:' version: 6.0.3 @@ -12179,11 +12185,11 @@ snapshots: - prettier - prettier-plugin-astro - '@astrojs/cloudflare@13.1.7(@types/node@25.9.1)(astro@6.1.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.55.2)(typescript@6.0.3)(yaml@2.9.0))(jiti@2.7.0)(lightningcss@1.32.0)(wrangler@4.110.0)(yaml@2.9.0)': + '@astrojs/cloudflare@13.1.7(@types/node@25.9.1)(astro@6.1.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.55.2)(typescript@6.0.3)(yaml@2.9.0))(jiti@2.7.0)(lightningcss@1.32.0)(wrangler@4.110.0(@cloudflare/workers-types@5.20260712.1))(yaml@2.9.0)': dependencies: '@astrojs/internal-helpers': 0.8.0 '@astrojs/underscore-redirects': 1.0.3 - '@cloudflare/vite-plugin': 1.44.0(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))(wrangler@4.110.0) + '@cloudflare/vite-plugin': 1.44.0(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))(wrangler@4.110.0(@cloudflare/workers-types@5.20260712.1)) astro: 6.1.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.55.2)(typescript@6.0.3)(yaml@2.9.0) piccolore: 0.1.3 tinyglobby: 0.2.15 @@ -13855,7 +13861,7 @@ snapshots: - utf-8-validate - workerd - '@cloudflare/vite-plugin@1.44.0(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))(wrangler@4.110.0)': + '@cloudflare/vite-plugin@1.44.0(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))(wrangler@4.110.0(@cloudflare/workers-types@5.20260712.1))': dependencies: '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260708.1) miniflare: 4.20260708.1 @@ -13907,19 +13913,6 @@ snapshots: - utf-8-validate - workerd - '@cloudflare/vite-plugin@1.44.0(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))(wrangler@4.110.0)': - dependencies: - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260708.1) - miniflare: 4.20260708.1 - unenv: 2.0.0-rc.24 - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) - wrangler: 4.110.0(@cloudflare/workers-types@5.20260712.1) - ws: 8.21.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - workerd - '@cloudflare/vitest-pool-workers@0.18.4(@cloudflare/workers-types@5.20260712.1)(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))': dependencies: '@vitest/runner': 4.1.5 @@ -14451,9 +14444,9 @@ snapshots: '@floating-ui/utils@0.2.11': {} - '@flue/cli@1.0.0-beta.9(@cfworker/json-schema@4.1.1)(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@types/node@25.9.1)(esbuild@0.28.1)(hono@4.12.23)(jiti@2.7.0)(typebox@1.1.38)(typescript@6.0.3)(wrangler@4.110.0)(ws@8.21.0)(yaml@2.9.0)(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1)': + '@flue/cli@1.0.0-beta.9(@cfworker/json-schema@4.1.1)(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@types/node@25.9.1)(esbuild@0.28.1)(hono@4.12.23)(jiti@2.7.0)(typebox@1.1.38)(typescript@6.0.3)(wrangler@4.110.0(@cloudflare/workers-types@5.20260712.1))(ws@8.21.0)(yaml@2.9.0)(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1)': dependencies: - '@cloudflare/vite-plugin': 1.44.0(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))(wrangler@4.110.0) + '@cloudflare/vite-plugin': 1.44.0(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))(wrangler@4.110.0(@cloudflare/workers-types@5.20260712.1)) '@flue/runtime': 1.0.0-beta.9(@cfworker/json-schema@4.1.1)(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(typebox@1.1.38)(typescript@6.0.3)(ws@8.21.0)(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1) '@flue/sdk': 1.0.0-beta.9 '@hono/node-server': 2.0.4(hono@4.12.23) From 4c3a3b207c38903f112dd14d78406acf38631649 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 11:42:38 +0100 Subject: [PATCH 082/137] docs(labeler-plan): ratify fetch-over-binding read path + foundation scope (W8.4 slice 3) --- .../plugin-registry-labelling-service/implementation-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 6024113e4f..f6cedf7ebc 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1053,6 +1053,8 @@ Dependencies: `W2.3`, registry publisher/profile data. Decisions (2026-07-14): history is not a new contract surface — `FindingSource` already includes `"history"` and the resolver already drops it (`policy-resolver.ts` excludes `source: "history"` before any category→label mapping; `isBlockingFinding` returns false for it), so "never auto-label" is structural, not a new guard. "Recommend review" is passive: a history finding surfaces through the existing assessment-detail projection an operator already opens; no `operational_events` push and no new "flag-for-review" operator action (D3). Two of the five inputs — recent handle/profile changes and verification state — live only in the aggregator's D1, which the labeler has no binding to reach (and `constants.ts` deliberately avoids ingesting profiles); they are deferred to a later slice gated on a ratified read-only labeler→aggregator read path (service-binding preferred over a second D1 binding), not built now (D1). Ratified (2026-07-16): slice 3 uses a read-only SERVICE BINDING to the aggregator — the aggregator exposes the publisher/profile/verification reads as an internal API and the labeler binds to it; no second D1 binding (coupling to the aggregator's physical schema was rejected). Repeated-checksum detection is global (cross-publisher): a checksum reused under a different DID is the sharper signal, needing a new `idx_assessments_artifact_checksum` and a cross-DID lookup (D2). Prior releases, active manual labels (reuse `getActiveLabelState`, filter `!automated && active`), and repeated checksums/findings come from the labeler's own D1. The finding contract is amended (touches W8.1): `source: "history"` findings cite a dedicated history/review category set exempt from the automated-block ∪ warning constraint that `validateFinding` enforces, rather than borrowing a warn category (D4). Neutral display facts (prior-release count, verification badge) are assembled at console read-time in the existing subject-history endpoint, not persisted as a pipeline `ContextBundle` (D5). The stage is DB-bound (`analyzeHistory(db, assessment, opts) → NormalizedFinding[]`), wired into `OrchestratorStages`/`STAGE_ORDER`/`stubStages` and run last. Because history is operator-only context that never becomes a label, the stage is best-effort — on any error it logs and returns no findings rather than throwing, so it can never fail the run and discard the decision-relevant findings from the other stages (adversary-flagged 2026-07-14). History findings keep sensitive specifics (cross-publisher checksum reuse, active manual-label identities/existence including redactions) in `privateDetail` only, never in the public-facing `title`/`publicSummary`; slice (2)'s surfacing must additionally render history findings in the operator projection only and exclude them from any public serializer. Sliced: (1) own-D1 history findings + the W8.1 amendment + the checksum index — unblocked; (2) console neutral-context surfacing — unblocked, optional; (3) the two aggregator-sourced inputs — blocked on D1's read path. +Ratified (2026-07-16, implementation shape for the read path): the service binding is FETCH-OVER-BINDING, not RPC — the aggregator is an HTTP/XRPC-only module Worker (`apps/aggregator/src/index.ts` exports a plain `{fetch,queue,scheduled}`, no `WorkerEntrypoint`), and the repo's sole precedent (`infra/perf-monitor`) consumes a bound Worker via `env.BINDING.fetch(...)`. Adding a WorkerEntrypoint/RPC class was rejected as a new pattern for marginal typing gain when the shared `@emdash-cms/registry-lexicons` view types already give the response contract. The FOUNDATION (labeler→aggregator binding + typed `aggregator-client.ts` over the EXISTING XRPC reads `getPackage`/`getLatestRelease`/`listReleases`) reuses routes that already serve what most consumers need: `releaseView.release.artifacts` supplies the acquire stage's URL+checksum (the assessment row has cid/checksum but no URL), and `packageView.profile.authors[]`/`security[]` supply W10.4 tiers 1–2. Internal reads omit the `atproto-accept-labelers` header so the view is unfiltered. The client does exactly one fetch per call (no caching — honors W10.4's fresh-read-at-notification-time rule); NotFound→null, transport/5xx→throw (caller treats as transient). Slice 3's own two inputs (verification state, recent handle/profile changes) are NOT in any current view and require NEW aggregator internal reads added on top of the foundation — that is slice 3's remaining work, not the foundation's. + ### `W8.5` Implement versioned policy resolver Resolve normalized findings under an immutable policy version: From 758294e21fb21186e8917ccf8f3e6f837d2447c8 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 12:13:30 +0100 Subject: [PATCH 083/137] docs(labeler-plan): record W10.5 CSPRNG confirm-token requirement (slice-C adversary) --- .../plugin-registry-labelling-service/implementation-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index f6cedf7ebc..64697e54d7 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1246,6 +1246,8 @@ Delivery uses Cloudflare Email Sending through the Workers `send_email` binding; Notify on block, warning, override, retraction, prolonged error, and emergency takedown. Include public summary/effect/reconsideration URL without private details. Delivery retries independently and never rolls back a label. +Hard requirement (2026-07-16, from the W10.4 slice-C adversary pass): the confirm token this slice generates MUST come from a CSPRNG with ≥128 bits of entropy. The confirm endpoint stores `SHA-256(token)` without a pepper (unlike the recipient hash, which is HMAC-peppered because email is low-entropy) and applies no in-worker rate limit, so the token's unguessability is the sole protection against a brute-force confirm of a contact whose recipient hash leaked. A weak or predictable token would make double opt-in bypassable through the public endpoint. Do not reuse a ULID or any monotonic/timestamped id as the token. + Dependencies: `W2.3`, `W10.3`, `W10.4`. ### `W10.6` Implement reconsideration workflow From e95281e60055c14dd2e7adb0542ecf5d17a6aeb8 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 12:38:51 +0100 Subject: [PATCH 084/137] feat(labeler): publisher-notification token endpoints (W10.4 slice C) (#2071) * feat(labeler): publisher-notification token endpoints (W10.4 slice C) Add the three public, token-authenticated landing endpoints an email recipient clicks: /notifications/confirm, /notifications/unsubscribe, and /notifications/not-me. Mounted outside /admin/* so neither the Cloudflare Access edge policy nor the operator guard applies -- the capability in the link is the sole authority. GET renders a confirmation page with a POST form; only POST mutates, so an email scanner's automated GET never confirms or suppresses. Responses are neutral and uniform regardless of token validity or on-file status, with the real outcome logged server-side only (never the token). Confirm always runs confirmContact's constant-time compare and never confirms a suppressed contact. Unsubscribe/not-me record an idempotent, hash-keyed suppression and decline any pending confirmation. Adds hashConfirmToken to notification-contacts (SHA-256 of the raw token, the stored confirm_token_hash form) as the shared verify/send contract. Co-Authored-By: Claude Opus 4.8 * feat(labeler): gate notification suppression on contact existence Address the adversary pass on W10.4 slice C. Unsubscribe/not-me now write a suppression only when a contact row already exists for the hash. A recipient who legitimately received mail always has one (ensureContact created it, and contacts are never swept), so gating costs legit users nothing while an attacker who fabricates a well-formed but unseen recipient hash writes no orphan suppression row. The response stays byte-identical (the caller only ever sees the neutral done page). Adds a read-only contactExists helper. Document the token-entropy cross-slice dependency at hashConfirmToken (and the confirm path): the pepper-less SHA-256 lookup with no in-worker rate limit is safe only if the W10.5 send path draws the token from a CSPRNG at >=128 bits. Co-Authored-By: Claude Opus 4.8 * fix(labeler): deny framing on notification landing pages (clickjacking defense-in-depth) * fix(labeler): tighten notification-page CSP to lock down script/object/form-action --------- Co-authored-by: Claude Opus 4.8 --- apps/labeler/src/index.ts | 6 + apps/labeler/src/notification-contacts.ts | 30 ++ apps/labeler/src/notification-endpoints.ts | 297 ++++++++++++++++++ .../test/notification-endpoints.test.ts | 246 +++++++++++++++ 4 files changed, 579 insertions(+) create mode 100644 apps/labeler/src/notification-endpoints.ts create mode 100644 apps/labeler/test/notification-endpoints.test.ts diff --git a/apps/labeler/src/index.ts b/apps/labeler/src/index.ts index d5d766fbbd..cd6d4e9ea4 100644 --- a/apps/labeler/src/index.ts +++ b/apps/labeler/src/index.ts @@ -10,6 +10,7 @@ import { drainDiscoveryDeadLetterBatch, processDiscoveryBatch } from "./discover import { LABELER_DISCOVERY_DO_NAME } from "./discovery-do.js"; import type { DiscoveryJob } from "./env.js"; import { didDocumentResponse, policyDocumentResponse } from "./identity.js"; +import { handleNotificationRequest, isNotificationPath } from "./notification-endpoints.js"; import { queryLabels } from "./query-labels.js"; import { reconcileAssessments } from "./reconciliation.js"; import { createRuntimeSigner, getRuntimeSigningSecret } from "./signing-runtime.js"; @@ -51,6 +52,11 @@ export default { if (pathname === CREATE_REPORT_PATH) return rejectModerationReport(request); return handleAssessmentXrpc(env, request, config); } + // Public publisher-notification landing pages (confirm / unsubscribe / + // not-me). Mounted outside `/admin/*` so the Access edge policy and the + // operator guard never apply — the capability in the link is the sole + // authority (see notification-endpoints.ts). + if (isNotificationPath(pathname)) return handleNotificationRequest(env.DB, request); // `/admin/api/*` is the Access-guarded operator read API; anything else // under `/admin` is the console SPA, served (with SPA deep-link // fallback) by the assets binding. The Access edge policy on `/admin/*` diff --git a/apps/labeler/src/notification-contacts.ts b/apps/labeler/src/notification-contacts.ts index 8817bb13a2..add1379d34 100644 --- a/apps/labeler/src/notification-contacts.ts +++ b/apps/labeler/src/notification-contacts.ts @@ -101,6 +101,27 @@ function getHmacKey(pepper: string): Promise { return key; } +/** + * SHA-256 hex (64 chars) of a raw confirmation token — the stored + * `confirm_token_hash` form. The confirm endpoint hashes the token from the + * email link through this before {@link confirmContact}'s constant-time compare; + * the send path (W10.5) stores the same digest via {@link recordConfirmSent}, so + * the raw token never touches the database. WebCrypto only, so it runs unchanged + * on workerd. + * + * SECURITY — token entropy is a hard cross-slice dependency. This digest is + * pepper-less (plain SHA-256, so an offline dictionary of candidate tokens maps + * straight to stored hashes), and the confirm endpoint has no in-worker rate + * limit. Its safety therefore rests entirely on the token being unguessable: + * the W10.5 send path MUST draw the token from a CSPRNG with at least 128 bits + * of entropy. A weak or predictable token combined with a leaked recipient hash + * would be brute-forceable through the confirm endpoint. + */ +export async function hashConfirmToken(token: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(token)); + return toHex(digest); +} + export async function getContactState( db: D1Database, recipientHashValue: string, @@ -211,6 +232,15 @@ export async function declineContact(db: D1Database, recipientHashValue: string) return result.meta.changes > 0; } +/** Whether a contact row exists for the hash, without reading its columns. */ +export async function contactExists(db: D1Database, recipientHashValue: string): Promise { + const row = await db + .prepare(`SELECT 1 FROM notification_contacts WHERE recipient_hash = ? LIMIT 1`) + .bind(recipientHashValue) + .first<{ 1: number }>(); + return row !== null; +} + export async function isSuppressed(db: D1Database, recipientHashValue: string): Promise { const row = await db .prepare(`SELECT 1 FROM notification_suppressions WHERE recipient_hash = ? LIMIT 1`) diff --git a/apps/labeler/src/notification-endpoints.ts b/apps/labeler/src/notification-endpoints.ts new file mode 100644 index 0000000000..b3abed96d7 --- /dev/null +++ b/apps/labeler/src/notification-endpoints.ts @@ -0,0 +1,297 @@ +/** + * Public, token-authenticated landing endpoints for publisher-notification + * emails (spec §18/§19, plan W10.4 slice C). These back the links a recipient + * clicks — confirm, unsubscribe, and "not me" — and are mounted OUTSIDE + * `/admin/*` so neither the Cloudflare Access edge policy nor the in-Worker + * operator guard applies: the capability in the link is the sole authority. + * + * The recipient hash (an HMAC-SHA256 digest, never plaintext email) travels in + * the link and is the unforgeable capability — an attacker cannot compute a + * victim's hash without the pepper. The confirm link additionally carries a + * single-use random token whose SHA-256 digest the send path stored; the raw + * token never touches the database. + * + * Safety properties: + * - GET renders a confirmation page with a POST form; only POST mutates. An + * email scanner's or link-prefetcher's automated GET therefore never + * confirms, unsubscribes, or suppresses. The token/hash live in hidden form + * fields, so the mutating POST carries them in the body, not the URL. + * - CSRF: a cross-site POST cannot supply a valid recipient hash (or confirm + * token) for a victim, so possession of the capability is the CSRF defense; + * a custom header (unsendable from a plain email-client form) is not used. + * - Uniform responses: the page a caller sees never reveals whether the token + * was valid, already consumed, or unknown, nor whether an address is on + * file. The real outcome is logged server-side only, without the token. + * - The confirm path always runs {@link confirmContact}'s constant-time + * compare (fed a non-matching sentinel for a suppressed contact), so elapsed + * time never reveals token validity, and a suppressed contact is never + * confirmed. + */ + +import { + confirmContact, + contactExists, + declineContact, + hashConfirmToken, + isSuppressed, + suppress, +} from "./notification-contacts.js"; + +type NotificationAction = "confirm" | "unsubscribe" | "not-me"; + +const ACTION_PATHS: Record = { + "/notifications/confirm": "confirm", + "/notifications/unsubscribe": "unsubscribe", + "/notifications/not-me": "not-me", +}; + +/** A well-formed recipient hash: lowercase hex of an HMAC-SHA256 digest. */ +const RECIPIENT_HASH = /^[0-9a-f]{64}$/; + +/** URL-safe (base64url/hex) raw confirm token, length-bounded to cap hashing. */ +const CONFIRM_TOKEN = /^[A-Za-z0-9_-]{1,512}$/; + +/** + * A 64-hex value no real digest collides with (2^-256). Used both as a stand-in + * recipient hash when the supplied one is malformed — so the confirm path still + * runs its full constant-time compare against an absent row — and as the token + * hash fed to {@link confirmContact} for a suppressed contact, so the compare + * runs full-length yet can never flip the row. + */ +const UNMATCHABLE_HASH = "0".repeat(64); + +export function isNotificationPath(pathname: string): boolean { + return pathname in ACTION_PATHS; +} + +export async function handleNotificationRequest( + db: D1Database, + request: Request, + now: () => Date = () => new Date(), +): Promise { + const pathname = new URL(request.url).pathname; + const action = ACTION_PATHS[pathname]; + if (!action) return htmlResponse(notFoundPage(), 404); + + if (request.method === "GET") return htmlResponse(formPage(action, readParams(request, action))); + if (request.method !== "POST") + return new Response("method not allowed", { + status: 405, + headers: { allow: "GET, POST", "cache-control": "no-store" }, + }); + + const params = await readPostParams(request, action); + switch (action) { + case "confirm": + await performConfirm(db, params.recipientHash, params.token, now()); + break; + case "unsubscribe": + await performSuppression(db, params.recipientHash, "unsubscribe", now()); + break; + case "not-me": + await performSuppression(db, params.recipientHash, "not_me", now()); + break; + } + return htmlResponse(donePage(action)); +} + +interface RequestParams { + /** The raw recipient hash, unvalidated (echoed only after escaping). */ + recipientHash: string; + /** The raw confirm token, unvalidated; empty for non-confirm actions. */ + token: string; +} + +function readParams(request: Request, action: NotificationAction): RequestParams { + const query = new URL(request.url).searchParams; + return { + recipientHash: query.get("c") ?? "", + token: action === "confirm" ? (query.get("t") ?? "") : "", + }; +} + +async function readPostParams( + request: Request, + action: NotificationAction, +): Promise { + let form: FormData; + try { + form = await request.formData(); + } catch { + return { recipientHash: "", token: "" }; + } + const recipientHash = form.get("c"); + const token = form.get("t"); + return { + recipientHash: typeof recipientHash === "string" ? recipientHash : "", + token: action === "confirm" && typeof token === "string" ? token : "", + }; +} + +/** + * Flip `unconfirmed -> confirmed` iff the token matches, and never for a + * suppressed contact. A malformed recipient hash routes to an absent row and a + * suppressed contact to a non-matching sentinel token, so the constant-time + * compare always runs full-length regardless of validity or suppression. + * + * This endpoint has no rate limit and the token lookup is pepper-less, so its + * safety depends on the token's entropy — see {@link hashConfirmToken}. + */ +async function performConfirm( + db: D1Database, + rawRecipientHash: string, + rawToken: string, + now: Date, +): Promise { + const recipientHash = RECIPIENT_HASH.test(rawRecipientHash) ? rawRecipientHash : UNMATCHABLE_HASH; + const tokenHash = CONFIRM_TOKEN.test(rawToken) + ? await hashConfirmToken(rawToken) + : UNMATCHABLE_HASH; + const suppressed = await isSuppressed(db, recipientHash); + const effectiveTokenHash = suppressed ? UNMATCHABLE_HASH : tokenHash; + const confirmed = await confirmContact(db, recipientHash, effectiveTokenHash, now.toISOString()); + logOutcome( + "confirm", + rawRecipientHash, + suppressed ? "suppressed" : confirmed ? "confirmed" : "no-op", + ); +} + +/** + * Record a suppression (idempotent) and decline any pending confirmation, so a + * suppressed address can never later confirm. A confirmed opt-in is not + * downgraded — it keeps its state and gains a suppression row that the send path + * honors. + * + * The write is gated on an existing contact row: a legitimate recipient always + * has one (they were sent mail, which created it via `ensureContact`, and + * contacts are never swept), so gating costs them nothing while an attacker who + * fabricates a well-formed but unseen hash writes no orphan suppression row. A + * malformed or unknown hash is a neutral no-op; the response is byte-identical + * either way because the caller only ever sees {@link donePage}. + */ +async function performSuppression( + db: D1Database, + rawRecipientHash: string, + reason: "unsubscribe" | "not_me", + now: Date, +): Promise { + if (!RECIPIENT_HASH.test(rawRecipientHash)) { + logOutcome(reason, rawRecipientHash, "ignored-malformed"); + return; + } + if (!(await contactExists(db, rawRecipientHash))) { + logOutcome(reason, rawRecipientHash, "ignored-no-contact"); + return; + } + await suppress(db, rawRecipientHash, reason, now.toISOString(), now.getTime()); + await declineContact(db, rawRecipientHash); + logOutcome(reason, rawRecipientHash, "suppressed"); +} + +/** + * Log the real outcome for operability without leaking the capability: the raw + * token is never logged, and only an 8-char prefix of the recipient hash is + * recorded for correlation. + */ +function logOutcome(action: string, rawRecipientHash: string, outcome: string): void { + console.log("[notifications]", { + action, + outcome, + hashPrefix: rawRecipientHash.slice(0, 8), + }); +} + +function htmlResponse(body: string, status = 200): Response { + return new Response(body, { + status, + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", + "x-frame-options": "DENY", + "content-security-policy": + "default-src 'none'; script-src 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'", + }, + }); +} + +const ACTION_COPY: Record = { + confirm: { + title: "Confirm notifications", + prompt: + "Confirm that this address should receive publisher notifications from the emdash labeler.", + button: "Confirm", + }, + unsubscribe: { + title: "Unsubscribe", + prompt: "Stop sending publisher notifications from the emdash labeler to this address.", + button: "Unsubscribe", + }, + "not-me": { + title: "Not my address", + prompt: "Report that you did not expect this message and should not be contacted again.", + button: "Do not contact me", + }, +}; + +const DONE_COPY: Record = { + confirm: + "If your confirmation link was valid, this address is now confirmed. You can close this page.", + unsubscribe: + "This address will no longer receive publisher notifications from the emdash labeler. You can close this page.", + "not-me": + "Thank you. This address will not be contacted again by the emdash labeler. You can close this page.", +}; + +function formPage(action: NotificationAction, params: RequestParams): string { + const copy = ACTION_COPY[action]; + const hidden = [``]; + if (action === "confirm") + hidden.push(``); + return page( + copy.title, + `

${escapeHtml(copy.prompt)}

+
+ ${hidden.join("\n\t\t\t")} + +
`, + ); +} + +function donePage(action: NotificationAction): string { + return page(ACTION_COPY[action].title, `

${escapeHtml(DONE_COPY[action])}

`); +} + +function notFoundPage(): string { + return page("Not found", "

This link is not valid.

"); +} + +function page(title: string, body: string): string { + return ` + + + + + +${escapeHtml(title)} + + +
+

${escapeHtml(title)}

+${body} +
+ + +`; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/apps/labeler/test/notification-endpoints.test.ts b/apps/labeler/test/notification-endpoints.test.ts new file mode 100644 index 0000000000..588cafd8ff --- /dev/null +++ b/apps/labeler/test/notification-endpoints.test.ts @@ -0,0 +1,246 @@ +import { applyD1Migrations, env, SELF } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { + ensureContact, + getContactState, + hashConfirmToken, + isSuppressed, + recipientHash, + recordConfirmSent, + suppress, +} from "../src/notification-contacts.js"; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; +} + +const testEnv = env as unknown as TestEnv; +const db = () => testEnv.DB; + +const PEPPER = "test-notification-hash-pepper"; +let seq = 0; + +/** A distinct recipient hash per test, so rows never collide across cases. */ +async function freshHash(): Promise { + seq++; + return recipientHash(PEPPER, `notify-${seq}@example.test`); +} + +/** Seed an unconfirmed contact carrying a pending confirmation token. */ +async function seedPending(rawToken: string): Promise { + const hash = await freshHash(); + await ensureContact(db(), hash, "2026-07-16T00:00:00.000Z"); + await recordConfirmSent(db(), hash, await hashConfirmToken(rawToken), 1_000); + return hash; +} + +function get(path: string, init?: RequestInit): Promise { + return SELF.fetch(`https://labeler.test${path}`, init); +} + +function post(path: string, fields: Record): Promise { + return SELF.fetch(`https://labeler.test${path}`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(fields).toString(), + }); +} + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); +}); + +describe("route mounting and Access bypass", () => { + it("serves the confirm page without any Cloudflare Access assertion", async () => { + const response = await get("/notifications/confirm"); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8"); + }); + + it("still guards /admin/api with Access (proving notifications are not behind it)", async () => { + const response = await get("/admin/api/assessments", { + headers: { "X-EmDash-Request": "1" }, + }); + expect(response.status).toBe(401); + }); + + it("sets no-store and no-referrer so the token does not leak downstream", async () => { + const response = await get("/notifications/confirm?c=abc&t=xyz"); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("referrer-policy")).toBe("no-referrer"); + }); + + it("denies framing so the state-changing form cannot be clickjacked", async () => { + const response = await get("/notifications/confirm?c=abc&t=xyz"); + expect(response.headers.get("x-frame-options")).toBe("DENY"); + expect(response.headers.get("content-security-policy")).toContain("frame-ancestors 'none'"); + expect(response.headers.get("content-security-policy")).toContain("form-action 'self'"); + expect(response.headers.get("content-security-policy")).toContain("default-src 'none'"); + }); + + it("rejects non-GET/POST methods with an Allow header", async () => { + const response = await get("/notifications/confirm", { method: "PUT" }); + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("GET, POST"); + }); +}); + +describe("GET does not mutate", () => { + it("confirm GET renders a form and leaves the contact unconfirmed", async () => { + const token = "confirm-token-get"; + const hash = await seedPending(token); + + const response = await get(`/notifications/confirm?c=${hash}&t=${encodeURIComponent(token)}`); + expect(response.status).toBe(200); + const body = await response.text(); + expect(body).toContain('
{ + const hash = await freshHash(); + await ensureContact(db(), hash, "2026-07-16T00:00:00.000Z"); + + const response = await get(`/notifications/unsubscribe?c=${hash}`); + expect(response.status).toBe(200); + expect(await isSuppressed(db(), hash)).toBe(false); + }); + + it("escapes caller-supplied values reflected into the form", async () => { + const response = await get( + `/notifications/confirm?c=${encodeURIComponent('">')}`, + ); + const body = await response.text(); + expect(body).not.toContain(""); + expect(body).toContain("<script>"); + }); +}); + +describe("confirm", () => { + it("confirms an unconfirmed contact on a valid token", async () => { + const token = "confirm-token-valid"; + const hash = await seedPending(token); + + const response = await post("/notifications/confirm", { c: hash, t: token }); + expect(response.status).toBe(200); + + const state = await getContactState(db(), hash); + expect(state?.confirmState).toBe("confirmed"); + expect(state?.confirmTokenHash).toBeNull(); + }); + + it("is an idempotent no-op once already confirmed", async () => { + const token = "confirm-token-twice"; + const hash = await seedPending(token); + + const first = await post("/notifications/confirm", { c: hash, t: token }); + const second = await post("/notifications/confirm", { c: hash, t: token }); + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(await first.text()).toBe(await second.text()); + expect((await getContactState(db(), hash))?.confirmState).toBe("confirmed"); + }); + + it("never confirms a suppressed contact even with a matching token", async () => { + const token = "confirm-token-suppressed"; + const hash = await seedPending(token); + // A suppression that did not decline the contact (e.g. a hard bounce) + // leaves an unconfirmed row with a live token; the endpoint's suppression + // guard must still refuse the confirm. + await suppress(db(), hash, "bounce", "2026-07-16T00:00:00.000Z", 1_000); + + const response = await post("/notifications/confirm", { c: hash, t: token }); + expect(response.status).toBe(200); + expect((await getContactState(db(), hash))?.confirmState).toBe("unconfirmed"); + }); + + it("returns the same neutral page for valid, wrong, and unknown tokens", async () => { + const token = "confirm-token-uniform"; + const validHash = await seedPending(token); + const wrongHash = await seedPending(token); + const unknownHash = await freshHash(); + + const valid = await post("/notifications/confirm", { c: validHash, t: token }); + const wrong = await post("/notifications/confirm", { c: wrongHash, t: "not-the-token" }); + const unknown = await post("/notifications/confirm", { c: unknownHash, t: token }); + const malformed = await post("/notifications/confirm", { c: "not-a-hash", t: token }); + + const validBody = await valid.text(); + expect(await wrong.text()).toBe(validBody); + expect(await unknown.text()).toBe(validBody); + expect(await malformed.text()).toBe(validBody); + for (const response of [valid, wrong, unknown, malformed]) expect(response.status).toBe(200); + + // The neutral page did not confirm the contacts that should not have been. + expect((await getContactState(db(), wrongHash))?.confirmState).toBe("unconfirmed"); + expect(await getContactState(db(), unknownHash)).toBeNull(); + }); +}); + +describe("unsubscribe", () => { + it("records a suppression and is idempotent, keeping the first reason", async () => { + const hash = await freshHash(); + await ensureContact(db(), hash, "2026-07-16T00:00:00.000Z"); + + const first = await post("/notifications/unsubscribe", { c: hash }); + const second = await post("/notifications/unsubscribe", { c: hash }); + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(await isSuppressed(db(), hash)).toBe(true); + expect(await reasonFor(hash)).toBe("unsubscribe"); + expect((await getContactState(db(), hash))?.confirmState).toBe("declined"); + }); + + it("writes no row for a malformed recipient hash", async () => { + const response = await post("/notifications/unsubscribe", { c: "bogus" }); + expect(response.status).toBe(200); + const count = await db() + .prepare("SELECT COUNT(*) AS n FROM notification_suppressions WHERE recipient_hash = ?") + .bind("bogus") + .first<{ n: number }>(); + expect(count?.n).toBe(0); + }); +}); + +describe("not-me", () => { + it("suppresses with the not_me reason and declines any pending confirmation", async () => { + const hash = await seedPending("pending-token"); + + const response = await post("/notifications/not-me", { c: hash }); + expect(response.status).toBe(200); + expect(await isSuppressed(db(), hash)).toBe(true); + expect(await reasonFor(hash)).toBe("not_me"); + expect((await getContactState(db(), hash))?.confirmState).toBe("declined"); + }); + + it("writes no suppression for a well-formed hash with no contact row, yet returns the same done page", async () => { + // A recipient who legitimately received mail always has a contact row, so + // a well-formed hash with none is an attacker-fabricated value: it must + // write no orphan suppression row while staying response-uniform. + const seeded = await freshHash(); + await ensureContact(db(), seeded, "2026-07-16T00:00:00.000Z"); + const suppressed = await post("/notifications/not-me", { c: seeded }); + + const orphan = await freshHash(); + const noop = await post("/notifications/not-me", { c: orphan }); + + expect(noop.status).toBe(200); + expect(await noop.text()).toBe(await suppressed.text()); + expect(await isSuppressed(db(), orphan)).toBe(false); + expect(await reasonFor(orphan)).toBeUndefined(); + // The seeded contact, which does exist, was suppressed. + expect(await isSuppressed(db(), seeded)).toBe(true); + }); +}); + +async function reasonFor(hash: string): Promise { + const row = await db() + .prepare("SELECT reason FROM notification_suppressions WHERE recipient_hash = ?") + .bind(hash) + .first<{ reason: string }>(); + return row?.reason; +} From 201cdc07c047b9d5f0a917f8aa879fb9204e5a9e Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 12:55:58 +0100 Subject: [PATCH 085/137] feat(labeler): aggregator service binding + read-only client (W8.4 slice-3 foundation) (#2070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(labeler): aggregator service binding + read-only client Add the AGGREGATOR service binding to the labeler Worker and a typed, read-only client over the aggregator's XRPC surface (getPackage, getLatestRelease, listReleases). Fetch-over-binding matching the perf-monitor pattern; the aggregator stays HTTP/XRPC-only with no RPC entrypoint. Each method does exactly one fetch per call (no caching/retry/memoization, per W10.4), sends no atproto-accept-labelers header so internal reads see the unfiltered view, and URL-encodes every param value against fixed-constant NSIDs. XRPC NotFound resolves to null; other non-2xx and transport failures throw. Stub the binding in vitest.config.ts so the workerd pool can start. * fix(labeler): send blank accept-labelers header for genuinely unfiltered aggregator reads Omitting the header applies the aggregator's default trusted-labeler redaction, so a redacted subject presents as NotFound/null — silently blinding the labeler's own analysis to the subjects it most needs to see. Send a blank value to opt out. Documents the analysis-only boundary and the checksum re-verification downstream. * test(labeler): prove blank accept-labelers header survives the aggregator binding transport * fix(labeler): treat empty listReleases cursor as first page, not a malformed 400 --- apps/labeler/src/aggregator-client.ts | 139 +++++ apps/labeler/test/aggregator-client.test.ts | 198 +++++++ apps/labeler/vitest.config.ts | 18 + apps/labeler/worker-configuration.d.ts | 596 ++++++++++++++------ apps/labeler/wrangler.jsonc | 9 + 5 files changed, 775 insertions(+), 185 deletions(-) create mode 100644 apps/labeler/src/aggregator-client.ts create mode 100644 apps/labeler/test/aggregator-client.test.ts diff --git a/apps/labeler/src/aggregator-client.ts b/apps/labeler/src/aggregator-client.ts new file mode 100644 index 0000000000..0d0b1a0c45 --- /dev/null +++ b/apps/labeler/src/aggregator-client.ts @@ -0,0 +1,139 @@ +/** + * Read-only client over the aggregator Worker's XRPC surface, reached via + * the `AGGREGATOR` service binding (fetch-over-binding; the aggregator is + * HTTP/XRPC-only with no RPC entrypoint). + * + * Each method performs exactly one `fetch` per call with no memoization, + * caching, or retry: internal consumers require a fresh read at call time + * (W10.4). Callers layer their own retry/backoff if they want it. + * + * Every request sends a BLANK `atproto-accept-labelers` header to obtain the + * genuinely unfiltered view. Counter-intuitively, OMITTING the header does the + * opposite: the aggregator resolves an absent header to its default policy + * (every trusted labeler, `redact: true`), so a subject redacted by any trusted + * labeler — plausibly including this labeler itself — presents as `NotFound` + * and is indistinguishable from an unindexed subject. A blank value parses to + * an empty accepted-labelers set, which the aggregator enforces as a no-op. + * The labeler is the moderation authority performing analysis; it must not be + * blinded to redacted subjects when resolving a takedown contact, reading + * history context, or re-assessing a flagged subject. + * + * Boundary: this unfiltered view is for INTERNAL ANALYSIS ONLY. Its results + * must never be surfaced to a public serving path without re-applying takedown + * enforcement at that layer (see the ratified sync.getRecord / artifact-mirror + * serving decisions) — an unfiltered read that reached a public surface would + * re-expose taken-down content. + * + * Views are returned as their `@emdash-cms/registry-lexicons` types without + * runtime validation: the aggregator is a first-party in-process binding, and + * the acquire stage independently re-verifies artifact checksums downstream. + * + * Error contract: a missing subject (XRPC `NotFound`, HTTP 404) resolves to + * `null`. Any other non-2xx status, or a transport failure, throws — callers + * may treat a throw as transient and retry the whole read. + */ + +import type { AggregatorDefs, AggregatorListReleases } from "@emdash-cms/registry-lexicons"; + +/** + * XRPC endpoint host. Irrelevant to routing — a service binding dispatches by + * binding, not DNS — but `fetch` needs a syntactically valid absolute URL. + */ +const XRPC_BASE = "https://aggregator/xrpc"; + +/** + * ATProto label-negotiation header. Sent with a blank value on every read to + * opt out of the aggregator's default trusted-labeler redaction (see the + * module doc). Mirrors the aggregator's own local constant; this is a stable + * protocol string, not currently exported from a shared package. + */ +const ACCEPT_LABELERS_HEADER = "atproto-accept-labelers"; + +/** NSIDs are fixed per method and never derived from caller data. Only query + * param *values* carry caller data, and every value is `encodeURIComponent`- + * encoded in {@link buildUrl}; the param *keys* are hard-coded literals. There + * is therefore no path for caller data to alter the target host or path. */ +const NSID = { + getPackage: "com.emdashcms.experimental.aggregator.getPackage", + getLatestRelease: "com.emdashcms.experimental.aggregator.getLatestRelease", + listReleases: "com.emdashcms.experimental.aggregator.listReleases", +} as const; + +/** Build an XRPC GET URL from a constant NSID and caller-supplied param + * values. Keys are the caller's fixed literals; each value is URL-encoded. */ +function buildUrl(nsid: string, params: Record): string { + const query = Object.entries(params) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join("&"); + return `${XRPC_BASE}/${nsid}?${query}`; +} + +/** The `error` field of an XRPC error envelope, or undefined if the body + * isn't a recognisable `{ error }` object. */ +function parseXrpcErrorName(body: string): string | undefined { + try { + const parsed: unknown = JSON.parse(body); + if (parsed && typeof parsed === "object" && "error" in parsed) { + const { error } = parsed; + if (typeof error === "string") return error; + } + } catch { + // Non-JSON error body (e.g. an infra-level 502); fall through to throw. + } + return undefined; +} + +export class AggregatorClient { + readonly #fetcher: Fetcher; + + constructor(fetcher: Fetcher) { + this.#fetcher = fetcher; + } + + /** Fetch the package view for `(did, slug)`, or `null` if not indexed. */ + async getPackage(did: string, slug: string): Promise { + const url = buildUrl(NSID.getPackage, { did, slug }); + return this.#query(NSID.getPackage, url); + } + + /** Fetch the highest-precedence live release for `(did, pkg)`, or `null` + * if the package has no eligible release. */ + async getLatestRelease(did: string, pkg: string): Promise { + const url = buildUrl(NSID.getLatestRelease, { did, package: pkg }); + return this.#query(NSID.getLatestRelease, url); + } + + /** List releases for `(did, pkg)`, newest first, one page per call. + * Returns `null` if the parent package isn't indexed. */ + async listReleases( + did: string, + pkg: string, + cursor?: string, + ): Promise { + const params: Record = { did, package: pkg }; + // Only a non-empty cursor is a page token; an empty string would decode as + // a malformed cursor (the aggregator 400s), so treat it as "first page". + if (cursor) params["cursor"] = cursor; + const url = buildUrl(NSID.listReleases, params); + return this.#query(NSID.listReleases, url); + } + + /** One fetch, no retry. `NotFound` → `null`; any other non-2xx or a + * transport failure throws. */ + async #query(nsid: string, url: string): Promise { + const response = await this.#fetcher.fetch(url, { + headers: { [ACCEPT_LABELERS_HEADER]: "" }, + }); + if (response.ok) { + return response.json(); + } + const body = await response.text(); + const errorName = parseXrpcErrorName(body); + if (response.status === 404 && errorName === "NotFound") { + return null; + } + throw new Error( + `Aggregator ${nsid} failed: ${response.status}${errorName ? ` ${errorName}` : ""}`, + ); + } +} diff --git a/apps/labeler/test/aggregator-client.test.ts b/apps/labeler/test/aggregator-client.test.ts new file mode 100644 index 0000000000..c237e1ff6b --- /dev/null +++ b/apps/labeler/test/aggregator-client.test.ts @@ -0,0 +1,198 @@ +import { env } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; + +import { AggregatorClient } from "../src/aggregator-client.js"; + +/** Records every URL and request init the client fetches and returns a + * caller-supplied Response (or throws, to simulate a transport failure). */ +function mockFetcher(handler: (url: string) => Response) { + const urls: string[] = []; + const inits: (RequestInit | undefined)[] = []; + const fetcher = { + fetch: (input: string, init?: RequestInit) => { + urls.push(input); + inits.push(init); + return Promise.resolve(handler(input)); + }, + } as unknown as Fetcher; + return { fetcher, urls, inits }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function notFoundResponse(): Response { + return jsonResponse({ error: "NotFound", message: "No package indexed." }, 404); +} + +const BASE = "https://aggregator/xrpc"; + +const PACKAGE_VIEW = { + uri: "at://did:plc:abc123/com.emdashcms.experimental.package.profile/my-plugin", + cid: "bafyreib2rxk3rybk3examplecidvalue", + did: "did:plc:abc123", + slug: "my-plugin", + profile: { + $type: "com.emdashcms.experimental.package.profile", + id: "at://did:plc:abc123/com.emdashcms.experimental.package.profile/my-plugin", + type: "plugin", + license: "MIT", + authors: [{ name: "Ada", handle: "ada.example" }], + security: [{ contact: "security@example.test" }], + slug: "my-plugin", + }, + indexedAt: "2026-07-01T00:00:00.000Z", + labels: [], + latestVersion: "1.2.0", +}; + +const RELEASE_VIEW = { + uri: "at://did:plc:abc123/com.emdashcms.experimental.package.release/1.2.0", + cid: "bafyreirelease123examplecidvalue", + did: "did:plc:abc123", + package: "my-plugin", + version: "1.2.0", + release: { + $type: "com.emdashcms.experimental.package.release", + package: "my-plugin", + version: "1.2.0", + artifacts: { + tarball: { + url: "https://cdn.example/my-plugin-1.2.0.tgz", + checksum: "sha256-0123456789abcdef", + }, + }, + }, + mirrors: [], + indexedAt: "2026-07-01T00:00:00.000Z", + labels: [], +}; + +describe("AggregatorClient.getPackage", () => { + it("builds the getPackage URL with URL-encoded params and parses the view", async () => { + const { fetcher, urls } = mockFetcher(() => jsonResponse(PACKAGE_VIEW)); + const view = await new AggregatorClient(fetcher).getPackage("did:plc:abc123", "my-plugin"); + + expect(urls).toEqual([ + `${BASE}/com.emdashcms.experimental.aggregator.getPackage?did=did%3Aplc%3Aabc123&slug=my-plugin`, + ]); + expect(view).toEqual(PACKAGE_VIEW); + }); + + it("returns null on a NotFound (404) error", async () => { + const { fetcher } = mockFetcher(() => notFoundResponse()); + const view = await new AggregatorClient(fetcher).getPackage("did:plc:abc123", "missing"); + expect(view).toBeNull(); + }); + + it("sends a blank atproto-accept-labelers header to opt out of default redaction", async () => { + const { fetcher, inits } = mockFetcher(() => jsonResponse(PACKAGE_VIEW)); + await new AggregatorClient(fetcher).getPackage("did:plc:abc123", "my-plugin"); + + const headers = new Headers(inits[0]?.headers); + expect(headers.get("atproto-accept-labelers")).toBe(""); + }); + + it("throws on a 5xx response", async () => { + const { fetcher } = mockFetcher(() => new Response("upstream boom", { status: 500 })); + await expect( + new AggregatorClient(fetcher).getPackage("did:plc:abc123", "my-plugin"), + ).rejects.toThrow(/getPackage failed: 500/); + }); + + it("throws when the binding fetch rejects (transport failure)", async () => { + const fetcher = { + fetch: () => Promise.reject(new Error("binding unreachable")), + } as unknown as Fetcher; + await expect( + new AggregatorClient(fetcher).getPackage("did:plc:abc123", "my-plugin"), + ).rejects.toThrow("binding unreachable"); + }); +}); + +describe("AggregatorClient.getLatestRelease", () => { + it("builds the getLatestRelease URL with a `package` param and parses the view", async () => { + const { fetcher, urls } = mockFetcher(() => jsonResponse(RELEASE_VIEW)); + const view = await new AggregatorClient(fetcher).getLatestRelease( + "did:plc:abc123", + "my-plugin", + ); + + expect(urls).toEqual([ + `${BASE}/com.emdashcms.experimental.aggregator.getLatestRelease?did=did%3Aplc%3Aabc123&package=my-plugin`, + ]); + expect(view).toEqual(RELEASE_VIEW); + }); + + it("returns null on a NotFound (404) error", async () => { + const { fetcher } = mockFetcher(() => notFoundResponse()); + const view = await new AggregatorClient(fetcher).getLatestRelease("did:plc:abc123", "missing"); + expect(view).toBeNull(); + }); +}); + +describe("AggregatorClient.listReleases", () => { + it("omits the cursor param when none is given and parses the page", async () => { + const page = { releases: [RELEASE_VIEW], cursor: "next-page-cursor" }; + const { fetcher, urls } = mockFetcher(() => jsonResponse(page)); + const result = await new AggregatorClient(fetcher).listReleases("did:plc:abc123", "my-plugin"); + + expect(urls).toEqual([ + `${BASE}/com.emdashcms.experimental.aggregator.listReleases?did=did%3Aplc%3Aabc123&package=my-plugin`, + ]); + expect(result).toEqual(page); + }); + + it("appends a URL-encoded cursor param when given", async () => { + const { fetcher, urls } = mockFetcher(() => jsonResponse({ releases: [] })); + await new AggregatorClient(fetcher).listReleases("did:plc:abc123", "my-plugin", "abc=="); + + expect(urls).toEqual([ + `${BASE}/com.emdashcms.experimental.aggregator.listReleases?did=did%3Aplc%3Aabc123&package=my-plugin&cursor=abc%3D%3D`, + ]); + }); + + it("omits the cursor param for an empty-string cursor (first page, not a 400)", async () => { + const { fetcher, urls } = mockFetcher(() => jsonResponse({ releases: [] })); + await new AggregatorClient(fetcher).listReleases("did:plc:abc123", "my-plugin", ""); + + expect(urls).toEqual([ + `${BASE}/com.emdashcms.experimental.aggregator.listReleases?did=did%3Aplc%3Aabc123&package=my-plugin`, + ]); + }); + + it("returns null when the parent package is NotFound", async () => { + const { fetcher } = mockFetcher(() => notFoundResponse()); + const result = await new AggregatorClient(fetcher).listReleases("did:plc:abc123", "missing"); + expect(result).toBeNull(); + }); +}); + +describe("AggregatorClient param encoding", () => { + it("percent-encodes reserved characters so they cannot alter the query", async () => { + const { fetcher, urls } = mockFetcher(() => jsonResponse(PACKAGE_VIEW)); + await new AggregatorClient(fetcher).getPackage("did:plc:a&b c", "s/l&ug"); + + expect(urls).toEqual([ + `${BASE}/com.emdashcms.experimental.aggregator.getPackage?did=did%3Aplc%3Aa%26b%20c&slug=s%2Fl%26ug`, + ]); + }); +}); + +describe("AGGREGATOR binding transport", () => { + // The unfiltered-read contract depends on the blank atproto-accept-labelers + // header surviving the service-binding hop: if workerd dropped an + // empty-valued header, the aggregator would fall back to default redaction + // and return NotFound for redacted subjects, silently blinding analysis. + // The vitest AGGREGATOR stub echoes the inbound header as a marker. + it("delivers a blank accept-labelers header across the service binding", async () => { + const response = await env.AGGREGATOR.fetch("https://aggregator/xrpc/probe", { + headers: { "atproto-accept-labelers": "" }, + }); + expect(response.headers.get("x-test-accept-labelers")).toBe("empty"); + }); +}); diff --git a/apps/labeler/vitest.config.ts b/apps/labeler/vitest.config.ts index 9cf0d5dc6b..cb4adca95d 100644 --- a/apps/labeler/vitest.config.ts +++ b/apps/labeler/vitest.config.ts @@ -22,6 +22,24 @@ export default defineConfig({ LABEL_SIGNING_PRIVATE_KEY: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE", NOTIFICATION_HASH_PEPPER: "test-notification-hash-pepper", }, + // wrangler.jsonc declares an AGGREGATOR service binding to the + // aggregator Worker, which doesn't exist in the test runtime. + // Stub it so miniflare can start; most tests inject their own mock + // Fetcher rather than using this binding. The stub still fails loud + // (501) for an accidental call, and echoes the inbound + // atproto-accept-labelers header as a marker so a binding-transport + // test can prove the client's blank value survives the hop (a + // dropped empty header would read `absent`, not `empty`). + serviceBindings: { + AGGREGATOR: (request) => { + const raw = request.headers.get("atproto-accept-labelers"); + const marker = raw === null ? "absent" : raw === "" ? "empty" : `value:${raw}`; + return new Response("AGGREGATOR is stubbed in tests", { + status: 501, + headers: { "x-test-accept-labelers": marker }, + }); + }, + }, }, }), ], diff --git a/apps/labeler/worker-configuration.d.ts b/apps/labeler/worker-configuration.d.ts index cba90b0aca..5d654f933f 100644 --- a/apps/labeler/worker-configuration.d.ts +++ b/apps/labeler/worker-configuration.d.ts @@ -1,6 +1,6 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: ccfd2a4d903b4c76107d4323cc123869) -// Runtime types generated with workerd@1.20260611.1 2026-02-24 nodejs_compat +// Generated by Wrangler by running `wrangler types` (hash: e21c438219f0e3a8c154bb09b450524b) +// Runtime types generated with workerd@1.20260708.1 2026-02-24 nodejs_compat interface __BaseEnv_Env { EVIDENCE: R2Bucket; DB: D1Database; @@ -17,6 +17,7 @@ interface __BaseEnv_Env { NOTIFICATION_HASH_PEPPER: string; LABEL_SUBSCRIPTION: DurableObjectNamespace; LABELER_DISCOVERY_DO: DurableObjectNamespace; + AGGREGATOR: Fetcher /* emdash-aggregator */; } declare namespace Cloudflare { interface GlobalProps { @@ -456,7 +457,8 @@ interface ExecutionContext { readonly exports: Cloudflare.Exports; readonly props: Props; cache?: CacheContext; - tracing?: Tracing; + readonly access?: CloudflareAccessContext; + tracing: Tracing; } type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise; type ExportedHandlerConnectHandler = (socket: Socket, env: Env, ctx: ExecutionContext) => void | Promise; @@ -512,6 +514,10 @@ interface CachePurgeOptions { interface CacheContext { purge(options: CachePurgeOptions): Promise; } +interface CloudflareAccessContext { + readonly aud: string; + getIdentity(): Promise; +} declare abstract class ColoLocalActorNamespace { get(actorId: string): Fetcher; } @@ -541,11 +547,11 @@ declare abstract class DurableObjectNamespace; jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace; } -type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high"; +type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high" | "us"; interface DurableObjectNamespaceNewUniqueIdOptions { jurisdiction?: DurableObjectJurisdiction; } -type DurableObjectLocationHint = "wnam" | "enam" | "sam" | "weur" | "eeur" | "apac" | "oc" | "afr" | "me"; +type DurableObjectLocationHint = "wnam" | "enam" | "sam" | "weur" | "eeur" | "apac" | "apac-ne" | "apac-se" | "oc" | "afr" | "me"; type DurableObjectRoutingMode = "primary-only"; interface DurableObjectNamespaceGetDurableObjectOptions { locationHint?: DurableObjectLocationHint; @@ -641,6 +647,7 @@ interface DurableObjectFacets { get(name: string, getStartupOptions: () => FacetStartupOptions | Promise>): Fetcher; abort(name: string, reason: any): void; delete(name: string): void; + clone(src: string, dst: string): void; } interface FacetStartupOptions { id?: DurableObjectId | string; @@ -3316,6 +3323,28 @@ interface EventSourceEventSourceInit { withCredentials?: boolean; fetcher?: Fetcher; } +interface ExecOutput { + readonly stdout: ArrayBuffer; + readonly stderr: ArrayBuffer; + readonly exitCode: number; +} +interface ContainerExecOptions { + cwd?: string; + env?: Record; + user?: string; + stdin?: ReadableStream | "pipe"; + stdout?: "pipe" | "ignore"; + stderr?: "pipe" | "ignore" | "combined"; +} +interface ExecProcess { + readonly stdin: WritableStream | null; + readonly stdout: ReadableStream | null; + readonly stderr: ReadableStream | null; + readonly pid: number; + readonly exitCode: Promise; + output(): Promise; + kill(signal?: number): void; +} interface Container { get running(): boolean; start(options?: ContainerStartupOptions): void; @@ -3329,6 +3358,7 @@ interface Container { snapshotDirectory(options: ContainerDirectorySnapshotOptions): Promise; snapshotContainer(options: ContainerSnapshotOptions): Promise; interceptOutboundHttps(addr: string, binding: Fetcher): Promise; + exec(cmd: string[], options?: ContainerExecOptions): Promise; } interface ContainerDirectorySnapshot { id: string; @@ -3499,11 +3529,58 @@ declare abstract class Performance { } interface Tracing { enterSpan(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T; + startActiveSpan(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; + end(): void; +} +/** + * Represents the identity of a user authenticated via Cloudflare Access. + * This matches the result of calling /cdn-cgi/access/get-identity. + * + * The exact structure of the returned object depends on the identity provider + * configuration for the Access application. The fields below represent commonly + * available properties, but additional provider-specific fields may be present. + */ +interface CloudflareAccessIdentity extends Record { + /** The user's email address, if available from the identity provider. */ + email?: string; + /** The user's display name. */ + name?: string; + /** The user's unique identifier. */ + user_uuid?: string; + /** The Cloudflare account ID. */ + account_id?: string; + /** Login timestamp (Unix epoch seconds). */ + iat?: number; + /** The user's IP address at authentication time. */ + ip?: string; + /** Authentication methods used (e.g., "pwd"). */ + amr?: string[]; + /** Identity provider information. */ + idp?: { + id: string; + type: string; + }; + /** Geographic information about where the user authenticated. */ + geo?: { + country: string; + }; + /** Group memberships from the identity provider. */ + groups?: Array<{ + id: string; + name: string; + email?: string; + }>; + /** Device posture check results, keyed by check ID. */ + devicePosture?: Record; + /** True if the user connected via Cloudflare WARP. */ + is_warp?: boolean; + /** True if the user is authenticated via Cloudflare Gateway. */ + is_gateway?: boolean; } // ============================================================================ // Agent Memory @@ -11096,78 +11173,6 @@ declare abstract class BrowserRun { */ quickAction(action: 'markdown', options: BrowserRunMarkdownOptions): Promise; } -interface BasicImageTransformations { - /** - * Maximum width in image pixels. The value must be an integer. - */ - width?: number; - /** - * Maximum height in image pixels. The value must be an integer. - */ - height?: number; - /** - * Resizing mode as a string. It affects interpretation of width and height - * options: - * - scale-down: Similar to contain, but the image is never enlarged. If - * the image is larger than given width or height, it will be resized. - * Otherwise its original size will be kept. - * - contain: Resizes to maximum size that fits within the given width and - * height. If only a single dimension is given (e.g. only width), the - * image will be shrunk or enlarged to exactly match that dimension. - * Aspect ratio is always preserved. - * - cover: Resizes (shrinks or enlarges) to fill the entire area of width - * and height. If the image has an aspect ratio different from the ratio - * of width and height, it will be cropped to fit. - * - crop: The image will be shrunk and cropped to fit within the area - * specified by width and height. The image will not be enlarged. For images - * smaller than the given dimensions it's the same as scale-down. For - * images larger than the given dimensions, it's the same as cover. - * See also trim. - * - pad: Resizes to the maximum size that fits within the given width and - * height, and then fills the remaining area with a background color - * (white by default). Use of this mode is not recommended, as the same - * effect can be more efficiently achieved with the contain mode and the - * CSS object-fit: contain property. - * - squeeze: Stretches and deforms to the width and height given, even if it - * breaks aspect ratio - */ - fit?: "scale-down" | "contain" | "cover" | "crop" | "pad" | "squeeze"; - /** - * Image segmentation using artificial intelligence models. Sets pixels not - * within selected segment area to transparent e.g "foreground" sets every - * background pixel as transparent. - */ - segment?: "foreground"; - /** - * When cropping with fit: "cover", this defines the side or point that should - * be left uncropped. The value is either a string - * "left", "right", "top", "bottom", "auto", or "center" (the default), - * or an object {x, y} containing focal point coordinates in the original - * image expressed as fractions ranging from 0.0 (top or left) to 1.0 - * (bottom or right), 0.5 being the center. {fit: "cover", gravity: "top"} will - * crop bottom or left and right sides as necessary, but won’t crop anything - * from the top. {fit: "cover", gravity: {x:0.5, y:0.2}} will crop each side to - * preserve as much as possible around a point at 20% of the height of the - * source image. - */ - gravity?: 'face' | 'left' | 'right' | 'top' | 'bottom' | 'center' | 'auto' | 'entropy' | BasicImageTransformationsGravityCoordinates; - /** - * Background color to add underneath the image. Applies only to images with - * transparency (such as PNG). Accepts any CSS color (#RRGGBB, rgba(…), - * hsl(…), etc.) - */ - background?: string; - /** - * Number of degrees (90, 180, 270) to rotate the image by. width and height - * options refer to axes after rotation. - */ - rotate?: 0 | 90 | 180 | 270 | 360; -} -interface BasicImageTransformationsGravityCoordinates { - x?: number; - y?: number; - mode?: 'remainder' | 'box-center'; -} /** * In addition to the properties you can set in the RequestInit dict * that you pass as an argument to the Request constructor, you can @@ -11206,6 +11211,8 @@ interface RequestInitCfProperties extends Record { * (e.g. { '200-299': 86400, '404': 1, '500-599': 0 }) */ cacheTtlByStatus?: Record; + /** Controls how responses with a `Vary` header are cached for this request. */ + vary?: RequestInitCfPropertiesVary; /** * Explicit Cache-Control header value to set on the response stored in cache. * This gives full control over cache directives (e.g. 'public, max-age=3600, s-maxage=86400'). @@ -11243,6 +11250,17 @@ interface RequestInitCfProperties extends Record { cacheReserveMinimumFileSize?: number; scrapeShield?: boolean; apps?: boolean; + /** + * Controls whether an outbound gRPC-web subrequest from this Worker is + * converted to gRPC at the Cloudflare edge. + * + * - `"passthrough"`: forward the subrequest unchanged as gRPC-web (default). + * - `"convert"`: convert the gRPC-web subrequest to gRPC at the edge. + * + * Provides per-request control over the same edge conversion behavior + * gated by the `auto_grpc_convert` compatibility flag. + */ + grpcWeb?: "passthrough" | "convert"; image?: RequestInitCfPropertiesImage; minify?: RequestInitCfPropertiesImageMinify; mirage?: boolean; @@ -11263,6 +11281,244 @@ interface RequestInitCfProperties extends Record { */ resolveOverride?: string; } +/** + * Controls how Workers Standard Vary handles a request header listed by an + * origin `Vary` response header: + * + * - `"normalize"`: normalize the request header value before it is used in the + * cache variance key. + * - `"passthrough"`: use the raw request header value in the cache variance + * key. + * - `"bypass"`: bypass cache when the header appears in the origin `Vary` + * response header. + */ +type RequestInitCfPropertiesVaryAction = "normalize" | "passthrough" | "bypass"; +/** Configuration for Workers Standard Vary support. */ +interface RequestInitCfPropertiesVary { + /** The fallback action for varied request headers not listed in `headers`. */ + default: RequestInitCfPropertiesVaryHeader; + /** + * Lowercase request header names and their Vary configuration. + * + * The `accept` header can include `media_types`, the `accept-language` + * header can include `languages`, and other headers support only `action`. + */ + headers?: RequestInitCfPropertiesVaryHeaders; +} +/** Common Vary behavior for a single request header. */ +interface RequestInitCfPropertiesVaryHeader { + /** How this request header contributes to cache variance. */ + action: RequestInitCfPropertiesVaryAction; +} +/** Vary behavior for the `accept` request header. */ +interface RequestInitCfPropertiesVaryAcceptHeader extends RequestInitCfPropertiesVaryHeader { + /** + * Media types to keep when normalizing the `Accept` request header. + * + * Named `media_types` to match the serialized `cf.vary` configuration. + */ + media_types?: string[]; +} +/** Vary behavior for the `accept-language` request header. */ +interface RequestInitCfPropertiesVaryAcceptLanguageHeader extends RequestInitCfPropertiesVaryHeader { + /** + * Language tags to keep when normalizing the `Accept-Language` request + * header. + */ + languages?: string[]; +} +/** + * Lowercase request header names and their Vary behavior. + * + * The index signature allows arbitrary custom request headers beyond the + * well-known `accept` and `accept-language` specializations. + */ +interface RequestInitCfPropertiesVaryHeaders { + accept?: RequestInitCfPropertiesVaryAcceptHeader; + "accept-language"?: RequestInitCfPropertiesVaryAcceptLanguageHeader; + [header: string]: RequestInitCfPropertiesVaryHeader | RequestInitCfPropertiesVaryAcceptHeader | RequestInitCfPropertiesVaryAcceptLanguageHeader | undefined; +} +interface BasicImageTransformations { + /** + * Maximum width in image pixels. The value must be an integer. + */ + width?: number; + /** + * Maximum height in image pixels. The value must be an integer. + */ + height?: number; + /** + * When cropping with fit: "cover", this defines the side or point that should + * be left uncropped. The value is either a string + * "left", "right", "top", "bottom", "auto", or "center" (the default), + * or an object {x, y} containing focal point coordinates in the original + * image expressed as fractions ranging from 0.0 (top or left) to 1.0 + * (bottom or right), 0.5 being the center. {fit: "cover", gravity: "top"} will + * crop bottom or left and right sides as necessary, but won’t crop anything + * from the top. {fit: "cover", gravity: {x:0.5, y:0.2}} will crop each side to + * preserve as much as possible around a point at 20% of the height of the + * source image. + */ + gravity?: 'face' | 'left' | 'right' | 'top' | 'bottom' | 'center' | 'auto' | 'entropy' | BasicImageTransformationsGravityCoordinates; + /** + * Specifies how closely the image is cropped toward detected faces when combined + * with the gravity=face option. Accepts a valid range between 0.0 (includes as much + * of the background as possible) and 1.0 (crops the image as closely to the face as + * possible). The default is 0. + */ + zoom?: number; + /** + * Resizing mode as a string. It affects interpretation of width and height + * options: + * - scale-down: Similar to contain, but the image is never enlarged. If + * the image is larger than given width or height, it will be resized. + * Otherwise its original size will be kept. + * - scale-up: Similar to contain, but the image is never shrunk. If the + * image is smaller than the given width or height, it will be resized. + * Otherwise its original size will be kept. + * - contain: Resizes to maximum size that fits within the given width and + * height. If only a single dimension is given (e.g. only width), the + * image will be shrunk or enlarged to exactly match that dimension. + * Aspect ratio is always preserved. + * - cover: Resizes (shrinks or enlarges) to fill the entire area of width + * and height. If the image has an aspect ratio different from the ratio + * of width and height, it will be cropped to fit. + * - crop: The image will be shrunk and cropped to fit within the area + * specified by width and height. The image will not be enlarged. For images + * smaller than the given dimensions it's the same as scale-down. For + * images larger than the given dimensions, it's the same as cover. + * See also trim. + * - pad: Resizes to the maximum size that fits within the given width and + * height, and then fills the remaining area with a background color + * (white by default). Use of this mode is not recommended, as the same + * effect can be more efficiently achieved with the contain mode and the + * CSS object-fit: contain property. + * - squeeze: Stretches and deforms to the width and height given, even if it + * breaks aspect ratio + */ + fit?: "scale-down" | "scale-up" | "contain" | "cover" | "crop" | "pad" | "squeeze"; + /** + * Allows you to trim your image. Takes dpr into account and is performed before + * resizing or rotation. + * + * It can be used as: + * - left, top, right, bottom - it will specify the number of pixels to cut + * off each side + * - width, height - the width/height you'd like to end up with - can be used + * in combination with the properties above + * - border - this will automatically trim the surroundings of an image based on + * it's color. It consists of three properties: + * - color: rgb or hex representation of the color you wish to trim (todo: verify the rgba bit) + * - tolerance: difference from color to treat as color + * - keep: the number of pixels of border to keep + */ + trim?: "border" | { + top?: number; + bottom?: number; + left?: number; + right?: number; + width?: number; + height?: number; + border?: boolean | { + color?: string; + tolerance?: number; + keep?: number; + }; + }; + /** + * Background color to add underneath the image. Applies only to images with + * transparency (such as PNG). Accepts any CSS color (#RRGGBB, rgba(…), + * hsl(…), etc.) + */ + background?: string; + /** + * Flips the images horizontally, vertically, or both. Flipping is applied before + * rotation, so if you apply flip=h,rotate=90 then the image will be flipped + * horizontally, then rotated by 90 degrees. + */ + flip?: 'h' | 'v' | 'hv'; + /** + * Number of degrees (90, 180, 270) to rotate the image by. width and height + * options refer to axes after rotation. + */ + rotate?: 0 | 90 | 180 | 270 | 360; + /** + * Strength of sharpening filter to apply to the image. Floating-point + * number between 0 (no sharpening, default) and 10 (maximum). 1.0 is a + * recommended value for downscaled images. + */ + sharpen?: number; + /** + * Radius of a blur filter (approximate gaussian). Maximum supported radius + * is 250. + */ + blur?: number; + /** + * Increase contrast by a factor. A value of 1.0 equals no change, a value of + * 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is + * ignored. + */ + contrast?: number; + /** + * Increase brightness by a factor. A value of 1.0 equals no change, a value + * of 0.5 equals half brightness, and a value of 2.0 equals twice as bright. + * 0 is ignored. + */ + brightness?: number; + /** + * Increase exposure by a factor. A value of 1.0 equals no change, a value of + * 0.5 darkens the image, and a value of 2.0 lightens the image. 0 is ignored. + */ + gamma?: number; + /** + * Increase contrast by a factor. A value of 1.0 equals no change, a value of + * 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is + * ignored. + */ + saturation?: number; + /** + * Device Pixel Ratio. Default 1. Multiplier for width/height that makes it + * easier to specify higher-DPI sizes in . + */ + dpr?: number; + /** + * Adds a border around the image. The border is added after resizing. Border + * width takes dpr into account, and can be specified either using a single + * width property, or individually for each side. + */ + border?: { + color: string; + width: number; + } | { + color: string; + top: number; + right: number; + bottom: number; + left: number; + }; + /** + * Image segmentation using artificial intelligence models. Sets pixels not + * within selected segment area to transparent e.g "foreground" sets every + * background pixel as transparent. + */ + segment?: "foreground"; + /** + * Controls the algorithm used when an image needs to be enlarged. This + * parameter works with any fit mode that upscales, such as `contain`, + * `cover`, and `scale-up`. It has no effect when `fit=scale-down` or when + * the target dimensions are smaller than the source. + * - interpolate: Uses bicubic interpolation, which may reduce image quality. + * This is the default behavior when `upscale` is not specified. + * - generate: Uses AI upscaling to produce sharper, more detailed results + * when enlarging images. + */ + upscale?: "interpolate" | "generate"; +} +interface BasicImageTransformationsGravityCoordinates { + x?: number; + y?: number; + mode?: 'remainder' | 'box-center'; +} interface RequestInitCfPropertiesImageDraw extends BasicImageTransformations { /** * Absolute URL of the image file to use for the drawing. It can be any of @@ -11317,39 +11573,6 @@ interface RequestInitCfPropertiesImageDraw extends BasicImageTransformations { right?: number; } interface RequestInitCfPropertiesImage extends BasicImageTransformations { - /** - * Device Pixel Ratio. Default 1. Multiplier for width/height that makes it - * easier to specify higher-DPI sizes in . - */ - dpr?: number; - /** - * Allows you to trim your image. Takes dpr into account and is performed before - * resizing or rotation. - * - * It can be used as: - * - left, top, right, bottom - it will specify the number of pixels to cut - * off each side - * - width, height - the width/height you'd like to end up with - can be used - * in combination with the properties above - * - border - this will automatically trim the surroundings of an image based on - * it's color. It consists of three properties: - * - color: rgb or hex representation of the color you wish to trim (todo: verify the rgba bit) - * - tolerance: difference from color to treat as color - * - keep: the number of pixels of border to keep - */ - trim?: "border" | { - top?: number; - bottom?: number; - left?: number; - right?: number; - width?: number; - height?: number; - border?: boolean | { - color?: string; - tolerance?: number; - keep?: number; - }; - }; /** * Quality setting from 1-100 (useful values are in 60-90 range). Lower values * make images look worse, but load faster. The default is 85. It applies only @@ -11391,17 +11614,6 @@ interface RequestInitCfPropertiesImage extends BasicImageTransformations { * output formats always discard metadata. */ metadata?: "keep" | "copyright" | "none"; - /** - * Strength of sharpening filter to apply to the image. Floating-point - * number between 0 (no sharpening, default) and 10 (maximum). 1.0 is a - * recommended value for downscaled images. - */ - sharpen?: number; - /** - * Radius of a blur filter (approximate gaussian). Maximum supported radius - * is 250. - */ - blur?: number; /** * Overlays are drawn in the order they appear in the array (last array * entry is the topmost layer). @@ -11413,50 +11625,6 @@ interface RequestInitCfPropertiesImage extends BasicImageTransformations { * the origin. */ "origin-auth"?: "share-publicly"; - /** - * Adds a border around the image. The border is added after resizing. Border - * width takes dpr into account, and can be specified either using a single - * width property, or individually for each side. - */ - border?: { - color: string; - width: number; - } | { - color: string; - top: number; - right: number; - bottom: number; - left: number; - }; - /** - * Increase brightness by a factor. A value of 1.0 equals no change, a value - * of 0.5 equals half brightness, and a value of 2.0 equals twice as bright. - * 0 is ignored. - */ - brightness?: number; - /** - * Increase contrast by a factor. A value of 1.0 equals no change, a value of - * 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is - * ignored. - */ - contrast?: number; - /** - * Increase exposure by a factor. A value of 1.0 equals no change, a value of - * 0.5 darkens the image, and a value of 2.0 lightens the image. 0 is ignored. - */ - gamma?: number; - /** - * Increase contrast by a factor. A value of 1.0 equals no change, a value of - * 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is - * ignored. - */ - saturation?: number; - /** - * Flips the images horizontally, vertically, or both. Flipping is applied before - * rotation, so if you apply flip=h,rotate=90 then the image will be flipped - * horizontally, then rotated by 90 degrees. - */ - flip?: 'h' | 'v' | 'hv'; /** * Slightly reduces latency on a cache miss by selecting a * quickest-to-compress file format, at a cost of increased file size and @@ -12074,6 +12242,13 @@ interface ForwardableEmailMessage extends EmailMessage { * @returns A promise that resolves when the email message is replied. */ reply(message: EmailMessage): Promise; + /** + * Reply to the sender of this email message with a message built from the given + * fields. Threading headers (In-Reply-To/References) are set automatically. + * @param builder The reply message contents. + * @returns A promise that resolves when the email message is replied. + */ + reply(builder: EmailReplyMessageBuilder): Promise; } /** A file attachment for an email message */ type EmailAttachment = { @@ -12094,23 +12269,46 @@ interface EmailAddress { name: string; email: string; } +/** + * Recipient fields for `SendEmail.send()`. At least one of `to`, `cc`, or + * `bcc` must be provided. + */ +type EmailDestinations = { + to?: string | EmailAddress | (string | EmailAddress)[]; + cc?: string | EmailAddress | (string | EmailAddress)[]; + bcc?: string | EmailAddress | (string | EmailAddress)[]; +} & ({ + to: string | EmailAddress | (string | EmailAddress)[]; +} | { + cc: string | EmailAddress | (string | EmailAddress)[]; +} | { + bcc: string | EmailAddress | (string | EmailAddress)[]; +}); +/** + * Fields shared by all composed emails (no recipients). Used directly by + * `ForwardableEmailMessage.reply()`, which always replies to the original + * sender, and extended by `EmailMessageBuilder` for `SendEmail.send()`. + */ +interface EmailReplyMessageBuilder { + from: string | EmailAddress; + subject: string; + replyTo?: string | EmailAddress; + headers?: Record; + text?: string; + html?: string; + attachments?: EmailAttachment[]; +} +/** + * Fields for composing an email without constructing raw MIME, for + * `SendEmail.send()`. Requires at least one of `to`, `cc`, or `bcc`. + */ +type EmailMessageBuilder = EmailReplyMessageBuilder & EmailDestinations; /** * A binding that allows a Worker to send email messages. */ interface SendEmail { send(message: EmailMessage): Promise; - send(builder: { - from: string | EmailAddress; - to: string | EmailAddress | (string | EmailAddress)[]; - subject: string; - replyTo?: string | EmailAddress; - cc?: string | EmailAddress | (string | EmailAddress)[]; - bcc?: string | EmailAddress | (string | EmailAddress)[]; - headers?: Record; - text?: string; - html?: string; - attachments?: EmailAttachment[]; - }): Promise; + send(builder: EmailMessageBuilder): Promise; } declare abstract class EmailEvent extends ExtendableEvent { readonly message: ForwardableEmailMessage; @@ -12932,6 +13130,11 @@ declare namespace CloudflareWorkersModule { export type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; export type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number; export type WorkflowDelayDuration = WorkflowSleepDuration; + export type WorkflowDynamicDelayContext = { + ctx: WorkflowStepContext; + error: Error; + }; + export type WorkflowDelayFunction = (input: WorkflowDynamicDelayContext) => WorkflowDelayDuration | Promise; export type WorkflowTimeoutDuration = WorkflowSleepDuration; export type WorkflowRetentionDuration = WorkflowSleepDuration; export type WorkflowBackoff = 'constant' | 'linear' | 'exponential'; @@ -12939,12 +13142,13 @@ declare namespace CloudflareWorkersModule { export type WorkflowStepConfig = { retries?: { limit: number; - delay: WorkflowDelayDuration | number; + delay: WorkflowDelayDuration | number | WorkflowDelayFunction; backoff?: WorkflowBackoff; }; timeout?: WorkflowTimeoutDuration | number; sensitive?: WorkflowStepSensitivity; }; + export type WorkflowStepRollbackConfig = Pick; export type WorkflowCronSchedule = { /** Cron expression that triggered this event. */ cron: string; @@ -12964,27 +13168,40 @@ declare namespace CloudflareWorkersModule { type: string; sensitive?: WorkflowStepSensitivity; }; - export type WorkflowStepContext = { + export type WorkflowStepContext = { step: { name: string; count: number; }; attempt: number; - config: WorkflowStepConfig; + config: { + retries?: { + limit: number; + backoff?: WorkflowBackoff; + } & (Delay extends WorkflowDelayFunction ? {} : { + delay: WorkflowDelayDuration | number; + }); + timeout?: WorkflowTimeoutDuration | number; + sensitive?: WorkflowStepSensitivity; + }; }; export type WorkflowRollbackContext = { + ctx: WorkflowStepContext; error: Error; output: T | undefined; + /** @deprecated Use `ctx.step.name` and `ctx.step.count` instead. */ stepName: string; }; export type WorkflowRollbackHandler = (ctx: WorkflowRollbackContext) => Promise; export type WorkflowStepRollbackOptions = { - rollback?: WorkflowRollbackHandler; - rollbackConfig?: WorkflowStepConfig; + rollback: WorkflowRollbackHandler; + rollbackConfig?: WorkflowStepRollbackConfig; }; export abstract class WorkflowStep { do>(name: string, callback: (ctx: WorkflowStepContext) => Promise, rollbackOptions?: WorkflowStepRollbackOptions): Promise; - do>(name: string, config: WorkflowStepConfig, callback: (ctx: WorkflowStepContext) => Promise, rollbackOptions?: WorkflowStepRollbackOptions): Promise; + do, const C extends WorkflowStepConfig>(name: string, config: C, callback: (ctx: WorkflowStepContext) => Promise, rollbackOptions?: WorkflowStepRollbackOptions): Promise; sleep: (name: string, duration: WorkflowSleepDuration) => Promise; sleepUntil: (name: string, timestamp: Date | number) => Promise; waitForEvent>(name: string, options: { @@ -13875,7 +14092,7 @@ declare namespace TailStream { interface ConnectEventInfo { readonly type: "connect"; } - type EventOutcome = "ok" | "canceled" | "exception" | "unknown" | "killSwitch" | "daemonDown" | "exceededCpu" | "exceededMemory" | "loadShed" | "responseStreamDisconnected" | "scriptNotFound" | "internalError"; + type EventOutcome = "ok" | "canceled" | "exception" | "unknown" | "killSwitch" | "daemonDown" | "exceededCpu" | "exceededMemory" | "loadShed" | "responseStreamDisconnected" | "scriptNotFound" | "internalError" | "exceededWallTime"; interface ScriptVersion { readonly id: string; readonly tag?: string; @@ -13894,6 +14111,7 @@ declare namespace TailStream { readonly dispatchNamespace?: string; readonly entrypoint?: string; readonly executionModel: string; + readonly durableObjectId?: string; readonly scriptName?: string; readonly scriptTags?: string[]; readonly scriptVersion?: ScriptVersion; @@ -14448,6 +14666,13 @@ interface WorkflowError { code?: number; message: string; } +interface WorkflowInstanceTerminateOptions { + /** + * If true, run registered rollback handlers before terminating the instance. + * Only steps that registered rollback handlers are rolled back. + */ + rollback?: boolean; +} interface WorkflowInstanceRestartOptions { /** * Restart from a specific step. If omitted, the instance restarts from the beginning. @@ -14481,8 +14706,9 @@ declare abstract class WorkflowInstance { public resume(): Promise; /** * Terminate the instance. If it is errored, terminated or complete, an error will be thrown. + * @param options Options for termination, including whether registered rollback handlers should run. */ - public terminate(): Promise; + public terminate(options?: WorkflowInstanceTerminateOptions): Promise; /** * Restart the instance. Optionally restart from a specific step, preserving * cached results for all steps before it. diff --git a/apps/labeler/wrangler.jsonc b/apps/labeler/wrangler.jsonc index 8daeae21cf..924b5aeede 100644 --- a/apps/labeler/wrangler.jsonc +++ b/apps/labeler/wrangler.jsonc @@ -43,6 +43,15 @@ "ai": { "binding": "AI", }, + // Read-only service binding to the aggregator Worker's XRPC surface + // (fetch-over-binding; the aggregator is HTTP/XRPC-only, no RPC entrypoint). + // Consumed via src/aggregator-client.ts. + "services": [ + { + "binding": "AGGREGATOR", + "service": "emdash-aggregator", + }, + ], // Operator console SPA (apps/labeler/console), built to ./dist/console by // `console:build`. `run_worker_first: true` keeps the Worker the sole // router: the SPA fallback only fires where index.ts explicitly calls From 523a2991b55c7793222425eeeb23bdd5059cc059 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 13:36:30 +0100 Subject: [PATCH 086/137] docs(labeler-plan): record W10.5 send-path contracts and redaction-scope note (W10.4 slice-A adversary) Co-Authored-By: Claude Opus 4.8 --- .../plugin-registry-labelling-service/implementation-plan.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 64697e54d7..83d7b0ce70 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1240,6 +1240,8 @@ Dependencies: registry profile/package reads. Decisions (2026-07-16): all three contact sources already exist as lexicon fields (package `security[]` — required; package `authors[]` — contact optional; publisher profile `contact[]` with security/general kinds) — no lexicon changes. Resolution walks the tiers for the first entry carrying a non-empty EMAIL (url-only contacts are skipped, not "resolved"), preferring `kind: security` within tier 3, with a fresh read at notification time via the W8.4 slice-3 aggregator service binding (no caching; no self-fetch/PDS path). Anti-abuse is DOUBLE OPT-IN: unverified publishers receive one content-neutral confirmation mail and substantive notices only after confirming (the public assessment API and the monitored reconsideration inbox remain the channel for unconfirmed publishers); verified publishers skip confirmation; universal suppression honors unsubscribe/"not me"/hard-failure, with per-address and per-DID rate limits on confirmation sends — a third-party victim named in hostile metadata receives at most one neutral mail, ever. No resolvable contact is an expected non-error terminal state (delivery marked undeliverable, kept for audit), with an operator `operational_event` added only for emergency-takedown notices that cannot be delivered. Addresses are keyed as HMAC-SHA256 over the lowercased/trimmed email with a secret pepper binding; plaintext lives only on the delivery row, cleared on successful send, undelivered rows swept after 30 days (versioned constant); audit/dedup query hashes only. The publisher-facing delivery table keeps the spec's `notifications` naming — the existing `notification_outbox` is the OPERATOR alert subsystem and stays untouched; W10.4 owns `notification_contacts` (confirm state, token hash, rate-limit timestamps) and `notification_suppressions`, plus the confirm/unsubscribe/not-me token endpoints. Noted for W10.5: spec §14.4 keys `notifications.action_id` to `operator_actions`, but automated blocks live in `issuance_actions` — the notification trigger needs a source covering automated issuance. +Observed (2026-07-16, slice-A adversary): the aggregator's `isRedacted` gate on both `getPackage` and the new `getPublisher` reacts only to `!takedown`, so a subject carrying `publisher-compromised` (but not taken down) still serves its contact emails publicly. This is pre-existing parity with `getPackage`, not a slice-A regression, and is arguably intended (a compromised publisher's security contact should stay reachable). Recorded here as a confirm item for whenever aggregator redaction policy is next revisited — not blocking, no change made. + ### `W10.5` Implement notification outbox and email adapter Delivery uses Cloudflare Email Sending through the Workers `send_email` binding; the sending domain is onboarded on the emdashcms.com zone (`wrangler email sending enable`). No provider API keys. Messages include both `html` and `text` bodies. @@ -1248,6 +1250,8 @@ Notify on block, warning, override, retraction, prolonged error, and emergency t Hard requirement (2026-07-16, from the W10.4 slice-C adversary pass): the confirm token this slice generates MUST come from a CSPRNG with ≥128 bits of entropy. The confirm endpoint stores `SHA-256(token)` without a pepper (unlike the recipient hash, which is HMAC-peppered because email is low-entropy) and applies no in-worker rate limit, so the token's unguessability is the sole protection against a brute-force confirm of a contact whose recipient hash leaked. A weak or predictable token would make double opt-in bypassable through the public endpoint. Do not reuse a ULID or any monotonic/timestamped id as the token. +Send-path contracts (2026-07-16, from the two W10.4 slice-A adversary passes): the slice-A `seedPublisherContact` returns `{seeded:true}` even when the contact already exists in a `confirmed` or `declined` state (`ensureContact` is insert-if-absent, so it no-ops), and it does not — cannot — apply send rate limits because there is no send path yet. So this slice MUST: (1) gate every send on the confirm-state machine (`canSendConfirm`/`confirmState`), NEVER on `seeded:true` — a send keyed off `seeded:true` would re-mail confirmed recipients and, worse, recipients who explicitly said "not me" (`declined`); (2) enforce per-address AND per-DID rate limits on confirmation sends, the only thing preventing hostile metadata naming `victim@x` from turning double opt-in into an email-bomb amplifier; (3) re-check suppression atomically at send time rather than trusting the seeded row, since slice-A's seed-time `isSuppressed`→`ensureContact` is check-then-act and a suppression can land in the gap. + Dependencies: `W2.3`, `W10.3`, `W10.4`. ### `W10.6` Implement reconsideration workflow From 09e795d9e6e4f1853097a2b38f451f42315ba1c4 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 13:40:22 +0100 Subject: [PATCH 087/137] docs(labeler-plan): ratify slice-3 verification-state read + issuer-redaction policy (W8.4) Co-Authored-By: Claude Opus 4.8 --- .../plugin-registry-labelling-service/implementation-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 83d7b0ce70..a30af14b3e 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1055,6 +1055,8 @@ Decisions (2026-07-14): history is not a new contract surface — `FindingSource Ratified (2026-07-16, implementation shape for the read path): the service binding is FETCH-OVER-BINDING, not RPC — the aggregator is an HTTP/XRPC-only module Worker (`apps/aggregator/src/index.ts` exports a plain `{fetch,queue,scheduled}`, no `WorkerEntrypoint`), and the repo's sole precedent (`infra/perf-monitor`) consumes a bound Worker via `env.BINDING.fetch(...)`. Adding a WorkerEntrypoint/RPC class was rejected as a new pattern for marginal typing gain when the shared `@emdash-cms/registry-lexicons` view types already give the response contract. The FOUNDATION (labeler→aggregator binding + typed `aggregator-client.ts` over the EXISTING XRPC reads `getPackage`/`getLatestRelease`/`listReleases`) reuses routes that already serve what most consumers need: `releaseView.release.artifacts` supplies the acquire stage's URL+checksum (the assessment row has cid/checksum but no URL), and `packageView.profile.authors[]`/`security[]` supply W10.4 tiers 1–2. Internal reads omit the `atproto-accept-labelers` header so the view is unfiltered. The client does exactly one fetch per call (no caching — honors W10.4's fresh-read-at-notification-time rule); NotFound→null, transport/5xx→throw (caller treats as transient). Slice 3's own two inputs (verification state, recent handle/profile changes) are NOT in any current view and require NEW aggregator internal reads added on top of the foundation — that is slice 3's remaining work, not the foundation's. +Ratified (2026-07-16, slice-3 build outcome + adversary pass): of slice 3's two inputs, only VERIFICATION STATE is buildable now — it has a real `publisher_verifications` table (issuer/subject/expiry/tombstone) fed by `ingestPublisherVerification`. RECENT HANDLE/PROFILE CHANGES is genuinely un-ingested: `publishers` is upsert-only current-state with no change log, and the jetstream client does not subscribe to the atproto identity events that carry handle changes, so this input is blocked on W4-era ingestion work (a new ticket), not a read path. It ships as a documented seam with no code path — verified NOT half-wired (no empty-changes return that could read downstream as "verified: no suspicious changes"). Verification state is exposed via a NEW public aggregator read `com.emdashcms.experimental.aggregator.getPublisherVerification` (subject_did → non-tombstoned claims, newest-first). Issuer-redaction decision: because each verification claim is authored by a DIFFERENT party (`issuer_did`, its own records), the read applies redaction PER CLAIM under the request's accepted-labeler policy — a claim whose issuer carries an accepted redact label is filtered from the view (not a 404 on the whole view, so one redacted issuer never hides a publisher's whole verification state). The internal labeler reader sends a blank `atproto-accept-labelers` header → accepted set empty → nothing filtered → it sees every claim including redacted-issuer ones and weighs trust itself (D5's read-time neutral badge is separate from the pipeline finding, which carries issuer DIDs in `privateDetail` only). Expiry is NOT filtered server-side: expired-but-non-tombstoned claims are returned with `expiresAt` so the labeler consumer re-evaluates in-force state itself (the read intentionally surfaces expired claims; the migration index's "unexpired" hot-path scope is an index concern, not a read filter). + ### `W8.5` Implement versioned policy resolver Resolve normalized findings under an immutable policy version: From 6baeb416afcca86a48e62d435e0d6d3bf9d71b44 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 13:43:24 +0100 Subject: [PATCH 088/137] feat(labeler): publisher contact resolution via aggregator binding (W10.4 slice A) (#2073) * feat(labeler): publisher contact resolution via aggregator binding (W10.4 slice A) Resolve a package's takedown/notification email by walking three tiers in spec order -- package security[], package authors[], then the publisher profile contact[] (preferring kind:security within the tier) -- through the aggregator service binding with a fresh single read per tier and no caching. url-only contacts are skipped, not treated as resolved; "no resolvable contact" is an expected terminal outcome, not an error, and only a transport failure throws. Seed the double-opt-in state row on resolution: hash the email via recipientHash and ensureContact, honoring suppression so a victim named in hostile metadata is never re-seeded into a fresh unconfirmed state, and never resetting an already-confirmed contact. Plaintext email is held in memory only -- hashed immediately, never persisted or logged (logs carry the public DID and an 8-char hash prefix). Sending the confirmation mail is W10.5; this seam resolves and seeds only. Tier 3 (the publisher profile contact[]) was already indexed in the aggregator (publishers.contact) but not exposed by any read, so add a minimal getPublisher XRPC query and publisherView definition, mirroring getPackage's label hydration and takedown redaction. Add a getPublisher client method and extend the test AGGREGATOR stub to serve realistic getPackage/getPublisher views so the resolver is exercised end to end through the real binding. * test(aggregator): getPublisher redaction on the profile record URI Mirrors the getPackage symmetric test: a !takedown on the publisher profile URI (not just the DID subject) redacts getPublisher to 404, closing the coverage parity gap. --- .changeset/aggregator-get-publisher.md | 5 + .../src/routes/xrpc/getPublisher.ts | 62 ++++ apps/aggregator/src/routes/xrpc/router.ts | 5 + apps/aggregator/src/routes/xrpc/views.ts | 81 +++++ apps/aggregator/test/read-api.test.ts | 139 ++++++++ apps/labeler/src/aggregator-client.ts | 7 + apps/labeler/src/publisher-contact.ts | 200 +++++++++++ apps/labeler/test/publisher-contact.test.ts | 313 ++++++++++++++++++ apps/labeler/vitest.config.ts | 61 +++- .../experimental/aggregator/defs.json | 45 +++ .../experimental/aggregator/getPublisher.json | 35 ++ .../registry-lexicons/src/generated/index.ts | 1 + .../emdashcms/experimental/aggregator/defs.ts | 49 +++ .../experimental/aggregator/getPublisher.ts | 37 +++ packages/registry-lexicons/src/index.ts | 3 + .../registry-lexicons/tests/types.test.ts | 1 + 16 files changed, 1039 insertions(+), 5 deletions(-) create mode 100644 .changeset/aggregator-get-publisher.md create mode 100644 apps/aggregator/src/routes/xrpc/getPublisher.ts create mode 100644 apps/labeler/src/publisher-contact.ts create mode 100644 apps/labeler/test/publisher-contact.test.ts create mode 100644 packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/getPublisher.json create mode 100644 packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/getPublisher.ts diff --git a/.changeset/aggregator-get-publisher.md b/.changeset/aggregator-get-publisher.md new file mode 100644 index 0000000000..8cef3b8b25 --- /dev/null +++ b/.changeset/aggregator-get-publisher.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-lexicons": minor +--- + +Adds an aggregator `getPublisher` query and `publisherView` definition, returning a publisher's signed profile (display name, description, and identity-level contact channels) by DID alongside any hydrated labels — the publisher counterpart to the existing `getPackage` read. diff --git a/apps/aggregator/src/routes/xrpc/getPublisher.ts b/apps/aggregator/src/routes/xrpc/getPublisher.ts new file mode 100644 index 0000000000..8b0ef8c9c9 --- /dev/null +++ b/apps/aggregator/src/routes/xrpc/getPublisher.ts @@ -0,0 +1,62 @@ +/** + * `com.emdashcms.experimental.aggregator.getPublisher` — single publisher + * profile by DID. Returns the lexicon's `publisherView` envelope (decoded + * record + cid + indexedAt + empty labels unless hydrated). + * + * Throws `XRPCError("NotFound")` when no row matches. Publisher deletes + * hard-delete the `publishers` row, so a NotFound covers both the + * never-indexed and the deleted cases — clients treat them equivalently. + */ + +import { json, XRPCError } from "@atcute/xrpc-server"; +import { type AggregatorDefs, type AggregatorGetPublisher } from "@emdash-cms/registry-lexicons"; + +import { parseSignatureMetadataCid } from "../../utils.js"; +import { hydrateLabels, isRedacted } from "./label-enforcement.js"; +import { getRequestLabelerPolicy } from "./request-policy.js"; +import { type PublisherRow, publisherColumns, publisherUri, publisherView } from "./views.js"; + +export async function getPublisher( + env: Env, + params: AggregatorGetPublisher.$params, + request: Request, +): Promise { + // `first-primary` for the same reason as getPackage: a label written by + // the labeler between two reads should be reflected on the next read. + const session = env.DB.withSession("first-primary"); + const row = await session + .prepare(`SELECT ${publisherColumns()} FROM publishers WHERE did = ?`) + .bind(params.did) + .first(); + if (!row) { + throw new XRPCError({ + status: 404, + error: "NotFound", + message: `No publisher indexed under ${params.did}.`, + }); + } + + const { accepted } = getRequestLabelerPolicy(request); + const uri = publisherUri(row); + const labelsByUri = await hydrateLabels( + session, + accepted, + [ + { uri, currentCid: parseSignatureMetadataCid(row.signature_metadata) ?? undefined }, + { uri: row.did }, + ], + Date.now(), + ); + const labels = [...(labelsByUri.get(uri) ?? []), ...(labelsByUri.get(row.did) ?? [])]; + if (isRedacted(labels, accepted)) { + // Indistinguishable from absence — that is what redaction means. + throw new XRPCError({ + status: 404, + error: "NotFound", + message: `No publisher indexed under ${params.did}.`, + }); + } + + const view: AggregatorDefs.PublisherView = publisherView(row, labels); + return json(view); +} diff --git a/apps/aggregator/src/routes/xrpc/router.ts b/apps/aggregator/src/routes/xrpc/router.ts index 4f3eafc488..99a28906d7 100644 --- a/apps/aggregator/src/routes/xrpc/router.ts +++ b/apps/aggregator/src/routes/xrpc/router.ts @@ -25,6 +25,7 @@ import { XRPCError, XRPCRouter } from "@atcute/xrpc-server"; import { AggregatorGetLatestRelease, AggregatorGetPackage, + AggregatorGetPublisher, AggregatorListReleases, AggregatorResolvePackage, AggregatorSearchPackages, @@ -32,6 +33,7 @@ import { import { getLatestRelease } from "./getLatestRelease.js"; import { getPackage } from "./getPackage.js"; +import { getPublisher } from "./getPublisher.js"; import { listReleases } from "./listReleases.js"; import { resolveRequestLabelerPolicy } from "./request-policy.js"; import { resolvePackage } from "./resolvePackage.js"; @@ -171,5 +173,8 @@ function createRouter(env: Env): XRPCRouter { router.addQuery(AggregatorResolvePackage.mainSchema, { handler: ({ params, request }) => resolvePackage(env, params, request), }); + router.addQuery(AggregatorGetPublisher.mainSchema, { + handler: ({ params, request }) => getPublisher(env, params, request), + }); return router; } diff --git a/apps/aggregator/src/routes/xrpc/views.ts b/apps/aggregator/src/routes/xrpc/views.ts index fa99508c31..43c4217f4a 100644 --- a/apps/aggregator/src/routes/xrpc/views.ts +++ b/apps/aggregator/src/routes/xrpc/views.ts @@ -43,6 +43,19 @@ export interface PackageRow { indexed_at: string | null; } +/** Subset of columns from `publishers` we read for `publisherView`. */ +export interface PublisherRow { + did: string; + display_name: string; + description: string | null; + url: string | null; + contact: string | null; // JSON array of { kind, url?, email? } + updated_at: string | null; + signature_metadata: string | null; + verified_at: string; + indexed_at: string | null; +} + /** Subset of columns from `releases` we read for `releaseView`. */ export interface ReleaseRow { did: string; @@ -79,6 +92,19 @@ const PACKAGE_VIEW_COLUMN_NAMES = [ "indexed_at", ] as const; +/** Column list backing `PublisherRow`. */ +const PUBLISHER_VIEW_COLUMN_NAMES = [ + "did", + "display_name", + "description", + "url", + "contact", + "updated_at", + "signature_metadata", + "verified_at", + "indexed_at", +] as const; + /** Column list backing `ReleaseRow`. */ const RELEASE_VIEW_COLUMN_NAMES = [ "did", @@ -108,6 +134,11 @@ export function releaseColumns(prefix = ""): string { return RELEASE_VIEW_COLUMN_NAMES.map((c) => `${prefix}${c}`).join(", "); } +/** SELECT-clause string for `PublisherRow`, optionally prefixed for JOINs. */ +export function publisherColumns(prefix = ""): string { + return PUBLISHER_VIEW_COLUMN_NAMES.map((c) => `${prefix}${c}`).join(", "); +} + /** AT URI of a package's profile record — the `packageView.uri` and the * hydration subject handlers hydrate labels for before calling * `packageView`. Single source of truth so the two never drift. No return @@ -125,6 +156,12 @@ export function releaseUri(row: Pick) { return `at://${row.did}/${NSID.packageRelease}/${row.rkey}` as const; } +/** AT URI of a publisher's profile record — the `publisherView.uri` and a + * label-hydration subject. The rkey is always `self` (enforced at ingest). */ +export function publisherUri(row: Pick) { + return `at://${row.did}/${NSID.publisherProfile}/self` as const; +} + /** Caps a combined labels array at the lexicon's maxLength. A view's * labels are the union of multiple hydrated subjects (e.g. a release's own * URI plus its parent package and publisher DID). Hydration returns @@ -211,6 +248,50 @@ export function releaseView(row: ReleaseRow, labels: LabelView[] = []): Aggregat }; } +/** + * Map a `publishers` row to the lexicon's `publisherView`. The synthesized + * `profile` field reconstructs the publisher.profile record JSON from the + * normalised columns — same field values the publisher signed. For + * byte-identical bytes, clients call `sync.getRecord` and re-verify. + * + * `labels` are hydrated by the caller (profile URI + publisher DID subjects) + * and passed in; defaulting to `[]` covers the accepted-policy-empty case. + */ +export function publisherView( + row: PublisherRow, + labels: LabelView[] = [], +): AggregatorDefs.PublisherView { + const uri = publisherUri(row); + const cid = parseSignatureMetadataCid(row.signature_metadata) ?? ""; + return { + uri, + cid, + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- `did` is consumer-validated at write time + did: row.did as `did:${string}:${string}`, + profile: synthesizePublisherProfile(row), + indexedAt: row.indexed_at ?? row.verified_at, + labels: toLexiconLabels(capLabels(labels, uri)), + }; +} + +/** Reconstruct the `com.emdashcms.experimental.publisher.profile` record + * JSON from the row's columns. Optional fields are omitted (rather than + * emitted as null) so the JSON shape matches what a publisher would have + * written. Same passthrough-shape caveat as `synthesizePackageProfile`: + * typed as `Record` because clients re-validate against + * the published lexicon. */ +function synthesizePublisherProfile(row: PublisherRow): Record { + const profile: Record = { + $type: NSID.publisherProfile, + displayName: row.display_name, + }; + if (row.description !== null) profile["description"] = row.description; + if (row.url !== null) profile["url"] = row.url; + if (row.contact !== null) profile["contact"] = parseJsonArray(row.contact); + if (row.updated_at !== null) profile["updatedAt"] = row.updated_at; + return profile; +} + /** Reconstruct the `com.emdashcms.experimental.package.profile` record * JSON from the row's columns. Field set matches what the consumer's * `ingestPackageProfile` writer accepts; optional fields are omitted diff --git a/apps/aggregator/test/read-api.test.ts b/apps/aggregator/test/read-api.test.ts index 9982eab655..25a240e34f 100644 --- a/apps/aggregator/test/read-api.test.ts +++ b/apps/aggregator/test/read-api.test.ts @@ -155,6 +155,48 @@ function releaseUri(pkg: string, version: string, did: string = DID_A): string { return `at://${did}/${NSID.packageRelease}/${pkg}:${version}`; } +function publisherUri(did: string = DID_A): string { + return `at://${did}/${NSID.publisherProfile}/self`; +} + +interface SeedPublisherOpts { + did?: string; + displayName?: string; + description?: string | null; + url?: string | null; + contact?: unknown[] | null; + updatedAt?: string | null; + cid?: string; + indexedAt?: string; + verifiedAt?: string; +} + +async function seedPublisher(opts: SeedPublisherOpts = {}): Promise { + const did = opts.did ?? DID_A; + const indexedAt = opts.indexedAt ?? NOW.toISOString(); + await testEnv.DB.prepare( + `INSERT INTO publishers + (did, display_name, description, url, contact, updated_at, + record_blob, signature_metadata, verified_at, indexed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + did, + opts.displayName ?? "Demo Publisher", + opts.description ?? null, + opts.url ?? null, + opts.contact === null + ? null + : JSON.stringify(opts.contact ?? [{ kind: "security", email: "sec@pub.test" }]), + opts.updatedAt ?? null, + new Uint8Array([0xb1, 0xb2, 0xb3]), + JSON.stringify({ cid: opts.cid ?? "bafypub" }), + opts.verifiedAt ?? NOW.toISOString(), + indexedAt, + ) + .run(); +} + /** Seeds a `labelers` row so a DID is "available" per W4.4's resolution: * `trusted = 1` rows feed the missing-header default set; any row (trusted * or not) makes a DID acceptable when explicitly requested. */ @@ -398,6 +440,103 @@ describe("getPackage", () => { }); }); +describe("getPublisher", () => { + it("returns the publisherView envelope for an indexed publisher", async () => { + await seedPublisher({ + displayName: "Acme Plugin Co.", + description: "We make plugins", + url: "https://acme.test", + contact: [{ kind: "security", email: "security@acme.test" }], + updatedAt: NOW.toISOString(), + }); + + const res = await SELF.fetch(`https://test/xrpc/${NSID.aggregatorGetPublisher}?did=${DID_A}`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).toMatchObject({ + uri: publisherUri(), + cid: "bafypub", + did: DID_A, + indexedAt: NOW.toISOString(), + labels: [], + }); + expect(body).not.toHaveProperty("slug"); + const profile = body["profile"] as Record; + expect(profile["$type"]).toBe(NSID.publisherProfile); + expect(profile["displayName"]).toBe("Acme Plugin Co."); + expect(profile["contact"]).toEqual([{ kind: "security", email: "security@acme.test" }]); + }); + + it("omits optional profile fields the publisher did not set", async () => { + await seedPublisher({ + displayName: "Minimal", + description: null, + url: null, + contact: null, + updatedAt: null, + }); + const res = await SELF.fetch(`https://test/xrpc/${NSID.aggregatorGetPublisher}?did=${DID_A}`); + const body = (await res.json()) as { profile: Record }; + expect(body.profile).not.toHaveProperty("description"); + expect(body.profile).not.toHaveProperty("url"); + expect(body.profile).not.toHaveProperty("contact"); + expect(body.profile).not.toHaveProperty("updatedAt"); + }); + + it("returns 404 NotFound when no publisher is indexed", async () => { + const res = await SELF.fetch(`https://test/xrpc/${NSID.aggregatorGetPublisher}?did=${DID_B}`); + expect(res.status).toBe(404); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("NotFound"); + }); + + it("returns 400 InvalidRequest when did is missing", async () => { + const res = await SELF.fetch(`https://test/xrpc/${NSID.aggregatorGetPublisher}`); + expect(res.status).toBe(400); + }); + + it("sets Cache-Control: private, no-store on success", async () => { + await seedPublisher(); + const res = await SELF.fetch(`https://test/xrpc/${NSID.aggregatorGetPublisher}?did=${DID_A}`); + expect(res.headers.get("cache-control")).toBe("private, no-store"); + }); + + it("404s (redacted) when a default-accepted source's !takedown is active on the publisher DID", async () => { + await seedLabeler(LABELER_DID, true); + await seedPublisher(); + await seedLabelState({ uri: DID_A, val: "!takedown" }); + + const res = await SELF.fetch(`https://test/xrpc/${NSID.aggregatorGetPublisher}?did=${DID_A}`); + expect(res.status).toBe(404); + }); + + it("404s (redacted) when the !takedown is active on the profile record URI, not the DID", async () => { + await seedLabeler(LABELER_DID, true); + await seedPublisher(); + await seedLabelState({ uri: publisherUri(), val: "!takedown" }); + + const res = await SELF.fetch(`https://test/xrpc/${NSID.aggregatorGetPublisher}?did=${DID_A}`); + expect(res.status).toBe(404); + }); + + it("hydrates labels on the publisher profile record URI", async () => { + await seedLabeler(LABELER_DID, true); + await seedPublisher(); + await seedLabelState({ uri: publisherUri(), val: "unverified-publisher" }); + + const res = await SELF.fetch(`https://test/xrpc/${NSID.aggregatorGetPublisher}?did=${DID_A}`); + expect(res.status).toBe(200); + const body = (await res.json()) as { labels: Array<{ src: string; uri: string; val: string }> }; + expect(body.labels).toContainEqual( + expect.objectContaining({ + src: LABELER_DID, + uri: publisherUri(), + val: "unverified-publisher", + }), + ); + }); +}); + describe("listReleases", () => { it("returns releases ordered by descending semver", async () => { await seedPackage({ slug: "demo", latestVersion: "2.0.0" }); diff --git a/apps/labeler/src/aggregator-client.ts b/apps/labeler/src/aggregator-client.ts index 0d0b1a0c45..8daea76987 100644 --- a/apps/labeler/src/aggregator-client.ts +++ b/apps/labeler/src/aggregator-client.ts @@ -55,6 +55,7 @@ const ACCEPT_LABELERS_HEADER = "atproto-accept-labelers"; * is therefore no path for caller data to alter the target host or path. */ const NSID = { getPackage: "com.emdashcms.experimental.aggregator.getPackage", + getPublisher: "com.emdashcms.experimental.aggregator.getPublisher", getLatestRelease: "com.emdashcms.experimental.aggregator.getLatestRelease", listReleases: "com.emdashcms.experimental.aggregator.listReleases", } as const; @@ -96,6 +97,12 @@ export class AggregatorClient { return this.#query(NSID.getPackage, url); } + /** Fetch the publisher profile view for `did`, or `null` if not indexed. */ + async getPublisher(did: string): Promise { + const url = buildUrl(NSID.getPublisher, { did }); + return this.#query(NSID.getPublisher, url); + } + /** Fetch the highest-precedence live release for `(did, pkg)`, or `null` * if the package has no eligible release. */ async getLatestRelease(did: string, pkg: string): Promise { diff --git a/apps/labeler/src/publisher-contact.ts b/apps/labeler/src/publisher-contact.ts new file mode 100644 index 0000000000..956684eb1b --- /dev/null +++ b/apps/labeler/src/publisher-contact.ts @@ -0,0 +1,200 @@ +/** + * Publisher-contact resolution for the notification subsystem (spec §18, + * plan W10.4 slice A). Given a package subject, walks three contact tiers in + * priority order and returns the first entry carrying a non-empty EMAIL — the + * address a takedown/warning notice would be offered to. A url-only contact is + * skipped, not treated as resolved: this channel is email, and a URL cannot + * receive one. + * + * Tiers (spec order): + * 1. package `security[]` — required per-package security contacts. + * 2. package `authors[]` — author contact (email optional). + * 3. publisher `contact[]` — identity-level channels, preferring `kind: + * security` over `general`/other within the tier. + * + * The reads go through the aggregator service binding ({@link AggregatorClient}) + * with a fresh single fetch per tier and NO caching — a notice must resolve + * against the publisher's current metadata at send time. The aggregator read is + * deliberately unfiltered (blank accept-labelers, see the client's module doc): + * resolving a takedown contact must work even for a subject this labeler has + * itself redacted. + * + * "No resolvable contact" is an expected terminal outcome (`{ none: ... }`), + * not an error. Only a transport/aggregator failure throws, propagated from the + * client so the caller can retry the whole resolution. + * + * PII: the resolved email is returned in memory for the caller to hash + * immediately via {@link seedPublisherContact}. It is never persisted or logged + * here — logs carry the (public) publisher DID and at most an 8-char prefix of + * the recipient hash, matching slice C's `logOutcome`. + */ + +import type { AggregatorClient } from "./aggregator-client.js"; +import { ensureContact, isSuppressed, recipientHash } from "./notification-contacts.js"; + +/** The package subject a notice concerns. `did` is the publisher DID; it also + * keys the tier-3 publisher-profile read. */ +export interface ContactTarget { + did: string; + slug: string; +} + +/** Which tier a resolved email came from — carried for observability and to + * let the caller shape tier-specific copy. */ +export type ContactTier = "package_security" | "package_author" | "publisher_profile"; + +export interface ResolvedContact { + /** The address verbatim from the record; normalization happens at hashing + * time in {@link recipientHash}, so this may carry surrounding whitespace. */ + email: string; + tier: ContactTier; + /** Present only for `publisher_profile`: the contact channel `kind` + * (`security` | `general` | other), when the record carried one. */ + kind?: string; +} + +/** The only expected non-resolution: every tier was walked and no entry + * carried a non-empty email (url-only entries don't count). */ +export type NoContactReason = "no_email_contact"; + +export type ContactResolution = ResolvedContact | { none: NoContactReason }; + +export async function resolvePublisherContact( + client: AggregatorClient, + target: ContactTarget, +): Promise { + const pkg = await client.getPackage(target.did, target.slug); + if (pkg) { + const profile = asRecord(pkg.profile); + if (profile) { + const securityEmail = firstEmail(asArray(profile["security"])); + if (securityEmail !== null) { + return { email: securityEmail, tier: "package_security" }; + } + const authorEmail = firstEmail(asArray(profile["authors"])); + if (authorEmail !== null) { + return { email: authorEmail, tier: "package_author" }; + } + } + } + + // Tier 3 is keyed by DID alone, so it is attempted even when the package + // read misses (e.g. the package was deleted between discovery and notice) + // — the publisher entity may still be reachable. + const publisher = await client.getPublisher(target.did); + if (publisher) { + const profile = asRecord(publisher.profile); + if (profile) { + const preferred = preferredPublisherEmail(asArray(profile["contact"])); + if (preferred !== null) { + return { email: preferred.email, tier: "publisher_profile", kind: preferred.kind }; + } + } + } + + return { none: "no_email_contact" }; +} + +export type SeedSkipReason = NoContactReason | "suppressed"; + +export type SeedOutcome = + | { seeded: true; recipientHash: string; tier: ContactTier } + | { seeded: false; reason: SeedSkipReason }; + +/** + * Resolve the publisher contact and seed the double-opt-in state row so slice + * C's confirm flow can proceed. On a resolved email: hash it under the pepper + * and {@link ensureContact} (insert-if-absent as `unconfirmed`). Sending the + * confirmation mail is W10.5 — this only seeds the contact and returns its + * recipient hash for the send path to use. + * + * Anti-abuse: a suppressed address is never seeded, so a victim who unsubscribed + * or reported "not me" stays off the list even if named again in fresh hostile + * metadata. `ensureContact` is insert-if-absent, so an already-confirmed or + * declined contact is never reset to `unconfirmed`; the per-address/per-DID + * rate limits and confirm-state machine (slices B/C) govern the rest. + * + * Never logs plaintext email — only the DID and a hash prefix. + */ +export async function seedPublisherContact( + client: AggregatorClient, + db: D1Database, + pepper: string, + target: ContactTarget, + nowIso: string, +): Promise { + const resolution = await resolvePublisherContact(client, target); + if ("none" in resolution) { + logResolve(target.did, resolution.none, undefined); + return { seeded: false, reason: resolution.none }; + } + + const hash = await recipientHash(pepper, resolution.email); + if (await isSuppressed(db, hash)) { + logResolve(target.did, "suppressed", hash); + return { seeded: false, reason: "suppressed" }; + } + + await ensureContact(db, hash, nowIso); + logResolve(target.did, `seeded:${resolution.tier}`, hash); + return { seeded: true, recipientHash: hash, tier: resolution.tier }; +} + +/** First entry with a non-empty `email`, or null. Skips url-only entries and + * anything that isn't a `{ email }`-shaped object. */ +function firstEmail(entries: readonly unknown[]): string | null { + for (const entry of entries) { + const email = readEmail(entry); + if (email !== null) return email; + } + return null; +} + +/** Tier-3 selection: an entry with `kind: security` and an email wins outright; + * otherwise the first entry carrying any email (regardless of kind). */ +function preferredPublisherEmail( + entries: readonly unknown[], +): { email: string; kind?: string } | null { + let fallback: { email: string; kind?: string } | null = null; + for (const entry of entries) { + const record = asRecord(entry); + if (record === null) continue; + const email = readEmail(record); + if (email === null) continue; + const kind = typeof record["kind"] === "string" ? record["kind"] : undefined; + if (kind === "security") return { email, kind }; + if (fallback === null) fallback = kind === undefined ? { email } : { email, kind }; + } + return fallback; +} + +/** The `email` of a contact-shaped value, if present and non-empty after + * trimming; otherwise null. Returns the value verbatim (untrimmed). */ +function readEmail(entry: unknown): string | null { + const record = asRecord(entry); + if (record === null) return null; + const email = record["email"]; + if (typeof email !== "string") return null; + return email.trim().length > 0 ? email : null; +} + +function asRecord(value: unknown): Record | null { + return isRecord(value) ? value : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asArray(value: unknown): readonly unknown[] { + return Array.isArray(value) ? value : []; +} + +function logResolve(did: string, outcome: string, recipientHashValue: string | undefined): void { + console.log("[notifications]", { + action: "resolve", + outcome, + did, + hashPrefix: recipientHashValue?.slice(0, 8), + }); +} diff --git a/apps/labeler/test/publisher-contact.test.ts b/apps/labeler/test/publisher-contact.test.ts new file mode 100644 index 0000000000..e95ef9fed5 --- /dev/null +++ b/apps/labeler/test/publisher-contact.test.ts @@ -0,0 +1,313 @@ +import { applyD1Migrations, env } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { AggregatorClient } from "../src/aggregator-client.js"; +import { + getContactState, + hashConfirmToken, + recipientHash, + recordConfirmSent, + confirmContact, + suppress, +} from "../src/notification-contacts.js"; +import { + type ContactTarget, + resolvePublisherContact, + seedPublisherContact, +} from "../src/publisher-contact.js"; + +interface TestEnv { + DB: D1Database; + AGGREGATOR: Fetcher; + TEST_MIGRATIONS: Parameters[1]; +} + +const testEnv = env as unknown as TestEnv; +const db = () => testEnv.DB; + +const PEPPER = "pepper-contact"; +const TARGET: ContactTarget = { did: "did:plc:publisher", slug: "some-plugin" }; + +/** + * A fake {@link AggregatorClient} whose `getPackage`/`getPublisher` return the + * supplied verbatim `profile` (wrapped in the view envelope the real methods + * return) or `null` for a not-indexed read. Records call counts so a test can + * prove the tier walk short-circuits and doesn't over-fetch. + */ +function makeClient(pkgProfile: unknown, pubProfile: unknown) { + const calls = { getPackage: 0, getPublisher: 0 }; + const client = { + getPackage: () => { + calls.getPackage++; + return Promise.resolve(pkgProfile === null ? null : { profile: pkgProfile }); + }, + getPublisher: () => { + calls.getPublisher++; + return Promise.resolve(pubProfile === null ? null : { profile: pubProfile }); + }, + } as unknown as AggregatorClient; + return { client, calls }; +} + +const packageProfile = (opts: { security?: unknown[]; authors?: unknown[] }) => ({ + $type: "com.emdashcms.experimental.package.profile", + type: "emdash-plugin", + license: "MIT", + security: opts.security ?? [], + authors: opts.authors ?? [], +}); + +const publisherProfile = (contact: unknown[]) => ({ + $type: "com.emdashcms.experimental.publisher.profile", + displayName: "Acme", + contact, +}); + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); +}); + +describe("resolvePublisherContact tier walk", () => { + it("tier 1: returns the first package security email", async () => { + const { client, calls } = makeClient( + packageProfile({ + security: [{ email: "sec@pkg.test" }], + authors: [{ name: "A", email: "a@pkg.test" }], + }), + null, + ); + const result = await resolvePublisherContact(client, TARGET); + expect(result).toEqual({ email: "sec@pkg.test", tier: "package_security" }); + // A tier-1 hit must not read the publisher profile. + expect(calls.getPublisher).toBe(0); + }); + + it("tier 1 url-only falls through to a tier 2 author email", async () => { + const { client } = makeClient( + packageProfile({ + security: [{ url: "https://pkg.test/security" }], + authors: [ + { name: "A", url: "https://a.test" }, + { name: "B", email: "b@pkg.test" }, + ], + }), + null, + ); + const result = await resolvePublisherContact(client, TARGET); + expect(result).toEqual({ email: "b@pkg.test", tier: "package_author" }); + }); + + it("all package contacts url-only falls through to tier 3 publisher contact", async () => { + const { client, calls } = makeClient( + packageProfile({ + security: [{ url: "https://pkg.test/security" }], + authors: [{ name: "A", url: "https://a.test" }], + }), + publisherProfile([{ kind: "general", email: "hello@acme.test" }]), + ); + const result = await resolvePublisherContact(client, TARGET); + expect(result).toEqual({ + email: "hello@acme.test", + tier: "publisher_profile", + kind: "general", + }); + expect(calls.getPublisher).toBe(1); + }); + + it("tier 3 prefers a kind:security channel over an earlier general one", async () => { + const { client } = makeClient( + packageProfile({ + security: [{ url: "https://pkg.test/s" }], + authors: [{ name: "A", url: "https://a.test" }], + }), + publisherProfile([ + { kind: "general", email: "general@acme.test" }, + { kind: "security", email: "security@acme.test" }, + ]), + ); + const result = await resolvePublisherContact(client, TARGET); + expect(result).toEqual({ + email: "security@acme.test", + tier: "publisher_profile", + kind: "security", + }); + }); + + it("tier 3 takes the first email when no security channel carries one", async () => { + const { client } = makeClient( + packageProfile({ + security: [{ url: "https://pkg.test/s" }], + authors: [{ name: "A", url: "https://a.test" }], + }), + publisherProfile([ + { kind: "security", url: "https://acme.test/security" }, + { kind: "general", email: "general@acme.test" }, + ]), + ); + const result = await resolvePublisherContact(client, TARGET); + expect(result).toEqual({ + email: "general@acme.test", + tier: "publisher_profile", + kind: "general", + }); + }); + + it("returns none when no tier carries an email", async () => { + const { client } = makeClient( + packageProfile({ + security: [{ url: "https://pkg.test/s" }], + authors: [{ name: "A", url: "https://a.test" }], + }), + publisherProfile([{ kind: "general", url: "https://acme.test" }]), + ); + const result = await resolvePublisherContact(client, TARGET); + expect(result).toEqual({ none: "no_email_contact" }); + }); + + it("still resolves tier 3 when the package is not indexed", async () => { + const { client, calls } = makeClient( + null, + publisherProfile([{ kind: "security", email: "security@acme.test" }]), + ); + const result = await resolvePublisherContact(client, TARGET); + expect(result).toEqual({ + email: "security@acme.test", + tier: "publisher_profile", + kind: "security", + }); + expect(calls.getPackage).toBe(1); + expect(calls.getPublisher).toBe(1); + }); + + it("returns none when neither package nor publisher is indexed", async () => { + const { client } = makeClient(null, null); + expect(await resolvePublisherContact(client, TARGET)).toEqual({ none: "no_email_contact" }); + }); + + it("skips whitespace-only emails", async () => { + const { client } = makeClient( + packageProfile({ + security: [{ email: " " }], + authors: [{ name: "A", email: "real@pkg.test" }], + }), + null, + ); + expect(await resolvePublisherContact(client, TARGET)).toEqual({ + email: "real@pkg.test", + tier: "package_author", + }); + }); + + it("propagates a transport failure rather than reporting no contact", async () => { + const client = { + getPackage: () => Promise.reject(new Error("binding unreachable")), + getPublisher: () => Promise.reject(new Error("binding unreachable")), + } as unknown as AggregatorClient; + await expect(resolvePublisherContact(client, TARGET)).rejects.toThrow("binding unreachable"); + }); +}); + +describe("seedPublisherContact", () => { + it("seeds an unconfirmed contact keyed by the recipient hash", async () => { + const email = "seed-me@pkg.test"; + const { client } = makeClient(packageProfile({ security: [{ email }] }), null); + const outcome = await seedPublisherContact( + client, + db(), + PEPPER, + TARGET, + new Date().toISOString(), + ); + + const expectedHash = await recipientHash(PEPPER, email); + expect(outcome).toEqual({ + seeded: true, + recipientHash: expectedHash, + tier: "package_security", + }); + const state = await getContactState(db(), expectedHash); + expect(state?.confirmState).toBe("unconfirmed"); + }); + + it("does not seed a suppressed address", async () => { + const email = "suppressed@pkg.test"; + const hash = await recipientHash(PEPPER, email); + await suppress(db(), hash, "unsubscribe", new Date().toISOString(), Date.now()); + + const { client } = makeClient(packageProfile({ security: [{ email }] }), null); + const outcome = await seedPublisherContact( + client, + db(), + PEPPER, + TARGET, + new Date().toISOString(), + ); + + expect(outcome).toEqual({ seeded: false, reason: "suppressed" }); + expect(await getContactState(db(), hash)).toBeNull(); + }); + + it("does not reset an already-confirmed contact to unconfirmed", async () => { + const email = "confirmed@pkg.test"; + const hash = await recipientHash(PEPPER, email); + const token = "raw-confirm-token"; + const tokenHash = await hashConfirmToken(token); + const now = new Date().toISOString(); + await seedPublisherContact( + makeClient(packageProfile({ security: [{ email }] }), null).client, + db(), + PEPPER, + TARGET, + now, + ); + await recordConfirmSent(db(), hash, tokenHash, Date.now()); + await confirmContact(db(), hash, tokenHash, now); + expect((await getContactState(db(), hash))?.confirmState).toBe("confirmed"); + + const { client } = makeClient(packageProfile({ security: [{ email }] }), null); + const outcome = await seedPublisherContact( + client, + db(), + PEPPER, + TARGET, + new Date().toISOString(), + ); + + expect(outcome).toEqual({ seeded: true, recipientHash: hash, tier: "package_security" }); + expect((await getContactState(db(), hash))?.confirmState).toBe("confirmed"); + }); + + it("reports no contact without touching the database", async () => { + const { client } = makeClient( + packageProfile({ security: [{ url: "https://pkg.test/s" }] }), + null, + ); + const outcome = await seedPublisherContact( + client, + db(), + PEPPER, + TARGET, + new Date().toISOString(), + ); + expect(outcome).toEqual({ seeded: false, reason: "no_email_contact" }); + }); +}); + +describe("resolvePublisherContact over the AGGREGATOR service binding", () => { + // Drives the resolver through the real binding and both new read methods: + // the stub's package view is url-only (tiers 1-2 miss) and its publisher + // view carries a security email (tier 3 hits), so one resolution proves the + // full walk survives the binding hop end to end. + it("walks package + publisher reads through the binding to a tier-3 email", async () => { + const client = new AggregatorClient(testEnv.AGGREGATOR); + const result = await resolvePublisherContact(client, { + did: "did:plc:stubpublisher", + slug: "stub-plugin", + }); + expect(result).toEqual({ + email: "security@stub.example", + tier: "publisher_profile", + kind: "security", + }); + }); +}); diff --git a/apps/labeler/vitest.config.ts b/apps/labeler/vitest.config.ts index cb4adca95d..e54a64359f 100644 --- a/apps/labeler/vitest.config.ts +++ b/apps/labeler/vitest.config.ts @@ -6,6 +6,13 @@ import { configDefaults, defineConfig } from "vitest/config"; const migrationsPath = fileURLToPath(new URL("./migrations", import.meta.url)); const migrations = await readD1Migrations(migrationsPath); +function jsonView(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + export default defineConfig({ // The console (console/vitest.config.ts) and calibration // (calibration/unit.vitest.config.ts) run their own suites separately -- @@ -25,13 +32,57 @@ export default defineConfig({ // wrangler.jsonc declares an AGGREGATOR service binding to the // aggregator Worker, which doesn't exist in the test runtime. // Stub it so miniflare can start; most tests inject their own mock - // Fetcher rather than using this binding. The stub still fails loud - // (501) for an accidental call, and echoes the inbound - // atproto-accept-labelers header as a marker so a binding-transport - // test can prove the client's blank value survives the hop (a - // dropped empty header would read `absent`, not `empty`). + // Fetcher rather than using this binding. + // + // The stub serves realistic getPackage/getPublisher views so a + // consumer (the contact resolver) can be exercised end-to-end + // through the real binding: the package view carries only url-only + // contacts (tiers 1-2 miss) and the publisher view carries a + // security-kind email (tier 3 hits), so one resolution walks the + // full tier chain across the binding hop. Any other path still fails + // loud (501) and echoes the inbound atproto-accept-labelers header + // as a marker, so a binding-transport test can prove the client's + // blank value survives the hop (a dropped empty header reads + // `absent`, not `empty`). serviceBindings: { AGGREGATOR: (request) => { + const path = new URL(request.url).pathname; + if (path.endsWith("/com.emdashcms.experimental.aggregator.getPackage")) { + return jsonView({ + uri: "at://did:plc:stubpublisher/com.emdashcms.experimental.package.profile/stub-plugin", + cid: "bafyreistubpackagecidvalue", + did: "did:plc:stubpublisher", + slug: "stub-plugin", + indexedAt: "2026-07-01T00:00:00.000Z", + labels: [], + profile: { + $type: "com.emdashcms.experimental.package.profile", + id: "at://did:plc:stubpublisher/com.emdashcms.experimental.package.profile/stub-plugin", + type: "emdash-plugin", + license: "MIT", + security: [{ url: "https://stub.example/security" }], + authors: [{ name: "Stub Author", url: "https://stub.example/author" }], + slug: "stub-plugin", + }, + }); + } + if (path.endsWith("/com.emdashcms.experimental.aggregator.getPublisher")) { + return jsonView({ + uri: "at://did:plc:stubpublisher/com.emdashcms.experimental.publisher.profile/self", + cid: "bafyreistubpublishercidvalue", + did: "did:plc:stubpublisher", + indexedAt: "2026-07-01T00:00:00.000Z", + labels: [], + profile: { + $type: "com.emdashcms.experimental.publisher.profile", + displayName: "Stub Publisher", + contact: [ + { kind: "general", url: "https://stub.example/contact" }, + { kind: "security", email: "security@stub.example" }, + ], + }, + }); + } const raw = request.headers.get("atproto-accept-labelers"); const marker = raw === null ? "absent" : raw === "" ? "empty" : `value:${raw}`; return new Response("AGGREGATOR is stubbed in tests", { diff --git a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/defs.json b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/defs.json index 8df59b347a..2a27896336 100644 --- a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/defs.json +++ b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/defs.json @@ -59,6 +59,51 @@ } } }, + "publisherView": { + "type": "object", + "description": "An aggregator's view of a publisher: the signed publisher.profile record verbatim (identity-level contact channels, display name), denormalised metadata for display, and any labels applied to the publisher DID by trusted labelers the aggregator hydrates.", + "required": ["uri", "cid", "did", "profile", "indexedAt"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the publisher.profile record the aggregator indexed (rkey is always 'self'). Pins exactly which record version this view describes." + }, + "cid": { + "type": "string", + "format": "cid", + "description": "CID of the profile record content the aggregator indexed. Lets clients confirm they're working with the same bytes the aggregator did." + }, + "did": { + "type": "string", + "format": "did", + "description": "Publisher DID. Denormalised convenience; equivalent to the DID portion of `uri`." + }, + "handle": { + "type": "string", + "format": "handle", + "description": "Publisher's current handle, if known. Best-effort: handles are mutable and may be stale at the moment of read." + }, + "profile": { + "type": "unknown", + "description": "The signed publisher.profile record verbatim, passed through from the publisher's repo (carrying its $type, displayName, contact channels, etc.)." + }, + "indexedAt": { + "type": "string", + "format": "datetime", + "description": "When the aggregator first indexed this publisher." + }, + "labels": { + "type": "array", + "description": "Hydrated labels applying to this publisher DID, per the labelers the request asked for via the atproto-accept-labelers header.", + "maxLength": 64, + "items": { + "type": "ref", + "ref": "com.atproto.label.defs#label" + } + } + } + }, "releaseView": { "type": "object", "description": "An aggregator's view of a release: the signed record verbatim, the mirror URLs the aggregator is currently serving the primary artifact from, and any hydrated labels.", diff --git a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/getPublisher.json b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/getPublisher.json new file mode 100644 index 0000000000..dc325c5ee0 --- /dev/null +++ b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/getPublisher.json @@ -0,0 +1,35 @@ +{ + "lexicon": 1, + "id": "com.emdashcms.experimental.aggregator.getPublisher", + "description": "Get a single publisher profile by its DID. EXPERIMENTAL.", + "defs": { + "main": { + "type": "query", + "description": "Returns the aggregator's view of one publisher, including the signed publisher.profile record (identity-level contact channels, display name, description) and any hydrated labels. Returns NotFound if the aggregator has no profile indexed for this DID, or if the publisher has been tombstoned.", + "parameters": { + "type": "params", + "required": ["did"], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "Publisher DID." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "com.emdashcms.experimental.aggregator.defs#publisherView" + } + }, + "errors": [ + { + "name": "NotFound", + "description": "No publisher profile indexed under the given DID. Aggregators that hard-delete on publisher deletion (rather than soft-deleting) return NotFound for both the never-indexed and the deleted cases — clients should treat them equivalently." + } + ] + } + } +} diff --git a/packages/registry-lexicons/src/generated/index.ts b/packages/registry-lexicons/src/generated/index.ts index a436d3f899..5012384e8d 100644 --- a/packages/registry-lexicons/src/generated/index.ts +++ b/packages/registry-lexicons/src/generated/index.ts @@ -1,6 +1,7 @@ export * as ComEmdashcmsExperimentalAggregatorDefs from "./types/com/emdashcms/experimental/aggregator/defs.js"; export * as ComEmdashcmsExperimentalAggregatorGetLatestRelease from "./types/com/emdashcms/experimental/aggregator/getLatestRelease.js"; export * as ComEmdashcmsExperimentalAggregatorGetPackage from "./types/com/emdashcms/experimental/aggregator/getPackage.js"; +export * as ComEmdashcmsExperimentalAggregatorGetPublisher from "./types/com/emdashcms/experimental/aggregator/getPublisher.js"; export * as ComEmdashcmsExperimentalAggregatorListReleases from "./types/com/emdashcms/experimental/aggregator/listReleases.js"; export * as ComEmdashcmsExperimentalAggregatorResolvePackage from "./types/com/emdashcms/experimental/aggregator/resolvePackage.js"; export * as ComEmdashcmsExperimentalAggregatorSearchPackages from "./types/com/emdashcms/experimental/aggregator/searchPackages.js"; diff --git a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/defs.ts b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/defs.ts index 62232557f7..9d856de507 100644 --- a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/defs.ts +++ b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/defs.ts @@ -62,6 +62,49 @@ const _packageViewSchema = /*#__PURE__*/ v.object({ */ uri: /*#__PURE__*/ v.resourceUriString(), }); +const _publisherViewSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.aggregator.defs#publisherView", + ), + ), + /** + * CID of the profile record content the aggregator indexed. Lets clients confirm they're working with the same bytes the aggregator did. + */ + cid: /*#__PURE__*/ v.cidString(), + /** + * Publisher DID. Denormalised convenience; equivalent to the DID portion of `uri`. + */ + did: /*#__PURE__*/ v.didString(), + /** + * Publisher's current handle, if known. Best-effort: handles are mutable and may be stale at the moment of read. + */ + handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.handleString()), + /** + * When the aggregator first indexed this publisher. + */ + indexedAt: /*#__PURE__*/ v.datetimeString(), + /** + * Hydrated labels applying to this publisher DID, per the labelers the request asked for via the atproto-accept-labelers header. + * @maxLength 64 + */ + get labels() { + return /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(ComAtprotoLabelDefs.labelSchema), + [/*#__PURE__*/ v.arrayLength(0, 64)], + ), + ); + }, + /** + * The signed publisher.profile record verbatim, passed through from the publisher's repo (carrying its $type, displayName, contact channels, etc.). + */ + profile: /*#__PURE__*/ v.unknown(), + /** + * AT URI of the publisher.profile record the aggregator indexed (rkey is always 'self'). Pins exactly which record version this view describes. + */ + uri: /*#__PURE__*/ v.resourceUriString(), +}); const _releaseViewSchema = /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.optional( /*#__PURE__*/ v.literal( @@ -133,13 +176,19 @@ const _releaseViewSchema = /*#__PURE__*/ v.object({ }); type packageView$schematype = typeof _packageViewSchema; +type publisherView$schematype = typeof _publisherViewSchema; type releaseView$schematype = typeof _releaseViewSchema; export interface packageViewSchema extends packageView$schematype {} +export interface publisherViewSchema extends publisherView$schematype {} export interface releaseViewSchema extends releaseView$schematype {} export const packageViewSchema = _packageViewSchema as packageViewSchema; +export const publisherViewSchema = _publisherViewSchema as publisherViewSchema; export const releaseViewSchema = _releaseViewSchema as releaseViewSchema; export interface PackageView extends v.InferInput {} +export interface PublisherView extends v.InferInput< + typeof publisherViewSchema +> {} export interface ReleaseView extends v.InferInput {} diff --git a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/getPublisher.ts b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/getPublisher.ts new file mode 100644 index 0000000000..be7a902315 --- /dev/null +++ b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/getPublisher.ts @@ -0,0 +1,37 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as ComEmdashcmsExperimentalAggregatorDefs from "./defs.js"; + +const _mainSchema = /*#__PURE__*/ v.query( + "com.emdashcms.experimental.aggregator.getPublisher", + { + params: /*#__PURE__*/ v.object({ + /** + * Publisher DID. + */ + did: /*#__PURE__*/ v.didString(), + }), + output: { + type: "lex", + get schema() { + return ComEmdashcmsExperimentalAggregatorDefs.publisherViewSchema; + }, + }, + }, +); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params extends v.InferInput {} +export type $output = v.InferXRPCBodyInput; + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "com.emdashcms.experimental.aggregator.getPublisher": mainSchema; + } +} diff --git a/packages/registry-lexicons/src/index.ts b/packages/registry-lexicons/src/index.ts index 2696f2b991..341ff3e9a7 100644 --- a/packages/registry-lexicons/src/index.ts +++ b/packages/registry-lexicons/src/index.ts @@ -24,6 +24,7 @@ export * as AggregatorDefs from "./generated/types/com/emdashcms/experimental/aggregator/defs.js"; export * as AggregatorGetLatestRelease from "./generated/types/com/emdashcms/experimental/aggregator/getLatestRelease.js"; export * as AggregatorGetPackage from "./generated/types/com/emdashcms/experimental/aggregator/getPackage.js"; +export * as AggregatorGetPublisher from "./generated/types/com/emdashcms/experimental/aggregator/getPublisher.js"; export * as AggregatorListReleases from "./generated/types/com/emdashcms/experimental/aggregator/listReleases.js"; export * as AggregatorResolvePackage from "./generated/types/com/emdashcms/experimental/aggregator/resolvePackage.js"; export * as AggregatorSearchPackages from "./generated/types/com/emdashcms/experimental/aggregator/searchPackages.js"; @@ -57,6 +58,7 @@ export const NSID = { aggregatorDefs: "com.emdashcms.experimental.aggregator.defs", aggregatorGetLatestRelease: "com.emdashcms.experimental.aggregator.getLatestRelease", aggregatorGetPackage: "com.emdashcms.experimental.aggregator.getPackage", + aggregatorGetPublisher: "com.emdashcms.experimental.aggregator.getPublisher", aggregatorListReleases: "com.emdashcms.experimental.aggregator.listReleases", aggregatorResolvePackage: "com.emdashcms.experimental.aggregator.resolvePackage", aggregatorSearchPackages: "com.emdashcms.experimental.aggregator.searchPackages", @@ -107,6 +109,7 @@ export const RECORD_NSIDS = [ export const QUERY_NSIDS = [ NSID.aggregatorGetLatestRelease, NSID.aggregatorGetPackage, + NSID.aggregatorGetPublisher, NSID.aggregatorListReleases, NSID.aggregatorResolvePackage, NSID.aggregatorSearchPackages, diff --git a/packages/registry-lexicons/tests/types.test.ts b/packages/registry-lexicons/tests/types.test.ts index ec88efb0eb..5574a24e7f 100644 --- a/packages/registry-lexicons/tests/types.test.ts +++ b/packages/registry-lexicons/tests/types.test.ts @@ -321,6 +321,7 @@ describe("NSID map", () => { "com.emdashcms.experimental.aggregator.defs", "com.emdashcms.experimental.aggregator.getLatestRelease", "com.emdashcms.experimental.aggregator.getPackage", + "com.emdashcms.experimental.aggregator.getPublisher", "com.emdashcms.experimental.aggregator.listReleases", "com.emdashcms.experimental.aggregator.resolvePackage", "com.emdashcms.experimental.aggregator.searchPackages", From 9501d61489695e239c35e7310cc78b78a3a1a6f3 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 14:12:07 +0100 Subject: [PATCH 089/137] feat(labeler): verification-state history input via aggregator read (W8.4 slice 3) (#2074) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(labeler): verification-state history input via aggregator read (W8.4 slice 3) Adds the publisher verification-state input to analyzeHistory, the first of W8.4 slice 3's two aggregator-sourced history inputs. - registry-lexicons: new getPublisherVerification query + publisherVerificationView / verificationClaimView defs, returning the non-tombstoned verification claims indexed for a publisher DID. - aggregator: read-only route over publisher_verifications, redaction-gated on the publisher DID exactly like getPackage (a blank accept-labelers header opts out, so an internal caller reads unfiltered). - labeler: AggregatorClient.getPublisherVerification; analyzeHistory consumes it behind an optional reader and emits a publisher-verification history finding with issuer/handle specifics in privateDetail only. The aggregator read has its own try/catch so a failure never discards the own-D1 findings. The second slice-3 input, recent handle/profile changes, is not built: the aggregator keeps only current profile state (publishers is upserted) and does not ingest the atproto identity events that carry handle changes, so no change history exists to expose. Blocked on ingestion, not a read path. Co-Authored-By: Claude Opus 4.8 * fix(aggregator): redact per-issuer on getPublisherVerification (W8.4 slice 3 review) Each verification claim is authored by its issuer's repo, so a subject-keyed lookup could resurface a taken-down issuer's vouch. Hydrate labels for the subject DID and every distinct issuer DID in one batched call; a redacted subject 404s the whole view, a redacted issuer drops only its own claim. A blank accept-labelers header (the internal labeler read) has an empty accepted set, so nothing is filtered and every claim is seen. Also correct the shipped docs: the read is NOT indistinguishable from absence (a non-redacted DID returns 200, possibly with an empty verifications array; only a redacted subject 404s), and it returns claims regardless of expiry — expiresAt is present for the consumer to evaluate in-force state, not a server-side filter. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../aggregator-publisher-verification-read.md | 5 + .../routes/xrpc/getPublisherVerification.ts | 98 ++++++++++ apps/aggregator/src/routes/xrpc/router.ts | 5 + apps/aggregator/src/routes/xrpc/views.ts | 71 +++++++ apps/aggregator/test/read-api.test.ts | 183 ++++++++++++++++++ apps/labeler/src/aggregator-client.ts | 23 ++- apps/labeler/src/findings.ts | 4 +- apps/labeler/src/history-context.ts | 84 +++++++- apps/labeler/test/aggregator-client.test.ts | 65 +++++++ apps/labeler/test/history-context.test.ts | 95 ++++++++- apps/labeler/vitest.config.ts | 35 +++- .../experimental/aggregator/defs.json | 68 +++++++ .../aggregator/getPublisherVerification.json | 35 ++++ .../registry-lexicons/src/generated/index.ts | 1 + .../emdashcms/experimental/aggregator/defs.ts | 84 ++++++++ .../aggregator/getPublisherVerification.ts | 37 ++++ packages/registry-lexicons/src/index.ts | 4 + .../registry-lexicons/tests/types.test.ts | 1 + 18 files changed, 884 insertions(+), 14 deletions(-) create mode 100644 .changeset/aggregator-publisher-verification-read.md create mode 100644 apps/aggregator/src/routes/xrpc/getPublisherVerification.ts create mode 100644 packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/getPublisherVerification.json create mode 100644 packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/getPublisherVerification.ts diff --git a/.changeset/aggregator-publisher-verification-read.md b/.changeset/aggregator-publisher-verification-read.md new file mode 100644 index 0000000000..7b85fce193 --- /dev/null +++ b/.changeset/aggregator-publisher-verification-read.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-lexicons": minor +--- + +Adds the `com.emdashcms.experimental.aggregator.getPublisherVerification` query and its `publisherVerificationView` / `verificationClaimView` definitions: read the verification claims an aggregator has indexed for a publisher DID. diff --git a/apps/aggregator/src/routes/xrpc/getPublisherVerification.ts b/apps/aggregator/src/routes/xrpc/getPublisherVerification.ts new file mode 100644 index 0000000000..3b832bb229 --- /dev/null +++ b/apps/aggregator/src/routes/xrpc/getPublisherVerification.ts @@ -0,0 +1,98 @@ +/** + * `com.emdashcms.experimental.aggregator.getPublisherVerification` — the + * verification state indexed for a publisher DID: the non-tombstoned + * `publisher_verifications` claims naming it as subject, as operator/display + * context. + * + * Redaction runs at two scopes, because a claim is authored by its ISSUER's + * repo, not the subject's: + * - the subject DID gates the whole view (redacted → NotFound), the same rule + * `getPackage` applies to a publisher subject; + * - each claim's issuer DID gates only that claim (redacted issuer → the claim + * is dropped from `verifications`), so one taken-down issuer can't hide a + * publisher's other, still-valid claims. + * An internal caller opting out with a blank `atproto-accept-labelers` header + * has an empty accepted set, so nothing is redacted — it reads every claim, + * including redacted-issuer ones, and weighs trust itself. + * + * An empty `verifications` array is a valid 200 (an unverified publisher, or one + * whose every claim's issuer is redacted for this caller) — a NON-redacted + * subject is never a 404, so 404 (redacted subject) is distinguishable from an + * empty result, unlike `getPackage` where absence is also a 404. + * + * The companion input in plan W8.4 slice 3 — recent handle/profile *changes* — + * has no read here because the aggregator does not ingest a history of them: + * `publishers` is upserted to current state only and handle changes arrive as + * atproto identity events the ingestor does not subscribe to + * (`jetstream-client.ts`). Exposing them is blocked on that ingestion work, + * not on a read path. + */ + +import { json, XRPCError } from "@atcute/xrpc-server"; +import { + type AggregatorDefs, + type AggregatorGetPublisherVerification, +} from "@emdash-cms/registry-lexicons"; + +import { hydrateLabels, isRedacted } from "./label-enforcement.js"; +import { getRequestLabelerPolicy } from "./request-policy.js"; +import { + type PublisherVerificationRow, + publisherVerificationColumns, + publisherVerificationView, +} from "./views.js"; + +/** Upper bound on claims returned in one view. A publisher with more than this + * many verification claims is pathological; the view's lexicon `maxLength` + * matches. */ +const MAX_VERIFICATION_CLAIMS = 100; + +export async function getPublisherVerification( + env: Env, + params: AggregatorGetPublisherVerification.$params, + request: Request, +): Promise { + // `first-primary` for read-after-write consistency with a takedown label + // that could land between two reads, mirroring `getPackage`. + const session = env.DB.withSession("first-primary"); + const result = await session + .prepare( + `SELECT ${publisherVerificationColumns()} FROM publisher_verifications + WHERE subject_did = ? AND tombstoned_at IS NULL + ORDER BY created_at DESC, issuer_did ASC + LIMIT ?`, + ) + .bind(params.did, MAX_VERIFICATION_CLAIMS) + .all(); + const rows = result.results ?? []; + + const { accepted } = getRequestLabelerPolicy(request); + // One batched hydration for the subject DID plus every distinct issuer DID + // in the result. `hydrateLabels` chunks its `uri IN (...)` at 50, so this + // stays a single query unless a publisher has 50+ distinct issuers. + const issuerDids = [...new Set(rows.map((row) => row.issuer_did))]; + const subjects = [params.did, ...issuerDids].map((uri) => ({ uri })); + const labelsByUri = await hydrateLabels(session, accepted, subjects, Date.now()); + + const subjectLabels = labelsByUri.get(params.did) ?? []; + if (isRedacted(subjectLabels, accepted)) { + // A redacted publisher DID hides the whole view — the caller can't tell it + // apart from an empty result, which is the point of redaction. + throw new XRPCError({ + status: 404, + error: "NotFound", + message: `No publisher indexed under ${params.did}.`, + }); + } + + const visibleRows = rows.filter( + (row) => !isRedacted(labelsByUri.get(row.issuer_did) ?? [], accepted), + ); + + const view: AggregatorDefs.PublisherVerificationView = publisherVerificationView( + params.did, + visibleRows, + subjectLabels, + ); + return json(view); +} diff --git a/apps/aggregator/src/routes/xrpc/router.ts b/apps/aggregator/src/routes/xrpc/router.ts index 99a28906d7..9e345533fc 100644 --- a/apps/aggregator/src/routes/xrpc/router.ts +++ b/apps/aggregator/src/routes/xrpc/router.ts @@ -26,6 +26,7 @@ import { AggregatorGetLatestRelease, AggregatorGetPackage, AggregatorGetPublisher, + AggregatorGetPublisherVerification, AggregatorListReleases, AggregatorResolvePackage, AggregatorSearchPackages, @@ -34,6 +35,7 @@ import { import { getLatestRelease } from "./getLatestRelease.js"; import { getPackage } from "./getPackage.js"; import { getPublisher } from "./getPublisher.js"; +import { getPublisherVerification } from "./getPublisherVerification.js"; import { listReleases } from "./listReleases.js"; import { resolveRequestLabelerPolicy } from "./request-policy.js"; import { resolvePackage } from "./resolvePackage.js"; @@ -176,5 +178,8 @@ function createRouter(env: Env): XRPCRouter { router.addQuery(AggregatorGetPublisher.mainSchema, { handler: ({ params, request }) => getPublisher(env, params, request), }); + router.addQuery(AggregatorGetPublisherVerification.mainSchema, { + handler: ({ params, request }) => getPublisherVerification(env, params, request), + }); return router; } diff --git a/apps/aggregator/src/routes/xrpc/views.ts b/apps/aggregator/src/routes/xrpc/views.ts index 43c4217f4a..5704a1fa1e 100644 --- a/apps/aggregator/src/routes/xrpc/views.ts +++ b/apps/aggregator/src/routes/xrpc/views.ts @@ -357,6 +357,77 @@ function synthesizePackageRelease(row: ReleaseRow): Record { return release; } +/** Subset of columns from `publisher_verifications` we read for + * `verificationClaimView`. */ +export interface PublisherVerificationRow { + issuer_did: string; + subject_handle: string; + subject_display_name: string; + created_at: string; + expires_at: string | null; + indexed_at: string | null; + verified_at: string; +} + +const PUBLISHER_VERIFICATION_VIEW_COLUMN_NAMES = [ + "issuer_did", + "subject_handle", + "subject_display_name", + "created_at", + "expires_at", + "indexed_at", + "verified_at", +] as const; + +/** SELECT-clause string for `PublisherVerificationRow`, optionally prefixed + * for JOINs. */ +export function publisherVerificationColumns(prefix = ""): string { + return PUBLISHER_VERIFICATION_VIEW_COLUMN_NAMES.map((c) => `${prefix}${c}`).join(", "); +} + +/** + * Map the `publisher_verifications` rows naming `did` as subject to the + * lexicon's `publisherVerificationView`. `labels` are hydrated by the caller + * (the publisher DID subject) and passed in; an empty `rows` is a valid view + * (an unverified publisher), distinct from the caller throwing NotFound for a + * redacted DID. + */ +export function publisherVerificationView( + did: string, + rows: PublisherVerificationRow[], + labels: LabelView[] = [], +): AggregatorDefs.PublisherVerificationView { + return { + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- `did` is the route param, validated as a DID by the lexicon + did: did as `did:${string}:${string}`, + verifications: rows.map(verificationClaimView), + labels: toLexiconVerificationLabels(capLabels(labels, did)), + }; +} + +function verificationClaimView( + row: PublisherVerificationRow, +): AggregatorDefs.VerificationClaimView { + const claim: AggregatorDefs.VerificationClaimView = { + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- issuer_did is consumer-validated at ingest time + issuer: row.issuer_did as `did:${string}:${string}`, + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- subject_handle is consumer-validated at ingest time + handle: row.subject_handle as AggregatorDefs.VerificationClaimView["handle"], + displayName: row.subject_display_name, + createdAt: row.created_at, + indexedAt: row.indexed_at ?? row.verified_at, + }; + if (row.expires_at !== null) claim.expiresAt = row.expires_at; + return claim; +} + +function toLexiconVerificationLabels( + labels: LabelView[], +): AggregatorDefs.PublisherVerificationView["labels"] { + // eslint-disable-next-line typescript/no-unsafe-type-assertion + return labels as AggregatorDefs.PublisherVerificationView["labels"]; +} + function parseJsonArray(json: string): unknown[] { try { const parsed: unknown = JSON.parse(json); diff --git a/apps/aggregator/test/read-api.test.ts b/apps/aggregator/test/read-api.test.ts index 25a240e34f..22f8dccc6c 100644 --- a/apps/aggregator/test/read-api.test.ts +++ b/apps/aggregator/test/read-api.test.ts @@ -250,6 +250,43 @@ async function seedLabelState(opts: { .run(); } +interface SeedVerificationOpts { + issuerDid?: string; + rkey?: string; + subjectDid?: string; + handle?: string; + displayName?: string; + createdAt?: string; + expiresAt?: string | null; + indexedAt?: string; + tombstoned?: boolean; +} + +async function seedVerification(opts: SeedVerificationOpts = {}): Promise { + await testEnv.DB.prepare( + `INSERT INTO publisher_verifications + (issuer_did, rkey, subject_did, subject_handle, subject_display_name, + created_at, expires_at, record_blob, signature_metadata, verified_at, + indexed_at, tombstoned_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + opts.issuerDid ?? DID_B, + opts.rkey ?? "3kaverifyrkey000", + opts.subjectDid ?? DID_A, + opts.handle ?? "publisher.example", + opts.displayName ?? "Publisher", + opts.createdAt ?? NOW.toISOString(), + opts.expiresAt === undefined ? null : opts.expiresAt, + new Uint8Array([0xc1, 0xc2, 0xc3]), + JSON.stringify({ cid: "bafyverif" }), + NOW.toISOString(), + opts.indexedAt ?? NOW.toISOString(), + opts.tombstoned ? NOW.toISOString() : null, + ) + .run(); +} + /** An expiry that is an hour in the past as an instant, rendered with a * +14:00 offset so its raw string compares lexically AFTER the current UTC * timestamp — the case that text comparison gets wrong. */ @@ -1108,3 +1145,149 @@ describe("XRPC dispatcher", () => { expect(res.status).toBe(404); }); }); + +describe("getPublisherVerification", () => { + it("returns the verification claims naming a DID as subject, newest first", async () => { + await seedVerification({ + issuerDid: DID_B, + rkey: "3kolder000000000", + createdAt: "2026-01-01T00:00:00.000Z", + handle: "pub.example", + displayName: "Pub", + }); + await seedVerification({ + issuerDid: DID_B, + rkey: "3knewer000000000", + createdAt: "2026-03-01T00:00:00.000Z", + handle: "pub.example", + displayName: "Pub", + expiresAt: "2027-01-01T00:00:00.000Z", + }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetPublisherVerification}?did=${DID_A}`, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + did: string; + labels: unknown[]; + verifications: Array>; + }; + expect(body.did).toBe(DID_A); + expect(body.labels).toEqual([]); + expect(body.verifications).toHaveLength(2); + // created_at DESC — the March claim comes first. + expect(body.verifications[0]).toMatchObject({ + issuer: DID_B, + handle: "pub.example", + displayName: "Pub", + createdAt: "2026-03-01T00:00:00.000Z", + expiresAt: "2027-01-01T00:00:00.000Z", + indexedAt: NOW.toISOString(), + }); + // The older claim has no expiry — expiresAt is omitted, not null. + expect(body.verifications[1]).not.toHaveProperty("expiresAt"); + }); + + it("returns an empty verifications array for a DID with no claims (not a 404)", async () => { + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetPublisherVerification}?did=${DID_A}`, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { did: string; verifications: unknown[] }; + expect(body.verifications).toEqual([]); + }); + + it("excludes tombstoned claims", async () => { + await seedVerification({ rkey: "3klive0000000000" }); + await seedVerification({ rkey: "3kdead0000000000", tombstoned: true }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetPublisherVerification}?did=${DID_A}`, + ); + const body = (await res.json()) as { verifications: unknown[] }; + expect(body.verifications).toHaveLength(1); + }); + + it("returns 400 InvalidRequest when the did param is missing", async () => { + const res = await SELF.fetch(`https://test/xrpc/${NSID.aggregatorGetPublisherVerification}`); + expect(res.status).toBe(400); + }); + + it("sets Cache-Control: private, no-store on success", async () => { + await seedVerification(); + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetPublisherVerification}?did=${DID_A}`, + ); + expect(res.headers.get("cache-control")).toBe("private, no-store"); + }); + + it("404s (redacted) when a default-accepted source's !takedown covers the DID", async () => { + await seedLabeler(LABELER_DID, true); + await seedVerification(); + await seedLabelState({ uri: DID_A, val: "!takedown" }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetPublisherVerification}?did=${DID_A}`, + ); + expect(res.status).toBe(404); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("NotFound"); + }); + + it("serves the view unfiltered for a blank accept-labelers header despite an active takedown", async () => { + await seedLabeler(LABELER_DID, true); + await seedVerification(); + await seedLabelState({ uri: DID_A, val: "!takedown" }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetPublisherVerification}?did=${DID_A}`, + { headers: { "atproto-accept-labelers": "" } }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { verifications: unknown[]; labels: unknown[] }; + expect(body.verifications).toHaveLength(1); + expect(body.labels).toEqual([]); + }); + + it("drops a claim whose issuer is redacted for a default-policy caller, keeping the others", async () => { + const redactedIssuer = "did:plc:issuertakedown00000000000"; + const okIssuer = "did:plc:issuerok0000000000000000"; + await seedLabeler(LABELER_DID, true); + await seedVerification({ issuerDid: redactedIssuer, rkey: "3kredacted000000" }); + await seedVerification({ issuerDid: okIssuer, rkey: "3kok000000000000" }); + // Takedown is on the ISSUER's DID, not the subject's. + await seedLabelState({ uri: redactedIssuer, val: "!takedown" }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetPublisherVerification}?did=${DID_A}`, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { verifications: Array<{ issuer: string }> }; + // The subject is not redacted, so the view is served; only the + // redacted-issuer claim is filtered out. + expect(body.verifications).toHaveLength(1); + expect(body.verifications[0]?.issuer).toBe(okIssuer); + }); + + it("keeps a redacted-issuer claim for a blank accept-labelers caller (internal unfiltered read)", async () => { + const redactedIssuer = "did:plc:issuertakedown00000000000"; + const okIssuer = "did:plc:issuerok0000000000000000"; + await seedLabeler(LABELER_DID, true); + await seedVerification({ issuerDid: redactedIssuer, rkey: "3kredacted000000" }); + await seedVerification({ issuerDid: okIssuer, rkey: "3kok000000000000" }); + await seedLabelState({ uri: redactedIssuer, val: "!takedown" }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetPublisherVerification}?did=${DID_A}`, + { headers: { "atproto-accept-labelers": "" } }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { verifications: Array<{ issuer: string }> }; + // Empty accepted set → nothing redacted → the labeler sees both claims. + expect(body.verifications).toHaveLength(2); + expect(body.verifications.map((v) => v.issuer).toSorted()).toEqual( + [okIssuer, redactedIssuer].toSorted(), + ); + }); +}); diff --git a/apps/labeler/src/aggregator-client.ts b/apps/labeler/src/aggregator-client.ts index 8daea76987..4a3077f7ad 100644 --- a/apps/labeler/src/aggregator-client.ts +++ b/apps/labeler/src/aggregator-client.ts @@ -33,7 +33,11 @@ * may treat a throw as transient and retry the whole read. */ -import type { AggregatorDefs, AggregatorListReleases } from "@emdash-cms/registry-lexicons"; +import type { + AggregatorDefs, + AggregatorGetPublisherVerification, + AggregatorListReleases, +} from "@emdash-cms/registry-lexicons"; /** * XRPC endpoint host. Irrelevant to routing — a service binding dispatches by @@ -58,6 +62,7 @@ const NSID = { getPublisher: "com.emdashcms.experimental.aggregator.getPublisher", getLatestRelease: "com.emdashcms.experimental.aggregator.getLatestRelease", listReleases: "com.emdashcms.experimental.aggregator.listReleases", + getPublisherVerification: "com.emdashcms.experimental.aggregator.getPublisherVerification", } as const; /** Build an XRPC GET URL from a constant NSID and caller-supplied param @@ -125,6 +130,22 @@ export class AggregatorClient { return this.#query(NSID.listReleases, url); } + /** Fetch the verification state indexed for a publisher `did` — the + * non-tombstoned verification claims naming it as subject. Returns a view + * with an empty `verifications` array for an unverified publisher; `null` + * only when the DID is redacted by a labeler the aggregator's default + * policy accepts (which the blank accept-labelers header opts out of, so an + * indexed subject resolves to the view). */ + async getPublisherVerification( + did: string, + ): Promise { + const url = buildUrl(NSID.getPublisherVerification, { did }); + return this.#query( + NSID.getPublisherVerification, + url, + ); + } + /** One fetch, no retry. `NotFound` → `null`; any other non-2xx or a * transport failure throws. */ async #query(nsid: string, url: string): Promise { diff --git a/apps/labeler/src/findings.ts b/apps/labeler/src/findings.ts index 961c5e9692..6796992d21 100644 --- a/apps/labeler/src/findings.ts +++ b/apps/labeler/src/findings.ts @@ -45,12 +45,14 @@ export type FindingSourceMetadata = ToolFindingMetadata | ModelFindingMetadata; export type HistoryFindingCategory = | "publisher-history" | "shared-artifact" - | "active-manual-label"; + | "active-manual-label" + | "publisher-verification"; export const HISTORY_FINDING_CATEGORIES: ReadonlySet = new Set([ "publisher-history", "shared-artifact", "active-manual-label", + "publisher-verification", ]); export interface NormalizedFinding { diff --git a/apps/labeler/src/history-context.ts b/apps/labeler/src/history-context.ts index b8227d9c03..93c34b6573 100644 --- a/apps/labeler/src/history-context.ts +++ b/apps/labeler/src/history-context.ts @@ -1,8 +1,10 @@ /** - * Publisher-history context stage (plan W8.4, slice 1). Produces + * Publisher-history context stage (plan W8.4, slices 1 + 3). Produces * `source: "history"` normalized findings from the labeler's OWN D1 — prior * releases from the publishing DID, the same artifact checksum submitted under - * other DIDs, and existing active manual labels on the subject. + * other DIDs, and existing active manual labels on the subject — plus, when a + * read-only aggregator binding is supplied, the publisher's verification state + * (slice 3). * * These findings are bounded, factual context an operator reads through the * assessment projection. They are structurally never turned into labels: the @@ -11,12 +13,15 @@ * dedicated `HISTORY_FINDING_CATEGORIES` set (`findings.ts`), disjoint from the * policy's label vocabulary. * - * The two deferred inputs — recent handle/profile changes and verification - * state — live only in the aggregator's D1, which the labeler has no binding to - * reach; they are gated on a ratified read path (plan W8.4 D1) and not built - * here. + * Slice 3's second deferred input — recent handle/profile *changes* — has no + * read here: the aggregator ingests only current profile state (`publishers` is + * upserted, keeping no prior values) and does not subscribe to the atproto + * identity events that carry handle changes, so a change history does not exist + * to expose. It is blocked on that ingestion work, not on a read path. */ +import type { AggregatorGetPublisherVerification } from "@emdash-cms/registry-lexicons"; + import { getActiveLabelState, getCurrentSubjectByUri, @@ -34,6 +39,15 @@ const DEFAULT_SHARED_PUBLISHER_LIMIT = 20; /** How many URIs/DIDs to name in a finding's private detail. */ const SAMPLE_SIZE = 5; +/** + * Read-only aggregator surface `analyzeHistory` needs for slice 3. Narrowed to + * the one method so tests inject a plain object and the stage doesn't depend on + * the whole `AggregatorClient`. `AggregatorClient` satisfies it structurally. + */ +export interface PublisherVerificationReader { + getPublisherVerification(did: string): Promise; +} + export interface HistoryContextOptions { /** The labeler's own DID (`src`) — the stream whose active manual labels * count as context. */ @@ -41,6 +55,10 @@ export interface HistoryContextOptions { priorReleaseLimit?: number; sharedPublisherLimit?: number; now?: Date; + /** Read-only aggregator binding for the publisher's verification state + * (slice 3). When omitted, the verification input is skipped and only the + * own-D1 findings are produced. */ + aggregator?: PublisherVerificationReader; } /** @@ -97,6 +115,23 @@ export async function analyzeHistory( .map((winner) => winner.val); if (activeManualLabels.length > 0) findings.push(activeManualLabelFinding(activeManualLabels)); + // Slice-3 aggregator read. Its own try/catch so an aggregator failure + // skips only the verification context and never discards the own-D1 + // findings already gathered above — a verification read that threw into + // the outer catch would return `[]` and lose the decision-adjacent + // context the operator relies on. + if (opts.aggregator && subject) { + try { + const state = await opts.aggregator.getPublisherVerification(subject.did); + const finding = verificationStateFinding(subject.did, state, opts.now ?? new Date()); + if (finding) findings.push(finding); + } catch (err) { + console.error( + `[history-context] verification-state lookup failed, skipping verification context: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + return findings; } catch (err) { console.error( @@ -151,6 +186,43 @@ function sharedArtifactFinding(checksum: string, dids: string[], limit: number): }; } +function verificationStateFinding( + did: string, + state: AggregatorGetPublisherVerification.$output | null, + now: Date, +): NormalizedFinding | null { + // `null` means the aggregator has no view (a redacted publisher DID under + // the default policy); an empty `verifications` array means an unverified + // publisher. Neither is a finding — the console assembles the neutral + // "unverified" display state at read time (plan W8.4 D5). Emit only when + // there is verification state to record, mirroring the own-D1 findings. + if (!state || state.verifications.length === 0) return null; + + const claims = state.verifications; + const inForce = claims.filter((claim) => !isExpired(claim.expiresAt, now)).length; + const issuers = [...new Set(claims.map((claim) => claim.issuer))]; + // Issuer identities and the subject's bound handle are indexed from public + // records, but the finding keeps them out of the public-facing title and + // summary and in privateDetail — consistent with the other history findings + // and with history being an operator-only projection. + return { + source: "history", + category: "publisher-verification", + severity: "info", + title: "Publisher verification context", + publicSummary: "Operator-only publisher-verification context is recorded for this subject.", + privateDetail: `Publisher ${did} has ${claims.length} indexed verification ${pluralize(claims.length, "claim")} (${inForce} currently in force) from ${issuers.length} ${pluralize(issuers.length, "issuer")}: ${issuers.slice(0, SAMPLE_SIZE).join(", ")}.`, + evidenceRefs: [], + sourceMetadata: toolMetadata(), + }; +} + +function isExpired(expiresAt: string | undefined, now: Date): boolean { + if (expiresAt === undefined) return false; + const expiry = Date.parse(expiresAt); + return !Number.isNaN(expiry) && expiry <= now.getTime(); +} + function activeManualLabelFinding(vals: string[]): NormalizedFinding { // The existence and identity of manual labels (including redactions) must not // leak into a public projection, so the title and publicSummary reveal diff --git a/apps/labeler/test/aggregator-client.test.ts b/apps/labeler/test/aggregator-client.test.ts index c237e1ff6b..6cf67059ca 100644 --- a/apps/labeler/test/aggregator-client.test.ts +++ b/apps/labeler/test/aggregator-client.test.ts @@ -72,6 +72,21 @@ const RELEASE_VIEW = { labels: [], }; +const PUBLISHER_VERIFICATION_VIEW = { + did: "did:plc:abc123", + verifications: [ + { + issuer: "did:plc:issuer0000000000000000000", + handle: "ada.example", + displayName: "Ada", + createdAt: "2026-03-01T00:00:00.000Z", + expiresAt: "2027-03-01T00:00:00.000Z", + indexedAt: "2026-03-01T00:00:00.000Z", + }, + ], + labels: [], +}; + describe("AggregatorClient.getPackage", () => { it("builds the getPackage URL with URL-encoded params and parses the view", async () => { const { fetcher, urls } = mockFetcher(() => jsonResponse(PACKAGE_VIEW)); @@ -172,6 +187,39 @@ describe("AggregatorClient.listReleases", () => { }); }); +describe("AggregatorClient.getPublisherVerification", () => { + it("builds the getPublisherVerification URL and parses the view", async () => { + const { fetcher, urls } = mockFetcher(() => jsonResponse(PUBLISHER_VERIFICATION_VIEW)); + const view = await new AggregatorClient(fetcher).getPublisherVerification("did:plc:abc123"); + + expect(urls).toEqual([ + `${BASE}/com.emdashcms.experimental.aggregator.getPublisherVerification?did=did%3Aplc%3Aabc123`, + ]); + expect(view).toEqual(PUBLISHER_VERIFICATION_VIEW); + }); + + it("returns null on a NotFound (404) error (redacted DID)", async () => { + const { fetcher } = mockFetcher(() => notFoundResponse()); + const view = await new AggregatorClient(fetcher).getPublisherVerification("did:plc:redacted"); + expect(view).toBeNull(); + }); + + it("sends a blank atproto-accept-labelers header to opt out of default redaction", async () => { + const { fetcher, inits } = mockFetcher(() => jsonResponse(PUBLISHER_VERIFICATION_VIEW)); + await new AggregatorClient(fetcher).getPublisherVerification("did:plc:abc123"); + + const headers = new Headers(inits[0]?.headers); + expect(headers.get("atproto-accept-labelers")).toBe(""); + }); + + it("throws on a 5xx response", async () => { + const { fetcher } = mockFetcher(() => new Response("upstream boom", { status: 500 })); + await expect( + new AggregatorClient(fetcher).getPublisherVerification("did:plc:abc123"), + ).rejects.toThrow(/getPublisherVerification failed: 500/); + }); +}); + describe("AggregatorClient param encoding", () => { it("percent-encodes reserved characters so they cannot alter the query", async () => { const { fetcher, urls } = mockFetcher(() => jsonResponse(PACKAGE_VIEW)); @@ -195,4 +243,21 @@ describe("AGGREGATOR binding transport", () => { }); expect(response.headers.get("x-test-accept-labelers")).toBe("empty"); }); + + // Drives the real binding through AggregatorClient (not an injected mock + // Fetcher): proves the getPublisherVerification method reaches the bound + // Worker, sends the blank accept-labelers header across the hop, and parses + // the view. The vitest AGGREGATOR stub serves the canned view. + it("reads publisher verification state through the real AGGREGATOR binding", async () => { + const view = await new AggregatorClient(env.AGGREGATOR).getPublisherVerification( + "did:plc:abc123", + ); + expect(view).not.toBeNull(); + expect(view?.did).toBe("did:plc:abc123"); + expect(view?.verifications).toHaveLength(1); + expect(view?.verifications[0]).toMatchObject({ + issuer: "did:plc:issuerstub00000000000000", + handle: "stub.example", + }); + }); }); diff --git a/apps/labeler/test/history-context.test.ts b/apps/labeler/test/history-context.test.ts index 1169b58eb5..e21b78a55c 100644 --- a/apps/labeler/test/history-context.test.ts +++ b/apps/labeler/test/history-context.test.ts @@ -1,3 +1,4 @@ +import type { AggregatorGetPublisherVerification } from "@emdash-cms/registry-lexicons"; import { createLabelSigner, type LabelSigner } from "@emdash-cms/registry-moderation"; import { applyD1Migrations, env } from "cloudflare:test"; import { beforeAll, describe, expect, it } from "vitest"; @@ -8,7 +9,7 @@ import { HISTORY_FINDING_CATEGORIES, validateFindings, } from "../src/findings.js"; -import { analyzeHistory } from "../src/history-context.js"; +import { analyzeHistory, type PublisherVerificationReader } from "../src/history-context.js"; import { MODERATION_POLICY } from "../src/policy.js"; import { issueManualLabel } from "../src/service.js"; import { initializeSigningState } from "../src/signing-rotation.js"; @@ -287,4 +288,96 @@ describe("analyzeHistory", () => { }), ).resolves.toEqual([]); }); + + it("reports the publisher's verification state read from the aggregator", async () => { + const did = "did:plc:verified0000000000000000"; + const subject = await seedSubject(did, "verified"); + const reader = readerReturning( + verificationView([ + { issuer: "did:plc:issuerA00000000000000000", handle: "pub.example" }, + { issuer: "did:plc:issuerB00000000000000000", handle: "pub.example" }, + ]), + ); + + const findings = await analyzeHistory( + testEnv.DB, + assessmentFor({ uri: subject.uri, cid: subject.cid }), + { src: LABELER_DID, aggregator: reader }, + ); + + const verification = findings.find((f) => f.category === "publisher-verification"); + expect(verification).toBeDefined(); + expect(verification!.source).toBe("history"); + // Issuer identities are indexed from public records but stay out of the + // public-facing fields, consistent with the other history findings. + expect(verification!.title).not.toContain("did:plc:issuerA00000000000000000"); + expect(verification!.publicSummary).not.toContain("did:plc:issuerA00000000000000000"); + expect(verification!.privateDetail).toContain("did:plc:issuerA00000000000000000"); + expect(verification!.privateDetail).toContain("2 issuers"); + + // The orchestrator validates every stage's output; the verification + // category must be admitted by the amended validator. + const validated = validateFindings(findings, { + allowedCategories: allowedFindingCategories(MODERATION_POLICY), + resolvableEvidenceIds: new Set(), + }); + expect(validated.some((f) => f.category === "publisher-verification")).toBe(true); + }); + + it("omits a verification finding for an unverified publisher (no indexed claims)", async () => { + const did = "did:plc:unverified00000000000000"; + const subject = await seedSubject(did, "unverified"); + const reader = readerReturning(verificationView([])); + + const findings = await analyzeHistory( + testEnv.DB, + assessmentFor({ uri: subject.uri, cid: subject.cid }), + { src: LABELER_DID, aggregator: reader }, + ); + + expect(findings.find((f) => f.category === "publisher-verification")).toBeUndefined(); + }); + + it("preserves the own-D1 findings when the aggregator verification read fails", async () => { + const did = "did:plc:aggfail00000000000000000"; + const current = await seedSubject(did, "aggfail-current"); + await seedSubject(did, "aggfail-older"); + const reader: PublisherVerificationReader = { + getPublisherVerification: () => Promise.reject(new Error("aggregator unreachable")), + }; + + const findings = await analyzeHistory( + testEnv.DB, + assessmentFor({ uri: current.uri, cid: current.cid }), + { src: LABELER_DID, aggregator: reader }, + ); + + // The own-D1 prior-releases finding survives; the failed aggregator read + // contributes nothing and never throws. + expect(findings.find((f) => f.category === "publisher-history")).toBeDefined(); + expect(findings.find((f) => f.category === "publisher-verification")).toBeUndefined(); + }); }); + +type VerificationView = AggregatorGetPublisherVerification.$output; + +function verificationView( + claims: Array<{ issuer: string; handle: string; expiresAt?: string }>, +): VerificationView { + return { + did: "did:plc:viewsubject0000000000000", + verifications: claims.map((claim) => ({ + issuer: claim.issuer, + handle: claim.handle, + displayName: "Publisher", + createdAt: "2026-03-01T00:00:00.000Z", + indexedAt: "2026-03-01T00:00:00.000Z", + ...(claim.expiresAt !== undefined ? { expiresAt: claim.expiresAt } : {}), + })), + labels: [], + } as VerificationView; +} + +function readerReturning(view: VerificationView | null): PublisherVerificationReader { + return { getPublisherVerification: () => Promise.resolve(view) }; +} diff --git a/apps/labeler/vitest.config.ts b/apps/labeler/vitest.config.ts index e54a64359f..10c2e48531 100644 --- a/apps/labeler/vitest.config.ts +++ b/apps/labeler/vitest.config.ts @@ -39,11 +39,13 @@ export default defineConfig({ // through the real binding: the package view carries only url-only // contacts (tiers 1-2 miss) and the publisher view carries a // security-kind email (tier 3 hits), so one resolution walks the - // full tier chain across the binding hop. Any other path still fails - // loud (501) and echoes the inbound atproto-accept-labelers header - // as a marker, so a binding-transport test can prove the client's - // blank value survives the hop (a dropped empty header reads - // `absent`, not `empty`). + // full tier chain across the binding hop. It also serves a canned + // getPublisherVerification view so a consumer-wiring test can drive + // AggregatorClient over the real binding end-to-end (W8.4 slice 3). + // Any other path still fails loud (501) and echoes the inbound + // atproto-accept-labelers header as a marker, so a binding-transport + // test can prove the client's blank value survives the hop (a + // dropped empty header reads `absent`, not `empty`). serviceBindings: { AGGREGATOR: (request) => { const path = new URL(request.url).pathname; @@ -85,6 +87,29 @@ export default defineConfig({ } const raw = request.headers.get("atproto-accept-labelers"); const marker = raw === null ? "absent" : raw === "" ? "empty" : `value:${raw}`; + const url = new URL(request.url); + if ( + url.pathname === + "/xrpc/com.emdashcms.experimental.aggregator.getPublisherVerification" + ) { + const did = url.searchParams.get("did") ?? "did:plc:unknown"; + return Response.json( + { + did, + verifications: [ + { + issuer: "did:plc:issuerstub00000000000000", + handle: "stub.example", + displayName: "Stub Publisher", + createdAt: "2026-03-01T00:00:00.000Z", + indexedAt: "2026-03-01T00:00:00.000Z", + }, + ], + labels: [], + }, + { headers: { "x-test-accept-labelers": marker } }, + ); + } return new Response("AGGREGATOR is stubbed in tests", { status: 501, headers: { "x-test-accept-labelers": marker }, diff --git a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/defs.json b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/defs.json index 2a27896336..45952b34f7 100644 --- a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/defs.json +++ b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/defs.json @@ -165,6 +165,74 @@ } } } + }, + "verificationClaimView": { + "type": "object", + "description": "One verification claim the aggregator has indexed for a subject: an issuer DID vouching for the subject as a trusted publisher, bound to the subject's handle and publisher-profile displayName at issuance. Whether the claim is currently in force (handle and displayName still match, and it has not expired) is a read-time concern; this view reports the facts as indexed.", + "required": ["issuer", "handle", "displayName", "createdAt", "indexedAt"], + "properties": { + "issuer": { + "type": "string", + "format": "did", + "description": "DID of the repo that issued the verification." + }, + "handle": { + "type": "string", + "format": "handle", + "description": "Subject handle bound at issuance." + }, + "displayName": { + "type": "string", + "description": "Subject publisher-profile displayName bound at issuance.", + "maxLength": 1024, + "maxGraphemes": 100 + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "When the verification was issued." + }, + "expiresAt": { + "type": "string", + "format": "datetime", + "description": "Optional expiry. Absent means no automatic expiry." + }, + "indexedAt": { + "type": "string", + "format": "datetime", + "description": "When the aggregator first indexed this verification." + } + } + }, + "publisherVerificationView": { + "type": "object", + "description": "An aggregator's view of the verification state indexed for a publisher DID: the non-tombstoned verification claims naming it as subject. Claims are returned regardless of expiry — the caller evaluates in-force state from each claim's `expiresAt`. An empty `verifications` array means the DID has no indexed claims (an unverified publisher), or every claim's issuer is redacted for this caller — distinct from NotFound, which means the subject DID itself is redacted.", + "required": ["did", "verifications"], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "Subject publisher DID the verification state is for." + }, + "verifications": { + "type": "array", + "description": "Non-tombstoned verification claims naming this DID as subject, newest issuance first.", + "maxLength": 100, + "items": { + "type": "ref", + "ref": "com.emdashcms.experimental.aggregator.defs#verificationClaimView" + } + }, + "labels": { + "type": "array", + "description": "Hydrated labels applying to this publisher DID, per the labelers the request asked for via the atproto-accept-labelers header.", + "maxLength": 64, + "items": { + "type": "ref", + "ref": "com.atproto.label.defs#label" + } + } + } } } } diff --git a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/getPublisherVerification.json b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/getPublisherVerification.json new file mode 100644 index 0000000000..22b8f59e22 --- /dev/null +++ b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/getPublisherVerification.json @@ -0,0 +1,35 @@ +{ + "lexicon": 1, + "id": "com.emdashcms.experimental.aggregator.getPublisherVerification", + "description": "Get the verification state indexed for a publisher DID. EXPERIMENTAL.", + "defs": { + "main": { + "type": "query", + "description": "Returns the non-tombstoned verification claims naming `did` as subject, as operator/display context. Claims are returned regardless of expiry — each carries `expiresAt` for the caller to evaluate in-force state. Returns an empty `verifications` array when the DID has no indexed claims. Redaction (the atproto-accept-labelers negotiation, same as the other read endpoints) applies at two scopes: a redacted subject DID returns NotFound; a claim whose issuer DID is redacted is dropped from `verifications`, since a claim is authored by its issuer, not the subject. A request that opts out with a blank header always gets the full view.", + "parameters": { + "type": "params", + "required": ["did"], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "Subject publisher DID." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "com.emdashcms.experimental.aggregator.defs#publisherVerificationView" + } + }, + "errors": [ + { + "name": "NotFound", + "description": "The subject publisher DID is redacted by a labeler the request accepts. An unknown or unverified DID is NOT a 404 — it returns 200 with an empty `verifications` array — so this status is distinguishable from absence." + } + ] + } + } +} diff --git a/packages/registry-lexicons/src/generated/index.ts b/packages/registry-lexicons/src/generated/index.ts index 5012384e8d..c9e0ea8749 100644 --- a/packages/registry-lexicons/src/generated/index.ts +++ b/packages/registry-lexicons/src/generated/index.ts @@ -2,6 +2,7 @@ export * as ComEmdashcmsExperimentalAggregatorDefs from "./types/com/emdashcms/e export * as ComEmdashcmsExperimentalAggregatorGetLatestRelease from "./types/com/emdashcms/experimental/aggregator/getLatestRelease.js"; export * as ComEmdashcmsExperimentalAggregatorGetPackage from "./types/com/emdashcms/experimental/aggregator/getPackage.js"; export * as ComEmdashcmsExperimentalAggregatorGetPublisher from "./types/com/emdashcms/experimental/aggregator/getPublisher.js"; +export * as ComEmdashcmsExperimentalAggregatorGetPublisherVerification from "./types/com/emdashcms/experimental/aggregator/getPublisherVerification.js"; export * as ComEmdashcmsExperimentalAggregatorListReleases from "./types/com/emdashcms/experimental/aggregator/listReleases.js"; export * as ComEmdashcmsExperimentalAggregatorResolvePackage from "./types/com/emdashcms/experimental/aggregator/resolvePackage.js"; export * as ComEmdashcmsExperimentalAggregatorSearchPackages from "./types/com/emdashcms/experimental/aggregator/searchPackages.js"; diff --git a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/defs.ts b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/defs.ts index 9d856de507..6b1cf94a66 100644 --- a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/defs.ts +++ b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/defs.ts @@ -62,6 +62,39 @@ const _packageViewSchema = /*#__PURE__*/ v.object({ */ uri: /*#__PURE__*/ v.resourceUriString(), }); +const _publisherVerificationViewSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.aggregator.defs#publisherVerificationView", + ), + ), + /** + * Subject publisher DID the verification state is for. + */ + did: /*#__PURE__*/ v.didString(), + /** + * Hydrated labels applying to this publisher DID, per the labelers the request asked for via the atproto-accept-labelers header. + * @maxLength 64 + */ + get labels() { + return /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(ComAtprotoLabelDefs.labelSchema), + [/*#__PURE__*/ v.arrayLength(0, 64)], + ), + ); + }, + /** + * Non-tombstoned verification claims naming this DID as subject, newest issuance first. + * @maxLength 100 + */ + get verifications() { + return /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(verificationClaimViewSchema), + [/*#__PURE__*/ v.arrayLength(0, 100)], + ); + }, +}); const _publisherViewSchema = /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.optional( /*#__PURE__*/ v.literal( @@ -174,21 +207,72 @@ const _releaseViewSchema = /*#__PURE__*/ v.object({ /*#__PURE__*/ v.stringLength(1, 64), ]), }); +const _verificationClaimViewSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.aggregator.defs#verificationClaimView", + ), + ), + /** + * When the verification was issued. + */ + createdAt: /*#__PURE__*/ v.datetimeString(), + /** + * Subject publisher-profile displayName bound at issuance. + * @maxLength 1024 + * @maxGraphemes 100 + */ + displayName: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 1024), + /*#__PURE__*/ v.stringGraphemes(0, 100), + ]), + /** + * Optional expiry. Absent means no automatic expiry. + */ + expiresAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), + /** + * Subject handle bound at issuance. + */ + handle: /*#__PURE__*/ v.handleString(), + /** + * When the aggregator first indexed this verification. + */ + indexedAt: /*#__PURE__*/ v.datetimeString(), + /** + * DID of the repo that issued the verification. + */ + issuer: /*#__PURE__*/ v.didString(), +}); type packageView$schematype = typeof _packageViewSchema; +type publisherVerificationView$schematype = + typeof _publisherVerificationViewSchema; type publisherView$schematype = typeof _publisherViewSchema; type releaseView$schematype = typeof _releaseViewSchema; +type verificationClaimView$schematype = typeof _verificationClaimViewSchema; export interface packageViewSchema extends packageView$schematype {} +export interface publisherVerificationViewSchema extends publisherVerificationView$schematype {} export interface publisherViewSchema extends publisherView$schematype {} export interface releaseViewSchema extends releaseView$schematype {} +export interface verificationClaimViewSchema extends verificationClaimView$schematype {} export const packageViewSchema = _packageViewSchema as packageViewSchema; +export const publisherVerificationViewSchema = + _publisherVerificationViewSchema as publisherVerificationViewSchema; export const publisherViewSchema = _publisherViewSchema as publisherViewSchema; export const releaseViewSchema = _releaseViewSchema as releaseViewSchema; +export const verificationClaimViewSchema = + _verificationClaimViewSchema as verificationClaimViewSchema; export interface PackageView extends v.InferInput {} +export interface PublisherVerificationView extends v.InferInput< + typeof publisherVerificationViewSchema +> {} export interface PublisherView extends v.InferInput< typeof publisherViewSchema > {} export interface ReleaseView extends v.InferInput {} +export interface VerificationClaimView extends v.InferInput< + typeof verificationClaimViewSchema +> {} diff --git a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/getPublisherVerification.ts b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/getPublisherVerification.ts new file mode 100644 index 0000000000..060da6e02a --- /dev/null +++ b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/getPublisherVerification.ts @@ -0,0 +1,37 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as ComEmdashcmsExperimentalAggregatorDefs from "./defs.js"; + +const _mainSchema = /*#__PURE__*/ v.query( + "com.emdashcms.experimental.aggregator.getPublisherVerification", + { + params: /*#__PURE__*/ v.object({ + /** + * Subject publisher DID. + */ + did: /*#__PURE__*/ v.didString(), + }), + output: { + type: "lex", + get schema() { + return ComEmdashcmsExperimentalAggregatorDefs.publisherVerificationViewSchema; + }, + }, + }, +); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params extends v.InferInput {} +export type $output = v.InferXRPCBodyInput; + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "com.emdashcms.experimental.aggregator.getPublisherVerification": mainSchema; + } +} diff --git a/packages/registry-lexicons/src/index.ts b/packages/registry-lexicons/src/index.ts index 341ff3e9a7..9fb249d72a 100644 --- a/packages/registry-lexicons/src/index.ts +++ b/packages/registry-lexicons/src/index.ts @@ -25,6 +25,7 @@ export * as AggregatorDefs from "./generated/types/com/emdashcms/experimental/ag export * as AggregatorGetLatestRelease from "./generated/types/com/emdashcms/experimental/aggregator/getLatestRelease.js"; export * as AggregatorGetPackage from "./generated/types/com/emdashcms/experimental/aggregator/getPackage.js"; export * as AggregatorGetPublisher from "./generated/types/com/emdashcms/experimental/aggregator/getPublisher.js"; +export * as AggregatorGetPublisherVerification from "./generated/types/com/emdashcms/experimental/aggregator/getPublisherVerification.js"; export * as AggregatorListReleases from "./generated/types/com/emdashcms/experimental/aggregator/listReleases.js"; export * as AggregatorResolvePackage from "./generated/types/com/emdashcms/experimental/aggregator/resolvePackage.js"; export * as AggregatorSearchPackages from "./generated/types/com/emdashcms/experimental/aggregator/searchPackages.js"; @@ -59,6 +60,8 @@ export const NSID = { aggregatorGetLatestRelease: "com.emdashcms.experimental.aggregator.getLatestRelease", aggregatorGetPackage: "com.emdashcms.experimental.aggregator.getPackage", aggregatorGetPublisher: "com.emdashcms.experimental.aggregator.getPublisher", + aggregatorGetPublisherVerification: + "com.emdashcms.experimental.aggregator.getPublisherVerification", aggregatorListReleases: "com.emdashcms.experimental.aggregator.listReleases", aggregatorResolvePackage: "com.emdashcms.experimental.aggregator.resolvePackage", aggregatorSearchPackages: "com.emdashcms.experimental.aggregator.searchPackages", @@ -110,6 +113,7 @@ export const QUERY_NSIDS = [ NSID.aggregatorGetLatestRelease, NSID.aggregatorGetPackage, NSID.aggregatorGetPublisher, + NSID.aggregatorGetPublisherVerification, NSID.aggregatorListReleases, NSID.aggregatorResolvePackage, NSID.aggregatorSearchPackages, diff --git a/packages/registry-lexicons/tests/types.test.ts b/packages/registry-lexicons/tests/types.test.ts index 5574a24e7f..0c199c22ac 100644 --- a/packages/registry-lexicons/tests/types.test.ts +++ b/packages/registry-lexicons/tests/types.test.ts @@ -322,6 +322,7 @@ describe("NSID map", () => { "com.emdashcms.experimental.aggregator.getLatestRelease", "com.emdashcms.experimental.aggregator.getPackage", "com.emdashcms.experimental.aggregator.getPublisher", + "com.emdashcms.experimental.aggregator.getPublisherVerification", "com.emdashcms.experimental.aggregator.listReleases", "com.emdashcms.experimental.aggregator.resolvePackage", "com.emdashcms.experimental.aggregator.searchPackages", From 722cb9c992bbb9df9821dfbef26b9504e8b31714 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 14:23:34 +0100 Subject: [PATCH 090/137] feat(labeler): production Workflow shell over AssessmentOrchestrator (#1978) (#2072) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(labeler): assessment Workflow shell with per-subject lock Wire the AssessmentOrchestrator to production as a Cloudflare Workflow: each verified subject runs as one Workflow instance whose id is a deterministic SHA-256 of (uri, cid). Creating an instance with an existing id throws, so a duplicate subject cannot start a second concurrent run — the instance-per- subject serialization IS the §14.1 per-subject lock, closing the duplicate-run race #1978 deferred. - assessment-workflow.ts: AssessmentWorkflow (WorkflowEntrypoint) runs the orchestrator inside a single durable step over executeAssessmentInstance, which loads the pending run, composes the orchestrator (stub stages for now), and finalizes. Idempotent resume: an already-finalized run returns its terminal state instead of re-entering the pending-guarded orchestrator. - assessment-dispatch.ts: instance-id derivation + dispatchAssessmentWorkflow, which treats an already-exists collision as an idempotent no-op and surfaces infra failures as AssessmentDispatchError. - discovery-consumer.ts narrows to "verified discovery → dispatch"; a dispatch failure retries the message (upstream steps are idempotent). - ASSESSMENT_WORKFLOW binding in wrangler.jsonc + worker entry export. Stages are stubStages until W7/W8 land the real acquire/deterministic/ dependency/AI/history adapters; the per-run AcquisitionHolder they need is constructed at the per-run buildStages() seam. The orchestrator's finalization race comments are corrected: the lock closes duplicate runs, not a delete or cancel racing an in-flight run — that still needs an in-batch state guard. * fix(labeler): runKey-derived Workflow id + resumable-from-running Address the adversary pass on the Workflow shell. Fix B (Matt's ruling — instance id from runKey, not subject): a subject-only id (SHA-256 of uri+cid) is permanent (Cloudflare retains instance ids ~30 days for completed AND failed runs), so it blocked all re-assessment — operator rerun / intel re-triggers mint a new run (new runKey, which already includes triggerId) for the same subject and would collide with the retained id and strand the run pending forever. The instance id is now the run's runKey itself (already a 64-char SHA-256 hex from computeRunKey, reused verbatim — no second formula). Same-trigger discovery redelivery → same runKey → same id → dedup; distinct triggers → distinct runKeys → distinct instances → re-assessment works. Two different triggers for one subject can now run concurrently — that falls back to the currency/supersede + idempotency-keyed issuance path, not the instance lock. Fix A (resumable-from-running): any failure after the pending→running CAS (reachable even with stub stages) left the row `running`; the step retry re-entered runAssessment, whose pending-guard threw again → run stranded `running` forever. runAssessment now accepts a `running` row and resumes it to finalization (stages recomputed, findings held in memory not persisted mid-run, label issuance idempotency-keyed). Minimal orchestrator change: broaden the entry guard + skip the CAS when already `running`. Fix C (deploy gate): with stub stages, a run finalizes `passed` AND issues a real signed assessment-passed label for every subject. Corrected the orchestrator header to say so and added a prominent DEPLOY GATE note in the workflow docblock — the shell must not reach an enforcing/consumed deployment until real stages land. Also: corrected the stale console-mutation rerun comment (discovery now dispatches) and reconciled the orchestrator's finalize race comments to the per-run (runKey) dedup semantics. Track D (delete/cancel/signing-flip vs finalize) remains gated on the in-batch assessment-state guard. Tests: runKey-id dispatch semantics (dedup on same runKey, distinct runKey → own instance); shell resume from a running row; orchestrator resume after a post-transition failure then success. * fix(labeler): close finalization label-leak race + mechanical deploy gate Address emdashbot review findings on #2072. Finding 2 (finalization CAS race): the run→toState CAS and the label inserts shared one db.batch, but a 0-row CAS (concurrent cancel/delete moved the run out of `running`) did not roll back the inserts — signed assessment-passed labels leaked for a cancelled subject, and a post-hoc re-read could not un-leak them. Now every finalization issuance action is gated in-batch on the assessment reaching `toState` (buildIssuanceStatements gains an optional requireAssessmentState; the label insert selects from the action, so gating the action gates the label). The batch is all-or-nothing against the CAS: a cancel/delete that no-ops the CAS also no-ops every label. The CAS row count is checked after commit — 0 means the race was lost, so the postCommits (which would otherwise mis-diagnose the absent label as a signing failure and throw) are skipped and AssessmentFinalizationConflictError is raised; the Workflow step retries and short-circuits on the now-terminal row. The existing second isSubjectCurrent check only covers delete/supersede down to the check-to-commit gap (the delete path tombstones the subject); it does not cover an operator cancel that leaves the subject current. The in-batch state guard closes both. Finding 1 (deploy gate): buildStages() now throws when import.meta.env.PROD, so a production build with only stub stages fails loudly instead of signing assessment-passed for every unscanned subject. import.meta.env.PROD is a Vite compile-time constant (true in `vite build`, false in dev and the vitest pool); a minimal ambient declaration types it without pulling in vite/client browser globals. Test: a stage that cancels the run mid-flight proves finalization issues no labels and throws the conflict. --- apps/labeler/calibration/tsconfig.json | 2 +- apps/labeler/src/assessment-dispatch.ts | 91 +++++++++++ apps/labeler/src/assessment-orchestrator.ts | 129 ++++++++++----- apps/labeler/src/assessment-workflow.ts | 109 +++++++++++++ apps/labeler/src/console-mutation-api.ts | 8 +- apps/labeler/src/discovery-consumer.ts | 42 ++++- apps/labeler/src/index.ts | 1 + apps/labeler/src/service.ts | 67 +++++--- apps/labeler/src/vite-env.d.ts | 15 ++ apps/labeler/test/assessment-dispatch.test.ts | 104 ++++++++++++ .../test/assessment-orchestrator.test.ts | 70 ++++++++ apps/labeler/test/assessment-workflow.test.ts | 149 ++++++++++++++++++ .../labeler/test/console-mutation-api.test.ts | 4 + apps/labeler/test/discovery-consumer.test.ts | 122 ++++++++++++++ apps/labeler/test/production-boundary.test.ts | 23 +-- apps/labeler/worker-configuration.d.ts | 3 +- apps/labeler/wrangler.jsonc | 12 ++ 17 files changed, 867 insertions(+), 84 deletions(-) create mode 100644 apps/labeler/src/assessment-dispatch.ts create mode 100644 apps/labeler/src/assessment-workflow.ts create mode 100644 apps/labeler/src/vite-env.d.ts create mode 100644 apps/labeler/test/assessment-dispatch.test.ts create mode 100644 apps/labeler/test/assessment-workflow.test.ts diff --git a/apps/labeler/calibration/tsconfig.json b/apps/labeler/calibration/tsconfig.json index 61162e2ca4..2c915190f6 100644 --- a/apps/labeler/calibration/tsconfig.json +++ b/apps/labeler/calibration/tsconfig.json @@ -5,5 +5,5 @@ "verbatimModuleSyntax": true, "noEmit": true }, - "include": ["**/*.ts", "../worker-configuration.d.ts"] + "include": ["**/*.ts", "../worker-configuration.d.ts", "../src/vite-env.d.ts"] } diff --git a/apps/labeler/src/assessment-dispatch.ts b/apps/labeler/src/assessment-dispatch.ts new file mode 100644 index 0000000000..c708c7c706 --- /dev/null +++ b/apps/labeler/src/assessment-dispatch.ts @@ -0,0 +1,91 @@ +/** + * Dispatch seam between a created assessment run and its Workflow. + * + * Each run executes as one Cloudflare Workflow instance whose id IS the run's + * `runKey` — the deterministic per-run identity already computed by the + * discovery consumer / rerun path (`computeRunKey`: SHA-256 over uri, cid, + * policy, model, prompt, scanner-set, and triggerId). It is a 64-char lowercase + * hex string, within the 100-char instance-id limit, so it is used verbatim as + * the id — no second hash, no second formula. + * + * Keying on the runKey (not the subject) gives the right dedup granularity: + * + * - Redelivery of the SAME discovery event recomputes the SAME runKey → same + * instance id → `create` collides → idempotent no-op, no duplicate run. + * - A re-assessment (operator rerun, intel re-trigger) mints a run with a new + * triggerId → different runKey → different instance id → it dispatches its + * own instance. A subject-only id would instead collide with the RETAINED + * id of the prior completed/failed run (Cloudflare keeps instance ids ~30 + * days) and strand the re-assessment `pending` forever. + * + * This is per-run serialization, not per-subject: two DIFFERENT triggers for one + * subject can run concurrently. That is acceptable — the orchestrator's currency + * re-check + supersede and idempotency-keyed label issuance keep the outcome + * correct (at worst recomputed wastefully); the instance id is not the arbiter + * there. + * + * This module holds no reference to `AssessmentOrchestrator` or + * `cloudflare:workers`: the discovery consumer imports only this, so the + * orchestrator reaches production solely through `assessment-workflow.ts`. + */ + +/** Payload the Workflow instance is triggered with. */ +export interface AssessmentWorkflowParams { + /** The pending assessment run the Workflow will drive to finalization. */ + assessmentId: string; +} + +/** + * Structural subset of the generated `Workflow` binding this module needs. A + * real `Workflow` satisfies it; tests supply an + * in-memory fake that enforces instance-id uniqueness the same way. + */ +export interface AssessmentWorkflowBinding { + create(options: { id: string; params: AssessmentWorkflowParams }): Promise<{ id: string }>; + get(id: string): Promise<{ id: string }>; +} + +/** + * Raised when the Workflow instance could not be created for an infrastructure + * reason (not an already-exists collision). The discovery consumer retries the + * message; every upstream step is idempotent, so redelivery re-dispatches. + */ +export class AssessmentDispatchError extends Error { + override readonly name = "AssessmentDispatchError"; +} + +export interface DispatchAssessmentInput { + /** The run's `runKey`, used verbatim as the Workflow instance id. */ + runKey: string; + assessmentId: string; +} + +/** + * Create the run's Workflow instance, or converge if its id already exists. + * Returns `"created"` on a fresh dispatch and `"exists"` when the runKey-derived + * id was already taken (redelivery of the same run). A `create` failure with no + * surviving instance is a real infrastructure error and throws + * `AssessmentDispatchError`. + */ +export async function dispatchAssessmentWorkflow( + workflow: AssessmentWorkflowBinding, + input: DispatchAssessmentInput, +): Promise<"created" | "exists"> { + const id = input.runKey; + const params: AssessmentWorkflowParams = { assessmentId: input.assessmentId }; + try { + await workflow.create({ id, params }); + return "created"; + } catch (err) { + // `create` throws on an already-taken id (same-run redelivery) and on + // transient infra failures alike. Disambiguate by probing for the + // instance: present means the collision is a redelivery (idempotent + // no-op); absent means the create genuinely failed and the caller retries. + const existing = await workflow.get(id).catch(() => null); + if (existing) return "exists"; + throw new AssessmentDispatchError( + `failed to dispatch assessment Workflow for run ${input.assessmentId}`, + { cause: err }, + ); + } +} diff --git a/apps/labeler/src/assessment-orchestrator.ts b/apps/labeler/src/assessment-orchestrator.ts index 3cfc23d663..a2d8e7f218 100644 --- a/apps/labeler/src/assessment-orchestrator.ts +++ b/apps/labeler/src/assessment-orchestrator.ts @@ -3,13 +3,17 @@ * through `running`, the analysis stages, and atomic finalization (spec * §9.9, §10). * - * BINDING DECISION: production wiring stops at `assessment-pending` - * (discovery-consumer.ts). Nothing in a production code path constructs or - * calls `AssessmentOrchestrator` — it ships as code, exercised only by - * tests, until W7/W8 supply real stage adapters (acquire, deterministic - * validation, dependency/SBOM, code/metadata AI, image AI). `stubStages` in - * this module exists for that same reason: test fixtures, not production - * defaults. + * Driven in production by `AssessmentWorkflow` (assessment-workflow.ts): each + * run executes as one Workflow instance whose id is the run's runKey, so a + * redelivered discovery event dedups onto the same instance rather than starting + * a duplicate (see assessment-dispatch.ts). The Workflow constructs this + * orchestrator per run and calls `runAssessment`. Until W7/W8 supply the real stage adapters + * (acquire, deterministic validation, dependency/SBOM, code/metadata AI, image + * AI), that wiring runs `stubStages` — every stage resolves with no findings, so + * `resolvePolicyOutcome` returns `passed` and finalization issues a real signed + * `assessment-passed` label for EVERY subject (not merely clearing its own + * `assessment-pending`). That is a hard deploy gate — see the DEPLOY GATE note + * in assessment-workflow.ts. */ import type { LabelSigner } from "@emdash-cms/registry-moderation"; @@ -48,6 +52,25 @@ export class StageTransientError extends Error { override readonly name = "StageTransientError"; } +/** + * Thrown when the finalization batch's CAS out of `running` changed no row — a + * cancel or delete raced finalization. The in-batch state guard means no labels + * were issued; this signals the caller (the Workflow step) to retry, where the + * now-terminal row short-circuits. + */ +export class AssessmentFinalizationConflictError extends Error { + override readonly name = "AssessmentFinalizationConflictError"; + constructor( + readonly assessmentId: string, + readonly expectedState: AssessmentState, + readonly actualState: AssessmentState | null, + ) { + super( + `assessment ${assessmentId} finalization lost the race: expected ${expectedState}, found ${actualState ?? "missing"}`, + ); + } +} + export interface StageContext { readonly assessment: Assessment; } @@ -119,14 +142,24 @@ export class AssessmentOrchestrator { const now = this.now(); const loaded = await getAssessment(this.db, runId); if (!loaded) throw new Error(`assessment ${runId} not found`); - if (loaded.state !== "pending") - throw new Error(`assessment ${runId} is not pending (state=${loaded.state})`); - const assessment = await transitionAssessmentState(this.db, { - id: runId, - from: "pending", - to: "running", - now, - }); + // Accept a `running` row left by a crashed prior attempt and resume it: the + // production driver is a Workflow step that retries `runAssessment`, and any + // failure after the pending→running CAS (e.g. a transient D1 error in + // `finalize`) leaves the row `running`. Re-running is safe — stages are + // recomputed (findings are held in memory, not persisted mid-run) and every + // label issuance is idempotency-keyed. A `pending` row is transitioned to + // `running` first; a terminal row is the caller's to short-circuit. + if (loaded.state !== "pending" && loaded.state !== "running") + throw new Error(`assessment ${runId} is not resumable (state=${loaded.state})`); + const assessment = + loaded.state === "pending" + ? await transitionAssessmentState(this.db, { + id: runId, + from: "pending", + to: "running", + now, + }) + : loaded; const findings: StageFinding[] = []; let transientExhausted = false; @@ -164,11 +197,11 @@ export class AssessmentOrchestrator { // label (an async round-trip) between here and `db.batch`, so a delete or // a cancel landing in that window still commits labels — the finalization // CAS guards its own row, but the issuance statements carry no - // assessment-state guard. Closing it requires either the per-subject - // workflow lock the spec mandates (§14.1) or an in-batch state guard on - // every issuance statement. That belongs with wiring the orchestrator to - // production in W7/W8; today the production-boundary test guarantees no - // production path reaches this method. + // assessment-state guard. The Workflow instance id (the runKey) dedups only + // redelivery of the same run (assessment-dispatch.ts) — not a delete or + // operator cancel against an in-flight run — so closing this window still + // needs an in-batch assessment-state guard on every issuance statement, + // tracked with the real-stage wiring. const current = await isSubjectCurrent(this.db, { uri: assessment.uri, cid: assessment.cid }); if (!current) { return transitionAssessmentState(this.db, { id: runId, from: "running", to: "stale", now }); @@ -257,6 +290,9 @@ export class AssessmentOrchestrator { }, now, false, + // Gate every finalization label on the run reaching `toState`, so a + // concurrent cancel/delete that no-ops the CAS also no-ops the labels. + { requireAssessmentState: toState }, ); statements.push(...built.statements); postCommits.push(built.postCommit); @@ -291,30 +327,26 @@ export class AssessmentOrchestrator { await issue(prior.val, true); } - // Final currency re-check with no signing between it and the commit, so - // the delete/cancel window is two adjacent D1 ops rather than spanning - // every label's signing round-trip above. Full closure still needs the - // workflow lock (see the re-check before this method); this shrinks the - // exposure in the interim. + // Final currency re-check with no signing between it and the commit, so the + // delete/cancel window is two adjacent D1 ops rather than spanning every + // label's signing round-trip above. // - // The same lock also closes a signing-state flip during the batch: the - // label inserts are guarded on active signing state and no-op if it - // flips, but the state CAS below is not, so a flip after this point - // could commit the terminal state without its labels. Deferred to the - // same W7/W8 production wiring as the delete race above. + // The batch is all-or-nothing against the run→toState CAS: every issuance + // action is gated on the assessment reaching `toState` + // (buildIssuanceStatements' requireAssessmentState), and the CAS's row count + // is checked after commit (below). A cancel or delete that moves the run out + // of `running` in this gap therefore no-ops the CAS AND every label — nothing + // leaks — and the lost race raises AssessmentFinalizationConflictError. // - // Three related concurrency gaps in this method are owned by that same - // W7/W8 workflow lock (§14.1), which serialises finalization per subject - // and makes them all unreachable — the production-boundary test proves - // nothing wires this method live until then: - // - a stale (CID-superseded) run returns without negating its own - // assessment-pending, so a superseded subject keeps advertising an - // in-progress assessment; - // - the finalization CAS result below is not checked, so if a - // concurrent cancel moved the run out of `running`, the CAS no-ops - // while the label inserts commit and this returns a run that never - // exited `running`; - // - `transitionAssessmentState` here and in the stale branch can throw + // Narrower gaps remain, tracked with the real-stage wiring: + // - a signing-state flip mid-batch: the label inserts are guarded on active + // signing state and no-op if it flips, but the CAS is not, so a flip could + // commit the terminal state with its labels suppressed (the CAS still + // changed a row, so the postCommit below surfaces it as a signing error); + // - a CID supersession landing in this gap does not move the run out of + // `running`, so the CAS succeeds and this run finalizes labels for its own + // CID (the pointer upsert is guarded on created-at ordering); + // - `transitionAssessmentState` in the stale branch below can throw // `AssessmentTransitionConflictError` under a racing delete. const stillCurrent = await isSubjectCurrent(this.db, { uri: assessment.uri, @@ -329,7 +361,18 @@ export class AssessmentOrchestrator { }); } - await this.db.batch(statements); + const results = await this.db.batch(statements); + // If the finalization CAS changed no row, a cancel/delete moved the run out + // of `running` between the currency re-check and commit. The in-batch state + // guard on every issuance statement means NO labels were written, so skip + // the postCommits (each would otherwise mis-diagnose the absent label as a + // signing failure and throw) and surface the lost race loudly — the Workflow + // step retries and short-circuits on the now-terminal row. + const casChanged = results[finalization.assessmentUpdateIndex]?.meta.changes ?? 0; + if (casChanged !== 1) { + const raced = await getAssessment(this.db, assessment.id); + throw new AssessmentFinalizationConflictError(assessment.id, toState, raced?.state ?? null); + } for (const postCommit of postCommits) await postCommit(); const finalised = await getAssessment(this.db, assessment.id); diff --git a/apps/labeler/src/assessment-workflow.ts b/apps/labeler/src/assessment-workflow.ts new file mode 100644 index 0000000000..de108a1cf2 --- /dev/null +++ b/apps/labeler/src/assessment-workflow.ts @@ -0,0 +1,109 @@ +/** + * Production assessment execution: one Cloudflare Workflow instance per run, a + * thin durable shell over `AssessmentOrchestrator`. The instance id is the run's + * runKey (see `assessment-dispatch.ts`); `run` loads the assessment and drives + * it through the orchestrator's stages and atomic finalization inside a single + * durable step. + * + * DEPLOY GATE: with `stubStages`, a run finalizes `passed` and issues a real + * signed `assessment-passed` label for EVERY subject — an unconditional "this is + * safe" attestation over unscanned content. This shell must NOT reach an + * enforcing or label-consuming production deployment until the real analysis + * stages (W7/W8) land; shipping it live before then would vouch for everything. + * + * The whole run executes in one `step.do`, not one step per stage: the + * orchestrator accumulates stage findings in memory and the acquire stage + * publishes the acquired artifact to an in-process `AcquisitionHolder` that + * downstream stages read, so a per-stage step boundary would drop that shared + * state across a durable resume. Running the orchestrator whole keeps its atomic + * finalization intact. The tradeoff is coarse resume granularity: a mid-run + * eviction re-runs the whole step. `executeAssessmentInstance` makes that + * idempotent — a terminal row short-circuits, and `runAssessment` resumes a row + * left `running` by a crashed attempt. Finer, per-stage durable resume lands + * with the real-stage wiring. + */ + +import { WorkflowEntrypoint } from "cloudflare:workers"; +import type { WorkflowEvent, WorkflowStep } from "cloudflare:workers"; + +import type { AssessmentWorkflowParams } from "./assessment-dispatch.js"; +import { TERMINAL_STATES, type AssessmentState } from "./assessment-lifecycle.js"; +import { + AssessmentOrchestrator, + stubStages, + type OrchestratorStages, +} from "./assessment-orchestrator.js"; +import { getAssessment } from "./assessment-store.js"; +import { getLabelerIdentityConfig } from "./config.js"; +import { MODERATION_POLICY } from "./policy.js"; +import { createRuntimeSigner, getRuntimeSigningSecret } from "./signing-runtime.js"; + +const RUN_STEP_CONFIG = { + retries: { limit: 3, delay: "10 seconds" as const, backoff: "exponential" as const }, +}; + +export class AssessmentWorkflow extends WorkflowEntrypoint { + override async run( + event: Readonly>, + step: WorkflowStep, + ): Promise<{ assessmentId: string; state: AssessmentState }> { + const { assessmentId } = event.payload; + const state = await step.do("assess-subject", RUN_STEP_CONFIG, () => + executeAssessmentInstance(this.env, assessmentId), + ); + return { assessmentId, state }; + } +} + +/** + * The shell body: load the pending run, drive it through the orchestrator, and + * return the finalized state. Separate from the entrypoint class so it is + * testable without Cloudflare's Workflow runtime (which has no local harness) — + * `run` above is the thin durable-step wrapper over it. + */ +export async function executeAssessmentInstance( + env: Env, + assessmentId: string, +): Promise { + const existing = await getAssessment(env.DB, assessmentId); + if (!existing) throw new Error(`assessment ${assessmentId} not found`); + // Idempotent resume for a durable-step retry: a terminal row means a prior + // attempt already finalized (its batch committed but the step result was + // lost) — return that state. A `pending` or crash-left `running` row falls + // through to `runAssessment`, which drives (or resumes) it to finalization. + if (TERMINAL_STATES.has(existing.state)) return existing.state; + + const config = await getLabelerIdentityConfig(env); + const versioned = await createRuntimeSigner(config, getRuntimeSigningSecret(env)); + const orchestrator = new AssessmentOrchestrator({ + db: env.DB, + config, + signer: versioned.signer, + policy: MODERATION_POLICY, + stages: buildStages(), + }); + const finalized = await orchestrator.runAssessment(assessmentId); + return finalized.state; +} + +/** + * The orchestrator's stage adapters, built per instance execution. Real + * adapters attach here in the W7/W8 follow-on — acquire (via the aggregator + * client), deterministic/dependency/AI scanning, and publisher history; today + * the run executes the exported stub stages. Building them per execution rather + * than sharing module-scope state keeps per-run state — notably the acquire + * stage's `AcquisitionHolder` — isolated to this instance, never shared across + * concurrent subjects. + */ +function buildStages(): OrchestratorStages { + // Mechanical enforcement of the DEPLOY GATE above: a production build must not + // run stub stages (which would sign `assessment-passed` for every unscanned + // subject). `import.meta.env.PROD` is a Vite compile-time constant — true in + // `vite build`, false in dev and the vitest pool — so this cannot be spoofed + // at runtime. Remove once real stages are wired here. + if (import.meta.env.PROD) + throw new Error( + "AssessmentWorkflow has only stub stages in a production build — refusing to issue assessment-passed for unscanned subjects. Wire the real analysis stages (W7/W8) before deploying.", + ); + return stubStages; +} diff --git a/apps/labeler/src/console-mutation-api.ts b/apps/labeler/src/console-mutation-api.ts index a47b916b39..85c1cec51a 100644 --- a/apps/labeler/src/console-mutation-api.ts +++ b/apps/labeler/src/console-mutation-api.ts @@ -545,9 +545,11 @@ function deferPublishAll(deps: ConsoleMutationDeps, issuanceKeys: readonly strin * `POST /admin/api/assessments/:id/rerun` — mints the immutable operator trigger * (`operator:`), creates a fresh run for the assessment's exact URI+CID * anchored to that trigger, and re-issues `assessment-pending`, all in one atomic - * batch with the audit row (spec §10/§11.2). Production wiring stops at pending - * (as initial discovery does today), so the run sits at `pending` until W7/W8 - * supply stage adapters. + * batch with the audit row (spec §10/§11.2). Initial discovery now dispatches an + * assessment Workflow after `pending`; this rerun path still stops at `pending` + * (its own Workflow dispatch is a follow-on). The operator trigger yields a + * distinct `runKey`, so the rerun maps to its own Workflow instance id rather + * than colliding with the prior run's — the re-assessment is not stranded. */ async function runRerun( request: Request, diff --git a/apps/labeler/src/discovery-consumer.ts b/apps/labeler/src/discovery-consumer.ts index 9318e761d6..f109804cc5 100644 --- a/apps/labeler/src/discovery-consumer.ts +++ b/apps/labeler/src/discovery-consumer.ts @@ -1,11 +1,14 @@ /** * Discovery queue consumer. Replaces the Jetstream-observed event with a - * verified subject, an idempotent assessment run, and (once verified) an - * `assessment-pending` label — spec §9.1 steps 5-7. Per binding decision, - * production wiring stops here: nothing in this file advances a run past - * `pending`. `assessment-orchestrator.ts` drives `pending → running → - * finalization` and is exercised only by tests until W7/W8 land real stage - * adapters. + * verified subject, an idempotent assessment run, an `assessment-pending` + * label, and a dispatched assessment Workflow instance — spec §9.1 steps 5-7. + * This file's job ends at dispatch: it never constructs + * `AssessmentOrchestrator` itself. The instance id is the run's runKey + * (assessment-dispatch.ts), so a redelivered discovery event dedups onto the + * same instance while a later re-assessment (distinct trigger → distinct + * runKey) gets its own. `AssessmentWorkflow` (assessment-workflow.ts) then + * drives `pending → running → finalization` through the orchestrator (still + * stub stages until W7/W8 land real stage adapters). * * Error policy mirrors the aggregator's records-consumer with one * difference (spec §9.1): a forged or unverifiable event is ALWAYS a dead @@ -30,6 +33,11 @@ import { } from "@atcute/identity-resolver"; import type { LabelSigner } from "@emdash-cms/registry-moderation"; +import { + AssessmentDispatchError, + dispatchAssessmentWorkflow, + type AssessmentWorkflowBinding, +} from "./assessment-dispatch.js"; import { AssessmentTransitionConflictError, automatedIdempotencyKey, @@ -80,6 +88,11 @@ export interface DiscoveryConsumerDeps { config: LabelerConfig; signer: LabelSigner; didDocumentResolver: DidDocumentResolverLike; + /** The assessment Workflow binding. Once a verified subject reaches + * `pending`, the consumer dispatches its run; the instance id is the run's + * runKey, so a redelivered event dedups onto the same instance + * (assessment-dispatch.ts). */ + assessmentWorkflow: AssessmentWorkflowBinding; fetch?: typeof fetch; now?: () => Date; /** @@ -272,6 +285,12 @@ async function classifyDiscoveryError( controller.retry(); return; } + if (err instanceof AssessmentDispatchError) { + // Subject verified and pending; only the Workflow dispatch failed. Retry + // so redelivery re-dispatches — every upstream step is idempotent. + controller.retry(); + return; + } if (err instanceof PdsVerificationError) { if (isTransient(err.reason, err.status)) { controller.retry(); @@ -381,6 +400,16 @@ async function verifyAndCreateRun( { uri, cid: job.cid, val: "assessment-pending" }, now, ); + + // Hand the run to its Workflow instance. The instance id is the run's runKey, + // so a redelivered event (same runKey) converges on the same instance rather + // than starting a second run. A dispatch infra failure throws + // AssessmentDispatchError → the message retries; upstream steps are all + // idempotent, so redelivery re-dispatches. + await dispatchAssessmentWorkflow(deps.assessmentWorkflow, { + runKey, + assessmentId: assessment.id, + }); } /** @@ -513,6 +542,7 @@ async function createProductionDiscoveryDeps(env: Env): Promise Promise; } +export interface BuildIssuanceOptions { + /** + * Gate the action insert (and thus its label) on the assessment row being in + * this state at commit time. Finalization passes its `toState` so the whole + * batch — the run→toState CAS and every label — is all-or-nothing against that + * transition: a concurrent cancel/delete that no-ops the CAS also no-ops every + * label, so no label leaks for a run that is no longer `running`. Requires an + * automated-assessment action (it carries the assessmentId to check). + */ + requireAssessmentState?: AssessmentState; +} + /** * Builds the two INSERT statements that record an issuance action and its * signed label, sharing the same signing/rotation guards for both the @@ -127,6 +139,7 @@ export async function buildIssuanceStatements( proposal: IssuanceProposal, now: Date, publicationPending: boolean, + options: BuildIssuanceOptions = {}, ): Promise { const signingStatus = await getSigningStatusIfInitialized(db); if (signingStatus?.phase === "paused") { @@ -190,6 +203,37 @@ export async function buildIssuanceStatements( // selects from an action that was never written. const isAutomatedNegation = action.type === "automated-assessment" && proposal.neg === true; + // Optional finalization guard: gate the action insert on the assessment still + // being in the required state at commit time. The label insert selects from + // this action, so gating the action gates the label too — a no-op action + // leaves no orphan label (same pattern as the §10 negation guard above). + const requireState = options.requireAssessmentState; + if (requireState !== undefined && action.type !== "automated-assessment") + throw new TypeError("requireAssessmentState requires an automated-assessment action"); + const stateGuardSql = + requireState === undefined + ? "" + : `\n\t\t\t\t AND EXISTS (SELECT 1 FROM assessments WHERE id = ? AND state = ?)`; + const actionBinds: unknown[] = [ + action.actor, + action.type, + action.reason, + action.idempotencyKey, + assessmentId, + now.toISOString(), + isPrebootstrap ? 1 : 0, + isPrebootstrap ? 1 : 0, + config.signingKeyVersion, + isAutomatedNegation ? 1 : 0, + signer.issuerDid, + proposal.uri, + proposal.val, + signer.issuerDid, + proposal.uri, + proposal.val, + ]; + if (requireState !== undefined) actionBinds.push(assessmentId, requireState); + const statements: D1PreparedStatement[] = [ db .prepare( @@ -213,27 +257,10 @@ export async function buildIssuanceStatements( ) AND l2.neg = 0 AND a2.type <> 'automated-assessment' ) - ) + )${stateGuardSql} ON CONFLICT(idempotency_key) DO NOTHING`, ) - .bind( - action.actor, - action.type, - action.reason, - action.idempotencyKey, - assessmentId, - now.toISOString(), - isPrebootstrap ? 1 : 0, - isPrebootstrap ? 1 : 0, - config.signingKeyVersion, - isAutomatedNegation ? 1 : 0, - signer.issuerDid, - proposal.uri, - proposal.val, - signer.issuerDid, - proposal.uri, - proposal.val, - ), + .bind(...actionBinds), db .prepare( `INSERT INTO issued_labels diff --git a/apps/labeler/src/vite-env.d.ts b/apps/labeler/src/vite-env.d.ts new file mode 100644 index 0000000000..3e5510e7fe --- /dev/null +++ b/apps/labeler/src/vite-env.d.ts @@ -0,0 +1,15 @@ +/** + * Minimal typing for the Vite compile-time env constants this Worker reads. + * The full `vite/client` types pull in browser globals the Worker tsconfig does + * not want, and these are the only members used. Vite statically replaces + * `import.meta.env.PROD` at build time (true in `vite build`, false in dev and + * the vitest pool), so it must be written as that literal to be replaced. + */ +interface ImportMetaEnv { + readonly PROD: boolean; + readonly DEV: boolean; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/labeler/test/assessment-dispatch.test.ts b/apps/labeler/test/assessment-dispatch.test.ts new file mode 100644 index 0000000000..f9d593525d --- /dev/null +++ b/apps/labeler/test/assessment-dispatch.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; + +import { + AssessmentDispatchError, + dispatchAssessmentWorkflow, + type AssessmentWorkflowBinding, + type AssessmentWorkflowParams, +} from "../src/assessment-dispatch.js"; + +// Stand-in runKeys (real ones are 64-char SHA-256 hex from computeRunKey). Two +// distinct values model same-subject-different-trigger re-assessment. +const RUNKEY_A = "a".repeat(64); +const RUNKEY_B = "b".repeat(64); + +interface Recorded { + id: string; + params: AssessmentWorkflowParams; +} + +/** Binding fake enforcing instance-id uniqueness, with hooks to simulate a + * `create` that fails while (or without) an instance surviving. */ +class FakeWorkflow implements AssessmentWorkflowBinding { + readonly instances = new Map(); + readonly created: Recorded[] = []; + /** When set, `create` rejects with this error. */ + failCreate: Error | undefined; + /** When true, a failing `create` still leaves the instance behind (models a + * create that ran but whose acknowledgement was lost). */ + persistOnFailure = false; + + create(options: { id: string; params: AssessmentWorkflowParams }): Promise<{ id: string }> { + if (this.failCreate) { + if (this.persistOnFailure) + this.instances.set(options.id, { id: options.id, params: options.params }); + return Promise.reject(this.failCreate); + } + if (this.instances.has(options.id)) + return Promise.reject(new Error(`instance ${options.id} already exists`)); + const recorded = { id: options.id, params: options.params }; + this.instances.set(options.id, recorded); + this.created.push(recorded); + return Promise.resolve({ id: options.id }); + } + + get(id: string): Promise<{ id: string }> { + const instance = this.instances.get(id); + if (!instance) return Promise.reject(new Error(`instance ${id} not found`)); + return Promise.resolve({ id }); + } +} + +describe("dispatchAssessmentWorkflow", () => { + it("creates the instance with the runKey as its id and returns 'created'", async () => { + const workflow = new FakeWorkflow(); + const outcome = await dispatchAssessmentWorkflow(workflow, { + runKey: RUNKEY_A, + assessmentId: "asmt_1", + }); + expect(outcome).toBe("created"); + expect(workflow.created).toEqual([{ id: RUNKEY_A, params: { assessmentId: "asmt_1" } }]); + }); + + it("returns 'exists' when the same runKey is dispatched again — redelivery dedup", async () => { + const workflow = new FakeWorkflow(); + await dispatchAssessmentWorkflow(workflow, { runKey: RUNKEY_A, assessmentId: "asmt_1" }); + const outcome = await dispatchAssessmentWorkflow(workflow, { + runKey: RUNKEY_A, + assessmentId: "asmt_1", + }); + expect(outcome).toBe("exists"); + expect(workflow.created).toHaveLength(1); + }); + + it("a distinct runKey (re-assessment's new trigger) dispatches its own instance", async () => { + const workflow = new FakeWorkflow(); + // Same subject, different trigger → different runKey → must not collide. + await dispatchAssessmentWorkflow(workflow, { runKey: RUNKEY_A, assessmentId: "asmt_1" }); + const outcome = await dispatchAssessmentWorkflow(workflow, { + runKey: RUNKEY_B, + assessmentId: "asmt_2", + }); + expect(outcome).toBe("created"); + expect(workflow.created.map((c) => c.id)).toEqual([RUNKEY_A, RUNKEY_B]); + }); + + it("returns 'exists' when create fails but the instance survives", async () => { + const workflow = new FakeWorkflow(); + workflow.failCreate = new Error("ack lost"); + workflow.persistOnFailure = true; + const outcome = await dispatchAssessmentWorkflow(workflow, { + runKey: RUNKEY_A, + assessmentId: "asmt_1", + }); + expect(outcome).toBe("exists"); + }); + + it("throws AssessmentDispatchError when create fails and no instance survives", async () => { + const workflow = new FakeWorkflow(); + workflow.failCreate = new Error("backend down"); + await expect( + dispatchAssessmentWorkflow(workflow, { runKey: RUNKEY_A, assessmentId: "asmt_1" }), + ).rejects.toBeInstanceOf(AssessmentDispatchError); + }); +}); diff --git a/apps/labeler/test/assessment-orchestrator.test.ts b/apps/labeler/test/assessment-orchestrator.test.ts index c38d2c25dd..8e07bb4869 100644 --- a/apps/labeler/test/assessment-orchestrator.test.ts +++ b/apps/labeler/test/assessment-orchestrator.test.ts @@ -14,6 +14,7 @@ import { } from "../src/artifact-acquisition.js"; import { computeRunKey, initialTriggerId, operatorTriggerId } from "../src/assessment-lifecycle.js"; import { + AssessmentFinalizationConflictError, AssessmentOrchestrator, StageTransientError, stubStages, @@ -707,6 +708,75 @@ describe("AssessmentOrchestrator: invalid findings", () => { }); }); +describe("AssessmentOrchestrator: resume from running", () => { + it("resumes a run left `running` by a crashed attempt and finalizes on the next call", async () => { + const run = await pendingRun({ name: "resume-running", cidValue: await cid("resume-running") }); + let attempts = 0; + const flaky: StageAdapter = () => { + attempts += 1; + // Attempt 1: a non-transient failure after the pending→running CAS + // (models a crash in a later stage or in finalize). Later attempts pass. + if (attempts === 1) throw new Error("stage crashed post-transition"); + return Promise.resolve([]); + }; + const orchestrator = await buildOrchestrator({ ...stubStages, deterministic: flaky }); + + await expect(orchestrator.runAssessment(run.id)).rejects.toThrow( + "stage crashed post-transition", + ); + expect((await getAssessment(testEnv.DB, run.id))?.state).toBe("running"); + + // Re-invoking (as the durable step retry does) resumes the `running` row + // and finalizes passed — no pending-guard rejection, no duplicate labels. + const finalized = await orchestrator.runAssessment(run.id); + expect(finalized.state).toBe("passed"); + + const pending = await testEnv.DB.prepare( + `SELECT l.neg FROM issued_labels l JOIN issuance_actions a ON a.id = l.action_id + WHERE l.uri = ? AND l.cid = ? AND l.val = 'assessment-pending'`, + ) + .bind(run.uri, run.cid) + .first<{ neg: number }>(); + expect(pending?.neg).toBe(1); + }); +}); + +describe("AssessmentOrchestrator: cancel racing finalization", () => { + it("issues no labels and throws when a cancel moves the run out of running before commit", async () => { + const run = await pendingRun({ name: "cancel-race", cidValue: await cid("cancel-race") }); + // A stage that cancels the run mid-flight simulates a delete/operator cancel + // landing after the currency check but before the finalization batch commits. + const cancelDuringStage: StageAdapter = async () => { + await transitionAssessmentState(testEnv.DB, { + id: run.id, + from: "running", + to: "cancelled", + }); + return []; + }; + const orchestrator = await buildOrchestrator({ + ...stubStages, + deterministic: cancelDuringStage, + }); + + await expect(orchestrator.runAssessment(run.id)).rejects.toBeInstanceOf( + AssessmentFinalizationConflictError, + ); + + // The run stays cancelled and NO label was issued — not the positive + // outcome label, not even the assessment-pending negation. + expect((await getAssessment(testEnv.DB, run.id))?.state).toBe("cancelled"); + const labels = await testEnv.DB.prepare( + `SELECT COUNT(*) AS n FROM issued_labels l + JOIN issuance_actions a ON a.id = l.action_id + WHERE a.assessment_id = ?`, + ) + .bind(run.id) + .first<{ n: number }>(); + expect(labels?.n).toBe(0); + }); +}); + describe("AssessmentOrchestrator: history stage never auto-labels (W8.4)", () => { it("runs the history stage, whose finding surfaces but is never turned into an issued label", async () => { const run = await pendingRun({ diff --git a/apps/labeler/test/assessment-workflow.test.ts b/apps/labeler/test/assessment-workflow.test.ts new file mode 100644 index 0000000000..27a51c1e4f --- /dev/null +++ b/apps/labeler/test/assessment-workflow.test.ts @@ -0,0 +1,149 @@ +/** + * The assessment Workflow is a thin durable shell over `AssessmentOrchestrator` + * (whose stage execution and finalization `assessment-orchestrator.test.ts` + * already covers). Cloudflare's Workflow runtime has no local test harness and + * the entrypoint class cannot be constructed in the workers pool, so these + * tests exercise `executeAssessmentInstance` — the shell body `run` wraps in a + * durable step — directly: it must load the pending run, compose the + * orchestrator, and finalize, plus resume idempotently on a re-run. + */ + +import { CODEC_RAW, create as createCid, toString as cidToString } from "@atcute/cid"; +import { applyD1Migrations, env } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { computeRunKey, initialTriggerId } from "../src/assessment-lifecycle.js"; +import { + createAssessmentRun, + createSubject, + getAssessment, + transitionAssessmentState, +} from "../src/assessment-store.js"; +import { executeAssessmentInstance } from "../src/assessment-workflow.js"; +import { MODERATION_POLICY } from "../src/policy.js"; +import { initializeSigningState } from "../src/signing-rotation.js"; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; +} + +const testEnv = env as unknown as TestEnv; +const LABELER_DID = "did:web:labels.emdashcms.com"; +const PUBLISHER_DID = "did:plc:publisher000000000000000000"; +const MULTIKEY = "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ"; + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); + await initializeSigningState(testEnv.DB, { + issuerDid: LABELER_DID, + keyVersion: "v1", + publicKeyMultibase: MULTIKEY, + }); +}); + +async function cid(seed: string): Promise { + const bytes = new TextEncoder().encode(seed); + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + const value = await createCid(CODEC_RAW, new Uint8Array(buffer)); + return cidToString(value); +} + +function releaseUri(name: string): string { + return `at://${PUBLISHER_DID}/com.emdashcms.experimental.package.release/${name}:1.0.0`; +} + +/** Creates a verified subject and a `pending` assessment run. */ +async function pendingRun(name: string): Promise<{ id: string; uri: string; cid: string }> { + const uri = releaseUri(name); + const cidValue = await cid(name); + await createSubject(testEnv.DB, { + uri, + cid: cidValue, + did: PUBLISHER_DID, + collection: "com.emdashcms.experimental.package.release", + rkey: `${name}:1.0.0`, + }); + const triggerId = initialTriggerId(cidValue); + const runKey = await computeRunKey({ + uri, + cid: cidValue, + policyVersion: MODERATION_POLICY.policyVersion, + modelId: "unassigned", + promptHash: "unassigned", + scannerSetVersion: "unassigned", + triggerId, + }); + const { assessment } = await createAssessmentRun(testEnv.DB, { + runKey, + uri, + cid: cidValue, + trigger: "initial", + triggerId, + policyVersion: MODERATION_POLICY.policyVersion, + coverageJson: "{}", + }); + await transitionAssessmentState(testEnv.DB, { + id: assessment.id, + from: "observed", + to: "verifying", + }); + await transitionAssessmentState(testEnv.DB, { + id: assessment.id, + from: "verifying", + to: "pending", + }); + return { id: assessment.id, uri, cid: cidValue }; +} + +const workflowEnv = env as unknown as Env; + +describe("executeAssessmentInstance", () => { + it("drives a pending run through the orchestrator to finalization (passed, stub stages)", async () => { + const run = await pendingRun("wf-happy"); + + const state = await executeAssessmentInstance(workflowEnv, run.id); + + expect(state).toBe("passed"); + const finalized = await getAssessment(testEnv.DB, run.id); + expect(finalized?.state).toBe("passed"); + + // The run's own assessment-pending is negated on finalization. + const pending = await testEnv.DB.prepare( + `SELECT l.neg FROM issued_labels l JOIN issuance_actions a ON a.id = l.action_id + WHERE l.uri = ? AND l.cid = ? AND l.val = 'assessment-pending'`, + ) + .bind(run.uri, run.cid) + .first<{ neg: number }>(); + expect(pending?.neg).toBe(1); + }); + + it("resumes idempotently: a re-run of an already-finalized assessment returns its state without re-entering the orchestrator", async () => { + const run = await pendingRun("wf-resume"); + + expect(await executeAssessmentInstance(workflowEnv, run.id)).toBe("passed"); + // A second execution (as a durable retry would do) must not throw on the + // now-terminal row — the orchestrator's pending-guard would otherwise + // reject it. + expect(await executeAssessmentInstance(workflowEnv, run.id)).toBe("passed"); + }); + + it("resumes a run left `running` by a crashed attempt and finalizes it", async () => { + const run = await pendingRun("wf-running-resume"); + // Simulate a prior attempt that made the pending→running CAS then crashed + // (e.g. a durable step evicted mid-finalize) before finalizing. + await transitionAssessmentState(testEnv.DB, { id: run.id, from: "pending", to: "running" }); + + const state = await executeAssessmentInstance(workflowEnv, run.id); + + expect(state).toBe("passed"); + expect((await getAssessment(testEnv.DB, run.id))?.state).toBe("passed"); + }); + + it("throws when the assessment does not exist", async () => { + await expect( + executeAssessmentInstance(workflowEnv, "asmt_00000000000000000000000000"), + ).rejects.toThrow(/not found/); + }); +}); diff --git a/apps/labeler/test/console-mutation-api.test.ts b/apps/labeler/test/console-mutation-api.test.ts index 25b2c7e17a..3992b11c8f 100644 --- a/apps/labeler/test/console-mutation-api.test.ts +++ b/apps/labeler/test/console-mutation-api.test.ts @@ -1770,6 +1770,10 @@ describe("console mutation: pause endpoint drives the discovery consumer gate (e config: CONFIG, signer: await testSigner(), didDocumentResolver: new KillSwitchStubResolver(), + assessmentWorkflow: { + create: (options) => Promise.resolve({ id: options.id }), + get: (id) => Promise.resolve({ id }), + }, verify: () => Promise.resolve({ cid: CID, diff --git a/apps/labeler/test/discovery-consumer.test.ts b/apps/labeler/test/discovery-consumer.test.ts index e0e140e7d7..e76529aa8f 100644 --- a/apps/labeler/test/discovery-consumer.test.ts +++ b/apps/labeler/test/discovery-consumer.test.ts @@ -8,6 +8,10 @@ import { import { applyD1Migrations, env } from "cloudflare:test"; import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + type AssessmentWorkflowBinding, + type AssessmentWorkflowParams, +} from "../src/assessment-dispatch.js"; import { automatedIdempotencyKey, computeRunKey, @@ -119,12 +123,44 @@ class FakeMessage implements MessageController { } } +interface FakeInstance { + id: string; + params: AssessmentWorkflowParams; +} + +/** In-memory stand-in for the assessment Workflow binding that enforces + * instance-id uniqueness the way the real one does: `create` throws when the id + * is already taken (the per-subject lock), `get` resolves it. `createError` + * simulates an infrastructure failure. */ +class FakeAssessmentWorkflow implements AssessmentWorkflowBinding { + readonly instances = new Map(); + readonly created: FakeInstance[] = []; + createError: Error | undefined; + + create(options: { id: string; params: AssessmentWorkflowParams }): Promise<{ id: string }> { + if (this.createError) return Promise.reject(this.createError); + if (this.instances.has(options.id)) + return Promise.reject(new Error(`instance ${options.id} already exists`)); + const instance = { id: options.id, params: options.params }; + this.instances.set(options.id, instance); + this.created.push(instance); + return Promise.resolve({ id: options.id }); + } + + get(id: string): Promise<{ id: string }> { + const instance = this.instances.get(id); + if (!instance) return Promise.reject(new Error(`instance ${id} not found`)); + return Promise.resolve({ id }); + } +} + async function buildDeps(): Promise { return { db: testEnv.DB, config, signer: await signer(), didDocumentResolver: new StubResolver(), + assessmentWorkflow: new FakeAssessmentWorkflow(), }; } @@ -259,6 +295,92 @@ describe("processDiscoveryMessage: verified create", () => { }); }); +describe("processDiscoveryMessage: Workflow dispatch and per-subject lock", () => { + it("dispatches one Workflow instance per verified subject, id derived from (uri, cid)", async () => { + const job = await jobFor({ rkey: rkey() }); + const workflow = new FakeAssessmentWorkflow(); + const deps = { ...(await buildDeps()), assessmentWorkflow: workflow }; + const msg = new FakeMessage(); + + await processDiscoveryMessage(job, msg, { ...deps, verify: verifiedFor(job) }); + + expect(msg.acked).toBe(1); + const runKey = await runKeyFor(job); + const assessment = await getAssessmentByRunKey(testEnv.DB, runKey); + expect(workflow.created).toHaveLength(1); + expect(workflow.created[0]?.id).toBe(runKey); + expect(workflow.created[0]?.params.assessmentId).toBe(assessment!.id); + }); + + it("redelivery does not start a second run — the instance id is the per-subject lock", async () => { + const job = await jobFor({ rkey: rkey() }); + const workflow = new FakeAssessmentWorkflow(); + const deps = { ...(await buildDeps()), assessmentWorkflow: workflow }; + + const first = new FakeMessage(); + const second = new FakeMessage(); + await processDiscoveryMessage(job, first, { ...deps, verify: verifiedFor(job) }); + await processDiscoveryMessage(job, second, { ...deps, verify: verifiedFor(job) }); + + // Second create collides on the deterministic id; dispatch converges + // rather than starting a duplicate run, and the message still acks. + expect(workflow.created).toHaveLength(1); + expect(first.acked).toBe(1); + expect(second.acked).toBe(1); + expect(second.retried).toBe(0); + }); + + it("distinct subjects (a new CID) get distinct Workflow instances", async () => { + const name = rkey(); + const workflow = new FakeAssessmentWorkflow(); + const deps = { ...(await buildDeps()), assessmentWorkflow: workflow }; + const jobV1 = await jobFor({ rkey: name, cid: await cid(`${name}-wf-v1`) }); + const jobV2 = { ...jobV1, operation: "update" as const, cid: await cid(`${name}-wf-v2`) }; + + await processDiscoveryMessage(jobV1, new FakeMessage(), { + ...deps, + verify: verifiedFor(jobV1), + }); + await processDiscoveryMessage(jobV2, new FakeMessage(), { + ...deps, + verify: verifiedFor(jobV2), + }); + + expect(workflow.created).toHaveLength(2); + expect(workflow.created[0]?.id).not.toBe(workflow.created[1]?.id); + }); + + it("retries (no dead letter) when dispatch fails, then re-dispatches on redelivery", async () => { + const job = await jobFor({ rkey: rkey() }); + const workflow = new FakeAssessmentWorkflow(); + workflow.createError = new Error("workflows backend unavailable"); + const deps = { ...(await buildDeps()), assessmentWorkflow: workflow }; + + const first = new FakeMessage(); + await processDiscoveryMessage(job, first, { ...deps, verify: verifiedFor(job) }); + + // Dispatch failed with no surviving instance → retry, not dead-letter. + expect(first.retried).toBe(1); + expect(first.acked).toBe(0); + expect(workflow.created).toHaveLength(0); + const dl = await testEnv.DB.prepare(`SELECT COUNT(*) AS n FROM dead_letters WHERE rkey = ?`) + .bind(job.rkey) + .first<{ n: number }>(); + expect(dl?.n).toBe(0); + // The run still reached pending before the dispatch failure. + const pending = await getAssessmentByRunKey(testEnv.DB, await runKeyFor(job)); + expect(pending?.state).toBe("pending"); + + // Redelivery with the backend recovered converges: same run, one instance. + workflow.createError = undefined; + const second = new FakeMessage(); + await processDiscoveryMessage(job, second, { ...deps, verify: verifiedFor(job) }); + expect(second.acked).toBe(1); + expect(workflow.created).toHaveLength(1); + expect(workflow.created[0]?.id).toBe(await runKeyFor(job)); + }); +}); + describe("processDiscoveryMessage: verification failures", () => { it("permanent verify failure (INVALID_PROOF) dead-letters and acks — nothing else happens", async () => { const job = await jobFor({ rkey: rkey() }); diff --git a/apps/labeler/test/production-boundary.test.ts b/apps/labeler/test/production-boundary.test.ts index a350df8a92..7a64821178 100644 --- a/apps/labeler/test/production-boundary.test.ts +++ b/apps/labeler/test/production-boundary.test.ts @@ -1,13 +1,16 @@ /** - * Binding decision: production wiring stops at `assessment-pending`. Nothing - * in a production code path may construct or call `AssessmentOrchestrator` - * until W7/W8 land real stage adapters — it exists only to be exercised by - * `assessment-orchestrator.test.ts`. + * Boundary invariant: `AssessmentOrchestrator` reaches production through + * exactly one door — `AssessmentWorkflow` (assessment-workflow.ts), which + * constructs it per run. The queue consumer, discovery DO, Jetstream ingestor, + * reconciliation pass, and worker entry must never construct or call it + * directly; their job ends at dispatching a Workflow instance. This keeps the + * per-subject serialization in one place (the Workflow instance id) rather than + * letting an ad-hoc call site drive a run outside the lock. * - * `?raw` pulls each production entry point's source as a string (a Vite - * import query, supported under `@cloudflare/vitest-pool-workers`'s Vite - * pipeline) so this check runs without any filesystem access at test time — - * a static grep for the module specifier, not a runtime behavioural test. + * `?raw` pulls each entry point's source as a string (a Vite import query, + * supported under `@cloudflare/vitest-pool-workers`'s Vite pipeline) so this + * check runs without any filesystem access at test time — a static grep for the + * module specifier, not a runtime behavioural test. */ import { describe, expect, it } from "vitest"; @@ -39,9 +42,9 @@ const PRODUCTION_ENTRY_POINTS: Record = { const IMPORTS_ORCHESTRATOR = /(?:from\s+["'][^"']*assessment-orchestrator[^"']*["']|import\(\s*["'][^"']*assessment-orchestrator|import\s+["'][^"']*assessment-orchestrator)/; -describe("production boundary: AssessmentOrchestrator is test-only", () => { +describe("production boundary: AssessmentOrchestrator is reached only via the Workflow", () => { for (const [name, source] of Object.entries(PRODUCTION_ENTRY_POINTS)) { - it(`${name} never imports assessment-orchestrator`, () => { + it(`${name} never imports assessment-orchestrator directly`, () => { expect(source).not.toMatch(IMPORTS_ORCHESTRATOR); }); } diff --git a/apps/labeler/worker-configuration.d.ts b/apps/labeler/worker-configuration.d.ts index 5d654f933f..fbd732422b 100644 --- a/apps/labeler/worker-configuration.d.ts +++ b/apps/labeler/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: e21c438219f0e3a8c154bb09b450524b) +// Generated by Wrangler by running `wrangler types` (hash: 5adbc65d092da13a1fa972a4426062c0) // Runtime types generated with workerd@1.20260708.1 2026-02-24 nodejs_compat interface __BaseEnv_Env { EVIDENCE: R2Bucket; @@ -18,6 +18,7 @@ interface __BaseEnv_Env { LABEL_SUBSCRIPTION: DurableObjectNamespace; LABELER_DISCOVERY_DO: DurableObjectNamespace; AGGREGATOR: Fetcher /* emdash-aggregator */; + ASSESSMENT_WORKFLOW: Workflow[0]['payload']>; } declare namespace Cloudflare { interface GlobalProps { diff --git a/apps/labeler/wrangler.jsonc b/apps/labeler/wrangler.jsonc index 924b5aeede..6aeeeb753f 100644 --- a/apps/labeler/wrangler.jsonc +++ b/apps/labeler/wrangler.jsonc @@ -52,6 +52,18 @@ "service": "emdash-aggregator", }, ], + // One Workflow instance per assessment run drives it to finalization (a + // durable shell over AssessmentOrchestrator). The instance id is the run's + // runKey, so a redelivered discovery event dedups onto the same instance + // while a re-assessment (distinct trigger → distinct runKey) gets its own. + // See src/assessment-workflow.ts and src/assessment-dispatch.ts. + "workflows": [ + { + "name": "emdash-labeler-assessment", + "binding": "ASSESSMENT_WORKFLOW", + "class_name": "AssessmentWorkflow", + }, + ], // Operator console SPA (apps/labeler/console), built to ./dist/console by // `console:build`. `run_worker_first: true` keeps the Worker the sole // router: the SPA fallback only fires where index.ts explicitly calls From 105c96493b442821a41ad31d6d1a1b15d0112a56 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 15:03:17 +0100 Subject: [PATCH 091/137] docs(labeler-plan): ratify W10.5 source-ref, prolonged-error defer, and slicing Co-Authored-By: Claude Opus 4.8 --- .../plugin-registry-labelling-service/implementation-plan.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index a30af14b3e..7c68f9f3b2 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1254,6 +1254,10 @@ Hard requirement (2026-07-16, from the W10.4 slice-C adversary pass): the confir Send-path contracts (2026-07-16, from the two W10.4 slice-A adversary passes): the slice-A `seedPublisherContact` returns `{seeded:true}` even when the contact already exists in a `confirmed` or `declined` state (`ensureContact` is insert-if-absent, so it no-ops), and it does not — cannot — apply send rate limits because there is no send path yet. So this slice MUST: (1) gate every send on the confirm-state machine (`canSendConfirm`/`confirmState`), NEVER on `seeded:true` — a send keyed off `seeded:true` would re-mail confirmed recipients and, worse, recipients who explicitly said "not me" (`declined`); (2) enforce per-address AND per-DID rate limits on confirmation sends, the only thing preventing hostile metadata naming `victim@x` from turning double opt-in into an email-bomb amplifier; (3) re-check suppression atomically at send time rather than trusting the seeded row, since slice-A's seed-time `isSuppressed`→`ensureContact` is check-then-act and a suppression can land in the gap. +Decisions (2026-07-16, ratified): **Source reference** — the new `notifications` delivery table references its trigger via a POLYMORPHIC `source_type` ('issuance' | 'operator') + `source_id`, with NO cross-table SQL FK (SQLite can't FK-to-one-of-two, and the writer enforces integrity). This deviates from spec §14.4's literal `action_id → operator_actions(id)`, which predates the ratified issuance/operator audit split: automated block/warning/retraction live in `issuance_actions`, operator override/emergency in `operator_actions`. Synthesizing system-attributed `operator_actions` rows was rejected — it pollutes the operator audit log with non-operator events. **Prolonged-error deferred**: W10.5 wires the five events with clear hook points (new block, new warning, override, retraction, emergency takedown); the sixth spec event — "assessment error unresolved past the operational target" — has no code representation (no stuck-run detection; reconciliation doesn't surface it), so it waits on a follow-up ticket that builds stuck-run detection into the reconciliation cron. Blocked publishers retain the public assessment API + reconsideration inbox meanwhile. + +Sliced (2026-07-16): **slice 1** — migration `0008` (`notifications` delivery table: polymorphic source, `kind` confirmation|notice, `state` pending/sent/failed/undeliverable, `attempts`/`last_error`/`provider_id`, nullable `plaintext_email` cleared on successful send, `recipient_hash`, timestamps) + CSPRNG confirm-token generation (base64url ≥128 bits from `crypto.getRandomValues`, hashed via the existing `hashConfirmToken`, persisted via the existing `recordConfirmSent`) + the gated send-orchestration CORE against an injected `NotificationSender` interface (no real email adapter yet, tested with a fake sender). The core is the security surface: fresh contact resolve → `recipientHash` → ATOMIC suppression re-check at send → confirm-state gate (`getContactState`/`canSendConfirm`, NEVER `seeded:true`): `confirmed` → substantive notice; `unconfirmed` → only if per-address (`last_confirm_sent_at_epoch_ms`) AND per-DID rate limits both pass → generate+record token + send confirmation mail (never a substantive notice); `declined`/`suppressed`/no-contact → mark undeliverable, no send. Per-DID confirmation rate limit needs its own queryable ledger keyed by publisher DID (count distinct-recipient confirmation sends in a window) — a malicious DID naming many `victim@x` is the threat per-address limits don't cover. STRICT double opt-in for ALL publishers in slice 1 (verified-publisher-skip-confirmation deferred to slice 2 — sending a content-neutral confirmation mail to a verified publisher is safe/more conservative, just higher friction). **slice 2** — the Cloudflare Email Sending adapter (`send_email` binding + wrangler config + vitest stub + `EmailMessage` with html+text bodies + confirmation-mail and substantive-notice templates carrying subject/label/public summary/assessment URL/effect/reconsideration, no private detail) implementing `NotificationSender`, + trigger wiring at the five issuance points (in-batch-guarded via the `gateOnIssuedLabelActionKey`/`requireAssessmentState` idiom so a signing-suppressed batch queues no dangling notification row), + verified-publisher-skip-confirmation via the W8.4 slice-3 `getPublisherVerification` read, + the cron retry sweep and 30-day undeliverable-row cleanup as a third `ctx.waitUntil` branch in `scheduled()`. + Dependencies: `W2.3`, `W10.3`, `W10.4`. ### `W10.6` Implement reconsideration workflow From 8da954ffed04054af96b274d1e7cd6d5e1723148 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 15:57:41 +0100 Subject: [PATCH 092/137] docs(labeler-plan): record W10.5 slice-1 lifetime-cap decision and slice-2 contracts Co-Authored-By: Claude Opus 4.8 --- .../plugin-registry-labelling-service/implementation-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 7c68f9f3b2..34c2bfb8b9 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1258,6 +1258,8 @@ Decisions (2026-07-16, ratified): **Source reference** — the new `notification Sliced (2026-07-16): **slice 1** — migration `0008` (`notifications` delivery table: polymorphic source, `kind` confirmation|notice, `state` pending/sent/failed/undeliverable, `attempts`/`last_error`/`provider_id`, nullable `plaintext_email` cleared on successful send, `recipient_hash`, timestamps) + CSPRNG confirm-token generation (base64url ≥128 bits from `crypto.getRandomValues`, hashed via the existing `hashConfirmToken`, persisted via the existing `recordConfirmSent`) + the gated send-orchestration CORE against an injected `NotificationSender` interface (no real email adapter yet, tested with a fake sender). The core is the security surface: fresh contact resolve → `recipientHash` → ATOMIC suppression re-check at send → confirm-state gate (`getContactState`/`canSendConfirm`, NEVER `seeded:true`): `confirmed` → substantive notice; `unconfirmed` → only if per-address (`last_confirm_sent_at_epoch_ms`) AND per-DID rate limits both pass → generate+record token + send confirmation mail (never a substantive notice); `declined`/`suppressed`/no-contact → mark undeliverable, no send. Per-DID confirmation rate limit needs its own queryable ledger keyed by publisher DID (count distinct-recipient confirmation sends in a window) — a malicious DID naming many `victim@x` is the threat per-address limits don't cover. STRICT double opt-in for ALL publishers in slice 1 (verified-publisher-skip-confirmation deferred to slice 2 — sending a content-neutral confirmation mail to a verified publisher is safe/more conservative, just higher friction). **slice 2** — the Cloudflare Email Sending adapter (`send_email` binding + wrangler config + vitest stub + `EmailMessage` with html+text bodies + confirmation-mail and substantive-notice templates carrying subject/label/public summary/assessment URL/effect/reconsideration, no private detail) implementing `NotificationSender`, + trigger wiring at the five issuance points (in-batch-guarded via the `gateOnIssuedLabelActionKey`/`requireAssessmentState` idiom so a signing-suppressed batch queues no dangling notification row), + verified-publisher-skip-confirmation via the W8.4 slice-3 `getPublisherVerification` read, + the cron retry sweep and 30-day undeliverable-row cleanup as a third `ctx.waitUntil` branch in `scheduled()`. +Slice-1 outcome (2026-07-16, ratified after the send-core adversary pass): **the confirmation mail is a true LIFETIME cap — at most one per address, EVER**, not one-per-interval (Matt's decision; the earlier "at most one, ever" wording is now literally enforced, superseding the per-address interval gate). Mechanism: the confirmation-path claim is a single atomic `INSERT ... SELECT ... WHERE NOT EXISTS (suppression) AND NOT EXISTS (prior non-undeliverable kind='confirmation' row for this recipient_hash)`, which enforces not-suppressed + lifetime-cap + concurrency (a racing second send's guard sees the first's row and inserts nothing) in one statement. `state != 'undeliverable'` is deliberate so a prior that never actually mailed (race-loser/suppressed-at-claim) does not block a later legitimate confirmation. Consequence: a persistently-named victim receives at most one content-neutral confirmation mail total, and one click of "not me" (→ declined+suppressed) stops everything permanently; an unconfirmed legit publisher who ignores that one mail is never re-nudged and keeps the public assessment API + reconsideration inbox as their channel (the double-opt-in bargain). The per-DID confirmation ledger cap is BEST-EFFORT under concurrency (count→check→write is not atomic — bounded overshoot admits more distinct victims but never more than one mail per victim, since the per-address lifetime cap is the hard bound); exact per-DID serialization needs a Durable Object and is deferred to slice 2. Slice-2 contracts recorded from the adversary pass: (a) the `NotificationSender` implementation MUST NOT put the confirm URL or raw token into its returned `error` string — it is persisted verbatim in `notifications.last_error` and never cleared, so a token there would leak to the DB; (b) a `failed` confirmation row retains `plaintext_email` but only the token HASH (not the raw token), so the slice-2 retry sweep must REGENERATE the token when re-sending a failed confirmation, not attempt to reuse stored data. + Dependencies: `W2.3`, `W10.3`, `W10.4`. ### `W10.6` Implement reconsideration workflow From 2a44112db2172722d8a3ba0f73abe23f15a72e08 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 16:10:41 +0100 Subject: [PATCH 093/137] docs(labeler-plan): add slice-2 sweep contract for cap-holding pending/failed rows Co-Authored-By: Claude Opus 4.8 --- .../plugin-registry-labelling-service/implementation-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 34c2bfb8b9..0bfd4cb0ab 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1258,7 +1258,7 @@ Decisions (2026-07-16, ratified): **Source reference** — the new `notification Sliced (2026-07-16): **slice 1** — migration `0008` (`notifications` delivery table: polymorphic source, `kind` confirmation|notice, `state` pending/sent/failed/undeliverable, `attempts`/`last_error`/`provider_id`, nullable `plaintext_email` cleared on successful send, `recipient_hash`, timestamps) + CSPRNG confirm-token generation (base64url ≥128 bits from `crypto.getRandomValues`, hashed via the existing `hashConfirmToken`, persisted via the existing `recordConfirmSent`) + the gated send-orchestration CORE against an injected `NotificationSender` interface (no real email adapter yet, tested with a fake sender). The core is the security surface: fresh contact resolve → `recipientHash` → ATOMIC suppression re-check at send → confirm-state gate (`getContactState`/`canSendConfirm`, NEVER `seeded:true`): `confirmed` → substantive notice; `unconfirmed` → only if per-address (`last_confirm_sent_at_epoch_ms`) AND per-DID rate limits both pass → generate+record token + send confirmation mail (never a substantive notice); `declined`/`suppressed`/no-contact → mark undeliverable, no send. Per-DID confirmation rate limit needs its own queryable ledger keyed by publisher DID (count distinct-recipient confirmation sends in a window) — a malicious DID naming many `victim@x` is the threat per-address limits don't cover. STRICT double opt-in for ALL publishers in slice 1 (verified-publisher-skip-confirmation deferred to slice 2 — sending a content-neutral confirmation mail to a verified publisher is safe/more conservative, just higher friction). **slice 2** — the Cloudflare Email Sending adapter (`send_email` binding + wrangler config + vitest stub + `EmailMessage` with html+text bodies + confirmation-mail and substantive-notice templates carrying subject/label/public summary/assessment URL/effect/reconsideration, no private detail) implementing `NotificationSender`, + trigger wiring at the five issuance points (in-batch-guarded via the `gateOnIssuedLabelActionKey`/`requireAssessmentState` idiom so a signing-suppressed batch queues no dangling notification row), + verified-publisher-skip-confirmation via the W8.4 slice-3 `getPublisherVerification` read, + the cron retry sweep and 30-day undeliverable-row cleanup as a third `ctx.waitUntil` branch in `scheduled()`. -Slice-1 outcome (2026-07-16, ratified after the send-core adversary pass): **the confirmation mail is a true LIFETIME cap — at most one per address, EVER**, not one-per-interval (Matt's decision; the earlier "at most one, ever" wording is now literally enforced, superseding the per-address interval gate). Mechanism: the confirmation-path claim is a single atomic `INSERT ... SELECT ... WHERE NOT EXISTS (suppression) AND NOT EXISTS (prior non-undeliverable kind='confirmation' row for this recipient_hash)`, which enforces not-suppressed + lifetime-cap + concurrency (a racing second send's guard sees the first's row and inserts nothing) in one statement. `state != 'undeliverable'` is deliberate so a prior that never actually mailed (race-loser/suppressed-at-claim) does not block a later legitimate confirmation. Consequence: a persistently-named victim receives at most one content-neutral confirmation mail total, and one click of "not me" (→ declined+suppressed) stops everything permanently; an unconfirmed legit publisher who ignores that one mail is never re-nudged and keeps the public assessment API + reconsideration inbox as their channel (the double-opt-in bargain). The per-DID confirmation ledger cap is BEST-EFFORT under concurrency (count→check→write is not atomic — bounded overshoot admits more distinct victims but never more than one mail per victim, since the per-address lifetime cap is the hard bound); exact per-DID serialization needs a Durable Object and is deferred to slice 2. Slice-2 contracts recorded from the adversary pass: (a) the `NotificationSender` implementation MUST NOT put the confirm URL or raw token into its returned `error` string — it is persisted verbatim in `notifications.last_error` and never cleared, so a token there would leak to the DB; (b) a `failed` confirmation row retains `plaintext_email` but only the token HASH (not the raw token), so the slice-2 retry sweep must REGENERATE the token when re-sending a failed confirmation, not attempt to reuse stored data. +Slice-1 outcome (2026-07-16, ratified after the send-core adversary pass): **the confirmation mail is a true LIFETIME cap — at most one per address, EVER**, not one-per-interval (Matt's decision; the earlier "at most one, ever" wording is now literally enforced, superseding the per-address interval gate). Mechanism: the confirmation-path claim is a single atomic `INSERT ... SELECT ... WHERE NOT EXISTS (suppression) AND NOT EXISTS (prior non-undeliverable kind='confirmation' row for this recipient_hash)`, which enforces not-suppressed + lifetime-cap + concurrency (a racing second send's guard sees the first's row and inserts nothing) in one statement. `state != 'undeliverable'` is deliberate so a prior that never actually mailed (race-loser/suppressed-at-claim) does not block a later legitimate confirmation. Consequence: a persistently-named victim receives at most one content-neutral confirmation mail total, and one click of "not me" (→ declined+suppressed) stops everything permanently; an unconfirmed legit publisher who ignores that one mail is never re-nudged and keeps the public assessment API + reconsideration inbox as their channel (the double-opt-in bargain). The per-DID confirmation ledger cap is BEST-EFFORT under concurrency (count→check→write is not atomic — bounded overshoot admits more distinct victims but never more than one mail per victim, since the per-address lifetime cap is the hard bound); exact per-DID serialization needs a Durable Object and is deferred to slice 2. Slice-2 contracts recorded from the adversary pass: (a) the `NotificationSender` implementation MUST NOT put the confirm URL or raw token into its returned `error` string — it is persisted verbatim in `notifications.last_error` and never cleared, so a token there would leak to the DB; (b) a `failed` confirmation row retains `plaintext_email` but only the token HASH (not the raw token), so the slice-2 retry sweep must REGENERATE the token when re-sending a failed confirmation, not attempt to reuse stored data; (c) because `pending` and `failed` confirmation rows HOLD the lifetime cap, a crash-stuck `pending` row or an exhausted-retry `failed` row permanently forecloses the email channel for that address unless the sweep handles them — the slice-2 sweep MUST re-drive stuck `pending` rows (not just `failed`), and when it terminally abandons a row it MUST flip it to `undeliverable` (which re-opens the cap for a later legitimate send) rather than leaving a dead `failed` row as a silent permanent lockout. Dependencies: `W2.3`, `W10.3`, `W10.4`. From 7e04ad107da48ca9f3f756c70e6efbc2093bc131 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 16:19:49 +0100 Subject: [PATCH 094/137] docs(labeler-plan): record verified Email Sending mechanics for W10.5 slice 2 Co-Authored-By: Claude Opus 4.8 --- .../plugin-registry-labelling-service/implementation-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 0bfd4cb0ab..6a47aad8d5 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1248,6 +1248,8 @@ Observed (2026-07-16, slice-A adversary): the aggregator's `isRedacted` gate on Delivery uses Cloudflare Email Sending through the Workers `send_email` binding; the sending domain is onboarded on the emdashcms.com zone (`wrangler email sending enable`). No provider API keys. Messages include both `html` and `text` bodies. +Verified mechanics (2026-07-16, against live Cloudflare docs — Email Sending is a distinct public-beta product, Workers Paid only, separate from Email Routing's verified-destination-only sends): ARBITRARY external recipients are supported once the domain is onboarded (the load-bearing feasibility fact; the bare `{"send_email":[{"name":"EMAIL"}]}` binding form with no destination restriction is the correct one — `destination_address`/`allowed_destination_addresses` are the old verified-only model). The structured `env.EMAIL.send({to, from, subject, html, text, headers})` API supersedes raw-MIME `EmailMessage`; multipart html+text is automatic; `from` must be on the onboarded domain; success returns `response.messageId` → our `provider_id`. Failures THROW typed errors with `.code`: retryable (`E_RATE_LIMIT_EXCEEDED`, `E_DELIVERY_FAILED`, `E_INTERNAL_SERVER_ERROR`), quota (`E_DAILY_LIMIT_EXCEEDED`), and terminal — notably `E_RECIPIENT_SUPPRESSED` (Cloudflare account-level hard-bounce/complaint suppression): the adapter MUST map that to our own `suppress(reason:'bounce')` + undeliverable, never retry it. Set `List-Unsubscribe: ` + `List-Unsubscribe-Post: List-Unsubscribe=One-Click` headers on every send (allowlisted + validated; Gmail/Yahoo bulk-sender requirement). Limits: 5 MiB/message, 50 recipients, 3k mails/mo included then $0.35/1k, daily quota auto-scales. Transactional-only ToS — our action-triggered notices qualify. Testing: miniflare/vitest-pool-workers natively simulates the binding (no stub needed; verify with a quick spike — binary attachments don't serialize locally, irrelevant here). OPERATOR-OWNED go-live prereq: one-time Email Sending onboarding of emdashcms.com (dashboard or `wrangler email sending enable`; auto-managed SPF/DKIM/DMARC on a cf-bounce subdomain, additive alongside any existing Email Routing config, 5-15 min propagation). + Notify on block, warning, override, retraction, prolonged error, and emergency takedown. Include public summary/effect/reconsideration URL without private details. Delivery retries independently and never rolls back a label. Hard requirement (2026-07-16, from the W10.4 slice-C adversary pass): the confirm token this slice generates MUST come from a CSPRNG with ≥128 bits of entropy. The confirm endpoint stores `SHA-256(token)` without a pepper (unlike the recipient hash, which is HMAC-peppered because email is low-entropy) and applies no in-worker rate limit, so the token's unguessability is the sole protection against a brute-force confirm of a contact whose recipient hash leaked. A weak or predictable token would make double opt-in bypassable through the public endpoint. Do not reuse a ULID or any monotonic/timestamped id as the token. From 738f94ea5be893ce530744d1eed0fb2eede485a5 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 16:41:59 +0100 Subject: [PATCH 095/137] feat(labeler): notification delivery table + CSPRNG token + gated send-core (W10.5 slice 1) (#2076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(labeler): gated notification send-core + CSPRNG confirm token (W10.5 slice 1) Migration 0008 adds the publisher-facing `notifications` delivery table (polymorphic issuance/operator source, confirmation|notice kind, pending/sent/failed/undeliverable state, plaintext cleared on success) and a per-DID `notification_confirm_ledger` for distinct-recipient rate limiting. `generateConfirmToken` draws a 256-bit base64url token from `crypto.getRandomValues`; only its SHA-256 digest is persisted via the existing `recordConfirmSent`. `sendNotification` orchestrates one delivery against an injected `NotificationSender`: fresh contact resolve, recipient hash, atomic suppression-guarded claim, and a confirm-state gate (never `seeded:true`) — confirmed => substantive notice; unconfirmed => a confirmation mail only when both the per-address and per-DID limits pass; declined/suppressed/no-contact => undeliverable, no send. The real email adapter and triggers are slice 2. * fix(labeler): close double-mail race + harden notification send-core (W10.5 slice 1 review) Adversary-pass fixes: - recordConfirmSent is now a compare-and-set: the UPDATE re-checks the per-address interval in SQL (last_confirm_sent IS NULL OR <= epochMs - minIntervalMs), so two triggers racing for the same unconfirmed address off a stale canSendConfirm snapshot can no longer both mail the victim. minIntervalMs is an optional param (default 0 for seeding callers); the send path passes CONFIRM_MIN_INTERVAL_MS. - Per-DID ledger: count and ledger-write moved adjacent to narrow the overshoot window (best-effort, bounded; exact serialization deferred to slice 2). The gate still returns before any write, so a sustained DID email-bomb costs no rows. - Migration: CHECK (state = 'undeliverable' OR recipient_hash IS NOT NULL); a sendable row must carry a hash. - finalizeSend / markRowUndeliverable UPDATEs gated on state = 'pending' — one-shot terminal transition, no double-finalize. - NotificationSender doc: slice-2 impls must not leak confirmUrl/token into the returned error string (persisted verbatim in last_error). New concurrency test proves the CAS: two Promise.all sends to one unconfirmed victim yield exactly one confirmation (mail once, loser row undeliverable). * fix(labeler): lifetime confirmation cap via atomic claim guard (W10.5 slice 1) Supersedes the interval-CAS: a confirmation mail now reaches an address at most ONCE EVER. The confirmation claimSend gains a second guard in the same INSERT...SELECT — AND NOT EXISTS (a prior notifications row for the hash with kind='confirmation' AND state != 'undeliverable'). This one atomic statement enforces suppression, the lifetime cap, and the concurrency race (of two racing sends exactly one inserts; the loser writes no row). An undeliverable prior (race loser / suppressed-at-claim that never mailed) never blocks a later send. - recordConfirmSent reverts to its state-only guard (drops the minIntervalMs param); it only stamps the token now. - Per-address interval gating removed from the send path; CONFIRM_MIN_INTERVAL_MS deleted (unreferenced). canSendConfirm left as-is (slice-B/C utility). - Ledger write moved after a successful claim, so a repeat trigger for an already-mailed address writes nothing and cannot bloat the per-DID ledger. - New outcome `already_mailed` for a claim blocked by the lifetime cap (suppression re-checked cheaply first for audit precision). Tests prove it: concurrent Promise.all sends mail exactly once (loser row-less); no second mail after a sent OR failed confirmation; an undeliverable-only prior still mails. All three fail with the guard removed. * fix(labeler): address emdashbot review on #2076 (ledger ordering, sent_at, suppress order) - Move recordConfirmLedgerEntry to after recordConfirmSent succeeds (still before send). A contact_state_changed abort now writes no ledger row, so it no longer eats a distinct-recipient slot — matching the comment's own contract. A failed SEND still consumes budget (the conservative fail-safe). - markRowUndeliverable no longer sets sent_at: a never-sent undeliverable row must not carry a provider-accepted timestamp (dropped the now param too). - Check isSuppressed before ensureContact in sendNotification (matching seedPublisherContact), so a suppressed address is never seeded with an unconfirmed contact row. New test drives the contact_state_changed abort via a DB proxy that flips the contact to declined during the per-DID count (after the state read, before the stamp) and asserts no ledger row + an undeliverable row with sent_at NULL; it fails if the ledger write is moved back before the stamp. --- .../labeler/migrations/0008_notifications.sql | 73 +++ apps/labeler/src/constants.ts | 15 + apps/labeler/src/notification-contacts.ts | 38 +- apps/labeler/src/notification-send.ts | 509 +++++++++++++++ .../test/notification-contacts.test.ts | 28 + apps/labeler/test/notification-send.test.ts | 609 ++++++++++++++++++ 6 files changed, 1269 insertions(+), 3 deletions(-) create mode 100644 apps/labeler/migrations/0008_notifications.sql create mode 100644 apps/labeler/src/notification-send.ts create mode 100644 apps/labeler/test/notification-send.test.ts diff --git a/apps/labeler/migrations/0008_notifications.sql b/apps/labeler/migrations/0008_notifications.sql new file mode 100644 index 0000000000..60a866c77e --- /dev/null +++ b/apps/labeler/migrations/0008_notifications.sql @@ -0,0 +1,73 @@ +-- Publisher-facing notification delivery (spec §14.4/§18/§19, plan W10.5). This +-- is the MUTABLE outbox the send path drives per recipient, distinct from both +-- `notification_outbox` (0005 — the OPERATOR alert subsystem) and the +-- `notification_contacts`/`notification_suppressions` double-opt-in ledger (0007, +-- which this send path reads). Two tables land together: +-- +-- * `notifications` — one row per delivery attempt, keyed by ULID. +-- A row is claimed `pending`, then flipped to `sent` (with `provider_id` and +-- `sent_at` set, `plaintext_email` CLEARED to NULL) or `failed`/`undeliverable` +-- (with `last_error`, `attempts` bumped). Mutable — a failed send is retried +-- independently and NEVER rolls back a label, so no immutability trigger. The +-- trigger is referenced polymorphically by (`source_type`, `source_id`): there +-- is NO SQL FK because the source is one of two audit tables (`issuance_actions` +-- for automated block/warning/retraction, `operator_actions` for override/ +-- emergency); the writer enforces referential integrity. +-- * `notification_confirm_ledger` — per-DID confirmation-send ledger. Per-address +-- rate limiting lives on `notification_contacts.last_confirm_sent_at_epoch_ms`, +-- but that does NOT cap a hostile publisher DID naming many DISTINCT victim +-- addresses. This ledger records one row per confirmation mail so the send path +-- can count DISTINCT recipients per DID in a rolling window before sending, and +-- prune rows below the window. Keyed by ULID; no plaintext, hashes only. +-- +-- `plaintext_email` is the ONLY plaintext in the subsystem (0007 stores hashes +-- only): a delivery row must hold the address until the mail is handed to the +-- provider, then clears it on success. Undelivered rows are swept after a +-- versioned retention window (W10.5 slice 2 cron), so plaintext for a failed send +-- does not linger indefinitely. +-- +-- Timestamp columns queries order/compare on gain an integer `*_epoch_ms` sibling +-- (RFC 3339 strings compare incorrectly across timezone offsets in SQL), matching +-- 0003-0007. `created_at`/`sent_at` are RFC 3339 audit strings; `created_at_epoch_ms` +-- is what the retention sweep orders on. + +CREATE TABLE notifications ( + id TEXT PRIMARY KEY, + source_type TEXT NOT NULL CHECK (source_type IN ('issuance', 'operator')), + source_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('confirmation', 'notice')), + channel TEXT NOT NULL DEFAULT 'email', + -- NULL only on a no-resolvable-contact `undeliverable` audit row, where no + -- address (hence no hash) exists. Every row that was actually sendable carries + -- the HMAC-SHA256 recipient hash. + recipient_hash TEXT, + state TEXT NOT NULL CHECK (state IN ('pending', 'sent', 'failed', 'undeliverable')), + attempts INTEGER NOT NULL DEFAULT 0, + provider_id TEXT, + last_error TEXT, + plaintext_email TEXT, + created_at TEXT NOT NULL, + created_at_epoch_ms INTEGER NOT NULL, + sent_at TEXT, + -- Only a no-resolvable-contact `undeliverable` audit row may omit the hash; + -- every sendable row (pending/sent/failed) must carry one. + CHECK (state = 'undeliverable' OR recipient_hash IS NOT NULL) +); + +CREATE INDEX idx_notifications_pending ON notifications(state, created_at_epoch_ms) + WHERE state IN ('pending', 'failed'); +CREATE INDEX idx_notifications_created ON notifications(created_at_epoch_ms); +CREATE INDEX idx_notifications_recipient ON notifications(recipient_hash); +CREATE INDEX idx_notifications_source ON notifications(source_type, source_id); + +CREATE TABLE notification_confirm_ledger ( + id TEXT PRIMARY KEY, + publisher_did TEXT NOT NULL, + recipient_hash TEXT NOT NULL, + sent_at_epoch_ms INTEGER NOT NULL +); + +CREATE INDEX idx_notification_confirm_ledger_did + ON notification_confirm_ledger(publisher_did, sent_at_epoch_ms); +CREATE INDEX idx_notification_confirm_ledger_sweep + ON notification_confirm_ledger(sent_at_epoch_ms); diff --git a/apps/labeler/src/constants.ts b/apps/labeler/src/constants.ts index 3f7933d0a6..c2f2a280bb 100644 --- a/apps/labeler/src/constants.ts +++ b/apps/labeler/src/constants.ts @@ -8,3 +8,18 @@ import { NSID } from "@emdash-cms/registry-lexicons"; export const WANTED_COLLECTIONS = [NSID.packageRelease] as const; + +/** + * Confirmation-send rate limits (plan W10.5). Versioned as a set: bump together + * if the double-opt-in throttle is retuned. The per-ADDRESS limit is a LIFETIME + * cap — a confirmation mail reaches an address at most once ever — enforced + * atomically by the confirmation delivery-row claim, so it needs no interval + * constant. The per-DID gate below is the remaining tunable: at most + * `CONFIRM_DID_MAX_DISTINCT_RECIPIENTS` DISTINCT recipients in a + * `CONFIRM_DID_WINDOW_MS` rolling window per publisher DID + * (`notification_confirm_ledger`), capping a hostile DID naming many distinct + * `victim@x` addresses — the amplification the per-address cap cannot see. + */ +export const CONFIRM_RATE_LIMIT_VERSION = 1; +export const CONFIRM_DID_WINDOW_MS = 24 * 60 * 60 * 1000; +export const CONFIRM_DID_MAX_DISTINCT_RECIPIENTS = 10; diff --git a/apps/labeler/src/notification-contacts.ts b/apps/labeler/src/notification-contacts.ts index add1379d34..6ce69ea202 100644 --- a/apps/labeler/src/notification-contacts.ts +++ b/apps/labeler/src/notification-contacts.ts @@ -122,6 +122,34 @@ export async function hashConfirmToken(token: string): Promise { return toHex(digest); } +/** + * Bytes of CSPRNG entropy per confirmation token — 32 (256 bits), comfortably + * above the 128-bit floor {@link hashConfirmToken}'s doc mandates. base64url of + * 32 bytes is 43 chars, within the confirm endpoint's `CONFIRM_TOKEN` bound. + */ +const CONFIRM_TOKEN_BYTES = 32; + +/** + * Fresh single-use confirmation token, base64url (no padding) of + * {@link CONFIRM_TOKEN_BYTES} CSPRNG bytes from `crypto.getRandomValues`. + * + * SECURITY — this token is the SOLE protection against a brute-force confirm of + * a contact whose recipient hash leaked: the confirm endpoint stores only the + * pepper-less SHA-256 digest and applies no rate limit (see + * {@link hashConfirmToken}). It MUST therefore be drawn from a CSPRNG with high + * entropy — never a ULID, `randomUUID`, or any monotonic/timestamped value. The + * raw token travels only in the confirm link; the caller persists its + * {@link hashConfirmToken} digest via `recordConfirmSent` and never the raw value. + */ +export function generateConfirmToken(): string { + const bytes = crypto.getRandomValues(new Uint8Array(CONFIRM_TOKEN_BYTES)); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + export async function getContactState( db: D1Database, recipientHashValue: string, @@ -156,9 +184,13 @@ export async function ensureContact( /** * Record that a confirmation mail was sent: stores the token hash the eventual - * confirm must match and stamps the send time the rate gate reads. Only touches - * an `unconfirmed` contact — a confirmed or declined row is never reopened even - * if a caller skips {@link canSendConfirm}. Returns whether a row was updated. + * confirm must match and stamps the send time. Only touches an `unconfirmed` + * contact — a confirmed or declined row is never reopened even if a caller skips + * {@link canSendConfirm}. Returns whether a row was updated. + * + * The lifetime "mail once ever" cap and the concurrency race are enforced + * upstream, atomically, by the confirmation delivery-row claim (W10.5), so this + * only needs to stamp the token on the still-unconfirmed contact. */ export async function recordConfirmSent( db: D1Database, diff --git a/apps/labeler/src/notification-send.ts b/apps/labeler/src/notification-send.ts new file mode 100644 index 0000000000..31e52c3c62 --- /dev/null +++ b/apps/labeler/src/notification-send.ts @@ -0,0 +1,509 @@ +/** + * Gated send-orchestration core for publisher notifications (spec §18/§19, plan + * W10.5 slice 1). This is the security surface of the subsystem: it decides, + * per triggering action, whether an address may be mailed and what it may + * receive, then records the delivery attempt in the `notifications` table + * (migration 0008). The actual email adapter is injected as a + * {@link NotificationSender}; slice 2 supplies the real Cloudflare Email Sending + * implementation and wires the triggers. + * + * The gate, in order: + * 1. Fresh contact resolve via {@link resolvePublisherContact} (no caching — + * a notice must resolve against the publisher's current metadata). No + * resolvable contact is an expected terminal state: an `undeliverable` + * audit row, no send. + * 2. `recipientHash` under the pepper. + * 3. Suppression / decline short-circuit: a suppressed address or a contact + * that said "not me" (`declined`) receives NOTHING — an `undeliverable` + * audit row, no send. + * 4. Confirm-state gate, NEVER `seeded:true`: + * * `confirmed` → substantive NOTICE. + * * `unconfirmed` → double opt-in: a content-neutral CONFIRMATION mail, + * sent to an address at most ONCE EVER (lifetime cap) and subject to the + * per-DID cap. Never a substantive notice. + * * `declined` → nothing (handled in step 3). + * + * Atomicity: every send is CLAIMED by inserting the pending `notifications` row + * guarded by `WHERE NOT EXISTS (suppression)`, so a suppression committed before + * the claim blocks the send even under a race with the step-3 read. The + * confirmation claim adds a second guard in the same statement — no prior + * non-`undeliverable` confirmation row for the hash — which enforces both the + * lifetime "mail once ever" cap and the concurrency race (of two racing sends + * exactly one inserts). A suppression landing in the sub-millisecond window + * between the claim and the external send is the accepted double-opt-in tradeoff: + * at most one content-neutral confirmation mail, ever. + * + * PII: `plaintext_email` lives only on the delivery row and is cleared to NULL on + * a successful send. Nothing here logs plaintext — logs carry the (public) + * publisher DID and at most an 8-char recipient-hash prefix, matching slice C's + * `logOutcome`. The confirm token is never logged. + */ + +import { ulid } from "ulidx"; + +import type { AggregatorClient } from "./aggregator-client.js"; +import { CONFIRM_DID_MAX_DISTINCT_RECIPIENTS, CONFIRM_DID_WINDOW_MS } from "./constants.js"; +import { + ensureContact, + generateConfirmToken, + getContactState, + hashConfirmToken, + isSuppressed, + recipientHash, + recordConfirmSent, +} from "./notification-contacts.js"; +import type { ContactTarget } from "./publisher-contact.js"; +import { resolvePublisherContact } from "./publisher-contact.js"; + +/** Which audit table the triggering action lives in. No SQL FK (SQLite can't + * FK-to-one-of-two); the writer here is the only producer of these rows. */ +export type NotificationSourceType = "issuance" | "operator"; + +export interface NotificationSource { + type: NotificationSourceType; + id: string; +} + +/** + * The public-safe body of a substantive notice (spec §18/§19). Carries only what + * a publisher may see: the label effect, a public summary, and the URLs of the + * public assessment and reconsideration channel. NO private evidence, findings, + * or exploit detail — the type is the enforcement. + */ +export interface NoticeContent { + subject: string; + publicSummary: string; + assessmentUrl: string; + effect: string; + reconsiderationUrl: string; +} + +export interface NotificationRequest { + source: NotificationSource; + target: ContactTarget; + notice: NoticeContent; +} + +export type SendResult = { ok: true; providerId?: string } | { ok: false; error: string }; + +export interface ConfirmationPayload { + to: string; + confirmUrl: string; + unsubscribeUrl: string; + notMeUrl: string; +} + +export interface NoticePayload extends NoticeContent { + to: string; + unsubscribeUrl: string; +} + +/** + * The injected email adapter. Slice 1 tests supply a fake that records calls; + * slice 2 supplies the Cloudflare Email Sending implementation. Each method + * returns a discriminated result — a transport failure is `{ ok: false }`, not a + * throw, so the caller records `failed` and lets the retry sweep pick it up. + * + * SECURITY (slice-2 contract): the `error` string is persisted verbatim in + * `notifications.last_error` and is NEVER cleared. An implementation MUST NOT put + * the `confirmUrl` or the raw confirm token into it — that would leak the + * single-use capability to the database. Return the provider's status/message + * only, never the payload it was given. + */ +export interface NotificationSender { + sendConfirmation(payload: ConfirmationPayload): Promise; + sendNotice(payload: NoticePayload): Promise; +} + +export interface SendContext { + db: D1Database; + aggregator: AggregatorClient; + pepper: string; + sender: NotificationSender; + /** Public origin of the notification landing endpoints (the + * `LABELER_SERVICE_URL` var), e.g. `https://labels.emdashcms.com`. The + * confirm/unsubscribe/not-me links are built against it. */ + origin: string; + now?: () => Date; +} + +export type SendOutcome = + | { status: "notice_sent"; recipientHash: string; providerId?: string } + | { status: "notice_failed"; recipientHash: string; error: string } + | { status: "confirmation_sent"; recipientHash: string; providerId?: string } + | { status: "confirmation_failed"; recipientHash: string; error: string } + | { status: "rate_limited"; recipientHash: string; scope: "did" } + | { status: "suppressed"; recipientHash: string } + | { status: "declined"; recipientHash: string } + | { status: "already_mailed"; recipientHash: string } + | { status: "skipped"; recipientHash: string; reason: "contact_state_changed" } + | { status: "no_contact" }; + +type NotificationKind = "confirmation" | "notice"; + +const LAST_ERROR_MAX = 500; + +/** + * Resolve, gate, and send one notification. The entry point slice-2 triggers + * call. Returns a terminal {@link SendOutcome}; it never throws for an expected + * non-delivery (no contact, suppressed, declined, rate-limited) — only an + * aggregator/transport read failure in {@link resolvePublisherContact} + * propagates, so the caller can retry the whole send. + */ +export async function sendNotification( + ctx: SendContext, + request: NotificationRequest, +): Promise { + const now = ctx.now ?? (() => new Date()); + const resolution = await resolvePublisherContact(ctx.aggregator, request.target); + if ("none" in resolution) { + await recordUndeliverable(ctx.db, request.source, "notice", null, resolution.none, now()); + logSend(request.target.did, "no_contact", undefined); + return { status: "no_contact" }; + } + + const hash = await recipientHash(ctx.pepper, resolution.email); + const nowDate = now(); + const nowIso = nowDate.toISOString(); + const nowMs = nowDate.getTime(); + + // Suppression is checked BEFORE ensureContact (matching seedPublisherContact), + // so a suppressed address is never seeded with an unconfirmed contact row. + if (await isSuppressed(ctx.db, hash)) { + await recordUndeliverable(ctx.db, request.source, "notice", hash, "suppressed", nowDate); + logSend(request.target.did, "suppressed", hash); + return { status: "suppressed", recipientHash: hash }; + } + + await ensureContact(ctx.db, hash, nowIso); + const state = await getContactState(ctx.db, hash); + + if (state === null || state.confirmState === "declined") { + if (state?.confirmState === "declined") { + await recordUndeliverable(ctx.db, request.source, "notice", hash, "declined", nowDate); + logSend(request.target.did, "declined", hash); + return { status: "declined", recipientHash: hash }; + } + logSend(request.target.did, "skipped:no_state", hash); + return { status: "skipped", recipientHash: hash, reason: "contact_state_changed" }; + } + + if (state.confirmState === "confirmed") { + return sendSubstantiveNotice(ctx, request, hash, resolution.email, nowDate); + } + + return sendConfirmationMail(ctx, request, hash, resolution.email, { + date: nowDate, + ms: nowMs, + }); +} + +async function sendSubstantiveNotice( + ctx: SendContext, + request: NotificationRequest, + hash: string, + email: string, + now: Date, +): Promise { + const id = newNotificationId(); + const claimed = await claimSend(ctx.db, id, request.source, "notice", hash, email, now); + if (!claimed) { + await recordUndeliverable(ctx.db, request.source, "notice", hash, "suppressed", now); + logSend(request.target.did, "suppressed", hash); + return { status: "suppressed", recipientHash: hash }; + } + + const result = await ctx.sender.sendNotice({ + ...request.notice, + to: email, + unsubscribeUrl: buildActionUrl(ctx.origin, "unsubscribe", hash), + }); + await finalizeSend(ctx.db, id, result, now); + if (result.ok) { + logSend(request.target.did, "notice_sent", hash); + return { status: "notice_sent", recipientHash: hash, providerId: result.providerId }; + } + logSend(request.target.did, "notice_failed", hash); + return { status: "notice_failed", recipientHash: hash, error: result.error }; +} + +async function sendConfirmationMail( + ctx: SendContext, + request: NotificationRequest, + hash: string, + email: string, + now: { date: Date; ms: number }, +): Promise { + // Per-DID best-effort gate. The count→claim→ledger sequence is a non-atomic + // check-then-act: concurrent confirmations to DISTINCT victims for one DID can + // overshoot the cap (each victim still receives at most one mail — the lifetime + // cap guarantees that — so it admits a few extra distinct victims, it never + // bombs anyone). Exact enforcement needs a serialized claim (a Durable Object) + // and is deferred to slice 2. The gate returns before any write, so a sustained + // DID email-bomb costs no rows; the ledger is written only once the token is + // stamped and the mail is about to go out, so neither an already-mailed repeat + // (blocked at the claim) nor a contact_state_changed abort (blocked at the + // stamp) leaves a ledger row eating a distinct-recipient slot. + const sinceMs = now.ms - CONFIRM_DID_WINDOW_MS; + const distinctRecipients = await countDistinctConfirmRecipients( + ctx.db, + request.target.did, + sinceMs, + ); + if (distinctRecipients >= CONFIRM_DID_MAX_DISTINCT_RECIPIENTS) { + logSend(request.target.did, "rate_limited:did", hash); + return { status: "rate_limited", recipientHash: hash, scope: "did" }; + } + + // Atomic claim: suppression + lifetime "mail once ever" cap + concurrency, all + // in one INSERT...SELECT (see claimSend). A false means one of those fired. + const id = newNotificationId(); + const claimed = await claimSend( + ctx.db, + id, + request.source, + "confirmation", + hash, + email, + now.date, + ); + if (!claimed) { + // Suppression is pre-checked upstream, so a false here is almost always the + // lifetime cap. The cheap re-check only distinguishes a suppression that + // landed in the pre-check→claim window — an audit nicety, not correctness. + if (await isSuppressed(ctx.db, hash)) { + logSend(request.target.did, "suppressed", hash); + return { status: "suppressed", recipientHash: hash }; + } + logSend(request.target.did, "already_mailed", hash); + return { status: "already_mailed", recipientHash: hash }; + } + + const token = generateConfirmToken(); + const tokenHash = await hashConfirmToken(token); + const recorded = await recordConfirmSent(ctx.db, hash, tokenHash, now.ms); + if (!recorded) { + // The claim already enforced the lifetime cap and concurrency; a false here + // is only the contact leaving `unconfirmed` between the state read and this + // stamp. Retire the claimed row rather than mail a dead-token confirmation. + // No ledger row was written, so the aborted mail consumes no per-DID budget. + await markRowUndeliverable(ctx.db, id, "contact_state_changed"); + logSend(request.target.did, "skipped:contact_state_changed", hash); + return { status: "skipped", recipientHash: hash, reason: "contact_state_changed" }; + } + + // Ledger only now — after a stamped token, before the send. A failed SEND still + // consumes budget (the conservative fail-safe), but an abort above does not. + await recordConfirmLedgerEntry(ctx.db, request.target.did, hash, now.ms); + + const result = await ctx.sender.sendConfirmation({ + to: email, + confirmUrl: buildActionUrl(ctx.origin, "confirm", hash, token), + unsubscribeUrl: buildActionUrl(ctx.origin, "unsubscribe", hash), + notMeUrl: buildActionUrl(ctx.origin, "not-me", hash), + }); + await finalizeSend(ctx.db, id, result, now.date); + if (result.ok) { + logSend(request.target.did, "confirmation_sent", hash); + return { status: "confirmation_sent", recipientHash: hash, providerId: result.providerId }; + } + logSend(request.target.did, "confirmation_failed", hash); + return { status: "confirmation_failed", recipientHash: hash, error: result.error }; +} + +/** + * Claim a send by inserting the pending delivery row, guarded atomically. The + * suppression guard (`WHERE NOT EXISTS (suppression)`) is on both kinds: a + * suppression committed before the claim blocks it. A `confirmation` claim adds a + * second guard enforcing the LIFETIME cap and the concurrency race in the same + * INSERT...SELECT — it inserts only if NO prior non-`undeliverable` confirmation + * row exists for this recipient hash, so an address is mailed a confirmation at + * most once ever, and of two racing sends exactly one inserts (the second's + * subquery sees the first's `pending` row). An `undeliverable` prior (a race + * loser or a suppressed-at-claim that never mailed) is excluded, so it can never + * block a later legitimate confirmation. Returns whether the row was inserted. + */ +async function claimSend( + db: D1Database, + id: string, + source: NotificationSource, + kind: NotificationKind, + hash: string, + email: string, + now: Date, +): Promise { + const columns = `(id, source_type, source_id, kind, channel, recipient_hash, state, attempts, + plaintext_email, created_at, created_at_epoch_ms)`; + const row = `SELECT ?, ?, ?, ?, 'email', ?, 'pending', 0, ?, ?, ?`; + const notSuppressed = `WHERE NOT EXISTS (SELECT 1 FROM notification_suppressions WHERE recipient_hash = ?)`; + const binds: (string | number)[] = [ + id, + source.type, + source.id, + kind, + hash, + email, + now.toISOString(), + now.getTime(), + hash, + ]; + + if (kind === "confirmation") { + const result = await db + .prepare( + `INSERT INTO notifications ${columns} ${row} ${notSuppressed} + AND NOT EXISTS ( + SELECT 1 FROM notifications + WHERE recipient_hash = ? AND kind = 'confirmation' AND state != 'undeliverable' + )`, + ) + .bind(...binds, hash) + .run(); + return result.meta.changes > 0; + } + + const result = await db + .prepare(`INSERT INTO notifications ${columns} ${row} ${notSuppressed}`) + .bind(...binds) + .run(); + return result.meta.changes > 0; +} + +/** Flip a claimed row to its terminal state. On success: `sent`, `provider_id` + * and `sent_at` set, `plaintext_email` CLEARED. On failure: `failed`, + * `last_error` recorded, `attempts` bumped, plaintext retained for the retry. + * The `AND state = 'pending'` guard makes this a one-shot transition — it can + * never overwrite an already-terminal row (belt-and-braces against a + * double-finalize). */ +async function finalizeSend( + db: D1Database, + id: string, + result: SendResult, + now: Date, +): Promise { + if (result.ok) { + await db + .prepare( + `UPDATE notifications + SET state = 'sent', provider_id = ?, sent_at = ?, plaintext_email = NULL, last_error = NULL + WHERE id = ? AND state = 'pending'`, + ) + .bind(result.providerId ?? null, now.toISOString(), id) + .run(); + return; + } + await db + .prepare( + `UPDATE notifications + SET state = 'failed', last_error = ?, attempts = attempts + 1 + WHERE id = ? AND state = 'pending'`, + ) + .bind(truncateError(result.error), id) + .run(); +} + +/** Insert a terminal `undeliverable` audit row for a send that was never + * attempted (no contact, suppressed, declined). Holds no plaintext — nothing + * will be sent. `recipient_hash` is NULL only for the no-contact case. */ +async function recordUndeliverable( + db: D1Database, + source: NotificationSource, + kind: NotificationKind, + hash: string | null, + reason: string, + now: Date, +): Promise { + await db + .prepare( + `INSERT INTO notifications + (id, source_type, source_id, kind, channel, recipient_hash, state, attempts, + last_error, created_at, created_at_epoch_ms) + VALUES (?, ?, ?, ?, 'email', ?, 'undeliverable', 0, ?, ?, ?)`, + ) + .bind( + newNotificationId(), + source.type, + source.id, + kind, + hash, + reason, + now.toISOString(), + now.getTime(), + ) + .run(); +} + +/** Flip an already-claimed pending row to `undeliverable`, clearing plaintext. + * Used when the contact left `unconfirmed` in the race after the claim. Leaves + * `sent_at` NULL — the mail was never handed to a provider. */ +async function markRowUndeliverable(db: D1Database, id: string, reason: string): Promise { + await db + .prepare( + `UPDATE notifications + SET state = 'undeliverable', last_error = ?, plaintext_email = NULL + WHERE id = ? AND state = 'pending'`, + ) + .bind(reason, id) + .run(); +} + +/** Distinct recipients this DID has sent confirmation mail to since `sinceMs` — + * the per-DID rate-limit read (counts victims, not repeats). */ +async function countDistinctConfirmRecipients( + db: D1Database, + did: string, + sinceMs: number, +): Promise { + const row = await db + .prepare( + `SELECT COUNT(DISTINCT recipient_hash) AS n FROM notification_confirm_ledger + WHERE publisher_did = ? AND sent_at_epoch_ms >= ?`, + ) + .bind(did, sinceMs) + .first<{ n: number }>(); + return row?.n ?? 0; +} + +async function recordConfirmLedgerEntry( + db: D1Database, + did: string, + hash: string, + epochMs: number, +): Promise { + await db + .prepare( + `INSERT INTO notification_confirm_ledger (id, publisher_did, recipient_hash, sent_at_epoch_ms) + VALUES (?, ?, ?, ?)`, + ) + .bind(`ncl_${ulid()}`, did, hash, epochMs) + .run(); +} + +function buildActionUrl( + origin: string, + action: "confirm" | "unsubscribe" | "not-me", + hash: string, + token?: string, +): string { + const url = new URL(`/notifications/${action}`, origin); + url.searchParams.set("c", hash); + if (token !== undefined) url.searchParams.set("t", token); + return url.toString(); +} + +function newNotificationId(): string { + return `ntf_${ulid()}`; +} + +function truncateError(error: string): string { + return error.length > LAST_ERROR_MAX ? error.slice(0, LAST_ERROR_MAX) : error; +} + +function logSend(did: string, outcome: string, hash: string | undefined): void { + console.log("[notifications]", { + action: "send", + outcome, + did, + hashPrefix: hash?.slice(0, 8), + }); +} diff --git a/apps/labeler/test/notification-contacts.test.ts b/apps/labeler/test/notification-contacts.test.ts index 721c1621cb..84636215fd 100644 --- a/apps/labeler/test/notification-contacts.test.ts +++ b/apps/labeler/test/notification-contacts.test.ts @@ -7,7 +7,9 @@ import { type ContactState, declineContact, ensureContact, + generateConfirmToken, getContactState, + hashConfirmToken, isSuppressed, recipientHash, recordConfirmSent, @@ -309,6 +311,32 @@ describe("canSendConfirm", () => { }); }); +describe("generateConfirmToken", () => { + it("emits base64url with at least 128 bits of entropy", () => { + const token = generateConfirmToken(); + expect(token).toMatch(/^[A-Za-z0-9_-]+$/); + // base64url has no padding; >=22 chars is >=128 bits, and this token is 32 bytes. + expect(token.length).toBeGreaterThanOrEqual(22); + expect(token).not.toContain("="); + }); + + it("is unique across draws (CSPRNG, not monotonic)", () => { + const tokens = new Set(Array.from({ length: 500 }, () => generateConfirmToken())); + expect(tokens.size).toBe(500); + }); + + it("survives the confirm round-trip: the send-path hash confirms against the endpoint re-hash", async () => { + const hash = await freshHash(); + const token = generateConfirmToken(); + await ensureContact(db(), hash, "2026-07-16T00:00:00.000Z"); + // Send path stores SHA-256(token); the raw token travels in the link. + await recordConfirmSent(db(), hash, await hashConfirmToken(token), 1_000); + // Confirm endpoint re-hashes the raw token from the link before comparing. + const presentedHash = await hashConfirmToken(token); + expect(await confirmContact(db(), hash, presentedHash, "2026-07-16T02:00:00.000Z")).toBe(true); + }); +}); + describe("schema", () => { it("stores no plaintext email column on either table", async () => { const contactColumns = await columnNames("notification_contacts"); diff --git a/apps/labeler/test/notification-send.test.ts b/apps/labeler/test/notification-send.test.ts new file mode 100644 index 0000000000..0975d79dea --- /dev/null +++ b/apps/labeler/test/notification-send.test.ts @@ -0,0 +1,609 @@ +import { applyD1Migrations, env } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { AggregatorClient } from "../src/aggregator-client.js"; +import { CONFIRM_DID_MAX_DISTINCT_RECIPIENTS } from "../src/constants.js"; +import { + confirmContact, + declineContact, + ensureContact, + hashConfirmToken, + recipientHash, + recordConfirmSent, + suppress, +} from "../src/notification-contacts.js"; +import type { + ConfirmationPayload, + NoticePayload, + NotificationRequest, + NotificationSender, + SendContext, + SendResult, +} from "../src/notification-send.js"; +import { sendNotification } from "../src/notification-send.js"; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; +} + +const testEnv = env as unknown as TestEnv; +const db = () => testEnv.DB; + +const PEPPER = "send-pepper"; +const ORIGIN = "https://labels.example"; + +let counter = 0; +function uniq(prefix: string): string { + counter++; + return `${prefix}-${counter}`; +} + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); +}); + +/** An AggregatorClient whose publisher-profile read carries `email` as a + * security-kind tier-3 contact (package reads 404, so tiers 1-2 miss). A null + * `email` makes both reads 404 — the no-resolvable-contact case. */ +function aggregatorFor(email: string | null): AggregatorClient { + const notFound = () => + new Response(JSON.stringify({ error: "NotFound" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + const fetcher = { + // AggregatorClient always calls fetch with a string URL (see aggregator-client.ts). + fetch: async (url: string) => { + if (url.includes("getPublisher") && !url.includes("getPublisherVerification") && email) { + return Response.json({ + did: "did:plc:stub", + profile: { contact: [{ kind: "security", email }] }, + }); + } + return notFound(); + }, + } as unknown as Fetcher; + return new AggregatorClient(fetcher); +} + +interface RecordingSender extends NotificationSender { + confirmations: ConfirmationPayload[]; + notices: NoticePayload[]; +} + +function recordingSender(result: SendResult = { ok: true, providerId: "prov-1" }): RecordingSender { + const confirmations: ConfirmationPayload[] = []; + const notices: NoticePayload[] = []; + return { + confirmations, + notices, + sendConfirmation: async (payload) => { + confirmations.push(payload); + return result; + }, + sendNotice: async (payload) => { + notices.push(payload); + return result; + }, + }; +} + +function context(email: string | null, sender: RecordingSender, now?: () => Date): SendContext { + return { + db: db(), + aggregator: aggregatorFor(email), + pepper: PEPPER, + sender, + origin: ORIGIN, + now, + }; +} + +/** + * Wraps a D1Database so that `flip` runs exactly once, during the per-DID + * distinct-recipient count — which the send path runs AFTER reading the contact + * state but BEFORE stamping the token. That is the deterministic seam for the + * contact_state_changed abort: a fake sender / aggregator fires too early or too + * late, so a mid-query DB side effect is the only way to flip the contact at that + * exact point. Only `prepare` and the count statement's `first` are intercepted; + * everything else delegates to the real database. + */ +function dbFlippingContactDuringCount(real: D1Database, flip: () => Promise): D1Database { + let fired = false; + const wrapStatement = (stmt: D1PreparedStatement, isTarget: boolean): D1PreparedStatement => + new Proxy(stmt, { + get(target, prop, receiver) { + if (prop === "bind") { + return (...args: unknown[]) => + wrapStatement( + (target.bind as (...a: unknown[]) => D1PreparedStatement)(...args), + isTarget, + ); + } + if (prop === "first" && isTarget) { + return async (...args: unknown[]) => { + if (!fired) { + fired = true; + await flip(); + } + return (target.first as (...a: unknown[]) => unknown)(...args); + }; + } + const value = Reflect.get(target, prop, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + return new Proxy(real, { + get(target, prop, receiver) { + if (prop === "prepare") { + return (query: string) => + wrapStatement(target.prepare(query), query.includes("COUNT(DISTINCT recipient_hash)")); + } + const value = Reflect.get(target, prop, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + +function request(did: string, sourceId: string): NotificationRequest { + return { + source: { type: "issuance", id: sourceId }, + target: { did, slug: "pkg" }, + notice: { + subject: "Your package was blocked", + publicSummary: "public summary", + assessmentUrl: "https://labels.example/a/1", + effect: "blocked", + reconsiderationUrl: "https://labels.example/reconsider", + }, + }; +} + +interface NotificationRow { + id: string; + kind: string; + state: string; + recipient_hash: string | null; + attempts: number; + provider_id: string | null; + last_error: string | null; + plaintext_email: string | null; + sent_at: string | null; +} + +async function rowsForSource(sourceId: string): Promise { + const result = await db() + .prepare(`SELECT * FROM notifications WHERE source_id = ? ORDER BY created_at_epoch_ms`) + .bind(sourceId) + .all(); + return result.results ?? []; +} + +async function ledgerCount(did: string): Promise { + const row = await db() + .prepare(`SELECT COUNT(*) AS n FROM notification_confirm_ledger WHERE publisher_did = ?`) + .bind(did) + .first<{ n: number }>(); + return row?.n ?? 0; +} + +async function storedTokenHash(hash: string): Promise { + const row = await db() + .prepare(`SELECT confirm_token_hash FROM notification_contacts WHERE recipient_hash = ?`) + .bind(hash) + .first<{ confirm_token_hash: string | null }>(); + return row?.confirm_token_hash ?? null; +} + +async function seedConfirmed(hash: string): Promise { + await ensureContact(db(), hash, "2026-07-16T00:00:00.000Z"); + const tokenHash = await hashConfirmToken("seed-token"); + await recordConfirmSent(db(), hash, tokenHash, 1_000); + await confirmContact(db(), hash, tokenHash, "2026-07-16T00:00:01.000Z"); +} + +describe("confirmed contact", () => { + it("sends a substantive notice, marks it sent, and clears plaintext", async () => { + const email = uniq("confirmed") + "@example.test"; + const hash = await recipientHash(PEPPER, email); + await seedConfirmed(hash); + const sender = recordingSender({ ok: true, providerId: "prov-notice" }); + const sourceId = uniq("src"); + + const outcome = await sendNotification(context(email, sender), request("did:plc:a", sourceId)); + + expect(outcome).toEqual({ + status: "notice_sent", + recipientHash: hash, + providerId: "prov-notice", + }); + expect(sender.notices).toHaveLength(1); + expect(sender.confirmations).toHaveLength(0); + expect(sender.notices[0]).toMatchObject({ + to: email, + subject: "Your package was blocked", + effect: "blocked", + assessmentUrl: "https://labels.example/a/1", + reconsiderationUrl: "https://labels.example/reconsider", + }); + expect(sender.notices[0]?.unsubscribeUrl).toBe( + `https://labels.example/notifications/unsubscribe?c=${hash}`, + ); + + const rows = await rowsForSource(sourceId); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + kind: "notice", + state: "sent", + recipient_hash: hash, + provider_id: "prov-notice", + plaintext_email: null, + attempts: 0, + }); + expect(rows[0]?.sent_at).not.toBeNull(); + }); + + it("records failed with attempts bumped and plaintext retained on a send failure", async () => { + const email = uniq("confirmedfail") + "@example.test"; + const hash = await recipientHash(PEPPER, email); + await seedConfirmed(hash); + const sender = recordingSender({ ok: false, error: "smtp down" }); + const sourceId = uniq("src"); + + const outcome = await sendNotification(context(email, sender), request("did:plc:a", sourceId)); + + expect(outcome).toEqual({ status: "notice_failed", recipientHash: hash, error: "smtp down" }); + const rows = await rowsForSource(sourceId); + expect(rows[0]).toMatchObject({ + kind: "notice", + state: "failed", + last_error: "smtp down", + attempts: 1, + plaintext_email: email, + }); + }); + + it("sends nothing to a confirmed but suppressed contact", async () => { + const email = uniq("confsupp") + "@example.test"; + const hash = await recipientHash(PEPPER, email); + await seedConfirmed(hash); + await suppress(db(), hash, "bounce", "2026-07-16T00:00:00.000Z", 1_000); + const sender = recordingSender(); + const sourceId = uniq("src"); + + const outcome = await sendNotification(context(email, sender), request("did:plc:a", sourceId)); + + expect(outcome).toEqual({ status: "suppressed", recipientHash: hash }); + expect(sender.notices).toHaveLength(0); + const rows = await rowsForSource(sourceId); + expect(rows[0]).toMatchObject({ state: "undeliverable", last_error: "suppressed" }); + }); +}); + +describe("unconfirmed contact — double opt-in", () => { + it("sends a confirmation mail, persisting only the token hash", async () => { + const email = uniq("unconf") + "@example.test"; + const hash = await recipientHash(PEPPER, email); + const did = uniq("did:plc"); + const sender = recordingSender({ ok: true, providerId: "prov-confirm" }); + const sourceId = uniq("src"); + + const outcome = await sendNotification(context(email, sender), request(did, sourceId)); + + expect(outcome).toEqual({ + status: "confirmation_sent", + recipientHash: hash, + providerId: "prov-confirm", + }); + expect(sender.confirmations).toHaveLength(1); + expect(sender.notices).toHaveLength(0); + + const confirmUrl = new URL(sender.confirmations[0]!.confirmUrl); + expect(confirmUrl.origin + confirmUrl.pathname).toBe( + "https://labels.example/notifications/confirm", + ); + expect(confirmUrl.searchParams.get("c")).toBe(hash); + const rawToken = confirmUrl.searchParams.get("t") ?? ""; + + // CSPRNG base64url, >=128 bits (>=22 chars); matches the confirm endpoint's token grammar. + expect(rawToken).toMatch(/^[A-Za-z0-9_-]+$/); + expect(rawToken.length).toBeGreaterThanOrEqual(22); + + // The stored hash is SHA-256(rawToken); the raw token is nowhere in the DB. + expect(await storedTokenHash(hash)).toBe(await hashConfirmToken(rawToken)); + const rows = await rowsForSource(sourceId); + expect(rows[0]).toMatchObject({ + kind: "confirmation", + state: "sent", + recipient_hash: hash, + provider_id: "prov-confirm", + plaintext_email: null, + }); + for (const row of rows) { + expect(row.plaintext_email).not.toBe(rawToken); + expect(row.last_error).not.toBe(rawToken); + } + expect(sender.confirmations[0]).toMatchObject({ + to: email, + unsubscribeUrl: `https://labels.example/notifications/unsubscribe?c=${hash}`, + notMeUrl: `https://labels.example/notifications/not-me?c=${hash}`, + }); + expect(await ledgerCount(did)).toBe(1); + }); + + it("records confirmation failure with plaintext retained (token still recorded)", async () => { + const email = uniq("unconffail") + "@example.test"; + const hash = await recipientHash(PEPPER, email); + const sender = recordingSender({ ok: false, error: "provider 500" }); + const sourceId = uniq("src"); + + const outcome = await sendNotification(context(email, sender), request(uniq("did"), sourceId)); + + expect(outcome).toEqual({ + status: "confirmation_failed", + recipientHash: hash, + error: "provider 500", + }); + const rows = await rowsForSource(sourceId); + expect(rows[0]).toMatchObject({ + kind: "confirmation", + state: "failed", + last_error: "provider 500", + attempts: 1, + plaintext_email: email, + }); + expect(await storedTokenHash(hash)).not.toBeNull(); + }); + + it("mails an address a confirmation at most once ever (lifetime cap)", async () => { + const email = uniq("lifetime") + "@example.test"; + const hash = await recipientHash(PEPPER, email); + const did = uniq("did:plc:life"); + const sender = recordingSender({ ok: true, providerId: "p1" }); + + const first = await sendNotification( + context(email, sender, () => new Date(1_000_000)), + request(did, uniq("src")), + ); + expect(first.status).toBe("confirmation_sent"); + expect(sender.confirmations).toHaveLength(1); + + // Arbitrary time later, a fresh trigger for the still-unconfirmed contact + // mails nothing and creates no row. + const laterSourceId = uniq("src"); + const second = await sendNotification( + context(email, sender, () => new Date(999_000_000)), + request(did, laterSourceId), + ); + expect(second).toEqual({ status: "already_mailed", recipientHash: hash }); + expect(sender.confirmations).toHaveLength(1); + expect(await rowsForSource(laterSourceId)).toHaveLength(0); + }); + + it("does not re-mail after a FAILED confirmation (the failed row still counts)", async () => { + const email = uniq("lifefail") + "@example.test"; + const hash = await recipientHash(PEPPER, email); + const did = uniq("did:plc:lifefail"); + + const failSender = recordingSender({ ok: false, error: "boom" }); + const first = await sendNotification( + context(email, failSender, () => new Date(1_000_000)), + request(did, uniq("src")), + ); + expect(first.status).toBe("confirmation_failed"); + + const okSender = recordingSender({ ok: true, providerId: "p" }); + const laterSourceId = uniq("src"); + const second = await sendNotification( + context(email, okSender, () => new Date(2_000_000)), + request(did, laterSourceId), + ); + expect(second).toEqual({ status: "already_mailed", recipientHash: hash }); + expect(okSender.confirmations).toHaveLength(0); + expect(await rowsForSource(laterSourceId)).toHaveLength(0); + }); + + it("still mails when the only prior confirmation row is undeliverable", async () => { + const email = uniq("undelprior") + "@example.test"; + const hash = await recipientHash(PEPPER, email); + const did = uniq("did:plc:undel"); + // A prior undeliverable confirmation row (e.g. a race loser or a + // suppressed-at-claim that never actually mailed) must not block a later send. + await db() + .prepare( + `INSERT INTO notifications + (id, source_type, source_id, kind, channel, recipient_hash, state, attempts, + created_at, created_at_epoch_ms) + VALUES (?, 'issuance', ?, 'confirmation', 'email', ?, 'undeliverable', 0, ?, ?)`, + ) + .bind(uniq("ntf_prior"), uniq("src"), hash, "2026-07-16T00:00:00.000Z", 1_000) + .run(); + const sender = recordingSender({ ok: true, providerId: "p" }); + const sourceId = uniq("src"); + + const outcome = await sendNotification( + context(email, sender, () => new Date(3_000_000)), + request(did, sourceId), + ); + + expect(outcome.status).toBe("confirmation_sent"); + expect(sender.confirmations).toHaveLength(1); + }); + + it("rate-limits per DID once the distinct-recipient cap is reached", async () => { + const did = uniq("did:plc:bulk"); + const nowMs = 5_000_000; + for (let i = 0; i < CONFIRM_DID_MAX_DISTINCT_RECIPIENTS; i++) { + await db() + .prepare( + `INSERT INTO notification_confirm_ledger (id, publisher_did, recipient_hash, sent_at_epoch_ms) + VALUES (?, ?, ?, ?)`, + ) + .bind(`ncl_bulk_${i}`, did, `victimhash${i}`, nowMs) + .run(); + } + const email = uniq("victim") + "@example.test"; + const hash = await recipientHash(PEPPER, email); + const sender = recordingSender(); + const sourceId = uniq("src"); + + const outcome = await sendNotification( + context(email, sender, () => new Date(nowMs)), + request(did, sourceId), + ); + + expect(outcome).toEqual({ status: "rate_limited", recipientHash: hash, scope: "did" }); + expect(sender.confirmations).toHaveLength(0); + expect(await rowsForSource(sourceId)).toHaveLength(0); + }); + + it("allows a confirmation when the DID is just below the cap", async () => { + const did = uniq("did:plc:justunder"); + const nowMs = 6_000_000; + for (let i = 0; i < CONFIRM_DID_MAX_DISTINCT_RECIPIENTS - 1; i++) { + await db() + .prepare( + `INSERT INTO notification_confirm_ledger (id, publisher_did, recipient_hash, sent_at_epoch_ms) + VALUES (?, ?, ?, ?)`, + ) + .bind(`ncl_under_${i}`, did, `underhash${i}`, nowMs) + .run(); + } + const email = uniq("under") + "@example.test"; + const sender = recordingSender(); + const sourceId = uniq("src"); + + const outcome = await sendNotification( + context(email, sender, () => new Date(nowMs)), + request(did, sourceId), + ); + + expect(outcome.status).toBe("confirmation_sent"); + expect(sender.confirmations).toHaveLength(1); + expect(await ledgerCount(did)).toBe(CONFIRM_DID_MAX_DISTINCT_RECIPIENTS); + }); + + it("mails a racing unconfirmed victim exactly once (atomic claim closes the double-mail race)", async () => { + const email = uniq("race") + "@example.test"; + const sender = recordingSender({ ok: true, providerId: "p" }); + // One shared context + request dispatched twice concurrently: both reads see + // the fresh unconfirmed contact. Only the atomic confirmation claim stops the + // second mail — its lifetime-cap NOT EXISTS sees the first's pending row, so + // the loser inserts no row at all. + const ctx = context(email, sender, () => new Date(9_000_000)); + const sourceId = uniq("src"); + const req = request(uniq("did:plc:race"), sourceId); + + const outcomes = await Promise.all([sendNotification(ctx, req), sendNotification(ctx, req)]); + + expect(outcomes.map((o) => o.status).toSorted()).toEqual([ + "already_mailed", + "confirmation_sent", + ]); + expect(sender.confirmations).toHaveLength(1); + + const rows = await rowsForSource(sourceId); + expect(rows).toHaveLength(1); + expect(rows[0]?.state).toBe("sent"); + }); + + it("aborts with no ledger row when the contact leaves unconfirmed mid-send", async () => { + const email = uniq("midflip") + "@example.test"; + const hash = await recipientHash(PEPPER, email); + const did = uniq("did:plc:midflip"); + const sender = recordingSender(); + const sourceId = uniq("src"); + + // Flip the contact to declined during the per-DID count — after the state read, + // before the token stamp — so recordConfirmSent aborts the send. + const ctx: SendContext = { + db: dbFlippingContactDuringCount(db(), async () => { + await declineContact(db(), hash); + }), + aggregator: aggregatorFor(email), + pepper: PEPPER, + sender, + origin: ORIGIN, + now: () => new Date(4_000_000), + }; + + const outcome = await sendNotification(ctx, request(did, sourceId)); + + expect(outcome).toEqual({ + status: "skipped", + recipientHash: hash, + reason: "contact_state_changed", + }); + expect(sender.confirmations).toHaveLength(0); + // The aborted mail consumed no per-DID budget... + expect(await ledgerCount(did)).toBe(0); + // ...and the claimed row is retired undeliverable, with sent_at left NULL. + const rows = await rowsForSource(sourceId); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + kind: "confirmation", + state: "undeliverable", + last_error: "contact_state_changed", + }); + expect(rows[0]?.sent_at).toBeNull(); + }); +}); + +describe("declined / suppressed / no-contact", () => { + it("sends nothing to a declined contact (never gates on seeded:true)", async () => { + const email = uniq("declined") + "@example.test"; + const hash = await recipientHash(PEPPER, email); + await ensureContact(db(), hash, "2026-07-16T00:00:00.000Z"); + await declineContact(db(), hash); + const sender = recordingSender(); + const sourceId = uniq("src"); + + const outcome = await sendNotification(context(email, sender), request("did:plc:a", sourceId)); + + expect(outcome).toEqual({ status: "declined", recipientHash: hash }); + expect(sender.confirmations).toHaveLength(0); + expect(sender.notices).toHaveLength(0); + const rows = await rowsForSource(sourceId); + expect(rows[0]).toMatchObject({ + kind: "notice", + state: "undeliverable", + last_error: "declined", + recipient_hash: hash, + plaintext_email: null, + }); + }); + + it("sends nothing to a suppressed contact and records undeliverable", async () => { + const email = uniq("suppressed") + "@example.test"; + const hash = await recipientHash(PEPPER, email); + await suppress(db(), hash, "unsubscribe", "2026-07-16T00:00:00.000Z", 1_000); + const sender = recordingSender(); + const sourceId = uniq("src"); + + const outcome = await sendNotification(context(email, sender), request("did:plc:a", sourceId)); + + expect(outcome).toEqual({ status: "suppressed", recipientHash: hash }); + expect(sender.confirmations).toHaveLength(0); + const rows = await rowsForSource(sourceId); + expect(rows[0]).toMatchObject({ state: "undeliverable", last_error: "suppressed" }); + }); + + it("records an undeliverable row with a null recipient hash when no contact resolves", async () => { + const sender = recordingSender(); + const sourceId = uniq("src"); + + const outcome = await sendNotification(context(null, sender), request("did:plc:a", sourceId)); + + expect(outcome).toEqual({ status: "no_contact" }); + expect(sender.confirmations).toHaveLength(0); + expect(sender.notices).toHaveLength(0); + const rows = await rowsForSource(sourceId); + expect(rows[0]).toMatchObject({ + kind: "notice", + state: "undeliverable", + recipient_hash: null, + last_error: "no_email_contact", + }); + }); +}); From 7d4682caa37885e42b0d48b8a5a86d92e3674ee5 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 18:16:25 +0100 Subject: [PATCH 096/137] feat(labeler): email adapter, issuance triggers, and retry sweep (W10.5 slice 2) (#2077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(labeler): email adapter, issuance triggers, and retry sweep (W10.5 slice 2) Cloudflare Email Sending adapter + the five ratified notification triggers + the cron retry sweep, completing the publisher-notification subsystem on top of slice 1's gated send-core. CloudflareEmailSender (notification-email.ts) implements NotificationSender over the unrestricted `send_email` binding (arbitrary external recipients): structured env.EMAIL.send() with html+text bodies, List-Unsubscribe + one-click headers on every message, from-address from a var. Thrown coded errors map to SendResult CODE ONLY (never the provider message or payload) — E_RECIPIENT_SUPPRESSED carries a `suppress: 'bounce'` discriminant the send-core turns into our own suppression + an undeliverable, never-retried row; other codes are retryable. Triggers (notification-triggers.ts) fire post-commit from the deferred tail at the five events — automated block/warning (assessment-workflow), reviewer label issue/retract, override, override-retract, and emergency takedown (console-mutation) — building a public-safe NoticeContent and calling sendNotification. A notification failure never touches the label: every entry point swallows and logs. Per-source dedup (a source already carrying a notifications row is skipped) plus an atomic per-source guard on the notice claim give the "one notice per action" invariant even under a replay race. Verified publishers (in-force verification claim, reused from history-context's expiry logic) skip double opt-in via a token-less confirm; the verification read fails CLOSED to the confirmation-mail path, and suppressed/declined addresses get nothing regardless. Cron sweep (notification-sweep.ts) re-drives `failed` and crash-stuck `pending` rows (attempts-capped via a CAS claim), abandoning the exhausted to `undeliverable` — which clears plaintext and reopens a confirmation's lifetime cap so the channel is never silently foreclosed. Confirmation retries mint a FRESH token (only the hash was stored); notice retries re-render public content from source (no extra PII persisted). Terminal rows and stale ledger entries are pruned, but a sent confirmation row is kept to hold the lifetime cap. Tests: adapter (success/providerId, per-code mapping, suppression discriminant, List-Unsubscribe headers, no-payload-in-error), triggers (each of the five events, dedup, verified-skip in-force/expired/read-failure/suppressed, throw isolation), sweep (retry->sent, fresh-token stamp, cap exhaustion + reopen, stuck-pending, provider suppression, 30-day cleanup, ledger prune). vitest runs bindings locally (remoteBindings:false) so the unrestricted send_email binding never proxies to real Cloudflare. * fix(labeler): sweep suppression guard, suppress-before-flip, transient-read self-heal (W10.5 slice 2 review) Adversary-pass fixes: - F1 (HIGH): the retry sweep re-mailed suppressed addresses. `claimRetry` had no suppression guard and the row was never re-checked, so a confirmed publisher whose notice failed transiently and who then clicked Unsubscribe (leaving a `failed` row, since declineContact only touches unconfirmed contacts) got re-mailed on the next cron, up to the attempt cap. `sweepRow` now re-checks `isSuppressed` before the claim and ABANDONS the row (undeliverable + plaintext cleared) rather than skipping — a skip would leave the suppressed failed row as a candidate forever, since retention only prunes terminal rows. Covers notice and confirmation uniformly. - F2 (MEDIUM): both suppression paths flipped the row to `undeliverable` (which reopens a confirmation's lifetime cap) BEFORE recording the suppression, so a concurrent live claim in the D1 round-trip gap saw cap-open + suppression-absent and could send a second mail. `markProviderSuppressed` (send-core) and `markSuppressed` (sweep) now `suppress()` FIRST, then flip: before the suppress write the still-non-undeliverable row blocks a concurrent claim via the lifetime guard, after it via the suppression guard — no admitting window. A crash between the two is covered by F1's new isSuppressed→abandon check. - F3 (LOW): the sweep's `loadAssessmentSafe` blanket-caught every getAssessment error and abandoned the notice, so a transient D1 read permanently dropped a legitimate block/warning notice. It now returns null only for a `TypeError` (malformed id — never a stored source_id) and rethrows anything else, so the claimed row stays `pending` and self-heals on the next pass, matching the operator path. Tests: sweep does NOT mail a suppressed address (notice + confirmation) and retires the row undeliverable; a transient assessment read leaves the notice row pending (attempts bumped, not abandoned). * fix(labeler): notify-deps failure never blocks a console label action The console mutation path built notify deps with a bare `await createNotifyDeps(env)` before `handleConsoleMutation`, so a deps-construction failure (e.g. a missing NOTIFICATION_HASH_PEPPER or a rejecting binding) threw before the label mutation ran — violating the invariant that a notification concern never blocks or fails a label. The workflow and scheduled paths already guard this; the console path did not. Wrap construction in `safeCreateNotifyDeps`, which logs under the `[notifications]` prefix and returns `undefined` on failure. The mutation proceeds and issues the label; the defer helpers already no-op when `notify` is undefined, so notifications degrade rather than the label failing. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- apps/labeler/src/assessment-workflow.ts | 30 +- apps/labeler/src/console-mutation-api.ts | 55 ++ apps/labeler/src/constants.ts | 17 + apps/labeler/src/index.ts | 33 ++ apps/labeler/src/notification-contacts.ts | 26 + apps/labeler/src/notification-email.ts | 241 +++++++++ apps/labeler/src/notification-send.ts | 129 ++++- apps/labeler/src/notification-sweep.ts | 323 +++++++++++ apps/labeler/src/notification-triggers.ts | 483 +++++++++++++++++ apps/labeler/src/operator-actions.ts | 19 + .../labeler/test/console-mutation-api.test.ts | 51 ++ apps/labeler/test/notification-email.test.ts | 144 +++++ apps/labeler/test/notification-sweep.test.ts | 504 ++++++++++++++++++ .../test/notification-triggers.test.ts | 381 +++++++++++++ apps/labeler/vitest.config.ts | 7 + apps/labeler/worker-configuration.d.ts | 4 +- apps/labeler/wrangler.jsonc | 9 + 17 files changed, 2446 insertions(+), 10 deletions(-) create mode 100644 apps/labeler/src/notification-email.ts create mode 100644 apps/labeler/src/notification-sweep.ts create mode 100644 apps/labeler/src/notification-triggers.ts create mode 100644 apps/labeler/test/notification-email.test.ts create mode 100644 apps/labeler/test/notification-sweep.test.ts create mode 100644 apps/labeler/test/notification-triggers.test.ts diff --git a/apps/labeler/src/assessment-workflow.ts b/apps/labeler/src/assessment-workflow.ts index de108a1cf2..1c80229cef 100644 --- a/apps/labeler/src/assessment-workflow.ts +++ b/apps/labeler/src/assessment-workflow.ts @@ -33,8 +33,9 @@ import { stubStages, type OrchestratorStages, } from "./assessment-orchestrator.js"; -import { getAssessment } from "./assessment-store.js"; +import { getAssessment, type Assessment } from "./assessment-store.js"; import { getLabelerIdentityConfig } from "./config.js"; +import { createNotifyDeps, notifyAssessmentOutcome } from "./notification-triggers.js"; import { MODERATION_POLICY } from "./policy.js"; import { createRuntimeSigner, getRuntimeSigningSecret } from "./signing-runtime.js"; @@ -71,7 +72,12 @@ export async function executeAssessmentInstance( // attempt already finalized (its batch committed but the step result was // lost) — return that state. A `pending` or crash-left `running` row falls // through to `runAssessment`, which drives (or resumes) it to finalization. - if (TERMINAL_STATES.has(existing.state)) return existing.state; + // The notify runs on the resume path too (dedup makes it idempotent), so a + // crash between the label commit and the notify still delivers. + if (TERMINAL_STATES.has(existing.state)) { + await notifyOutcome(env, existing); + return existing.state; + } const config = await getLabelerIdentityConfig(env); const versioned = await createRuntimeSigner(config, getRuntimeSigningSecret(env)); @@ -83,9 +89,29 @@ export async function executeAssessmentInstance( stages: buildStages(), }); const finalized = await orchestrator.runAssessment(assessmentId); + await notifyOutcome(env, finalized); return finalized.state; } +/** + * Fire the automated block/warning publisher notice, best-effort. Reaching a + * `blocked`/`warned` terminal state means finalization's CAS committed the + * labels, so this is a true post-commit side effect; it never affects the run. + * `notifyAssessmentOutcome` swallows its own send errors and dedups on the + * assessment id, so a Workflow-step retry re-drives it harmlessly. + */ +async function notifyOutcome(env: Env, assessment: Assessment): Promise { + if (assessment.state !== "blocked" && assessment.state !== "warned") return; + try { + await notifyAssessmentOutcome(await createNotifyDeps(env), assessment); + } catch (error) { + console.error("[notifications] assessment notify failed", { + assessmentId: assessment.id, + error: error instanceof Error ? error.message : String(error), + }); + } +} + /** * The orchestrator's stage adapters, built per instance execution. Real * adapters attach here in the W7/W8 follow-on — acquire (via the aggregator diff --git a/apps/labeler/src/console-mutation-api.ts b/apps/labeler/src/console-mutation-api.ts index 85c1cec51a..f0ae55555b 100644 --- a/apps/labeler/src/console-mutation-api.ts +++ b/apps/labeler/src/console-mutation-api.ts @@ -45,6 +45,13 @@ import { type MutationSpec, type OperatorActionType, } from "./mutation-guard.js"; +import { + notifyEmergencyTakedown, + notifyOperatorLabel, + notifyOverride, + notifyOverrideRetract, + type NotifyDeps, +} from "./notification-triggers.js"; import { buildOperationalEventInsert, buildOutboxInsert, @@ -138,6 +145,11 @@ export interface ConsoleMutationDeps { * cannot join the D1 batch, so it runs in the deferred tail; the discovery * consumer's `runKey` dedup absorbs a duplicate re-drive. */ sendDiscoveryJob: (job: DiscoveryJob) => Promise; + /** Publisher-notification deps (plan W10.5). When present, a label-affecting + * operator action fires a post-commit publisher notice in the deferred tail + * (never blocking or failing the label). Omitted in tests that don't exercise + * notifications. */ + notify?: NotifyDeps; } /** @@ -244,6 +256,7 @@ async function runLabelMutation( const outcome = await guardMutation(request, spec, guardDeps); if (outcome.outcome === "replay") { await assertIssuancePersisted(deps.db, [outcome.actionId]); + deferLabelNotify(deps, storedDescriptor(outcome.result)); return jsonData(outcome.result); } @@ -284,6 +297,7 @@ async function runLabelMutation( const returned = await commitMutation(deps.db, ctx, spec, statements, descriptor); await assertIssuancePersisted(deps.db, [returned.actionId]); deps.defer(deps.afterCommit(ctx.actionId)); + deferLabelNotify(deps, returned); return jsonData(returned); } @@ -541,6 +555,41 @@ function deferPublishAll(deps: ConsoleMutationDeps, issuanceKeys: readonly strin deps.defer(Promise.all(issuanceKeys.map((key) => deps.afterCommit(key)))); } +// ─── W10.5: post-commit publisher notifications (deferred, never blocking) ──── +// Each fires only once the authoritative batch has committed. The trigger +// swallows its own errors and dedups on the operator action id, so a replay +// re-drives it harmlessly and a notification failure never touches the label. + +function deferLabelNotify(deps: ConsoleMutationDeps, d: IssuedLabelDescriptor): void { + if (!deps.notify) return; + deps.defer( + notifyOperatorLabel(deps.notify, { + actionId: d.actionId, + uri: d.uri, + ...(d.cid !== null ? { cid: d.cid } : {}), + val: d.val, + neg: d.neg, + }), + ); +} + +function deferOverrideNotify(deps: ConsoleMutationDeps, d: OverrideDescriptor): void { + if (!deps.notify) return; + deps.defer(notifyOverride(deps.notify, { actionId: d.actionId, uri: d.uri, cid: d.cid })); +} + +function deferOverrideRetractNotify(deps: ConsoleMutationDeps, d: OverrideRetractDescriptor): void { + if (!deps.notify) return; + deps.defer(notifyOverrideRetract(deps.notify, { actionId: d.actionId, uri: d.uri, cid: d.cid })); +} + +function deferTakedownNotify(deps: ConsoleMutationDeps, d: EmergencyDescriptor): void { + if (!deps.notify) return; + deps.defer( + notifyEmergencyTakedown(deps.notify, { actionId: d.actionId, uri: d.uri, neg: d.neg }), + ); +} + /** * `POST /admin/api/assessments/:id/rerun` — mints the immutable operator trigger * (`operator:`), creates a fresh run for the assessment's exact URI+CID @@ -681,6 +730,7 @@ async function runOverride( const keys = overrideIssuanceKeys(stored.actionId, stored.negated, stored.issued); await assertIssuancePersisted(deps.db, keys); deferPublishAll(deps, keys); + deferOverrideNotify(deps, stored); return jsonData(stored); } @@ -733,6 +783,7 @@ async function runOverride( const committedKeys = overrideIssuanceKeys(returned.actionId, returned.negated, returned.issued); await assertIssuancePersisted(deps.db, committedKeys); deferPublishAll(deps, committedKeys); + deferOverrideNotify(deps, returned); return jsonData(returned); } @@ -761,6 +812,7 @@ async function runOverrideRetract( const keys = stored.negated.map((val) => overridePieceKey(stored.actionId, val, true)); await assertIssuancePersisted(deps.db, keys); deferPublishAll(deps, keys); + deferOverrideRetractNotify(deps, stored); return jsonData(stored); } @@ -810,6 +862,7 @@ async function runOverrideRetract( ); await assertIssuancePersisted(deps.db, committedKeys); deferPublishAll(deps, committedKeys); + deferOverrideRetractNotify(deps, returned); return jsonData(returned); } @@ -922,6 +975,7 @@ async function runEmergencyAction( if (outcome.outcome === "replay") { const stored = storedDescriptor(outcome.result); await assertIssuancePersisted(deps.db, [stored.actionId]); + if (action === "takedown") deferTakedownNotify(deps, stored); return jsonData(stored); } @@ -988,6 +1042,7 @@ async function runEmergencyAction( ); await assertIssuancePersisted(deps.db, [returned.actionId]); deps.defer(deps.afterCommit(returned.actionId)); + if (action === "takedown") deferTakedownNotify(deps, returned); return jsonData(returned); } diff --git a/apps/labeler/src/constants.ts b/apps/labeler/src/constants.ts index c2f2a280bb..2dce10d4dc 100644 --- a/apps/labeler/src/constants.ts +++ b/apps/labeler/src/constants.ts @@ -23,3 +23,20 @@ export const WANTED_COLLECTIONS = [NSID.packageRelease] as const; export const CONFIRM_RATE_LIMIT_VERSION = 1; export const CONFIRM_DID_WINDOW_MS = 24 * 60 * 60 * 1000; export const CONFIRM_DID_MAX_DISTINCT_RECIPIENTS = 10; + +/** + * Delivery retry-sweep tunables (plan W10.5 slice 2). Versioned as a set: bump + * together if the retry posture is retuned. The 5-minute cron is the natural + * backoff interval — each pass re-drives every `failed` row (and every crashed + * `pending` row older than {@link NOTIFICATION_STUCK_PENDING_MS}) once, capped at + * {@link NOTIFICATION_MAX_SEND_ATTEMPTS} attempts before a row is abandoned to + * `undeliverable` (which clears plaintext and, for a confirmation, reopens the + * lifetime cap so the channel is not silently foreclosed). Terminal rows older + * than {@link NOTIFICATION_RETENTION_MS} are pruned; a `sent` confirmation row is + * kept (it holds the lifetime cap and carries no plaintext). + */ +export const NOTIFICATION_SWEEP_VERSION = 1; +export const NOTIFICATION_MAX_SEND_ATTEMPTS = 5; +export const NOTIFICATION_STUCK_PENDING_MS = 15 * 60 * 1000; +export const NOTIFICATION_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +export const NOTIFICATION_SWEEP_BATCH = 200; diff --git a/apps/labeler/src/index.ts b/apps/labeler/src/index.ts index a16201e725..d9c7bd0962 100644 --- a/apps/labeler/src/index.ts +++ b/apps/labeler/src/index.ts @@ -11,6 +11,8 @@ import { LABELER_DISCOVERY_DO_NAME } from "./discovery-do.js"; import type { DiscoveryJob } from "./env.js"; import { didDocumentResponse, policyDocumentResponse } from "./identity.js"; import { handleNotificationRequest, isNotificationPath } from "./notification-endpoints.js"; +import { runNotificationSweep } from "./notification-sweep.js"; +import { createNotifyDeps, type NotifyDeps } from "./notification-triggers.js"; import { queryLabels } from "./query-labels.js"; import { reconcileAssessments } from "./reconciliation.js"; import { createRuntimeSigner, getRuntimeSigningSecret } from "./signing-runtime.js"; @@ -107,6 +109,21 @@ export default { }); }), ); + + // Publisher-notification retry sweep (plan W10.5): re-drive failed / + // crash-stuck sends, abandon the exhausted, prune terminal rows. Isolated in + // its own branch so a sweep failure never disturbs the passes above. + ctx.waitUntil( + (async () => { + try { + await runNotificationSweep(await createNotifyDeps(env)); + } catch (err) { + console.error("[labeler] notification sweep failed", { + error: err instanceof Error ? err.message : String(err), + }); + } + })(), + ); }, }; @@ -125,6 +142,21 @@ function getOperatorAccessConfig(env: Env): AccessAuthConfig { return parsed; } +/** Build notify deps for the console mutation path without ever throwing: a + * failure (e.g. a missing hash pepper) degrades notifications to `undefined` so + * the label mutation still commits. Notifications are a post-commit side effect + * and must never block a label action. */ +export async function safeCreateNotifyDeps(env: Env): Promise { + try { + return await createNotifyDeps(env); + } catch (err) { + console.error("[notifications] notify deps unavailable; skipping notifications", { + error: err instanceof Error ? err.message : String(err), + }); + return undefined; + } +} + async function handleConsoleApiRequest( env: Env, request: Request, @@ -155,6 +187,7 @@ async function handleConsoleApiRequest( sendDiscoveryJob: async (job) => { await env.DISCOVERY_QUEUE.send(job); }, + notify: await safeCreateNotifyDeps(env), }); } return handleConsoleApi(request, { diff --git a/apps/labeler/src/notification-contacts.ts b/apps/labeler/src/notification-contacts.ts index 6ce69ea202..a484282611 100644 --- a/apps/labeler/src/notification-contacts.ts +++ b/apps/labeler/src/notification-contacts.ts @@ -246,6 +246,32 @@ export async function confirmContact( return result.meta.changes > 0; } +/** + * Flip an `unconfirmed` contact straight to `confirmed` without a token — the + * verified-publisher path (W10.5 slice 2). A publisher carrying an in-force + * verification claim skips double opt-in, so a substantive notice may go out + * directly; this records that skip as an explicit confirm-state write distinct + * from token confirmation (no token was ever issued or matched). Guarded on + * `confirm_state = 'unconfirmed'` so it never reopens a `declined` contact — a + * publisher who said "not me" stays declined regardless of verification. Returns + * whether a row was updated. + */ +export async function confirmContactVerified( + db: D1Database, + recipientHashValue: string, + nowIso: string, +): Promise { + const result = await db + .prepare( + `UPDATE notification_contacts + SET confirm_state = 'confirmed', confirm_token_hash = NULL, confirmed_at = ? + WHERE recipient_hash = ? AND confirm_state = 'unconfirmed'`, + ) + .bind(nowIso, recipientHashValue) + .run(); + return result.meta.changes > 0; +} + /** * Decline the pending confirmation, clearing any outstanding token. Only touches * an `unconfirmed` contact — a confirmed opt-in is never revoked here (that goes diff --git a/apps/labeler/src/notification-email.ts b/apps/labeler/src/notification-email.ts new file mode 100644 index 0000000000..d8c8e4df9a --- /dev/null +++ b/apps/labeler/src/notification-email.ts @@ -0,0 +1,241 @@ +/** + * Cloudflare Email Sending adapter for the publisher-notification subsystem + * (spec §18/§19, plan W10.5 slice 2). Implements the injected + * {@link NotificationSender} the gated send core (`notification-send.ts`) drives, + * turning a {@link ConfirmationPayload} / {@link NoticePayload} into a real + * message through the `send_email` binding. + * + * Transport: the structured `env.EMAIL.send({ to, from, subject, html, text, + * headers, replyTo })` API (no raw MIME) — multipart html+text is assembled by + * the platform. Arbitrary external recipients are permitted because the binding + * is declared without a destination restriction (`{"send_email":[{"name": + * "EMAIL"}]}`), which requires the sending domain to be onboarded for Email + * Sending. `from` is a var (`NOTIFICATION_FROM_ADDRESS`) on the onboarded domain, + * never hardcoded. + * + * Every message carries `List-Unsubscribe` + `List-Unsubscribe-Post` (Gmail/Yahoo + * bulk-sender one-click unsubscribe) pointed at the recipient's capability link. + * + * SECURITY: `send()` throws typed errors carrying a `.code`. The returned + * {@link SendResult} error string is the CODE ONLY — never the provider message, + * which could echo the recipient address, nor the payload (confirm URL / token / + * body). That string is persisted verbatim in `notifications.last_error`, so + * leaking a capability or PII into it would leak to the database. The account + * hard-bounce/complaint suppression (`E_RECIPIENT_SUPPRESSED`) maps to a + * `suppress: 'bounce'` discriminant so the orchestration layer records our own + * suppression and retires the row `undeliverable` — it is never retried. + */ + +import type { + ConfirmationPayload, + NoticePayload, + NotificationSender, + SendResult, +} from "./notification-send.js"; + +/** Cloudflare account-level hard-bounce / complaint suppression — terminal, must + * never be retried, and mapped to our own suppression ledger. */ +const RECIPIENT_SUPPRESSED = "E_RECIPIENT_SUPPRESSED"; + +/** Codes we recognise for logging clarity; any other code is passed through as + * a retryable failure. All are transient/quota except the terminal one above. */ +const KNOWN_CODES: ReadonlySet = new Set([ + RECIPIENT_SUPPRESSED, + "E_RATE_LIMIT_EXCEEDED", + "E_DAILY_LIMIT_EXCEEDED", + "E_DELIVERY_FAILED", + "E_INTERNAL_SERVER_ERROR", +]); + +export interface EmailSenderConfig { + /** The `from` address on the onboarded sending domain. */ + fromAddress: string; + /** Optional display name shown alongside `fromAddress`. */ + fromName?: string; + /** Optional `Reply-To` (the monitored reconsideration inbox), so publisher + * replies route to a human rather than bouncing off the send-only domain. */ + replyTo?: string; +} + +export class CloudflareEmailSender implements NotificationSender { + readonly #email: SendEmail; + readonly #config: EmailSenderConfig; + + constructor(email: SendEmail, config: EmailSenderConfig) { + this.#email = email; + this.#config = config; + } + + async sendConfirmation(payload: ConfirmationPayload): Promise { + const content = confirmationContent(payload); + return this.#deliver(payload.to, payload.unsubscribeUrl, content); + } + + async sendNotice(payload: NoticePayload): Promise { + const content = noticeContent(payload); + return this.#deliver(payload.to, payload.unsubscribeUrl, content); + } + + async #deliver( + to: string, + unsubscribeUrl: string, + content: { subject: string; html: string; text: string }, + ): Promise { + try { + const result = await this.#email.send({ + to, + from: + this.#config.fromName !== undefined + ? { name: this.#config.fromName, email: this.#config.fromAddress } + : this.#config.fromAddress, + ...(this.#config.replyTo !== undefined ? { replyTo: this.#config.replyTo } : {}), + subject: content.subject, + html: content.html, + text: content.text, + headers: { + "List-Unsubscribe": `<${unsubscribeUrl}>`, + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }, + }); + return { ok: true, providerId: result.messageId }; + } catch (error) { + return mapSendError(error); + } + } +} + +/** Map a thrown send error to a {@link SendResult}. Only the provider CODE is + * surfaced — never the message (which can echo the recipient) or the payload. */ +function mapSendError(error: unknown): SendResult { + const code = readErrorCode(error); + if (code === RECIPIENT_SUPPRESSED) { + return { ok: false, error: RECIPIENT_SUPPRESSED, suppress: "bounce" }; + } + if (code !== undefined && KNOWN_CODES.has(code)) return { ok: false, error: code }; + return { ok: false, error: code ?? "E_SEND_FAILED" }; +} + +function readErrorCode(error: unknown): string | undefined { + if (typeof error === "object" && error !== null && "code" in error) { + const { code } = error; + if (typeof code === "string") return code; + } + return undefined; +} + +interface RenderedEmail { + subject: string; + html: string; + text: string; +} + +/** + * Content-neutral double-opt-in confirmation (spec §18): it asks the recipient + * to confirm they should receive package-security notices, and carries NO + * assessment specifics — a third party named in hostile metadata must learn + * nothing about any subject from this mail. + */ +function confirmationContent(payload: ConfirmationPayload): RenderedEmail { + const subject = "Confirm emdash plugin security notices"; + const lines = [ + "The emdash plugin labeler sends security notices to the contact addresses published for plugins and publishers.", + "This address was listed as a contact. To receive those notices, confirm below. If you do nothing, you will not be emailed again.", + `Confirm: ${payload.confirmUrl}`, + `Not you, or don't want these? ${payload.notMeUrl}`, + `Unsubscribe: ${payload.unsubscribeUrl}`, + ]; + const text = lines.join("\n\n") + "\n"; + const html = wrapHtml( + subject, + [ + paragraph( + "The emdash plugin labeler sends security notices to the contact addresses published for plugins and publishers.", + ), + paragraph( + "This address was listed as a contact. To receive those notices, confirm below. If you do nothing, you will not be emailed again.", + ), + action("Confirm notifications", payload.confirmUrl), + paragraph( + `Not you, or don't want these? Report this address · Unsubscribe`, + true, + ), + ].join("\n"), + ); + return { subject, html, text }; +} + +/** + * Substantive notice (spec §18/§19). Carries only the public-safe fields the + * {@link NoticePayload} allows — subject, label effect, public summary, and the + * public assessment + reconsideration URLs. NO private evidence or findings; the + * payload type is the enforcement. + */ +function noticeContent(payload: NoticePayload): RenderedEmail { + const subject = payload.subject; + const lines = [ + payload.publicSummary, + `Effect: ${payload.effect}`, + `Public assessment: ${payload.assessmentUrl}`, + `Request reconsideration: ${payload.reconsiderationUrl}`, + `Unsubscribe: ${payload.unsubscribeUrl}`, + ]; + const text = lines.join("\n\n") + "\n"; + const html = wrapHtml( + subject, + [ + paragraph(escapeHtml(payload.publicSummary)), + paragraph(`Effect: ${escapeHtml(payload.effect)}`, true), + action("View the public assessment", payload.assessmentUrl), + paragraph( + `If you believe this is mistaken, request reconsideration.`, + true, + ), + paragraph( + `Unsubscribe from these notices`, + true, + ), + ].join("\n"), + ); + return { subject, html, text }; +} + +function wrapHtml(title: string, body: string): string { + return ` + + + + +${escapeHtml(title)} + + +
+${body} +
+ + +`; +} + +/** A paragraph. `trusted` skips escaping when the caller has already escaped the + * interpolated values and is supplying its own markup (links, ``). */ +function paragraph(content: string, trusted = false): string { + return `

${trusted ? content : escapeHtml(content)}

`; +} + +function action(label: string, url: string): string { + return `

${escapeHtml(label)}

`; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +/** Attribute-context escape for a URL placed in `href="..."`. */ +function escapeAttr(value: string): string { + return escapeHtml(value); +} diff --git a/apps/labeler/src/notification-send.ts b/apps/labeler/src/notification-send.ts index 31e52c3c62..63b7c740aa 100644 --- a/apps/labeler/src/notification-send.ts +++ b/apps/labeler/src/notification-send.ts @@ -44,6 +44,7 @@ import { ulid } from "ulidx"; import type { AggregatorClient } from "./aggregator-client.js"; import { CONFIRM_DID_MAX_DISTINCT_RECIPIENTS, CONFIRM_DID_WINDOW_MS } from "./constants.js"; import { + confirmContactVerified, ensureContact, generateConfirmToken, getContactState, @@ -51,6 +52,8 @@ import { isSuppressed, recipientHash, recordConfirmSent, + suppress, + type SuppressionReason, } from "./notification-contacts.js"; import type { ContactTarget } from "./publisher-contact.js"; import { resolvePublisherContact } from "./publisher-contact.js"; @@ -84,7 +87,25 @@ export interface NotificationRequest { notice: NoticeContent; } -export type SendResult = { ok: true; providerId?: string } | { ok: false; error: string }; +/** + * A send attempt's result. A transport failure is `{ ok: false }`, not a throw, + * so the caller records `failed` and lets the retry sweep pick it up. + * + * `suppress` is the terminal-bounce discriminant: a provider hard-bounce / + * complaint suppression (Cloudflare's `E_RECIPIENT_SUPPRESSED`) that the + * orchestration layer maps to our own {@link SuppressionReason} — the row is + * retired `undeliverable` (never retried) and the address is added to the + * suppression ledger, so our do-not-contact set learns what the provider knows. + * + * SECURITY (slice-2 contract): `error` is persisted verbatim in + * `notifications.last_error` and NEVER cleared. An implementation MUST NOT put + * the `confirmUrl`, raw confirm token, or email body into it — that would leak a + * single-use capability / PII to the database. Return the provider's status code + * only, never the payload it was given. + */ +export type SendResult = + | { ok: true; providerId?: string } + | { ok: false; error: string; suppress?: SuppressionReason }; export interface ConfirmationPayload { to: string; @@ -124,6 +145,17 @@ export interface SendContext { * `LABELER_SERVICE_URL` var), e.g. `https://labels.emdashcms.com`. The * confirm/unsubscribe/not-me links are built against it. */ origin: string; + /** + * When true, an `unconfirmed` contact for a VERIFIED publisher is upgraded to + * `confirmed` in place (a token-less confirm) and receives the substantive + * notice directly, skipping double opt-in (plan W10.5 slice 2). The caller + * computes this from the publisher's in-force verification claims and MUST fail + * closed — an unreadable verification state leaves it unset, so the address + * falls back to the confirmation-mail path. Suppressed / declined contacts are + * short-circuited before this flag is consulted, so verification never revives + * a do-not-contact address. + */ + verifiedPublisher?: boolean; now?: () => Date; } @@ -132,10 +164,12 @@ export type SendOutcome = | { status: "notice_failed"; recipientHash: string; error: string } | { status: "confirmation_sent"; recipientHash: string; providerId?: string } | { status: "confirmation_failed"; recipientHash: string; error: string } + | { status: "provider_suppressed"; recipientHash: string } | { status: "rate_limited"; recipientHash: string; scope: "did" } | { status: "suppressed"; recipientHash: string } | { status: "declined"; recipientHash: string } | { status: "already_mailed"; recipientHash: string } + | { status: "already_notified"; recipientHash: string } | { status: "skipped"; recipientHash: string; reason: "contact_state_changed" } | { status: "no_contact" }; @@ -192,6 +226,18 @@ export async function sendNotification( return sendSubstantiveNotice(ctx, request, hash, resolution.email, nowDate); } + // Verified-publisher skip: an in-force verification claim upgrades the + // unconfirmed contact to confirmed in place and delivers the substantive + // notice directly. The upgrade is guarded on `unconfirmed`, so a decline that + // raced the state read leaves it false and we fall back to double opt-in. + if (ctx.verifiedPublisher === true) { + const upgraded = await confirmContactVerified(ctx.db, hash, nowIso); + if (upgraded) { + logSend(request.target.did, "verified_skip", hash); + return sendSubstantiveNotice(ctx, request, hash, resolution.email, nowDate); + } + } + return sendConfirmationMail(ctx, request, hash, resolution.email, { date: nowDate, ms: nowMs, @@ -208,9 +254,20 @@ async function sendSubstantiveNotice( const id = newNotificationId(); const claimed = await claimSend(ctx.db, id, request.source, "notice", hash, email, now); if (!claimed) { - await recordUndeliverable(ctx.db, request.source, "notice", hash, "suppressed", now); - logSend(request.target.did, "suppressed", hash); - return { status: "suppressed", recipientHash: hash }; + // The notice claim guards on suppression AND per-source dedup (see + // claimSend). A suppression that landed before the claim is recorded as an + // undeliverable audit row; otherwise a non-undeliverable notice row already + // exists for this source — a concurrent duplicate of the same action — so + // nothing is written and no second mail goes out (the hard "one notice per + // action" invariant, closed atomically rather than by the trigger's + // check-then-act dedup). + if (await isSuppressed(ctx.db, hash)) { + await recordUndeliverable(ctx.db, request.source, "notice", hash, "suppressed", now); + logSend(request.target.did, "suppressed", hash); + return { status: "suppressed", recipientHash: hash }; + } + logSend(request.target.did, "already_notified", hash); + return { status: "already_notified", recipientHash: hash }; } const result = await ctx.sender.sendNotice({ @@ -218,6 +275,11 @@ async function sendSubstantiveNotice( to: email, unsubscribeUrl: buildActionUrl(ctx.origin, "unsubscribe", hash), }); + if (!result.ok && result.suppress !== undefined) { + await markProviderSuppressed(ctx.db, id, hash, result.suppress, now); + logSend(request.target.did, "provider_suppressed", hash); + return { status: "provider_suppressed", recipientHash: hash }; + } await finalizeSend(ctx.db, id, result, now); if (result.ok) { logSend(request.target.did, "notice_sent", hash); @@ -302,6 +364,14 @@ async function sendConfirmationMail( unsubscribeUrl: buildActionUrl(ctx.origin, "unsubscribe", hash), notMeUrl: buildActionUrl(ctx.origin, "not-me", hash), }); + if (!result.ok && result.suppress !== undefined) { + // A provider hard-bounce on the confirmation mail retires the row + // `undeliverable` (which reopens the lifetime cap) and records the + // suppression, so the address is never re-mailed. + await markProviderSuppressed(ctx.db, id, hash, result.suppress, now.date); + logSend(request.target.did, "provider_suppressed", hash); + return { status: "provider_suppressed", recipientHash: hash }; + } await finalizeSend(ctx.db, id, result, now.date); if (result.ok) { logSend(request.target.did, "confirmation_sent", hash); @@ -362,9 +432,22 @@ async function claimSend( return result.meta.changes > 0; } + // A `notice` claim adds a per-source dedup guard in the same INSERT...SELECT: + // it inserts only if NO non-`undeliverable` notice row already exists for this + // (source_type, source_id). One triggering action therefore mails at most one + // substantive notice even under concurrent duplicate triggers (an original and + // a racing replay), of which exactly one claims. An `undeliverable` prior (a + // suppressed-at-claim that never mailed) is excluded so it can't foreclose a + // later legitimate notice for the same source. const result = await db - .prepare(`INSERT INTO notifications ${columns} ${row} ${notSuppressed}`) - .bind(...binds) + .prepare( + `INSERT INTO notifications ${columns} ${row} ${notSuppressed} + AND NOT EXISTS ( + SELECT 1 FROM notifications + WHERE source_type = ? AND source_id = ? AND kind = 'notice' AND state != 'undeliverable' + )`, + ) + .bind(...binds, source.type, source.id) .run(); return result.meta.changes > 0; } @@ -447,6 +530,38 @@ async function markRowUndeliverable(db: D1Database, id: string, reason: string): .run(); } +/** + * Retire a claimed pending row after the provider reported the recipient is + * hard-suppressed (Cloudflare `E_RECIPIENT_SUPPRESSED`): the row goes + * `undeliverable` with plaintext cleared and is never retried, and the address is + * added to our suppression ledger so every future send short-circuits. For a + * confirmation row the undeliverable transition reopens the lifetime cap, but the + * fresh suppression row bars any re-mail — the address is off the list for good. + * `last_error` carries only the reason code, never the email payload. + */ +async function markProviderSuppressed( + db: D1Database, + id: string, + hash: string, + reason: SuppressionReason, + now: Date, +): Promise { + // Suppress FIRST, THEN retire the row (which reopens a confirmation's lifetime + // cap). Before the suppression write the row is still a non-`undeliverable` + // confirmation prior, so a concurrent live claim is blocked by the lifetime + // guard; after it, by the suppression guard — closing the window where both + // cap-open and suppression-absent would admit a second mail. + await suppress(db, hash, reason, now.toISOString(), now.getTime()); + await db + .prepare( + `UPDATE notifications + SET state = 'undeliverable', plaintext_email = NULL, last_error = ?, attempts = attempts + 1 + WHERE id = ? AND state = 'pending'`, + ) + .bind(`provider_suppressed:${reason}`, id) + .run(); +} + /** Distinct recipients this DID has sent confirmation mail to since `sinceMs` — * the per-DID rate-limit read (counts victims, not repeats). */ async function countDistinctConfirmRecipients( @@ -479,7 +594,7 @@ async function recordConfirmLedgerEntry( .run(); } -function buildActionUrl( +export function buildActionUrl( origin: string, action: "confirm" | "unsubscribe" | "not-me", hash: string, diff --git a/apps/labeler/src/notification-sweep.ts b/apps/labeler/src/notification-sweep.ts new file mode 100644 index 0000000000..18c19ce0af --- /dev/null +++ b/apps/labeler/src/notification-sweep.ts @@ -0,0 +1,323 @@ +/** + * Publisher-notification retry sweep (spec §18, plan W10.5 slice 2). A cron + * branch that keeps the delivery table live: it re-drives failed and + * crash-stuck sends, abandons the exhausted ones, and prunes terminal rows. + * + * The 5-minute cron is the backoff interval — each pass re-drives every `failed` + * row and every `pending` row older than {@link NOTIFICATION_STUCK_PENDING_MS} + * (crashed in-flight, contract c) exactly once, bumping `attempts` at the claim. + * A row that has reached {@link NOTIFICATION_MAX_SEND_ATTEMPTS} is abandoned to + * `undeliverable` with plaintext cleared. For a CONFIRMATION row abandonment + * REOPENS the lifetime cap (the claim excludes `undeliverable` priors), so a + * crashed or exhausted confirmation never silently forecloses the channel — the + * slice-1 contract (c) this sweep exists to honor. + * + * Re-render, not re-store (plaintext minimization): a confirmation retry mints a + * FRESH token (only the hash was ever stored — contract b) and rebuilds its + * links; a notice retry re-derives its public content from the source row + * (`resolveNoticeForSource`). The recipient address is read from the row's + * `plaintext_email`, the only place it lives. + * + * Every step is best-effort and self-contained: an error on one row is logged + * and the sweep continues, and a sweep failure never disturbs the other cron + * branches (the caller wraps it in its own `waitUntil`+catch). + */ + +import { + CONFIRM_DID_WINDOW_MS, + NOTIFICATION_MAX_SEND_ATTEMPTS, + NOTIFICATION_RETENTION_MS, + NOTIFICATION_STUCK_PENDING_MS, + NOTIFICATION_SWEEP_BATCH, +} from "./constants.js"; +import { + generateConfirmToken, + hashConfirmToken, + isSuppressed, + recordConfirmSent, + suppress, + type SuppressionReason, +} from "./notification-contacts.js"; +import { buildActionUrl, type SendResult } from "./notification-send.js"; +import { resolveNoticeForSource, type NotifyDeps } from "./notification-triggers.js"; + +interface SweepRow { + id: string; + kind: "confirmation" | "notice"; + source_type: string; + source_id: string; + recipient_hash: string | null; + plaintext_email: string | null; + attempts: number; + state: string; +} + +export interface SweepStats { + scanned: number; + sent: number; + failed: number; + abandoned: number; + suppressed: number; + skipped: number; + cleaned: number; + prunedLedger: number; +} + +/** + * One sweep pass. Re-drives retryable rows, abandons exhausted ones, then prunes + * terminal rows and stale confirm-ledger entries. + */ +export async function runNotificationSweep(deps: NotifyDeps): Promise { + const now = deps.now ?? (() => new Date()); + const nowMs = now().getTime(); + const stats: SweepStats = { + scanned: 0, + sent: 0, + failed: 0, + abandoned: 0, + suppressed: 0, + skipped: 0, + cleaned: 0, + prunedLedger: 0, + }; + + const stuckBefore = nowMs - NOTIFICATION_STUCK_PENDING_MS; + const candidates = await deps.db + .prepare( + `SELECT id, kind, source_type, source_id, recipient_hash, plaintext_email, attempts, state + FROM notifications + WHERE state = 'failed' OR (state = 'pending' AND created_at_epoch_ms < ?) + ORDER BY created_at_epoch_ms + LIMIT ?`, + ) + .bind(stuckBefore, NOTIFICATION_SWEEP_BATCH) + .all(); + + for (const row of candidates.results ?? []) { + stats.scanned++; + try { + await sweepRow(deps, row, now(), stats); + } catch (error) { + console.error("[notifications] sweep row failed", { + id: row.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + stats.cleaned = await cleanupTerminalRows(deps.db, nowMs - NOTIFICATION_RETENTION_MS); + stats.prunedLedger = await pruneConfirmLedger(deps.db, nowMs - CONFIRM_DID_WINDOW_MS); + + console.log("[notifications]", { action: "sweep", ...stats }); + return stats; +} + +async function sweepRow( + deps: NotifyDeps, + row: SweepRow, + now: Date, + stats: SweepStats, +): Promise { + // Exhausted: abandon before any further send. For a confirmation this reopens + // the lifetime cap; plaintext is cleared either way. + if (row.attempts >= NOTIFICATION_MAX_SEND_ATTEMPTS) { + if (await abandonRow(deps.db, row.id, "max_attempts")) stats.abandoned++; + return; + } + + // A row with no address/hash can never send — treat as terminal. (Undeliverable + // audit rows never enter the candidate set; this guards a corrupt row.) + if (row.plaintext_email === null || row.recipient_hash === null) { + if (await abandonRow(deps.db, row.id, "no_plaintext")) stats.abandoned++; + return; + } + + // Suppression re-check — the live send gates every claim on it, but a + // suppression can land AFTER a row fails (e.g. a confirmed publisher whose + // notice failed transiently then clicks Unsubscribe). Abandon (not just skip): + // a suppressed `failed` row would otherwise linger as a candidate forever, since + // retention only deletes terminal rows. Covers notice AND confirmation uniformly. + if (await isSuppressed(deps.db, row.recipient_hash)) { + if (await abandonRow(deps.db, row.id, "suppressed")) stats.abandoned++; + return; + } + + // Exclusive claim: bump attempts under a CAS on the observed count, so two + // concurrent sweeps never re-drive the same row twice. The winner flips the + // row to `pending`; a stuck-pending row stays pending but its attempts advance. + const claimed = await claimRetry(deps.db, row.id, row.attempts); + if (!claimed) { + stats.skipped++; + return; + } + + const to = row.plaintext_email; + const hash = row.recipient_hash; + + let result: SendResult; + if (row.kind === "confirmation") { + // Fresh token every retry — only the hash was ever stored (contract b), and + // a reused token would be unrecoverable. Stamped on the still-unconfirmed + // contact; if it has since confirmed/declined, the confirmation is moot. + const token = generateConfirmToken(); + const tokenHash = await hashConfirmToken(token); + const recorded = await recordConfirmSent(deps.db, hash, tokenHash, now.getTime()); + if (!recorded) { + if (await finishAbandon(deps.db, row.id, "contact_state_changed")) stats.abandoned++; + return; + } + result = await deps.sender.sendConfirmation({ + to, + confirmUrl: buildActionUrl(deps.serviceUrl, "confirm", hash, token), + unsubscribeUrl: buildActionUrl(deps.serviceUrl, "unsubscribe", hash), + notMeUrl: buildActionUrl(deps.serviceUrl, "not-me", hash), + }); + } else { + const notice = await resolveNoticeForSource(deps, row.source_type, row.source_id); + if (!notice) { + if (await finishAbandon(deps.db, row.id, "source_unavailable")) stats.abandoned++; + return; + } + result = await deps.sender.sendNotice({ + ...notice, + to, + unsubscribeUrl: buildActionUrl(deps.serviceUrl, "unsubscribe", hash), + }); + } + + await finalizeRetry(deps.db, row.id, hash, result, now, stats); +} + +/** Finalize a re-driven row. Attempts were already bumped at the claim, so a + * failure does NOT bump again. Success clears plaintext; provider suppression + * retires the row and records our own suppression (never retried). */ +async function finalizeRetry( + db: D1Database, + id: string, + hash: string, + result: SendResult, + now: Date, + stats: SweepStats, +): Promise { + if (result.ok) { + await db + .prepare( + `UPDATE notifications + SET state = 'sent', provider_id = ?, sent_at = ?, plaintext_email = NULL, last_error = NULL + WHERE id = ? AND state = 'pending'`, + ) + .bind(result.providerId ?? null, now.toISOString(), id) + .run(); + stats.sent++; + return; + } + if (result.suppress !== undefined) { + await markSuppressed(db, id, hash, result.suppress, now); + stats.suppressed++; + return; + } + await db + .prepare( + `UPDATE notifications SET state = 'failed', last_error = ? WHERE id = ? AND state = 'pending'`, + ) + .bind(truncate(result.error), id) + .run(); + stats.failed++; +} + +/** CAS claim: only the sweep that observed `attempts` wins, flipping the row to + * `pending` and advancing the counter. */ +async function claimRetry(db: D1Database, id: string, attempts: number): Promise { + const result = await db + .prepare( + `UPDATE notifications SET state = 'pending', attempts = attempts + 1 + WHERE id = ? AND attempts = ? AND state IN ('failed', 'pending')`, + ) + .bind(id, attempts) + .run(); + return result.meta.changes > 0; +} + +/** Retire a not-yet-claimed row (exhausted / corrupt) to `undeliverable`, guarded + * on it still being retryable so a concurrent claim wins cleanly. */ +async function abandonRow(db: D1Database, id: string, reason: string): Promise { + const result = await db + .prepare( + `UPDATE notifications + SET state = 'undeliverable', plaintext_email = NULL, last_error = ? + WHERE id = ? AND state IN ('failed', 'pending')`, + ) + .bind(reason, id) + .run(); + return result.meta.changes > 0; +} + +/** Retire an already-claimed (`pending`) row to `undeliverable`. */ +async function finishAbandon(db: D1Database, id: string, reason: string): Promise { + const result = await db + .prepare( + `UPDATE notifications + SET state = 'undeliverable', plaintext_email = NULL, last_error = ? + WHERE id = ? AND state = 'pending'`, + ) + .bind(reason, id) + .run(); + return result.meta.changes > 0; +} + +async function markSuppressed( + db: D1Database, + id: string, + hash: string, + reason: SuppressionReason, + now: Date, +): Promise { + // Suppress FIRST, THEN retire the row. Flipping the row to `undeliverable` + // reopens the confirmation lifetime cap; recording the suppression before that + // flip means a concurrent live claim is blocked by the lifetime guard until the + // suppression is durable and by the suppression guard after — no window where a + // second mail slips through. A crash between the two is covered by the sweep's + // own isSuppressed→abandon check. + await suppress(db, hash, reason, now.toISOString(), now.getTime()); + await db + .prepare( + `UPDATE notifications + SET state = 'undeliverable', plaintext_email = NULL, last_error = ? + WHERE id = ? AND state = 'pending'`, + ) + .bind(`provider_suppressed:${reason}`, id) + .run(); +} + +/** + * Delete terminal rows older than retention: every `undeliverable` row and every + * SENT NOTICE. A `sent` CONFIRMATION row is KEPT — it holds the lifetime cap and + * carries no plaintext (cleared on send), so retaining it costs nothing and + * prevents an address being re-mailed a confirmation after 30 days. + */ +async function cleanupTerminalRows(db: D1Database, cutoffMs: number): Promise { + const result = await db + .prepare( + `DELETE FROM notifications + WHERE created_at_epoch_ms < ? + AND (state = 'undeliverable' OR (state = 'sent' AND kind = 'notice'))`, + ) + .bind(cutoffMs) + .run(); + return result.meta.changes ?? 0; +} + +/** Prune confirm-ledger rows below the per-DID rolling window — they can no + * longer affect the distinct-recipient count. */ +async function pruneConfirmLedger(db: D1Database, cutoffMs: number): Promise { + const result = await db + .prepare(`DELETE FROM notification_confirm_ledger WHERE sent_at_epoch_ms < ?`) + .bind(cutoffMs) + .run(); + return result.meta.changes ?? 0; +} + +const LAST_ERROR_MAX = 500; +function truncate(error: string): string { + return error.length > LAST_ERROR_MAX ? error.slice(0, LAST_ERROR_MAX) : error; +} diff --git a/apps/labeler/src/notification-triggers.ts b/apps/labeler/src/notification-triggers.ts new file mode 100644 index 0000000000..0bdb6dcb9d --- /dev/null +++ b/apps/labeler/src/notification-triggers.ts @@ -0,0 +1,483 @@ +/** + * Publisher-notification triggers (spec §18/§19, plan W10.5 slice 2). The glue + * between a committed label action and the gated send core + * (`notification-send.ts`): each of the five ratified events — automated block, + * automated warning, reviewer override, reviewer/override retraction, and + * emergency takedown — builds a {@link NotificationRequest} and drives it through + * {@link sendNotification}. + * + * These are POST-COMMIT side effects. A notification NEVER rolls back or fails a + * label action: callers invoke them from the deferred (`waitUntil`) tail after + * the authoritative batch has committed, and every entry point here swallows and + * logs its own errors. Delivery retries live independently in the cron sweep. + * + * Two guards wrap every send: + * - Per-source dedup: a source (`source_type`, `source_id`) already carrying a + * `notifications` row is skipped, so a Workflow-step retry or a mutation + * replay does not re-notify. `sendNotification`'s notice claim closes the + * residual concurrent race atomically. + * - Verified-publisher skip: a publisher with an in-force verification claim + * bypasses double opt-in (the notice goes out directly). The verification read + * fails CLOSED — any error leaves the publisher on the normal confirmation + * path, never looser. + * + * All notice copy is public-safe: subject line, label effect, the assessment's + * public summary, and the public assessment + reconsideration URLs. No findings, + * evidence, or private detail — the {@link NoticeContent} shape is the enforcement. + */ + +import { NSID } from "@emdash-cms/registry-lexicons"; + +import moderationPolicy from "../fixtures/moderation-policy.json"; +import { AggregatorClient } from "./aggregator-client.js"; +import { getAssessment, type Assessment } from "./assessment-store.js"; +import { getNotificationHashPepper } from "./notification-contacts.js"; +import { CloudflareEmailSender } from "./notification-email.js"; +import type { + NoticeContent, + NotificationRequest, + NotificationSender, + NotificationSource, + SendContext, +} from "./notification-send.js"; +import { sendNotification } from "./notification-send.js"; +import { getOperatorActionById } from "./operator-actions.js"; +import { getLabelDefinition } from "./policy.js"; +import type { ContactTarget } from "./publisher-contact.js"; + +/** + * Everything a trigger needs to resolve, gate, and send a notification. Built + * from the Worker `env` in production (real {@link CloudflareEmailSender} + + * {@link AggregatorClient}); tests inject a fake sender and aggregator. + */ +export interface NotifyDeps { + db: D1Database; + aggregator: AggregatorClient; + sender: NotificationSender; + pepper: string; + /** Public origin of the labeler service (`LABELER_SERVICE_URL`) — the base for + * the confirm/unsubscribe landing links AND the public assessment URL. */ + serviceUrl: string; + /** The monitored reconsideration URL from the moderation policy. */ + reconsiderationUrl: string; + now?: () => Date; +} + +/** + * Build the production {@link NotifyDeps} from the Worker env: the real Cloudflare + * Email Sending adapter over the `EMAIL` binding, the aggregator client over the + * `AGGREGATOR` service binding, and the peppered recipient hash. The reconsideration + * URL and reply-to inbox come from the ratified moderation-policy fixture. + */ +export async function createNotifyDeps(env: Env): Promise { + const pepper = await getNotificationHashPepper(env).get(); + return { + db: env.DB, + aggregator: new AggregatorClient(env.AGGREGATOR), + sender: new CloudflareEmailSender(env.EMAIL, { + fromAddress: env.NOTIFICATION_FROM_ADDRESS, + fromName: "emdash plugin labeler", + replyTo: moderationPolicy.contact.reconsiderationEmail, + }), + pepper, + serviceUrl: env.LABELER_SERVICE_URL, + reconsiderationUrl: moderationPolicy.contact.reconsiderationUrl, + }; +} + +interface OperatorLabelInput { + actionId: string; + uri: string; + cid?: string; + val: string; + neg: boolean; +} + +/** The public-safe URLs every notice carries — satisfied structurally by + * {@link NotifyDeps}, so a content builder takes either. */ +interface NoticeUrls { + serviceUrl: string; + reconsiderationUrl: string; +} + +// ── Pure notice-content builders ──────────────────────────────────────────── +// Shared by the live triggers AND the sweep's re-render-from-source. Keeping them +// pure and shared guarantees a retried send renders byte-identical copy to the +// original — nothing about the notice is persisted, so both paths derive it from +// the same source fields. + +/** Automated block/warning notice for a finalized run — null for any other + * outcome (so the sweep abandons a notice whose run is no longer blocked/warned). */ +function assessmentNoticeContent(urls: NoticeUrls, assessment: Assessment): NoticeContent | null { + if (assessment.state !== "blocked" && assessment.state !== "warned") return null; + const blocked = assessment.state === "blocked"; + return { + subject: blocked + ? "Your plugin release was blocked by the emdash labeler" + : "Your plugin release was flagged by the emdash labeler", + publicSummary: + assessment.publicSummary && assessment.publicSummary.length > 0 + ? assessment.publicSummary + : blocked + ? "A security assessment blocked this release." + : "A security assessment flagged this release with a warning.", + effect: blocked + ? "The release is blocked and hidden from install surfaces pending reconsideration." + : "The release is flagged with a warning.", + assessmentUrl: assessmentUrl(urls.serviceUrl, assessment.uri, assessment.cid), + reconsiderationUrl: urls.reconsiderationUrl, + }; +} + +function operatorLabelNoticeContent( + urls: NoticeUrls, + input: { uri: string; cid?: string; val: string; neg: boolean }, +): NoticeContent { + const shared = { + assessmentUrl: assessmentUrl(urls.serviceUrl, input.uri, input.cid), + reconsiderationUrl: urls.reconsiderationUrl, + }; + return input.neg + ? { + subject: "A label on your plugin was retracted", + publicSummary: `The "${input.val}" label was retracted from this subject.`, + effect: "The label no longer applies.", + ...shared, + } + : { + subject: "A label was applied to your plugin", + publicSummary: `The "${input.val}" label was applied to this subject.`, + effect: getLabelDefinition(input.val)?.officialEffect ?? "", + ...shared, + }; +} + +function overrideNoticeContent( + urls: NoticeUrls, + input: { uri: string; cid?: string }, +): NoticeContent { + return { + subject: "Your plugin release was unblocked", + publicSummary: "A reviewer overrode the automated block for this release.", + effect: "The release is no longer blocked.", + assessmentUrl: assessmentUrl(urls.serviceUrl, input.uri, input.cid), + reconsiderationUrl: urls.reconsiderationUrl, + }; +} + +function overrideRetractNoticeContent( + urls: NoticeUrls, + input: { uri: string; cid?: string }, +): NoticeContent { + return { + subject: "An override on your plugin release was retracted", + publicSummary: "The reviewer override for this release was retracted.", + effect: "The automated block applies again.", + assessmentUrl: assessmentUrl(urls.serviceUrl, input.uri, input.cid), + reconsiderationUrl: urls.reconsiderationUrl, + }; +} + +function emergencyNoticeContent( + urls: NoticeUrls, + input: { uri: string; neg: boolean }, +): NoticeContent { + const shared = { + assessmentUrl: assessmentUrl(urls.serviceUrl, input.uri), + reconsiderationUrl: urls.reconsiderationUrl, + }; + return input.neg + ? { + subject: "The takedown on your plugin was lifted", + publicSummary: "An emergency takedown affecting this subject was retracted.", + effect: "The takedown no longer applies.", + ...shared, + } + : { + subject: "Your plugin was taken down by the emdash labeler", + publicSummary: "An operator issued an emergency takedown affecting this subject.", + effect: getLabelDefinition("!takedown")?.officialEffect ?? "The subject is taken down.", + ...shared, + }; +} + +// ── Live trigger entry points ─────────────────────────────────────────────── + +/** Automated block/warning notice from a finalized assessment run. Source is the + * assessment id: a Workflow-step retry of the same run dedups on it, and repeated + * discovery of the same release dedups onto the same run (same runKey). */ +export async function notifyAssessmentOutcome( + deps: NotifyDeps, + assessment: Assessment, +): Promise { + const notice = assessmentNoticeContent(deps, assessment); + if (!notice) return; + const target = contactTargetFromUri(assessment.uri); + if (!target) return; + await runTrigger(deps, { type: "issuance", id: assessment.id }, target, notice); +} + +/** Reviewer label issue / retract (console `labels/issue` + `labels/retract`). */ +export async function notifyOperatorLabel( + deps: NotifyDeps, + input: OperatorLabelInput, +): Promise { + const target = contactTargetFromUri(input.uri); + if (!target) return; + await runTrigger( + deps, + { type: "operator", id: input.actionId }, + target, + operatorLabelNoticeContent(deps, input), + ); +} + +/** Reviewer false-positive override (console `assessments/:id/override`). */ +export async function notifyOverride( + deps: NotifyDeps, + input: { actionId: string; uri: string; cid: string }, +): Promise { + const target = contactTargetFromUri(input.uri); + if (!target) return; + await runTrigger( + deps, + { type: "operator", id: input.actionId }, + target, + overrideNoticeContent(deps, input), + ); +} + +/** Retraction of a reviewer override (console `assessments/:id/override-retract`). */ +export async function notifyOverrideRetract( + deps: NotifyDeps, + input: { actionId: string; uri: string; cid: string }, +): Promise { + const target = contactTargetFromUri(input.uri); + if (!target) return; + await runTrigger( + deps, + { type: "operator", id: input.actionId }, + target, + overrideRetractNoticeContent(deps, input), + ); +} + +/** Emergency takedown or its retraction (console `emergency/takedown[-retract]`). + * The subject may be redacted; the aggregator read is unfiltered (blank + * accept-labelers), so contact resolution still works. */ +export async function notifyEmergencyTakedown( + deps: NotifyDeps, + input: { actionId: string; uri: string; neg: boolean }, +): Promise { + const target = contactTargetFromUri(input.uri); + if (!target) return; + await runTrigger( + deps, + { type: "operator", id: input.actionId }, + target, + emergencyNoticeContent(deps, input), + ); +} + +/** + * Rebuild a NOTICE's public content from its source row, for the retry sweep. + * Nothing about the notice is persisted (plaintext minimization), so a retry + * re-derives it from the assessment (`issuance`) or operator action (`operator`) + * exactly as the live trigger did. Returns null when the source is gone or no + * longer warrants a notice (e.g. a run that is no longer blocked/warned, or an + * operator action whose type does not notify) — the sweep then abandons the row. + */ +export async function resolveNoticeForSource( + deps: NotifyDeps, + sourceType: string, + sourceId: string, +): Promise { + if (sourceType === "issuance") { + const assessment = await loadAssessmentSafe(deps.db, sourceId); + return assessment ? assessmentNoticeContent(deps, assessment) : null; + } + const action = await getOperatorActionById(deps.db, sourceId); + if (!action || action.subjectUri === null) return null; + const uri = action.subjectUri; + const cid = action.subjectCid ?? undefined; + switch (action.action) { + case "label-issue": + return operatorLabelNoticeContent(deps, { + uri, + cid, + val: action.labelValue ?? "", + neg: false, + }); + case "label-retract": + return operatorLabelNoticeContent(deps, { + uri, + cid, + val: action.labelValue ?? "", + neg: true, + }); + case "unblock-override": + return overrideNoticeContent(deps, { uri, cid }); + case "override-retract": + return overrideRetractNoticeContent(deps, { uri, cid }); + case "takedown": + return emergencyNoticeContent(deps, { uri, neg: operatorActionNeg(action.metadataJson) }); + default: + return null; + } +} + +/** `getAssessment` throws `TypeError` only for a malformed id — which a stored + * `source_id` never is — so that maps to "no notice" (null). Any OTHER error is a + * transient read failure: it must PROPAGATE, not abandon the row, so the sweep + * leaves the claimed row `pending` to self-heal on a later pass (matching the + * operator path's unwrapped read). Swallowing it would permanently drop a + * legitimate block/warning notice on a single flaky D1 read. */ +async function loadAssessmentSafe(db: D1Database, id: string): Promise { + try { + return await getAssessment(db, id); + } catch (error) { + if (error instanceof TypeError) return null; + throw error; + } +} + +/** The `neg` flag an emergency action stored in its metadata (issue vs retract). */ +function operatorActionNeg(metadataJson: string): boolean { + try { + const parsed: unknown = JSON.parse(metadataJson); + return ( + typeof parsed === "object" && parsed !== null && (parsed as { neg?: unknown }).neg === true + ); + } catch { + return false; + } +} + +/** + * Dedup, resolve verification (fail-closed), send, swallow+log. Shared by every + * trigger so the dedup and verified-skip policy is applied uniformly and a + * notification failure can never escape into the label path. + */ +async function runTrigger( + deps: NotifyDeps, + source: NotificationSource, + target: ContactTarget, + notice: NoticeContent, +): Promise { + const now = deps.now ?? (() => new Date()); + try { + if (await sourceAlreadyProcessed(deps.db, source)) { + logTrigger(source, target.did, "deduped"); + return; + } + const verifiedPublisher = await isVerifiedPublisher(deps.aggregator, target.did, now()); + const ctx: SendContext = { + db: deps.db, + aggregator: deps.aggregator, + pepper: deps.pepper, + sender: deps.sender, + origin: deps.serviceUrl, + verifiedPublisher, + now, + }; + const request: NotificationRequest = { source, target, notice }; + const outcome = await sendNotification(ctx, request); + logTrigger(source, target.did, outcome.status); + } catch (error) { + console.error("[notifications] trigger failed", { + sourceType: source.type, + sourceId: source.id, + did: target.did, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** Whether any `notifications` row already exists for this source — the primary + * dedup, so a retried Workflow step or a replayed operator mutation does not + * re-notify. */ +async function sourceAlreadyProcessed( + db: D1Database, + source: NotificationSource, +): Promise { + const row = await db + .prepare(`SELECT 1 FROM notifications WHERE source_type = ? AND source_id = ? LIMIT 1`) + .bind(source.type, source.id) + .first<{ 1: number }>(); + return row !== null; +} + +/** + * Whether the publisher holds an IN-FORCE verification claim — reuses the + * history-context notion of "in force" (a claim whose `expiresAt` is absent or in + * the future). FAILS CLOSED: a `null` view (redacted / unverified), an empty + * claim set, or ANY read error returns `false`, so the address stays on the + * stricter double-opt-in path. + */ +export async function isVerifiedPublisher( + aggregator: Pick, + did: string, + now: Date, +): Promise { + try { + const state = await aggregator.getPublisherVerification(did); + if (!state || state.verifications.length === 0) return false; + return state.verifications.some((claim) => isInForce(claim.expiresAt, now)); + } catch (error) { + console.error("[notifications] verification read failed, using double opt-in", { + did, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + +/** A claim is in force when it has no expiry or its expiry is still in the future + * (mirrors `history-context`'s `isExpired`). */ +function isInForce(expiresAt: string | undefined, now: Date): boolean { + if (expiresAt === undefined) return true; + const expiry = Date.parse(expiresAt); + if (Number.isNaN(expiry)) return true; + return expiry > now.getTime(); +} + +/** Public assessment URL for a subject — the `getCurrentAssessment` XRPC view, + * which resolves the subject's current assessment (the run this notice concerns). + * `cid` narrows it to the exact release when known. */ +function assessmentUrl(serviceUrl: string, uri: string, cid?: string): string { + const url = new URL(`/xrpc/${NSID.labelerGetCurrentAssessment}`, serviceUrl); + url.searchParams.set("uri", uri); + if (cid !== undefined && cid.length > 0) url.searchParams.set("cid", cid); + return url.toString(); +} + +/** + * The `{ did, slug }` a notice's contact resolution needs, parsed from the + * subject URI. A record URI (`at://did/collection/rkey`) yields the DID and the + * rkey as the slug — for a package record the rkey IS the package slug (tier-1/2 + * resolve); for a release the rkey misses the package tiers and resolution falls + * through to the DID-keyed publisher-profile contact (tier 3), which is the + * reliable channel. A bare DID subject (publisher-level action) resolves by DID + * alone. Anything unparseable returns null and the notice is skipped. + */ +export function contactTargetFromUri(uri: string): ContactTarget | null { + if (uri.startsWith("at://")) { + const [did, , rkey] = uri.slice("at://".length).split("/"); + if (did === undefined || did.length === 0) return null; + return { did, slug: rkey ?? "" }; + } + if (uri.startsWith("did:")) { + return { did: uri, slug: uri.split(":").at(-1) ?? uri }; + } + return null; +} + +function logTrigger(source: NotificationSource, did: string, outcome: string): void { + console.log("[notifications]", { + action: "trigger", + sourceType: source.type, + sourceId: source.id, + did, + outcome, + }); +} diff --git a/apps/labeler/src/operator-actions.ts b/apps/labeler/src/operator-actions.ts index 3f5a27d622..24e1293d47 100644 --- a/apps/labeler/src/operator-actions.ts +++ b/apps/labeler/src/operator-actions.ts @@ -135,6 +135,25 @@ export async function getOperatorActionByKey( return row ? rowToStoredOperatorAction(row) : null; } +/** Read one audit row by its `operator_actions.id` primary key — the + * notification subsystem's polymorphic `source_id` for an operator-triggered + * notice, re-read by the retry sweep to rebuild the notice from source. */ +export async function getOperatorActionById( + db: D1Database, + id: string, +): Promise { + const row = await db + .prepare( + `SELECT id, actor_type, actor_id, actor_email, actor_common_name, role, action, + subject_uri, subject_cid, label_value, reason, idempotency_key, + request_fingerprint, result_json, metadata_json, created_at, created_at_epoch_ms + FROM operator_actions WHERE id = ?`, + ) + .bind(id) + .first(); + return row ? rowToStoredOperatorAction(row) : null; +} + /** * Audit-log page, newest first, exclusive keyset on `(created_at_epoch_ms, id)` * over `idx_operator_actions_created`. `keyset.createdAt` is the ISO timestamp diff --git a/apps/labeler/test/console-mutation-api.test.ts b/apps/labeler/test/console-mutation-api.test.ts index 3992b11c8f..a7ed6d7d28 100644 --- a/apps/labeler/test/console-mutation-api.test.ts +++ b/apps/labeler/test/console-mutation-api.test.ts @@ -27,6 +27,7 @@ import { type MessageController, } from "../src/discovery-consumer.js"; import type { DiscoveryJob } from "../src/env.js"; +import { safeCreateNotifyDeps } from "../src/index.js"; import { OPERATOR_REQUEST_HEADER } from "../src/mutation-guard.js"; import type { DidDocumentResolverLike } from "../src/record-verification.js"; import { issueAutomatedAssessmentLabel, issueManualLabel } from "../src/service.js"; @@ -2137,3 +2138,53 @@ describe("console mutation: dead-letter replay and idempotency", () => { expect((await bodyError(second)).code).toBe("IDEMPOTENCY_KEY_CONFLICT"); }); }); + +describe("console mutation: notify-deps failure never blocks the label", () => { + it("returns undefined (never throws) when the hash pepper is unconfigured", async () => { + await expect(safeCreateNotifyDeps({} as unknown as Env)).resolves.toBeUndefined(); + }); + + it("returns undefined (never throws) when the pepper binding rejects", async () => { + const brokenEnv = { + NOTIFICATION_HASH_PEPPER: { + get: async () => { + throw new Error("secret store unavailable"); + }, + }, + } as unknown as Env; + await expect(safeCreateNotifyDeps(brokenEnv)).resolves.toBeUndefined(); + }); + + it("commits the label even when notify-deps construction failed (notify undefined)", async () => { + const uri = await seedReleaseSubject("notify-deps-down"); + const notify = await safeCreateNotifyDeps({} as unknown as Env); + expect(notify).toBeUndefined(); + + const key = nextKey(); + const response = await handleConsoleMutation( + post("/admin/api/labels/issue", { + uri, + val: "security-yanked", + confirmation: "notify-deps-down", + reason: "notify deps unavailable must not block issuance", + idempotencyKey: key, + }), + mutationDeps({ notify }), + ); + + expect(response.status).toBe(200); + const descriptor = await bodyData(response); + expect(descriptor).toMatchObject({ val: "security-yanked", uri, effect: "block" }); + + const persisted = await testEnv.DB.prepare( + `SELECT l.val, l.neg FROM issued_labels l + JOIN issuance_actions a ON a.id = l.action_id + WHERE a.idempotency_key = ?`, + ) + .bind(descriptor.actionId) + .first<{ val: string; neg: number }>(); + expect(persisted).toEqual({ val: "security-yanked", neg: 0 }); + + expect(await countRows(`SELECT COUNT(*) AS n FROM notifications`)).toBe(0); + }); +}); diff --git a/apps/labeler/test/notification-email.test.ts b/apps/labeler/test/notification-email.test.ts new file mode 100644 index 0000000000..12f6d14277 --- /dev/null +++ b/apps/labeler/test/notification-email.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; + +import { CloudflareEmailSender } from "../src/notification-email.js"; +import type { ConfirmationPayload, NoticePayload } from "../src/notification-send.js"; + +/** A fake `SendEmail` binding that records the composed message and either + * resolves with a messageId or throws a coded error. */ +function fakeEmail(behavior: { messageId: string } | { throw: unknown }): { + binding: SendEmail; + calls: EmailMessageBuilder[]; +} { + const calls: EmailMessageBuilder[] = []; + const binding = { + send: async (message: EmailMessage | EmailMessageBuilder) => { + calls.push(message as EmailMessageBuilder); + if ("throw" in behavior) throw behavior.throw; + return { messageId: behavior.messageId }; + }, + } as unknown as SendEmail; + return { binding, calls }; +} + +const CONFIG = { + fromAddress: "notifications@emdashcms.com", + fromName: "labeler", + replyTo: "recon@x", +}; + +const confirmation: ConfirmationPayload = { + to: "dev@example.test", + confirmUrl: "https://labels.example/notifications/confirm?c=abc&t=SECRETTOKEN", + unsubscribeUrl: "https://labels.example/notifications/unsubscribe?c=abc", + notMeUrl: "https://labels.example/notifications/not-me?c=abc", +}; + +const notice: NoticePayload = { + to: "dev@example.test", + subject: "Your plugin release was blocked", + publicSummary: "A security assessment blocked this release.", + assessmentUrl: "https://labels.example/xrpc/getCurrentAssessment?uri=at://x", + effect: "The release is blocked.", + reconsiderationUrl: "https://labels.example/reconsider", + unsubscribeUrl: "https://labels.example/notifications/unsubscribe?c=abc", +}; + +describe("CloudflareEmailSender success", () => { + it("sends a notice with html+text bodies and returns the provider messageId", async () => { + const email = fakeEmail({ messageId: "msg-1" }); + const sender = new CloudflareEmailSender(email.binding, CONFIG); + + const result = await sender.sendNotice(notice); + + expect(result).toEqual({ ok: true, providerId: "msg-1" }); + expect(email.calls).toHaveLength(1); + const sent = email.calls[0]!; + expect(sent.to).toBe("dev@example.test"); + expect(sent.from).toEqual({ name: "labeler", email: "notifications@emdashcms.com" }); + expect(sent.replyTo).toBe("recon@x"); + expect(sent.subject).toBe("Your plugin release was blocked"); + expect(sent.html).toContain("A security assessment blocked this release."); + expect(sent.text).toContain("A security assessment blocked this release."); + expect(sent.html?.length).toBeGreaterThan(0); + expect(sent.text?.length).toBeGreaterThan(0); + }); + + it("sets List-Unsubscribe + one-click headers on every send", async () => { + const email = fakeEmail({ messageId: "m" }); + const sender = new CloudflareEmailSender(email.binding, CONFIG); + + await sender.sendConfirmation(confirmation); + + const headers = email.calls[0]!.headers ?? {}; + expect(headers["List-Unsubscribe"]).toBe( + "", + ); + expect(headers["List-Unsubscribe-Post"]).toBe("List-Unsubscribe=One-Click"); + }); + + it("sends a content-neutral confirmation (no assessment specifics) returning the messageId", async () => { + const email = fakeEmail({ messageId: "c-1" }); + const sender = new CloudflareEmailSender(email.binding, CONFIG); + + const result = await sender.sendConfirmation(confirmation); + + expect(result).toEqual({ ok: true, providerId: "c-1" }); + const sent = email.calls[0]!; + // Carries the confirm/opt-out links, but nothing about any assessment. + expect(sent.text).toContain(confirmation.confirmUrl); + expect(sent.text).not.toContain("blocked"); + expect(sent.html).not.toContain("blocked"); + }); +}); + +describe("CloudflareEmailSender error mapping", () => { + it("maps E_RECIPIENT_SUPPRESSED to a bounce-suppression discriminant", async () => { + const email = fakeEmail({ throw: { code: "E_RECIPIENT_SUPPRESSED" } }); + const sender = new CloudflareEmailSender(email.binding, CONFIG); + + const result = await sender.sendNotice(notice); + + expect(result).toEqual({ ok: false, error: "E_RECIPIENT_SUPPRESSED", suppress: "bounce" }); + }); + + it.each([ + "E_RATE_LIMIT_EXCEEDED", + "E_DAILY_LIMIT_EXCEEDED", + "E_DELIVERY_FAILED", + "E_INTERNAL_SERVER_ERROR", + ])("maps %s to a retryable failure with no suppression", async (code) => { + const email = fakeEmail({ throw: { code } }); + const sender = new CloudflareEmailSender(email.binding, CONFIG); + + const result = await sender.sendNotice(notice); + + expect(result).toEqual({ ok: false, error: code }); + }); + + it("maps an unknown/absent code to a generic failure", async () => { + const email = fakeEmail({ throw: new Error("boom") }); + const sender = new CloudflareEmailSender(email.binding, CONFIG); + + expect(await sender.sendNotice(notice)).toEqual({ ok: false, error: "E_SEND_FAILED" }); + }); + + it("NEVER leaks the payload (confirm URL / token) into the error string", async () => { + // A provider error whose message echoes the recipient + confirm token — the + // adapter must surface only the code, never this message. + const email = fakeEmail({ + throw: { + code: "E_DELIVERY_FAILED", + message: `failed to deliver to ${confirmation.confirmUrl}`, + }, + }); + const sender = new CloudflareEmailSender(email.binding, CONFIG); + + const result = await sender.sendConfirmation(confirmation); + + expect(result).toEqual({ ok: false, error: "E_DELIVERY_FAILED" }); + if (!result.ok) { + expect(result.error).not.toContain("SECRETTOKEN"); + expect(result.error).not.toContain("confirm"); + } + }); +}); diff --git a/apps/labeler/test/notification-sweep.test.ts b/apps/labeler/test/notification-sweep.test.ts new file mode 100644 index 0000000000..c6260a8e4c --- /dev/null +++ b/apps/labeler/test/notification-sweep.test.ts @@ -0,0 +1,504 @@ +import { applyD1Migrations, env } from "cloudflare:test"; +import { ulid } from "ulidx"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { AggregatorClient } from "../src/aggregator-client.js"; +import { + NOTIFICATION_MAX_SEND_ATTEMPTS, + NOTIFICATION_RETENTION_MS, + NOTIFICATION_STUCK_PENDING_MS, +} from "../src/constants.js"; +import { + ensureContact, + hashConfirmToken, + recipientHash, + recordConfirmSent, + suppress, +} from "../src/notification-contacts.js"; +import type { ConfirmationPayload, NoticePayload, SendResult } from "../src/notification-send.js"; +import { runNotificationSweep } from "../src/notification-sweep.js"; +import type { NotifyDeps } from "../src/notification-triggers.js"; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; +} +const testEnv = env as unknown as TestEnv; +const db = () => testEnv.DB; + +const PEPPER = "sweep-pepper"; +const SERVICE = "https://labels.example"; +const RELEASE_URI = "at://did:plc:x/com.emdashcms.experimental.package.release/rel1"; +const NOW = new Date(10_000_000_000); + +let counter = 0; +const uniq = (p: string) => `${p}-${++counter}`; + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); +}); + +function dummyAggregator(): AggregatorClient { + return new AggregatorClient({ + fetch: async () => new Response("nope", { status: 404 }), + } as unknown as Fetcher); +} + +interface RecordingSender { + confirmations: ConfirmationPayload[]; + notices: NoticePayload[]; + sendConfirmation(p: ConfirmationPayload): Promise; + sendNotice(p: NoticePayload): Promise; +} +function recordingSender(result: SendResult = { ok: true, providerId: "swept" }): RecordingSender { + const confirmations: ConfirmationPayload[] = []; + const notices: NoticePayload[] = []; + return { + confirmations, + notices, + sendConfirmation: async (p) => (confirmations.push(p), result), + sendNotice: async (p) => (notices.push(p), result), + }; +} + +function deps(sender: RecordingSender): NotifyDeps { + return { + db: db(), + aggregator: dummyAggregator(), + sender, + pepper: PEPPER, + serviceUrl: SERVICE, + reconsiderationUrl: "https://recon.example", + now: () => NOW, + }; +} + +interface InsertRow { + kind: "confirmation" | "notice"; + sourceType?: string; + sourceId: string; + recipientHash?: string | null; + plaintext?: string | null; + state: string; + attempts: number; + createdMs?: number; +} +async function insertNotification(row: InsertRow): Promise { + const id = uniq("ntf"); + await db() + .prepare( + `INSERT INTO notifications + (id, source_type, source_id, kind, channel, recipient_hash, state, attempts, + plaintext_email, created_at, created_at_epoch_ms) + VALUES (?, ?, ?, ?, 'email', ?, ?, ?, ?, ?, ?)`, + ) + .bind( + id, + row.sourceType ?? "operator", + row.sourceId, + row.kind, + row.recipientHash ?? null, + row.state, + row.attempts, + row.plaintext ?? null, + new Date(row.createdMs ?? NOW.getTime()).toISOString(), + row.createdMs ?? NOW.getTime(), + ) + .run(); + return id; +} + +async function insertOperatorAction(id: string, val: string): Promise { + await db() + .prepare( + `INSERT INTO operator_actions + (id, actor_type, actor_id, role, action, subject_uri, subject_cid, label_value, + reason, idempotency_key, request_fingerprint, metadata_json, created_at, created_at_epoch_ms) + VALUES (?, 'human', 'op', 'reviewer', 'label-issue', ?, 'bafycid', ?, 'r', ?, 'fp', '{}', ?, ?)`, + ) + .bind(id, RELEASE_URI, val, uniq("idem"), NOW.toISOString(), NOW.getTime()) + .run(); +} + +async function rowState( + id: string, +): Promise<{ state: string; attempts: number; plaintext: string | null; provider: string | null }> { + const r = await db() + .prepare( + `SELECT state, attempts, plaintext_email AS plaintext, provider_id AS provider FROM notifications WHERE id = ?`, + ) + .bind(id) + .first<{ + state: string; + attempts: number; + plaintext: string | null; + provider: string | null; + }>(); + return r!; +} + +describe("failed notice retry", () => { + it("re-renders from source and marks it sent", async () => { + const sourceId = uniq("oact"); + await insertOperatorAction(sourceId, "security-yanked"); + const email = uniq("n") + "@x.test"; + const hash = await recipientHash(PEPPER, email); + const id = await insertNotification({ + kind: "notice", + sourceId, + recipientHash: hash, + plaintext: email, + state: "failed", + attempts: 1, + }); + const sender = recordingSender(); + + await runNotificationSweep(deps(sender)); + + expect(sender.notices).toHaveLength(1); + expect(sender.notices[0]?.to).toBe(email); + const row = await rowState(id); + expect(row).toMatchObject({ state: "sent", plaintext: null, provider: "swept" }); + }); + + it("abandons a notice whose source has vanished", async () => { + const email = uniq("gone") + "@x.test"; + const hash = await recipientHash(PEPPER, email); + const id = await insertNotification({ + kind: "notice", + sourceId: uniq("missing"), + recipientHash: hash, + plaintext: email, + state: "failed", + attempts: 1, + }); + const sender = recordingSender(); + + await runNotificationSweep(deps(sender)); + + expect(sender.notices).toHaveLength(0); + expect((await rowState(id)).state).toBe("undeliverable"); + }); +}); + +describe("failed confirmation retry", () => { + it("mints a FRESH token, stamps its hash, and sends", async () => { + const email = uniq("c") + "@x.test"; + const hash = await recipientHash(PEPPER, email); + await ensureContact(db(), hash, NOW.toISOString()); + const oldHash = await hashConfirmToken("old-token"); + await recordConfirmSent(db(), hash, oldHash, 1_000); + const id = await insertNotification({ + kind: "confirmation", + sourceId: uniq("src"), + recipientHash: hash, + plaintext: email, + state: "failed", + attempts: 2, + }); + const sender = recordingSender(); + + await runNotificationSweep(deps(sender)); + + expect(sender.confirmations).toHaveLength(1); + const url = new URL(sender.confirmations[0]!.confirmUrl); + const newToken = url.searchParams.get("t") ?? ""; + const stored = await db() + .prepare(`SELECT confirm_token_hash FROM notification_contacts WHERE recipient_hash = ?`) + .bind(hash) + .first<{ confirm_token_hash: string }>(); + expect(stored?.confirm_token_hash).toBe(await hashConfirmToken(newToken)); + expect(stored?.confirm_token_hash).not.toBe(oldHash); + expect((await rowState(id)).state).toBe("sent"); + }); + + it("abandons (undeliverable) when the contact is no longer unconfirmed", async () => { + const email = uniq("cdone") + "@x.test"; + const hash = await recipientHash(PEPPER, email); + // No unconfirmed contact row exists → recordConfirmSent no-ops → abandon. + const id = await insertNotification({ + kind: "confirmation", + sourceId: uniq("src"), + recipientHash: hash, + plaintext: email, + state: "failed", + attempts: 1, + }); + const sender = recordingSender(); + + await runNotificationSweep(deps(sender)); + + expect(sender.confirmations).toHaveLength(0); + expect((await rowState(id)).state).toBe("undeliverable"); + }); +}); + +describe("attempts cap", () => { + it("abandons an exhausted confirmation to undeliverable, clears plaintext, and reopens the lifetime cap", async () => { + const email = uniq("cap") + "@x.test"; + const hash = await recipientHash(PEPPER, email); + await ensureContact(db(), hash, NOW.toISOString()); + const id = await insertNotification({ + kind: "confirmation", + sourceId: uniq("src"), + recipientHash: hash, + plaintext: email, + state: "failed", + attempts: NOTIFICATION_MAX_SEND_ATTEMPTS, + }); + const sender = recordingSender(); + + await runNotificationSweep(deps(sender)); + + expect(sender.confirmations).toHaveLength(0); + const row = await rowState(id); + expect(row).toMatchObject({ state: "undeliverable", plaintext: null }); + // Cap reopened: no non-undeliverable confirmation row blocks a fresh send. + const blocking = await db() + .prepare( + `SELECT COUNT(*) AS n FROM notifications WHERE recipient_hash = ? AND kind = 'confirmation' AND state != 'undeliverable'`, + ) + .bind(hash) + .first<{ n: number }>(); + expect(blocking?.n).toBe(0); + }); +}); + +describe("stuck pending", () => { + it("re-drives a crash-stuck pending row", async () => { + const sourceId = uniq("oact"); + await insertOperatorAction(sourceId, "security-yanked"); + const email = uniq("stuck") + "@x.test"; + const hash = await recipientHash(PEPPER, email); + const id = await insertNotification({ + kind: "notice", + sourceId, + recipientHash: hash, + plaintext: email, + state: "pending", + attempts: 0, + createdMs: NOW.getTime() - NOTIFICATION_STUCK_PENDING_MS - 1, + }); + const sender = recordingSender(); + + await runNotificationSweep(deps(sender)); + + expect(sender.notices).toHaveLength(1); + expect((await rowState(id)).state).toBe("sent"); + }); + + it("leaves a FRESH pending row alone", async () => { + const email = uniq("fresh") + "@x.test"; + const hash = await recipientHash(PEPPER, email); + const id = await insertNotification({ + kind: "notice", + sourceId: uniq("oact"), + recipientHash: hash, + plaintext: email, + state: "pending", + attempts: 0, + createdMs: NOW.getTime(), + }); + const sender = recordingSender(); + + await runNotificationSweep(deps(sender)); + + expect(sender.notices).toHaveLength(0); + expect((await rowState(id)).state).toBe("pending"); + }); +}); + +describe("provider suppression during a retry", () => { + it("retires the row and records the suppression", async () => { + const email = uniq("bnc") + "@x.test"; + const hash = await recipientHash(PEPPER, email); + await ensureContact(db(), hash, NOW.toISOString()); + await recordConfirmSent(db(), hash, await hashConfirmToken("t"), 1_000); + const id = await insertNotification({ + kind: "confirmation", + sourceId: uniq("src"), + recipientHash: hash, + plaintext: email, + state: "failed", + attempts: 1, + }); + const sender = recordingSender({ + ok: false, + error: "E_RECIPIENT_SUPPRESSED", + suppress: "bounce", + }); + + await runNotificationSweep(deps(sender)); + + expect((await rowState(id)).state).toBe("undeliverable"); + const supp = await db() + .prepare(`SELECT reason FROM notification_suppressions WHERE recipient_hash = ?`) + .bind(hash) + .first<{ reason: string }>(); + expect(supp?.reason).toBe("bounce"); + }); +}); + +describe("retention cleanup + ledger prune", () => { + it("deletes old undeliverable and sent-notice rows but KEEPS sent confirmations", async () => { + const old = NOW.getTime() - NOTIFICATION_RETENTION_MS - 1; + const undel = await insertNotification({ + kind: "notice", + sourceId: uniq("s"), + recipientHash: "h1", + state: "undeliverable", + attempts: 0, + createdMs: old, + }); + const sentNotice = await insertNotification({ + kind: "notice", + sourceId: uniq("s"), + recipientHash: "h2", + state: "sent", + attempts: 0, + createdMs: old, + }); + const sentConfirm = await insertNotification({ + kind: "confirmation", + sourceId: uniq("s"), + recipientHash: "h3", + state: "sent", + attempts: 0, + createdMs: old, + }); + + await runNotificationSweep(deps(recordingSender())); + + expect(await exists(undel)).toBe(false); + expect(await exists(sentNotice)).toBe(false); + expect(await exists(sentConfirm)).toBe(true); + }); + + it("prunes confirm-ledger rows below the rolling window", async () => { + const oldMs = NOW.getTime() - 48 * 60 * 60 * 1000; + await db() + .prepare( + `INSERT INTO notification_confirm_ledger (id, publisher_did, recipient_hash, sent_at_epoch_ms) VALUES (?, ?, ?, ?)`, + ) + .bind(uniq("ncl"), "did:plc:old", "hh", oldMs) + .run(); + + await runNotificationSweep(deps(recordingSender())); + + const remaining = await db() + .prepare( + `SELECT COUNT(*) AS n FROM notification_confirm_ledger WHERE publisher_did = 'did:plc:old'`, + ) + .first<{ n: number }>(); + expect(remaining?.n).toBe(0); + }); +}); + +describe("suppression re-check (F1)", () => { + it("abandons and does NOT mail a failed NOTICE whose address was suppressed after it failed", async () => { + const sourceId = uniq("oact"); + await insertOperatorAction(sourceId, "security-yanked"); + const email = uniq("supp") + "@x.test"; + const hash = await recipientHash(PEPPER, email); + const id = await insertNotification({ + kind: "notice", + sourceId, + recipientHash: hash, + plaintext: email, + state: "failed", + attempts: 1, + }); + // The suppression lands after the row failed (e.g. an Unsubscribe click). + await suppress(db(), hash, "unsubscribe", NOW.toISOString(), NOW.getTime()); + const sender = recordingSender(); + + await runNotificationSweep(deps(sender)); + + expect(sender.notices).toHaveLength(0); + expect(await rowState(id)).toMatchObject({ state: "undeliverable", plaintext: null }); + }); + + it("abandons a suppressed failed CONFIRMATION without minting a token", async () => { + const email = uniq("csupp") + "@x.test"; + const hash = await recipientHash(PEPPER, email); + await ensureContact(db(), hash, NOW.toISOString()); + await recordConfirmSent(db(), hash, await hashConfirmToken("t"), 1_000); + const id = await insertNotification({ + kind: "confirmation", + sourceId: uniq("src"), + recipientHash: hash, + plaintext: email, + state: "failed", + attempts: 1, + }); + await suppress(db(), hash, "not_me", NOW.toISOString(), NOW.getTime()); + const sender = recordingSender(); + + await runNotificationSweep(deps(sender)); + + expect(sender.confirmations).toHaveLength(0); + expect((await rowState(id)).state).toBe("undeliverable"); + }); +}); + +describe("transient source read (F3)", () => { + it("keeps a notice row pending (self-heals) when the assessment read fails transiently — never abandons it", async () => { + const email = uniq("f3") + "@x.test"; + const hash = await recipientHash(PEPPER, email); + const sourceId = `asmt_${ulid()}`; // valid id shape → getAssessment reaches the (throwing) query + const id = await insertNotification({ + kind: "notice", + sourceType: "issuance", + sourceId, + recipientHash: hash, + plaintext: email, + state: "failed", + attempts: 1, + }); + const sender = recordingSender(); + const throwingDeps: NotifyDeps = { ...deps(sender), db: dbThrowingOnAssessments(db()) }; + + await runNotificationSweep(throwingDeps); + + expect(sender.notices).toHaveLength(0); + // Claimed (→pending, attempts bumped) but NOT abandoned: the next sweep re-drives it. + const row = await rowState(id); + expect(row.state).toBe("pending"); + expect(row.attempts).toBe(2); + }); +}); + +/** Wrap a D1Database so the `getAssessment` read (`FROM assessments`) throws a + * transient (non-TypeError) error; every other statement delegates. */ +function dbThrowingOnAssessments(real: D1Database): D1Database { + const wrapStmt = (stmt: D1PreparedStatement): D1PreparedStatement => + new Proxy(stmt, { + get(target, prop, receiver) { + if (prop === "bind") + return (...args: unknown[]) => + wrapStmt((target.bind as (...a: unknown[]) => D1PreparedStatement)(...args)); + if (prop === "first") + return async () => { + throw new Error("transient D1 read"); + }; + const v = Reflect.get(target, prop, receiver); + return typeof v === "function" ? v.bind(target) : v; + }, + }); + return new Proxy(real, { + get(target, prop, receiver) { + if (prop === "prepare") + return (query: string) => { + const stmt = target.prepare(query); + return query.includes("FROM assessments") ? wrapStmt(stmt) : stmt; + }; + const v = Reflect.get(target, prop, receiver); + return typeof v === "function" ? v.bind(target) : v; + }, + }); +} + +async function exists(id: string): Promise { + const r = await db().prepare(`SELECT 1 FROM notifications WHERE id = ?`).bind(id).first(); + return r !== null; +} diff --git a/apps/labeler/test/notification-triggers.test.ts b/apps/labeler/test/notification-triggers.test.ts new file mode 100644 index 0000000000..265652f459 --- /dev/null +++ b/apps/labeler/test/notification-triggers.test.ts @@ -0,0 +1,381 @@ +import { applyD1Migrations, env } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { AggregatorClient } from "../src/aggregator-client.js"; +import type { Assessment } from "../src/assessment-store.js"; +import { + confirmContact, + ensureContact, + hashConfirmToken, + recipientHash, + recordConfirmSent, + suppress, +} from "../src/notification-contacts.js"; +import type { ConfirmationPayload, NoticePayload, SendResult } from "../src/notification-send.js"; +import { + notifyAssessmentOutcome, + notifyEmergencyTakedown, + notifyOperatorLabel, + notifyOverride, + notifyOverrideRetract, + type NotifyDeps, +} from "../src/notification-triggers.js"; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; +} +const testEnv = env as unknown as TestEnv; +const db = () => testEnv.DB; + +const PEPPER = "trig-pepper"; +const SERVICE = "https://labels.example"; +const RECON = "https://recon.example/reconsider"; +const DID = "did:plc:x"; +const RELEASE_URI = `at://${DID}/com.emdashcms.experimental.package.release/rel1`; + +let counter = 0; +const uniq = (p: string) => `${p}-${++counter}`; + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); +}); + +interface AggregatorOpts { + email?: string; + verifications?: unknown[]; + verifyStatus?: number; +} + +function aggregatorFor(opts: AggregatorOpts): AggregatorClient { + const fetcher = { + fetch: async (url: string) => { + if (url.includes("getPublisherVerification")) { + if (opts.verifyStatus !== undefined && opts.verifyStatus !== 200) + return new Response("err", { status: opts.verifyStatus }); + return Response.json({ did: DID, verifications: opts.verifications ?? [], labels: [] }); + } + if (url.includes("getPublisher") && opts.email !== undefined) { + return Response.json({ + did: DID, + profile: { contact: [{ kind: "security", email: opts.email }] }, + }); + } + return new Response(JSON.stringify({ error: "NotFound" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + }, + } as unknown as Fetcher; + return new AggregatorClient(fetcher); +} + +interface RecordingSender { + confirmations: ConfirmationPayload[]; + notices: NoticePayload[]; + sendConfirmation(p: ConfirmationPayload): Promise; + sendNotice(p: NoticePayload): Promise; +} + +function recordingSender(result: SendResult = { ok: true, providerId: "p" }): RecordingSender { + const confirmations: ConfirmationPayload[] = []; + const notices: NoticePayload[] = []; + return { + confirmations, + notices, + sendConfirmation: async (p) => { + confirmations.push(p); + return result; + }, + sendNotice: async (p) => { + notices.push(p); + return result; + }, + }; +} + +function throwingSender(): RecordingSender { + return { + confirmations: [], + notices: [], + sendConfirmation: async () => { + throw new Error("transport exploded"); + }, + sendNotice: async () => { + throw new Error("transport exploded"); + }, + }; +} + +function deps(aggregator: AggregatorClient, sender: RecordingSender): NotifyDeps { + return { + db: db(), + aggregator, + sender, + pepper: PEPPER, + serviceUrl: SERVICE, + reconsiderationUrl: RECON, + }; +} + +function assessmentRow(overrides: Partial): Assessment { + return { + id: uniq("asmt"), + uri: RELEASE_URI, + cid: "bafycid", + state: "blocked", + publicSummary: "A finding was reported.", + ...overrides, + } as unknown as Assessment; +} + +async function seedConfirmed(email: string): Promise { + const hash = await recipientHash(PEPPER, email); + await ensureContact(db(), hash, "2026-07-16T00:00:00.000Z"); + const th = await hashConfirmToken("seed"); + await recordConfirmSent(db(), hash, th, 1_000); + await confirmContact(db(), hash, th, "2026-07-16T00:00:01.000Z"); + return hash; +} + +async function notificationRows(sourceId: string): Promise<{ kind: string; state: string }[]> { + const r = await db() + .prepare(`SELECT kind, state FROM notifications WHERE source_id = ?`) + .bind(sourceId) + .all<{ kind: string; state: string }>(); + return r.results ?? []; +} + +const IN_FORCE = [ + { issuer: "did:plc:issuer", handle: "x.test", createdAt: "2026-01-01T00:00:00.000Z" }, +]; +const EXPIRED = [ + { + issuer: "did:plc:issuer", + handle: "x.test", + createdAt: "2020-01-01T00:00:00.000Z", + expiresAt: "2021-01-01T00:00:00.000Z", + }, +]; + +describe("the five events each notify a confirmed publisher", () => { + it("automated block → notice from source 'issuance'", async () => { + const email = uniq("blk") + "@x.test"; + await seedConfirmed(email); + const sender = recordingSender(); + const a = assessmentRow({ state: "blocked" }); + + await notifyAssessmentOutcome(deps(aggregatorFor({ email }), sender), a); + + expect(sender.notices).toHaveLength(1); + expect(sender.notices[0]).toMatchObject({ + to: email, + subject: expect.stringContaining("blocked"), + }); + expect(await notificationRows(a.id)).toEqual([{ kind: "notice", state: "sent" }]); + }); + + it("automated warning → notice", async () => { + const email = uniq("warn") + "@x.test"; + await seedConfirmed(email); + const sender = recordingSender(); + const a = assessmentRow({ state: "warned" }); + + await notifyAssessmentOutcome(deps(aggregatorFor({ email }), sender), a); + + expect(sender.notices).toHaveLength(1); + expect(sender.notices[0]?.effect).toContain("warning"); + }); + + it("passed outcome does NOT notify", async () => { + const email = uniq("pass") + "@x.test"; + await seedConfirmed(email); + const sender = recordingSender(); + const a = assessmentRow({ state: "passed" }); + + await notifyAssessmentOutcome(deps(aggregatorFor({ email }), sender), a); + + expect(sender.notices).toHaveLength(0); + expect(await notificationRows(a.id)).toHaveLength(0); + }); + + it("operator label issue → notice from source 'operator'", async () => { + const email = uniq("lbl") + "@x.test"; + await seedConfirmed(email); + const sender = recordingSender(); + const actionId = uniq("oact"); + + await notifyOperatorLabel(deps(aggregatorFor({ email }), sender), { + actionId, + uri: RELEASE_URI, + cid: "bafycid", + val: "security-yanked", + neg: false, + }); + + expect(sender.notices).toHaveLength(1); + expect(await notificationRows(actionId)).toEqual([{ kind: "notice", state: "sent" }]); + }); + + it("operator override → notice", async () => { + const email = uniq("ovr") + "@x.test"; + await seedConfirmed(email); + const sender = recordingSender(); + const actionId = uniq("oact"); + + await notifyOverride(deps(aggregatorFor({ email }), sender), { + actionId, + uri: RELEASE_URI, + cid: "bafycid", + }); + + expect(sender.notices).toHaveLength(1); + expect(sender.notices[0]?.subject).toContain("unblocked"); + }); + + it("operator override-retract → notice", async () => { + const email = uniq("ovrr") + "@x.test"; + await seedConfirmed(email); + const sender = recordingSender(); + const actionId = uniq("oact"); + + await notifyOverrideRetract(deps(aggregatorFor({ email }), sender), { + actionId, + uri: RELEASE_URI, + cid: "bafycid", + }); + + expect(sender.notices).toHaveLength(1); + }); + + it("emergency takedown → notice", async () => { + const email = uniq("td") + "@x.test"; + await seedConfirmed(email); + const sender = recordingSender(); + const actionId = uniq("oact"); + + await notifyEmergencyTakedown(deps(aggregatorFor({ email }), sender), { + actionId, + uri: RELEASE_URI, + neg: false, + }); + + expect(sender.notices).toHaveLength(1); + expect(sender.notices[0]?.subject).toContain("taken down"); + }); +}); + +describe("dedup", () => { + it("an unchanged re-run for the same source does not re-notify", async () => { + const email = uniq("dedup") + "@x.test"; + await seedConfirmed(email); + const sender = recordingSender(); + const a = assessmentRow({ state: "blocked" }); + const d = deps(aggregatorFor({ email }), sender); + + await notifyAssessmentOutcome(d, a); + await notifyAssessmentOutcome(d, a); + + expect(sender.notices).toHaveLength(1); + expect(await notificationRows(a.id)).toHaveLength(1); + }); +}); + +describe("verified-publisher skip", () => { + it("an in-force verification claim delivers the notice without a confirmation mail", async () => { + const email = uniq("vok") + "@x.test"; + const sender = recordingSender(); + const a = assessmentRow({ state: "blocked" }); + + // The contact is UNCONFIRMED; verification upgrades it in place. + await notifyAssessmentOutcome( + deps(aggregatorFor({ email, verifications: IN_FORCE }), sender), + a, + ); + + expect(sender.notices).toHaveLength(1); + expect(sender.confirmations).toHaveLength(0); + const hash = await recipientHash(PEPPER, email); + const contact = await db() + .prepare(`SELECT confirm_state FROM notification_contacts WHERE recipient_hash = ?`) + .bind(hash) + .first<{ confirm_state: string }>(); + expect(contact?.confirm_state).toBe("confirmed"); + }); + + it("an EXPIRED claim falls back to double opt-in", async () => { + const email = uniq("vexp") + "@x.test"; + const sender = recordingSender(); + const a = assessmentRow({ state: "blocked" }); + + await notifyAssessmentOutcome( + deps(aggregatorFor({ email, verifications: EXPIRED }), sender), + a, + ); + + expect(sender.notices).toHaveLength(0); + expect(sender.confirmations).toHaveLength(1); + }); + + it("a verification read failure fails closed to double opt-in", async () => { + const email = uniq("vfail") + "@x.test"; + const sender = recordingSender(); + const a = assessmentRow({ state: "blocked" }); + + await notifyAssessmentOutcome(deps(aggregatorFor({ email, verifyStatus: 500 }), sender), a); + + expect(sender.notices).toHaveLength(0); + expect(sender.confirmations).toHaveLength(1); + }); + + it("a suppressed address gets NOTHING even when the publisher is verified", async () => { + const email = uniq("vsupp") + "@x.test"; + const hash = await recipientHash(PEPPER, email); + await suppress(db(), hash, "not_me", "2026-07-16T00:00:00.000Z", 1_000); + const sender = recordingSender(); + const a = assessmentRow({ state: "blocked" }); + + await notifyAssessmentOutcome( + deps(aggregatorFor({ email, verifications: IN_FORCE }), sender), + a, + ); + + expect(sender.notices).toHaveLength(0); + expect(sender.confirmations).toHaveLength(0); + }); +}); + +describe("provider hard-bounce", () => { + it("E_RECIPIENT_SUPPRESSED marks the row undeliverable and suppresses the address", async () => { + const email = uniq("bounce") + "@x.test"; + await seedConfirmed(email); + const hash = await recipientHash(PEPPER, email); + const sender = recordingSender({ + ok: false, + error: "E_RECIPIENT_SUPPRESSED", + suppress: "bounce", + }); + const a = assessmentRow({ state: "blocked" }); + + await notifyAssessmentOutcome(deps(aggregatorFor({ email }), sender), a); + + expect(await notificationRows(a.id)).toEqual([{ kind: "notice", state: "undeliverable" }]); + const supp = await db() + .prepare(`SELECT reason FROM notification_suppressions WHERE recipient_hash = ?`) + .bind(hash) + .first<{ reason: string }>(); + expect(supp?.reason).toBe("bounce"); + }); +}); + +describe("failure isolation", () => { + it("a sender that THROWS never propagates out of the trigger (the label is safe)", async () => { + const email = uniq("throw") + "@x.test"; + await seedConfirmed(email); + const a = assessmentRow({ state: "blocked" }); + + await expect( + notifyAssessmentOutcome(deps(aggregatorFor({ email }), throwingSender()), a), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/labeler/vitest.config.ts b/apps/labeler/vitest.config.ts index 10c2e48531..9b73bbd4e1 100644 --- a/apps/labeler/vitest.config.ts +++ b/apps/labeler/vitest.config.ts @@ -22,6 +22,13 @@ export default defineConfig({ }, plugins: [ cloudflareTest({ + // Use miniflare's LOCAL binding simulation, never a remote proxy to real + // Cloudflare. The unrestricted `send_email` binding (arbitrary recipients) + // would otherwise force a remote proxy session — real credentials, real + // mail. Locally, the EMAIL binding is simulated; adapter tests inject a + // fake `SendEmail` regardless (src/notification-email.ts takes the binding + // as a constructor arg), so no test depends on the local email behaviour. + remoteBindings: false, wrangler: { configPath: "./wrangler.jsonc" }, miniflare: { bindings: { diff --git a/apps/labeler/worker-configuration.d.ts b/apps/labeler/worker-configuration.d.ts index fbd732422b..5818dbbb72 100644 --- a/apps/labeler/worker-configuration.d.ts +++ b/apps/labeler/worker-configuration.d.ts @@ -6,9 +6,11 @@ interface __BaseEnv_Env { DB: D1Database; DISCOVERY_QUEUE: Queue; AI: Ai; + EMAIL: SendEmail; ASSETS: Fetcher; LABELER_DID: "did:web:labels.emdashcms.com"; LABELER_SERVICE_URL: "https://labels.emdashcms.com"; + NOTIFICATION_FROM_ADDRESS: "notifications@emdashcms.com"; LABEL_SIGNING_KEY_VERSION: "v1"; LABEL_SIGNING_PUBLIC_KEY: "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ"; JETSTREAM_URL: "wss://jetstream2.us-east.bsky.network/subscribe"; @@ -32,7 +34,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/apps/labeler/wrangler.jsonc b/apps/labeler/wrangler.jsonc index 6aeeeb753f..11cdad3818 100644 --- a/apps/labeler/wrangler.jsonc +++ b/apps/labeler/wrangler.jsonc @@ -43,6 +43,12 @@ "ai": { "binding": "AI", }, + // Cloudflare Email Sending for publisher notifications (plan W10.5). The + // unrestricted binding form (no destination_address) permits arbitrary + // external recipients, which requires the emdashcms.com sending domain to be + // onboarded for Email Sending (`wrangler email sending enable`, operator + // prereq). Consumed via src/notification-email.ts. + "send_email": [{ "name": "EMAIL" }], // Read-only service binding to the aggregator Worker's XRPC surface // (fetch-over-binding; the aggregator is HTTP/XRPC-only, no RPC entrypoint). // Consumed via src/aggregator-client.ts. @@ -111,6 +117,9 @@ "vars": { "LABELER_DID": "did:web:labels.emdashcms.com", "LABELER_SERVICE_URL": "https://labels.emdashcms.com", + // From-address for publisher notifications, on the onboarded Email Sending + // domain (plan W10.5). Consumed via src/notification-email.ts. + "NOTIFICATION_FROM_ADDRESS": "notifications@emdashcms.com", "LABEL_SIGNING_KEY_VERSION": "v1", "LABEL_SIGNING_PUBLIC_KEY": "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ", "JETSTREAM_URL": "wss://jetstream2.us-east.bsky.network/subscribe", From 0beb062cfbc89d8436735533389c6910c69ab155 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 20:16:24 +0100 Subject: [PATCH 097/137] docs(labeler-plan): ratify W10.6 reconsideration design decisions Record the operator-side case model (reconsiderations + immutable reconsideration_notes), outcome vocabulary (granted/denied/withdrawn), the three console mutation actions, and the ratified 'pipeline notice on every resolve' outcome-notice decision. Note that requirements 1-2 (published contact instructions, opaque assessment ID) are already satisfied by the public policy document and existing public assessment id. Co-Authored-By: Claude Opus 4.8 --- .../implementation-plan.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 6a47aad8d5..de33760fd7 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1276,6 +1276,18 @@ No authenticated appeals portal or SLA is introduced. Dependencies: `W9.5`, `W10.5`. +Decisions (2026-07-16, ratified): **Requirements 1 and 2 are already satisfied** — the "monitored contact instructions" are published machine-readably today (`/.well-known/emdash-labeler-policy.json` serializes the whole `moderation-policy.json`, including the `contact` block with `reconsiderationUrl` + `reconsiderationEmail`), and the "opaque assessment ID" a publisher quotes is the existing public `asmt_` (the `assessments.id` PK, emitted by `toPublicAssessment` and in every notice's `assessmentUrl`). No new public/XRPC surface is built; the prose instructions page lives externally on emdashcms.com and is operator-owned. W10.6's new code is purely the operator-side CASE MANAGEMENT plus the outcome notice. + +**Case model.** New mutable table `reconsiderations` keyed on subject `uri` + `cid` (STABLE across reruns — a reconsideration is about "this release," and reruns mint fresh `asmt_` ids), with a `triggering_assessment_id` reference for context. State `open` → `resolved`. At most one `open` case per (`uri`,`cid`) via a partial unique index; after resolution a fresh case may be opened. Records `opened_by`/`opened_at` and `resolved_by`/`resolved_at` actor+timestamp provenance and the resolution `outcome`. Private operator notes go in an append-only, immutable child table `reconsideration_notes` (id, `reconsideration_id`, actor fields, note text, `created_at`) — NOT on `operator_actions` (immutable but semi-public: its `reason` leaks into notices/events) — matching the codebase's append-only-audit ethos. Next migration is `0009_reconsiderations.sql`. + +**Outcome vocabulary.** `granted` (assessment changed in the publisher's favour — the operator separately runs the existing rerun/override/override-retract actions, which carry their own audit + notices; resolving records that the request was granted), `denied` (assessment upheld, no label change — the ONLY outcome for which nothing else notifies), `withdrawn` (requester withdrew / duplicate / moot — no notice). + +**Operator actions** (all via `guardMutation`/`commitMutation`, adding purely-additive `OperatorActionType` union members — no hand-rolled auth/CSRF/idempotency): `reconsideration-open` (reviewer; creates the case + an initial private note, emits a `reconsideration-opened` `operational_event`), `reconsideration-note` (reviewer; appends a private note), `reconsideration-resolve` (reviewer; sets `outcome`+state, emits `reconsideration-resolved` event, and — per the ratified notice decision — fires the outcome notice). Resolve issues NO label (closest existing analog is `runDeadLetterAction`: a status UPDATE guarded by current-state so a double-resolve is a 409, plus an ungated `operational_event` in the same batch). Reruns/overrides are NOT re-implemented — the operator uses the existing actions and the case's private notes/outcome tie them together for audit. + +**Outcome notice — "Pipeline notice on every resolve" (Matt's decision).** A `granted` or `denied` resolve fires a new `reconsideration-outcome` notice through the existing W10.5 double-opt-in pipeline, `source_type: 'operator'`, `source_id`: the resolve `operator_action` id — so dedup/replay-safety key on the resolve action and the sweep can re-render it. Requires: a `notifyReconsiderationOutcome` trigger + `reconsiderationOutcomeNoticeContent` builder in `notification-triggers.ts` (public summary + assessment URL + outcome + reconsideration URL, NO private detail — the private notes NEVER enter notice copy), a `deferReconsiderationNotify` helper called post-commit, and a `reconsideration-resolve` case in `resolveNoticeForSource` (else the retry sweep abandons the row). `withdrawn` fires no notice. On a `granted` outcome this notice layers on top of any override/retract notice — accepted: they carry distinct messages ("your block was lifted" vs "your reconsideration request was granted"), and both are double-opt-in + suppression gated like every substantive notice. New `operational_event` types `reconsideration-opened`/`reconsideration-resolved` (additive `OperationalEventType` members, severity `info`). + +**Console UI.** Case surface attaches to `AssessmentDetail.tsx` (the action hub) following the existing dialog-per-action + `invalidateKeys` pattern: an "Open reconsideration" / "Add note" / "Resolve" block showing the open case, its private notes, and the outcome. Localized + RTL-safe. (Console is not yet localized per W10.3 status; match the surrounding console conventions.) + ### `W10.7` Decide/implement optional ATProto decision notice Only if `W0.1` ratifies it: From d85240f70d6b413405637c617c240c143b9cc8a9 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 06:24:42 +0100 Subject: [PATCH 098/137] docs(labeler-plan): ratify prolonged-error escalation design (W10.5 follow-up) Two-stage ladder driven by the reconciliation cron: 24h -> operator operational_event, 72h -> publisher notice if the error is still the live unsuperseded run. Records the fire-once tracking table (0010), the issuance-source reuse that avoids a notifications.source_type rebuild, and the finalization-path invariant (assessmentNoticeContent stays null for error so only the cron notifies). Migration 0010 lands after 0009 (#2083). Co-Authored-By: Claude Opus 4.8 --- .../plugin-registry-labelling-service/implementation-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index de33760fd7..8a1366efd9 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1260,6 +1260,8 @@ Decisions (2026-07-16, ratified): **Source reference** — the new `notification Sliced (2026-07-16): **slice 1** — migration `0008` (`notifications` delivery table: polymorphic source, `kind` confirmation|notice, `state` pending/sent/failed/undeliverable, `attempts`/`last_error`/`provider_id`, nullable `plaintext_email` cleared on successful send, `recipient_hash`, timestamps) + CSPRNG confirm-token generation (base64url ≥128 bits from `crypto.getRandomValues`, hashed via the existing `hashConfirmToken`, persisted via the existing `recordConfirmSent`) + the gated send-orchestration CORE against an injected `NotificationSender` interface (no real email adapter yet, tested with a fake sender). The core is the security surface: fresh contact resolve → `recipientHash` → ATOMIC suppression re-check at send → confirm-state gate (`getContactState`/`canSendConfirm`, NEVER `seeded:true`): `confirmed` → substantive notice; `unconfirmed` → only if per-address (`last_confirm_sent_at_epoch_ms`) AND per-DID rate limits both pass → generate+record token + send confirmation mail (never a substantive notice); `declined`/`suppressed`/no-contact → mark undeliverable, no send. Per-DID confirmation rate limit needs its own queryable ledger keyed by publisher DID (count distinct-recipient confirmation sends in a window) — a malicious DID naming many `victim@x` is the threat per-address limits don't cover. STRICT double opt-in for ALL publishers in slice 1 (verified-publisher-skip-confirmation deferred to slice 2 — sending a content-neutral confirmation mail to a verified publisher is safe/more conservative, just higher friction). **slice 2** — the Cloudflare Email Sending adapter (`send_email` binding + wrangler config + vitest stub + `EmailMessage` with html+text bodies + confirmation-mail and substantive-notice templates carrying subject/label/public summary/assessment URL/effect/reconsideration, no private detail) implementing `NotificationSender`, + trigger wiring at the five issuance points (in-batch-guarded via the `gateOnIssuedLabelActionKey`/`requireAssessmentState` idiom so a signing-suppressed batch queues no dangling notification row), + verified-publisher-skip-confirmation via the W8.4 slice-3 `getPublisherVerification` read, + the cron retry sweep and 30-day undeliverable-row cleanup as a third `ctx.waitUntil` branch in `scheduled()`. +Prolonged-error follow-up (2026-07-17, ratified): the deferred sixth event is now designed as a TWO-STAGE escalation ladder driven by the reconciliation cron. **Threshold + audience (Matt's decision): 24h → operator alert; 72h → publisher notice if still unresolved.** An assessment in the terminal `error` state (retries already exhausted; `completed_at` stamped; `assessment-error` label issued; `error` has no exit transition and does not move the current-assessment pointer) that is NOT superseded — no newer run exists for the same `(uri, cid)` — escalates: at `PROLONGED_ERROR_OPERATOR_THRESHOLD_MS` (24h past `completed_at_epoch_ms`) a `assessment-prolonged-error` operational_event (severity `high`, `subject_uri` = assessment uri, `assessmentId` in payload, null `action_id`) is raised so operators can triage an infra-vs-publisher cause before the publisher is emailed; at `PROLONGED_ERROR_PUBLISHER_THRESHOLD_MS` (72h) — only if the error is still the live unsuperseded run — the publisher notice fires. Rationale for the ladder: an `error` is the labeler FAILING to assess (often our infra, sometimes a publisher-fixable artifact URL), so operators get a chance to self-heal before we email the publisher about our own failure. **Fire-once mechanism:** a small tracking table `assessment_error_escalations` (migration `0010`; `assessment_id` PK, `subject_uri`, `subject_cid`, nullable `operator_alerted_at_epoch_ms` / `publisher_notified_at_epoch_ms`, `created_at_epoch_ms`) makes each stage idempotent across the 5-minute cron ticks — the cron upserts the row, alerts once when `operator_alerted_at` is null, notifies once when past 72h and `publisher_notified_at` is null. **Source reuse (avoids a `notifications.source_type` CHECK rebuild):** the publisher notice reuses `source_type = 'issuance'`, `source_id = ` — an errored assessment never produces a block/warn notice, so the `(issuance, id)` key never collides with a finalization notice; a SEPARATE `prolongedErrorNoticeContent` builder is added, and `resolveNoticeForSource`'s issuance branch routes by loaded assessment state (`blocked`/`warned` → `assessmentNoticeContent`; `error` → `prolongedErrorNoticeContent`; else null). The finalization path's `assessmentNoticeContent` KEEPS returning null for `error` so `notifyAssessmentOutcome` never fires on error — only the cron's 72h path notifies. `assessment-prolonged-error` is an additive `OperationalEventType` member. Double-opt-in + suppression gating and `contactTargetFromUri(assessment.uri)` are reused unchanged, so an unconfirmed publisher gets only the neutral confirmation mail. Migration-order dependency: `0010` must land after the reconsideration `0009` (#2083), so this PR merges after #2083. + Slice-1 outcome (2026-07-16, ratified after the send-core adversary pass): **the confirmation mail is a true LIFETIME cap — at most one per address, EVER**, not one-per-interval (Matt's decision; the earlier "at most one, ever" wording is now literally enforced, superseding the per-address interval gate). Mechanism: the confirmation-path claim is a single atomic `INSERT ... SELECT ... WHERE NOT EXISTS (suppression) AND NOT EXISTS (prior non-undeliverable kind='confirmation' row for this recipient_hash)`, which enforces not-suppressed + lifetime-cap + concurrency (a racing second send's guard sees the first's row and inserts nothing) in one statement. `state != 'undeliverable'` is deliberate so a prior that never actually mailed (race-loser/suppressed-at-claim) does not block a later legitimate confirmation. Consequence: a persistently-named victim receives at most one content-neutral confirmation mail total, and one click of "not me" (→ declined+suppressed) stops everything permanently; an unconfirmed legit publisher who ignores that one mail is never re-nudged and keeps the public assessment API + reconsideration inbox as their channel (the double-opt-in bargain). The per-DID confirmation ledger cap is BEST-EFFORT under concurrency (count→check→write is not atomic — bounded overshoot admits more distinct victims but never more than one mail per victim, since the per-address lifetime cap is the hard bound); exact per-DID serialization needs a Durable Object and is deferred to slice 2. Slice-2 contracts recorded from the adversary pass: (a) the `NotificationSender` implementation MUST NOT put the confirm URL or raw token into its returned `error` string — it is persisted verbatim in `notifications.last_error` and never cleared, so a token there would leak to the DB; (b) a `failed` confirmation row retains `plaintext_email` but only the token HASH (not the raw token), so the slice-2 retry sweep must REGENERATE the token when re-sending a failed confirmation, not attempt to reuse stored data; (c) because `pending` and `failed` confirmation rows HOLD the lifetime cap, a crash-stuck `pending` row or an exhausted-retry `failed` row permanently forecloses the email channel for that address unless the sweep handles them — the slice-2 sweep MUST re-drive stuck `pending` rows (not just `failed`), and when it terminally abandons a row it MUST flip it to `undeliverable` (which re-opens the cap for a later legitimate send) rather than leaving a dead `failed` row as a silent permanent lockout. Dependencies: `W2.3`, `W10.3`, `W10.4`. From 8bb7bb092ab0532f3f27b68f4c8ede503cffd62b Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 06:55:09 +0100 Subject: [PATCH 099/137] docs(labeler-plan): record prolonged-error adversary fixes + Finding 2 acceptance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two MED fixes (scan-window LEFT JOIN so escalated rows leave the window; gated operator-alert insert against overlapping cron ticks). Finding 2 (the backlog/rollout head-start collapse) accepted per Matt's decision — no since-alert head-start gating; steady-state keeps the full 24h->72h ladder. Co-Authored-By: Claude Opus 4.8 --- .../plugin-registry-labelling-service/implementation-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 8a1366efd9..e1debccf25 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1260,7 +1260,7 @@ Decisions (2026-07-16, ratified): **Source reference** — the new `notification Sliced (2026-07-16): **slice 1** — migration `0008` (`notifications` delivery table: polymorphic source, `kind` confirmation|notice, `state` pending/sent/failed/undeliverable, `attempts`/`last_error`/`provider_id`, nullable `plaintext_email` cleared on successful send, `recipient_hash`, timestamps) + CSPRNG confirm-token generation (base64url ≥128 bits from `crypto.getRandomValues`, hashed via the existing `hashConfirmToken`, persisted via the existing `recordConfirmSent`) + the gated send-orchestration CORE against an injected `NotificationSender` interface (no real email adapter yet, tested with a fake sender). The core is the security surface: fresh contact resolve → `recipientHash` → ATOMIC suppression re-check at send → confirm-state gate (`getContactState`/`canSendConfirm`, NEVER `seeded:true`): `confirmed` → substantive notice; `unconfirmed` → only if per-address (`last_confirm_sent_at_epoch_ms`) AND per-DID rate limits both pass → generate+record token + send confirmation mail (never a substantive notice); `declined`/`suppressed`/no-contact → mark undeliverable, no send. Per-DID confirmation rate limit needs its own queryable ledger keyed by publisher DID (count distinct-recipient confirmation sends in a window) — a malicious DID naming many `victim@x` is the threat per-address limits don't cover. STRICT double opt-in for ALL publishers in slice 1 (verified-publisher-skip-confirmation deferred to slice 2 — sending a content-neutral confirmation mail to a verified publisher is safe/more conservative, just higher friction). **slice 2** — the Cloudflare Email Sending adapter (`send_email` binding + wrangler config + vitest stub + `EmailMessage` with html+text bodies + confirmation-mail and substantive-notice templates carrying subject/label/public summary/assessment URL/effect/reconsideration, no private detail) implementing `NotificationSender`, + trigger wiring at the five issuance points (in-batch-guarded via the `gateOnIssuedLabelActionKey`/`requireAssessmentState` idiom so a signing-suppressed batch queues no dangling notification row), + verified-publisher-skip-confirmation via the W8.4 slice-3 `getPublisherVerification` read, + the cron retry sweep and 30-day undeliverable-row cleanup as a third `ctx.waitUntil` branch in `scheduled()`. -Prolonged-error follow-up (2026-07-17, ratified): the deferred sixth event is now designed as a TWO-STAGE escalation ladder driven by the reconciliation cron. **Threshold + audience (Matt's decision): 24h → operator alert; 72h → publisher notice if still unresolved.** An assessment in the terminal `error` state (retries already exhausted; `completed_at` stamped; `assessment-error` label issued; `error` has no exit transition and does not move the current-assessment pointer) that is NOT superseded — no newer run exists for the same `(uri, cid)` — escalates: at `PROLONGED_ERROR_OPERATOR_THRESHOLD_MS` (24h past `completed_at_epoch_ms`) a `assessment-prolonged-error` operational_event (severity `high`, `subject_uri` = assessment uri, `assessmentId` in payload, null `action_id`) is raised so operators can triage an infra-vs-publisher cause before the publisher is emailed; at `PROLONGED_ERROR_PUBLISHER_THRESHOLD_MS` (72h) — only if the error is still the live unsuperseded run — the publisher notice fires. Rationale for the ladder: an `error` is the labeler FAILING to assess (often our infra, sometimes a publisher-fixable artifact URL), so operators get a chance to self-heal before we email the publisher about our own failure. **Fire-once mechanism:** a small tracking table `assessment_error_escalations` (migration `0010`; `assessment_id` PK, `subject_uri`, `subject_cid`, nullable `operator_alerted_at_epoch_ms` / `publisher_notified_at_epoch_ms`, `created_at_epoch_ms`) makes each stage idempotent across the 5-minute cron ticks — the cron upserts the row, alerts once when `operator_alerted_at` is null, notifies once when past 72h and `publisher_notified_at` is null. **Source reuse (avoids a `notifications.source_type` CHECK rebuild):** the publisher notice reuses `source_type = 'issuance'`, `source_id = ` — an errored assessment never produces a block/warn notice, so the `(issuance, id)` key never collides with a finalization notice; a SEPARATE `prolongedErrorNoticeContent` builder is added, and `resolveNoticeForSource`'s issuance branch routes by loaded assessment state (`blocked`/`warned` → `assessmentNoticeContent`; `error` → `prolongedErrorNoticeContent`; else null). The finalization path's `assessmentNoticeContent` KEEPS returning null for `error` so `notifyAssessmentOutcome` never fires on error — only the cron's 72h path notifies. `assessment-prolonged-error` is an additive `OperationalEventType` member. Double-opt-in + suppression gating and `contactTargetFromUri(assessment.uri)` are reused unchanged, so an unconfirmed publisher gets only the neutral confirmation mail. Migration-order dependency: `0010` must land after the reconsideration `0009` (#2083), so this PR merges after #2083. +Prolonged-error follow-up (2026-07-17, ratified): the deferred sixth event is now designed as a TWO-STAGE escalation ladder driven by the reconciliation cron. **Threshold + audience (Matt's decision): 24h → operator alert; 72h → publisher notice if still unresolved.** An assessment in the terminal `error` state (retries already exhausted; `completed_at` stamped; `assessment-error` label issued; `error` has no exit transition and does not move the current-assessment pointer) that is NOT superseded — no newer run exists for the same `(uri, cid)` — escalates: at `PROLONGED_ERROR_OPERATOR_THRESHOLD_MS` (24h past `completed_at_epoch_ms`) a `assessment-prolonged-error` operational_event (severity `high`, `subject_uri` = assessment uri, `assessmentId` in payload, null `action_id`) is raised so operators can triage an infra-vs-publisher cause before the publisher is emailed; at `PROLONGED_ERROR_PUBLISHER_THRESHOLD_MS` (72h) — only if the error is still the live unsuperseded run — the publisher notice fires. Rationale for the ladder: an `error` is the labeler FAILING to assess (often our infra, sometimes a publisher-fixable artifact URL), so operators get a chance to self-heal before we email the publisher about our own failure. **Fire-once mechanism:** a small tracking table `assessment_error_escalations` (migration `0010`; `assessment_id` PK, `subject_uri`, `subject_cid`, nullable `operator_alerted_at_epoch_ms` / `publisher_notified_at_epoch_ms`, `created_at_epoch_ms`) makes each stage idempotent across the 5-minute cron ticks — the cron upserts the row, alerts once when `operator_alerted_at` is null, notifies once when past 72h and `publisher_notified_at` is null. **Source reuse (avoids a `notifications.source_type` CHECK rebuild):** the publisher notice reuses `source_type = 'issuance'`, `source_id = ` — an errored assessment never produces a block/warn notice, so the `(issuance, id)` key never collides with a finalization notice; a SEPARATE `prolongedErrorNoticeContent` builder is added, and `resolveNoticeForSource`'s issuance branch routes by loaded assessment state (`blocked`/`warned` → `assessmentNoticeContent`; `error` → `prolongedErrorNoticeContent`; else null). The finalization path's `assessmentNoticeContent` KEEPS returning null for `error` so `notifyAssessmentOutcome` never fires on error — only the cron's 72h path notifies. `assessment-prolonged-error` is an additive `OperationalEventType` member. Double-opt-in + suppression gating and `contactTargetFromUri(assessment.uri)` are reused unchanged, so an unconfirmed publisher gets only the neutral confirmation mail. Migration-order dependency: `0010` must land after the reconsideration `0009` (#2083), so this PR merges after #2083. Adversary pass (2026-07-17): two MED fixes landed — (1) the escalation scan LEFT JOINs `assessment_error_escalations` and keeps only rows with work left, so a fully-escalated row drops out of the `LIMIT 200` window (otherwise a >200-error backlog starves newer errors indefinitely); (2) the operator-alert event insert is gated on the escalation still being un-alerted (Cloudflare does not serialize a cron trigger against its own next tick, so an unconditional insert could double-alert under overlap). **Finding 2 accepted (Matt's decision): the backlog/rollout head-start collapse stands** — an error first seen already past 72h fires the operator alert and the publisher notice in the same tick (no 24h→72h triage window); steady-state fresh errors keep the full head-start. Not enforcing a since-alert head-start keeps the two thresholds as the literal spec ("72h from error") and avoids rows lingering in the scan window. Slice-1 outcome (2026-07-16, ratified after the send-core adversary pass): **the confirmation mail is a true LIFETIME cap — at most one per address, EVER**, not one-per-interval (Matt's decision; the earlier "at most one, ever" wording is now literally enforced, superseding the per-address interval gate). Mechanism: the confirmation-path claim is a single atomic `INSERT ... SELECT ... WHERE NOT EXISTS (suppression) AND NOT EXISTS (prior non-undeliverable kind='confirmation' row for this recipient_hash)`, which enforces not-suppressed + lifetime-cap + concurrency (a racing second send's guard sees the first's row and inserts nothing) in one statement. `state != 'undeliverable'` is deliberate so a prior that never actually mailed (race-loser/suppressed-at-claim) does not block a later legitimate confirmation. Consequence: a persistently-named victim receives at most one content-neutral confirmation mail total, and one click of "not me" (→ declined+suppressed) stops everything permanently; an unconfirmed legit publisher who ignores that one mail is never re-nudged and keeps the public assessment API + reconsideration inbox as their channel (the double-opt-in bargain). The per-DID confirmation ledger cap is BEST-EFFORT under concurrency (count→check→write is not atomic — bounded overshoot admits more distinct victims but never more than one mail per victim, since the per-address lifetime cap is the hard bound); exact per-DID serialization needs a Durable Object and is deferred to slice 2. Slice-2 contracts recorded from the adversary pass: (a) the `NotificationSender` implementation MUST NOT put the confirm URL or raw token into its returned `error` string — it is persisted verbatim in `notifications.last_error` and never cleared, so a token there would leak to the DB; (b) a `failed` confirmation row retains `plaintext_email` but only the token HASH (not the raw token), so the slice-2 retry sweep must REGENERATE the token when re-sending a failed confirmation, not attempt to reuse stored data; (c) because `pending` and `failed` confirmation rows HOLD the lifetime cap, a crash-stuck `pending` row or an exhausted-retry `failed` row permanently forecloses the email channel for that address unless the sweep handles them — the slice-2 sweep MUST re-drive stuck `pending` rows (not just `failed`), and when it terminally abandons a row it MUST flip it to `undeliverable` (which re-opens the cap for a later legitimate send) rather than leaving a dead `failed` row as a silent permanent lockout. From 4c849c6ea6b229dffcdffe7f7cf29accb62b9b49 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 06:55:51 +0100 Subject: [PATCH 100/137] feat(labeler): reconsideration case management + outcome notice (W10.6 server) (#2083) * feat(labeler): reconsideration case management + outcome notice (W10.6 server) Operator-side manual reconsideration workflow. Publishers quote the opaque public assessment id (already served) to the monitored inbox; operators open a case, attach private notes, and resolve it with an outcome, which fires a publisher outcome notice through the W10.5 double-opt-in pipeline. - migration 0009: `reconsiderations` (mutable case keyed on subject uri+cid, stable across reruns; state open->resolved; partial unique index bounds one open case per subject; CHECK binds state<->outcome<->resolved provenance) and `reconsideration_notes` (append-only, immutable) for operator-only notes kept off the semi-public operator_actions.reason. - three reviewer console mutations (open/note/resolve) via guardMutation/ commitMutation: audit row + effects commit atomically; duplicate-open and double-resolve are 409s backstopped by the unique index and a state-guarded UPDATE. - reconsideration-outcome notice (granted/denied; withdrawn is silent) keyed on the resolve operator_action so the retry sweep re-renders identical public copy; private note text never enters notice copy. The notice is gated on the resolve winning its guarded UPDATE, on both the fresh and replay paths, so it fires at most once per case even under a concurrent-resolve + key-replay race. - read API: paginated case list + case-with-notes detail (reviewer-gated). Co-Authored-By: Claude Opus 4.8 * feat(labeler): gate reconsideration resolved-event on the race winner A losing concurrent resolve no-ops its state='open'-guarded UPDATE but its audit row still commits (needed for idempotency replay). Gate the reconsideration-resolved operational_event on the case's outcome_action_id being this action's, via a new gateOnResolvedReconsideration in-batch gate mirroring gateOnIssuedLabelActionKey, so the loser emits no phantom event claiming an outcome the case did not take. The loser still echoes its requested outcome in its own 200 descriptor; case state, event, and notice reflect only the winner. Addresses the emdashbot review tradeoff on #2083. Co-Authored-By: Claude Opus 4.8 * feat(labeler): fully enforce reconsideration resolve-provenance in the CHECK The table CHECK described a resolved row as carrying full provenance but only enforced outcome + resolved_at NOT NULL, leaving outcome_action_id, resolved_by_id, and resolved_at_epoch_ms to app code alone. Extend both branches so the schema enforces the whole lifecycle invariant: an open row carries zero resolve provenance; a resolved row carries all of it. Tests assert the CHECK rejects a partial-resolved row and an open row with stray provenance. Addresses the emdashbot re-review schema-defensiveness suggestion on #2083. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../migrations/0009_reconsiderations.sql | 82 ++ apps/labeler/src/console-api.ts | 53 ++ apps/labeler/src/console-mutation-api.ts | 427 ++++++++- apps/labeler/src/console-serialize.ts | 78 ++ apps/labeler/src/notification-triggers.ts | 66 ++ apps/labeler/src/operational-events.ts | 28 +- apps/labeler/src/operator-actions.ts | 5 +- apps/labeler/src/reconsiderations.ts | 343 ++++++++ .../test/console-reconsiderations.test.ts | 833 ++++++++++++++++++ 9 files changed, 1911 insertions(+), 4 deletions(-) create mode 100644 apps/labeler/migrations/0009_reconsiderations.sql create mode 100644 apps/labeler/src/reconsiderations.ts create mode 100644 apps/labeler/test/console-reconsiderations.test.ts diff --git a/apps/labeler/migrations/0009_reconsiderations.sql b/apps/labeler/migrations/0009_reconsiderations.sql new file mode 100644 index 0000000000..eebc808dd1 --- /dev/null +++ b/apps/labeler/migrations/0009_reconsiderations.sql @@ -0,0 +1,82 @@ +-- Publisher reconsideration case management (spec §18/§19, plan W10.6). Two +-- tables mirroring the operator_actions(immutable)/notifications(mutable) split: +-- +-- * `reconsiderations` — MUTABLE case, keyed on the subject release +-- (uri + cid) which is STABLE across reruns (a reconsideration is about +-- "this release"; a rerun mints a fresh assessment id). State open → +-- resolved; at most one open case per subject via a partial unique index. +-- * `reconsideration_notes` — APPEND-ONLY private operator notes, immutable +-- like the other audit logs. Kept OFF `operator_actions`, whose `reason` is +-- semi-public (folded into notices/events): a private note must never reach +-- notice copy. +-- +-- `triggering_assessment_id` is context only (the assessment the publisher +-- quoted when they wrote in); the case tracks the release, not that run. +-- +-- Timestamp columns that queries order on gain an integer `*_epoch_ms` sibling, +-- matching 0003/0004/0005 (RFC 3339 strings compare incorrectly across timezone +-- offsets in SQL). + +CREATE TABLE reconsiderations ( + id TEXT PRIMARY KEY, + subject_uri TEXT NOT NULL, + subject_cid TEXT NOT NULL, + triggering_assessment_id TEXT NOT NULL REFERENCES assessments(id), + state TEXT NOT NULL CHECK (state IN ('open', 'resolved')), + outcome TEXT CHECK (outcome IN ('granted', 'denied', 'withdrawn')), + opened_by_id TEXT NOT NULL, + opened_by_email TEXT, + opened_by_common_name TEXT, + opened_by_role TEXT NOT NULL CHECK (opened_by_role IN ('admin', 'reviewer')), + opened_at TEXT NOT NULL, + opened_at_epoch_ms INTEGER NOT NULL, + resolved_by_id TEXT, + resolved_by_email TEXT, + resolved_by_common_name TEXT, + resolved_at TEXT, + resolved_at_epoch_ms INTEGER, + outcome_action_id TEXT REFERENCES operator_actions(id), + CHECK ( + (state = 'open' AND outcome IS NULL AND resolved_at IS NULL AND resolved_at_epoch_ms IS NULL + AND resolved_by_id IS NULL AND outcome_action_id IS NULL) + OR (state = 'resolved' AND outcome IS NOT NULL AND resolved_at IS NOT NULL + AND resolved_at_epoch_ms IS NOT NULL AND resolved_by_id IS NOT NULL + AND outcome_action_id IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX idx_reconsiderations_open_subject + ON reconsiderations(subject_uri, subject_cid) WHERE state = 'open'; +CREATE INDEX idx_reconsiderations_state ON reconsiderations(state, opened_at_epoch_ms DESC); +CREATE INDEX idx_reconsiderations_subject + ON reconsiderations(subject_uri, subject_cid, opened_at_epoch_ms DESC); +CREATE INDEX idx_reconsiderations_opened ON reconsiderations(opened_at_epoch_ms DESC); +CREATE INDEX idx_reconsiderations_outcome_action ON reconsiderations(outcome_action_id) + WHERE outcome_action_id IS NOT NULL; + +CREATE TABLE reconsideration_notes ( + id TEXT PRIMARY KEY, + reconsideration_id TEXT NOT NULL REFERENCES reconsiderations(id), + author_id TEXT NOT NULL, + author_email TEXT, + author_common_name TEXT, + author_role TEXT NOT NULL CHECK (author_role IN ('admin', 'reviewer')), + note TEXT NOT NULL, + created_at TEXT NOT NULL, + created_at_epoch_ms INTEGER NOT NULL +); + +CREATE TRIGGER reconsideration_notes_immutable_update +BEFORE UPDATE ON reconsideration_notes +BEGIN + SELECT RAISE(ABORT, 'reconsideration notes are immutable'); +END; + +CREATE TRIGGER reconsideration_notes_immutable_delete +BEFORE DELETE ON reconsideration_notes +BEGIN + SELECT RAISE(ABORT, 'reconsideration notes are immutable'); +END; + +CREATE INDEX idx_reconsideration_notes_case + ON reconsideration_notes(reconsideration_id, created_at_epoch_ms ASC); diff --git a/apps/labeler/src/console-api.ts b/apps/labeler/src/console-api.ts index b4187c1d4a..3fef5a5001 100644 --- a/apps/labeler/src/console-api.ts +++ b/apps/labeler/src/console-api.ts @@ -41,6 +41,8 @@ import { serializeOperatorActionView, serializeOperatorFinding, serializePublisherHistory, + serializeReconsideration, + serializeReconsiderationNote, serializeSubjectLabel, serializeSubjectRecord, type Page, @@ -51,6 +53,11 @@ import { computeEffectPreview, computeOverrideEffectPreview } from "./label-effe import { MutationGuardError } from "./mutation-guard.js"; import { getOperatorActionsPage } from "./operator-actions.js"; import { guardRead, ReadGuardError, type ReadGuardDeps } from "./operator-read-guard.js"; +import { + getNotesForReconsideration, + getReconsiderationById, + getReconsiderationsPage, +} from "./reconsiderations.js"; import { assertNegatableBlockSet, NegatableBlockSetError, parseSubjectKind } from "./service.js"; const DEFAULT_LIMIT = 50; @@ -132,6 +139,11 @@ function matchRoute( return () => handleListAuditLog(request, url, deps); } else if (segments[0] === "dead-letters" && segments.length === 1) { return () => handleListDeadLetters(request, url, deps); + } else if (segments[0] === "reconsiderations") { + if (segments.length === 1) return () => handleListReconsiderations(request, url, deps); + const id = segments[1]; + if (id !== undefined && segments.length === 2) + return () => handleGetReconsideration(request, deps, id); } else if (segments[0] === "status" && segments.length === 1) { return () => handleGetStatus(request, deps); } else if (segments[0] === "whoami" && segments.length === 1) { @@ -391,6 +403,47 @@ function parseCursorId(raw: string): number { return id; } +/** Lists reconsideration cases newest-first for the operator console (plan + * W10.6). Keyset pagination on `opened_at`, mirroring the audit log. */ +async function handleListReconsiderations( + request: Request, + url: URL, + deps: ConsoleApiDeps, +): Promise { + requireGet(request); + const limit = parseLimit(url.searchParams); + const filterHash = await computeFilterHash({}); + const keyset = decodeReadCursor(url.searchParams.get("cursor"), filterHash); + + const rows = await getReconsiderationsPage(deps.db, keyset, limit); + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + const last = page.at(-1); + const body: Page> = { + items: page.map(serializeReconsideration), + ...(hasMore && last + ? { nextCursor: encodeCursor({ createdAt: last.openedAt, id: last.id }, filterHash) } + : {}), + }; + return jsonData(body); +} + +/** One reconsideration case plus its private note thread (oldest-first). */ +async function handleGetReconsideration( + request: Request, + deps: ConsoleApiDeps, + id: string, +): Promise { + requireGet(request); + const reconsideration = await getReconsiderationById(deps.db, id); + if (!reconsideration) throw new ReadGuardError("NOT_FOUND"); + const notes = await getNotesForReconsideration(deps.db, id); + return jsonData({ + reconsideration: serializeReconsideration(reconsideration), + notes: notes.map(serializeReconsiderationNote), + }); +} + async function handleGetStatus(request: Request, deps: ConsoleApiDeps): Promise { requireGet(request); const [pendingAssessments, deadLetterDepth, automation, jetstreamConnected] = await Promise.all([ diff --git a/apps/labeler/src/console-mutation-api.ts b/apps/labeler/src/console-mutation-api.ts index f0ae55555b..088c7b4253 100644 --- a/apps/labeler/src/console-mutation-api.ts +++ b/apps/labeler/src/console-mutation-api.ts @@ -15,7 +15,12 @@ import type { LabelSigner } from "@emdash-cms/registry-moderation"; import { ulid } from "ulidx"; -import type { AccessAuthConfig, AccessKeyResolver } from "./access-auth.js"; +import type { + AccessAuthConfig, + AccessKeyResolver, + OperatorIdentity, + OperatorRole, +} from "./access-auth.js"; import { automatedIdempotencyKey, computeRunKey, @@ -50,6 +55,7 @@ import { notifyOperatorLabel, notifyOverride, notifyOverrideRetract, + notifyReconsiderationOutcome, type NotifyDeps, } from "./notification-triggers.js"; import { @@ -60,6 +66,17 @@ import { } from "./operational-events.js"; import { ReadGuardError } from "./operator-read-guard.js"; import { getLabelDefinition, MODERATION_POLICY } from "./policy.js"; +import { + buildReconsiderationInsert, + buildReconsiderationNoteInsert, + buildReconsiderationResolveUpdate, + getOpenCaseForSubject, + getReconsiderationById, + isOpenReconsiderationConflict, + newReconsiderationId, + newReconsiderationNoteId, + type ReconsiderationOutcome, +} from "./reconsiderations.js"; import { assertNegatableBlockSet, LabelIssuanceUnavailableError, @@ -205,6 +222,15 @@ export async function handleConsoleMutation( if (segments[2] === "quarantine") return await runDeadLetterAction(request, deps, id, "quarantined"); } + if (segments[0] === "reconsiderations") { + if (segments.length === 2 && segments[1] === "open") + return await runReconsiderationOpen(request, deps); + if (segments.length === 3 && segments[1] !== undefined) { + const id = segments[1]; + if (segments[2] === "note") return await runReconsiderationNote(request, deps, id); + if (segments[2] === "resolve") return await runReconsiderationResolve(request, deps, id); + } + } throw new ReadGuardError("NOT_FOUND"); } catch (error) { if ( @@ -212,7 +238,8 @@ export async function handleConsoleMutation( error instanceof ReadGuardError || error instanceof LabelMutationError || error instanceof DeadLetterResolvedError || - error instanceof NoActiveLabelError + error instanceof NoActiveLabelError || + error instanceof ReconsiderationStateError ) return error.toResponse(); // A submitted override negation set that isn't exactly the live automated @@ -590,6 +617,24 @@ function deferTakedownNotify(deps: ConsoleMutationDeps, d: EmergencyDescriptor): ); } +/** A resolve fires an outcome notice only for `granted`/`denied`; `withdrawn` + * notifies nothing. Keyed on the resolve action id so a replay dedups. */ +function deferReconsiderationNotify( + deps: ConsoleMutationDeps, + d: ReconsiderationResolveDescriptor, +): void { + if (!deps.notify) return; + if (d.outcome !== "granted" && d.outcome !== "denied") return; + deps.defer( + notifyReconsiderationOutcome(deps.notify, { + actionId: d.actionId, + uri: d.uri, + cid: d.cid, + outcome: d.outcome, + }), + ); +} + /** * `POST /admin/api/assessments/:id/rerun` — mints the immutable operator trigger * (`operator:`), creates a fresh run for the assessment's exact URI+CID @@ -1336,3 +1381,381 @@ function parseDeadLetterId(raw: string): number { if (!Number.isSafeInteger(id)) throw new ReadGuardError("NOT_FOUND"); return id; } + +// ─── W10.6: reconsideration case management + outcome notice ────────────────── + +const NOTE_MAX_LENGTH = 10_000; +const RECONSIDERATION_OUTCOMES: ReadonlySet = new Set(["granted", "denied", "withdrawn"]); + +type ReconsiderationStateCode = "RECONSIDERATION_OPEN_EXISTS" | "RECONSIDERATION_RESOLVED"; + +/** A case-state conflict: a second open for a subject that already has an open + * case, or a resolve of an already-resolved case. A 409 rather than a spurious + * success. Static messages; neither echoes operator-supplied content. */ +class ReconsiderationStateError extends Error { + override readonly name = "ReconsiderationStateError"; + readonly code: ReconsiderationStateCode; + readonly status = 409; + + constructor(code: ReconsiderationStateCode) { + super( + code === "RECONSIDERATION_OPEN_EXISTS" + ? "An open reconsideration already exists for this subject" + : "Reconsideration is already resolved", + ); + this.code = code; + } + + toResponse(): Response { + return Response.json( + { error: { code: this.code, message: this.message } }, + { status: this.status }, + ); + } +} + +interface ReconsiderationOpenBody { + assessmentId: string; + note: string; +} + +/** `:id` folded into the parsed body so it joins the request fingerprint — a + * replayed key must target the same case. */ +interface ReconsiderationNoteBody { + reconsiderationId: string; + note: string; +} + +interface ReconsiderationResolveBody { + reconsiderationId: string; + outcome: ReconsiderationOutcome; + note?: string; +} + +interface ReconsiderationOpenDescriptor { + actionId: string; + reconsiderationId: string; + uri: string; + cid: string; + triggeringAssessmentId: string; + cts: string; +} + +interface ReconsiderationNoteDescriptor { + actionId: string; + reconsiderationId: string; + noteId: string; + cts: string; +} + +interface ReconsiderationResolveDescriptor { + actionId: string; + reconsiderationId: string; + outcome: ReconsiderationOutcome; + uri: string; + cid: string; + cts: string; +} + +/** The actor provenance columns shared by the case and its notes, derived like + * `commitMutation`'s audit actor: a human carries an email, a service a common name. */ +function reconsiderationActor(ctx: { identity: OperatorIdentity; role: OperatorRole }): { + id: string; + email: string | null; + commonName: string | null; + role: OperatorRole; +} { + return { + id: ctx.identity.sub, + email: ctx.identity.kind === "human" ? ctx.identity.email : null, + commonName: ctx.identity.kind === "service" ? ctx.identity.commonName : null, + role: ctx.role, + }; +} + +function requireNote(value: unknown): string { + if (typeof value !== "string" || value.trim().length === 0 || value.length > NOTE_MAX_LENGTH) + throw new MutationGuardError("INVALID_BODY"); + return value; +} + +function parseOutcome(value: unknown): ReconsiderationOutcome { + if (typeof value !== "string" || !RECONSIDERATION_OUTCOMES.has(value)) + throw new MutationGuardError("INVALID_BODY"); + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- validated against the set above + return value as ReconsiderationOutcome; +} + +/** + * `POST /admin/api/reconsiderations/open` — opens a case for the subject of the + * quoted assessment (uri + cid, stable across reruns) with its first private + * note, and emits a `reconsideration-opened` event, all in one atomic + * `commitMutation` batch with the audit row. Issues no label and sends no notice. + * A subject that already has an open case is a 409: the pre-check handles the + * common path, and the partial unique index closes the race between it and the + * commit (mapped back to the same 409 via `isOpenReconsiderationConflict`). + */ +async function runReconsiderationOpen( + request: Request, + deps: ConsoleMutationDeps, +): Promise { + const spec: MutationSpec = { + action: "reconsideration-open", + requiredRole: "reviewer", + parseBody: (raw) => ({ + assessmentId: requireString(raw.assessmentId), + note: requireNote(raw.note), + }), + auditFields: () => ({}), + }; + + const outcome = await guardMutation(request, spec, guardDepsOf(deps)); + if (outcome.outcome === "replay") + return jsonData(storedDescriptor(outcome.result)); + + const { ctx } = outcome; + const assessment = await loadAssessment(deps.db, ctx.body.assessmentId); + if (await getOpenCaseForSubject(deps.db, assessment.uri, assessment.cid)) + throw new ReconsiderationStateError("RECONSIDERATION_OPEN_EXISTS"); + + const reconsiderationId = newReconsiderationId(); + const actor = reconsiderationActor(ctx); + const caseInsert = buildReconsiderationInsert(deps.db, { + id: reconsiderationId, + subjectUri: assessment.uri, + subjectCid: assessment.cid, + triggeringAssessmentId: assessment.id, + openedById: actor.id, + openedByEmail: actor.email, + openedByCommonName: actor.commonName, + openedByRole: actor.role, + openedAt: ctx.now.toISOString(), + openedAtEpochMs: ctx.now.getTime(), + }); + const noteInsert = buildReconsiderationNoteInsert(deps.db, { + id: newReconsiderationNoteId(), + reconsiderationId, + authorId: actor.id, + authorEmail: actor.email, + authorCommonName: actor.commonName, + authorRole: actor.role, + note: ctx.body.note, + createdAt: ctx.now.toISOString(), + createdAtEpochMs: ctx.now.getTime(), + }); + const eventInsert = buildOperationalEventInsert(deps.db, { + id: newOperationalEventId(), + eventType: "reconsideration-opened", + severity: "info", + actionId: ctx.actionId, + subjectUri: assessment.uri, + payload: { reason: ctx.reason }, + now: ctx.now, + }); + + const descriptor: ReconsiderationOpenDescriptor = { + actionId: ctx.actionId, + reconsiderationId, + uri: assessment.uri, + cid: assessment.cid, + triggeringAssessmentId: assessment.id, + cts: ctx.now.toISOString(), + }; + const commitSpec: MutationSpec = { + ...spec, + auditFields: () => ({ + subjectUri: assessment.uri, + subjectCid: assessment.cid, + metadata: { reconsiderationId }, + }), + }; + try { + return jsonData( + await commitMutation( + deps.db, + ctx, + commitSpec, + [caseInsert, noteInsert, eventInsert], + descriptor, + ), + ); + } catch (error) { + if (isOpenReconsiderationConflict(error)) + throw new ReconsiderationStateError("RECONSIDERATION_OPEN_EXISTS"); + throw error; + } +} + +/** + * `POST /admin/api/reconsiderations/:id/note` — appends one private note. A note + * is allowed in any state (a resolved case still accepts post-hoc audit notes), + * so the only gate is that the case exists (404). Commits the note INSERT with + * the audit row; no event, no notice. + */ +async function runReconsiderationNote( + request: Request, + deps: ConsoleMutationDeps, + id: string, +): Promise { + const spec: MutationSpec = { + action: "reconsideration-note", + requiredRole: "reviewer", + parseBody: (raw) => ({ reconsiderationId: id, note: requireNote(raw.note) }), + auditFields: () => ({}), + }; + + const outcome = await guardMutation(request, spec, guardDepsOf(deps)); + if (outcome.outcome === "replay") + return jsonData(storedDescriptor(outcome.result)); + + const { ctx } = outcome; + const existing = await getReconsiderationById(deps.db, id); + if (!existing) throw new ReadGuardError("NOT_FOUND"); + + const actor = reconsiderationActor(ctx); + const noteId = newReconsiderationNoteId(); + const noteInsert = buildReconsiderationNoteInsert(deps.db, { + id: noteId, + reconsiderationId: id, + authorId: actor.id, + authorEmail: actor.email, + authorCommonName: actor.commonName, + authorRole: actor.role, + note: ctx.body.note, + createdAt: ctx.now.toISOString(), + createdAtEpochMs: ctx.now.getTime(), + }); + + const descriptor: ReconsiderationNoteDescriptor = { + actionId: ctx.actionId, + reconsiderationId: id, + noteId, + cts: ctx.now.toISOString(), + }; + const commitSpec: MutationSpec = { + ...spec, + auditFields: () => ({ + subjectUri: existing.subjectUri, + subjectCid: existing.subjectCid, + metadata: { reconsiderationId: id }, + }), + }; + return jsonData(await commitMutation(deps.db, ctx, commitSpec, [noteInsert], descriptor)); +} + +/** + * `POST /admin/api/reconsiderations/:id/resolve` — sets the outcome + state, + * emits a `reconsideration-resolved` event, and (for `granted`/`denied`) fires + * the outcome notice through the W10.5 pipeline. Issues no label. The resolve is + * a `state = 'open'`-guarded UPDATE (like `runDeadLetterAction`): a double-resolve + * is a zero-row UPDATE, and the pre-check reports it as a 409. The notice fires + * only when this action actually won the UPDATE, so a losing concurrent resolve + * does not re-notify under its own action id. + */ +async function runReconsiderationResolve( + request: Request, + deps: ConsoleMutationDeps, + id: string, +): Promise { + const spec: MutationSpec = { + action: "reconsideration-resolve", + requiredRole: "reviewer", + parseBody: (raw) => ({ + reconsiderationId: id, + outcome: parseOutcome(raw.outcome), + ...(raw.note === undefined ? {} : { note: requireNote(raw.note) }), + }), + auditFields: () => ({}), + }; + + const outcome = await guardMutation(request, spec, guardDepsOf(deps)); + if (outcome.outcome === "replay") { + const stored = storedDescriptor(outcome.result); + // Gate the replay notice on the same win check as the fresh path: a resolve + // that lost the guarded UPDATE (a concurrent distinct-key resolve) committed + // its audit row but never resolved the case, so replaying its key must not + // fire a second, possibly contradictory outcome notice under its own source id. + if (await resolveWon(deps.db, stored.reconsiderationId, stored.actionId)) + deferReconsiderationNotify(deps, stored); + return jsonData(stored); + } + + const { ctx } = outcome; + const existing = await getReconsiderationById(deps.db, id); + if (!existing) throw new ReadGuardError("NOT_FOUND"); + if (existing.state !== "open") throw new ReconsiderationStateError("RECONSIDERATION_RESOLVED"); + + const actor = reconsiderationActor(ctx); + const resolveUpdate = buildReconsiderationResolveUpdate(deps.db, { + id, + outcome: ctx.body.outcome, + resolvedById: actor.id, + resolvedByEmail: actor.email, + resolvedByCommonName: actor.commonName, + resolvedAt: ctx.now.toISOString(), + resolvedAtEpochMs: ctx.now.getTime(), + outcomeActionId: ctx.actionId, + }); + const statements: D1PreparedStatement[] = [resolveUpdate]; + if (ctx.body.note !== undefined) { + statements.push( + buildReconsiderationNoteInsert(deps.db, { + id: newReconsiderationNoteId(), + reconsiderationId: id, + authorId: actor.id, + authorEmail: actor.email, + authorCommonName: actor.commonName, + authorRole: actor.role, + note: ctx.body.note, + createdAt: ctx.now.toISOString(), + createdAtEpochMs: ctx.now.getTime(), + }), + ); + } + // The event is gated on this action winning the `state = 'open'` UPDATE, so a + // losing concurrent resolve (distinct idempotency key) commits its audit row but + // emits no phantom resolved-event. That loser still echoes its own requested + // outcome in its 200 descriptor; the authoritative case state, the resolved + // event, and the outcome notice all reflect only the race winner. + statements.push( + buildOperationalEventInsert(deps.db, { + id: newOperationalEventId(), + eventType: "reconsideration-resolved", + severity: "info", + actionId: ctx.actionId, + subjectUri: existing.subjectUri, + payload: { reason: ctx.reason }, + now: ctx.now, + gateOnResolvedReconsideration: { reconsiderationId: id, actionId: ctx.actionId }, + }), + ); + + const descriptor: ReconsiderationResolveDescriptor = { + actionId: ctx.actionId, + reconsiderationId: id, + outcome: ctx.body.outcome, + uri: existing.subjectUri, + cid: existing.subjectCid, + cts: ctx.now.toISOString(), + }; + const commitSpec: MutationSpec = { + ...spec, + auditFields: () => ({ + subjectUri: existing.subjectUri, + subjectCid: existing.subjectCid, + metadata: { reconsiderationId: id, outcome: ctx.body.outcome }, + }), + }; + const returned = await commitMutation(deps.db, ctx, commitSpec, statements, descriptor); + if (await resolveWon(deps.db, id, returned.actionId)) deferReconsiderationNotify(deps, returned); + return jsonData(returned); +} + +/** Whether this action won the `state = 'open'` resolve race: the case's + * `outcome_action_id` is ours. A loser that committed its audit row yet matched + * zero rows reads back the winner's id and skips its notice, so the outcome + * notice fires exactly once. */ +async function resolveWon(db: D1Database, id: string, actionId: string): Promise { + const current = await getReconsiderationById(db, id); + return current?.outcomeActionId === actionId; +} diff --git a/apps/labeler/src/console-serialize.ts b/apps/labeler/src/console-serialize.ts index 0eb151cece..84c9639deb 100644 --- a/apps/labeler/src/console-serialize.ts +++ b/apps/labeler/src/console-serialize.ts @@ -30,6 +30,12 @@ import type { FindingSeverity } from "./evidence.js"; import type { FindingSource } from "./findings.js"; import type { StoredOperatorAction } from "./operator-actions.js"; import { derivePublicState } from "./public-assessment.js"; +import type { + ReconsiderationOutcome, + ReconsiderationState, + StoredReconsideration, + StoredReconsiderationNote, +} from "./reconsiderations.js"; export interface AssessmentRun { id: string; @@ -165,6 +171,38 @@ export interface DeadLetter { resolvedByActionId: string | null; } +/** A reconsideration case for the operator console. */ +export interface ReconsiderationView { + id: string; + subjectUri: string; + subjectCid: string; + triggeringAssessmentId: string; + state: ReconsiderationState; + outcome: ReconsiderationOutcome | null; + openedById: string; + openedByEmail: string | null; + openedByCommonName: string | null; + openedByRole: OperatorRole; + openedAt: string; + resolvedById: string | null; + resolvedByEmail: string | null; + resolvedByCommonName: string | null; + resolvedAt: string | null; + outcomeActionId: string | null; +} + +/** A private reconsideration note for the operator console. */ +export interface ReconsiderationNoteView { + id: string; + reconsiderationId: string; + authorId: string; + authorEmail: string | null; + authorCommonName: string | null; + authorRole: OperatorRole; + note: string; + createdAt: string; +} + export interface Page { items: T[]; nextCursor?: string; @@ -296,6 +334,46 @@ export function serializeOperatorActionView(action: StoredOperatorAction): Opera }; } +/** A reconsideration case for the reviewer console. Every field is operator-only + * (Access-edge + reviewer gated), including the actor provenance. */ +export function serializeReconsideration(row: StoredReconsideration): ReconsiderationView { + return { + id: row.id, + subjectUri: row.subjectUri, + subjectCid: row.subjectCid, + triggeringAssessmentId: row.triggeringAssessmentId, + state: row.state, + outcome: row.outcome, + openedById: row.openedById, + openedByEmail: row.openedByEmail, + openedByCommonName: row.openedByCommonName, + openedByRole: row.openedByRole, + openedAt: row.openedAt, + resolvedById: row.resolvedById, + resolvedByEmail: row.resolvedByEmail, + resolvedByCommonName: row.resolvedByCommonName, + resolvedAt: row.resolvedAt, + outcomeActionId: row.outcomeActionId, + }; +} + +/** A private reconsideration note. The `note` text is operator-only and NEVER + * enters publisher notice copy. */ +export function serializeReconsiderationNote( + row: StoredReconsiderationNote, +): ReconsiderationNoteView { + return { + id: row.id, + reconsiderationId: row.reconsiderationId, + authorId: row.authorId, + authorEmail: row.authorEmail, + authorCommonName: row.authorCommonName, + authorRole: row.authorRole, + note: row.note, + createdAt: row.createdAt, + }; +} + export function serializeDeadLetter(row: StoredDeadLetter): DeadLetter { return { id: row.id, diff --git a/apps/labeler/src/notification-triggers.ts b/apps/labeler/src/notification-triggers.ts index 0bdb6dcb9d..7fc48107a5 100644 --- a/apps/labeler/src/notification-triggers.ts +++ b/apps/labeler/src/notification-triggers.ts @@ -201,6 +201,36 @@ function emergencyNoticeContent( }; } +/** The two reconsideration outcomes that notify. `withdrawn` fires nothing, so + * it is deliberately absent from this union. */ +export type ReconsiderationNoticeOutcome = "granted" | "denied"; + +function reconsiderationOutcomeNoticeContent( + urls: NoticeUrls, + input: { uri: string; cid?: string; outcome: ReconsiderationNoticeOutcome }, +): NoticeContent { + const granted = input.outcome === "granted"; + const shared = { + assessmentUrl: assessmentUrl(urls.serviceUrl, input.uri, input.cid), + reconsiderationUrl: urls.reconsiderationUrl, + }; + return granted + ? { + subject: "Your reconsideration request was granted", + publicSummary: + "After reviewing your reconsideration request, the labeler revised its assessment of this release.", + effect: "Any resulting label changes are reflected in the current assessment.", + ...shared, + } + : { + subject: "Your reconsideration request was reviewed", + publicSummary: + "After reviewing your reconsideration request, the labeler upheld its assessment of this release.", + effect: "The assessment stands unchanged.", + ...shared, + }; +} + // ── Live trigger entry points ─────────────────────────────────────────────── /** Automated block/warning notice from a finalized assessment run. Source is the @@ -279,6 +309,23 @@ export async function notifyEmergencyTakedown( ); } +/** Reconsideration outcome notice (console `reconsiderations/:id/resolve`). Fired + * only for `granted`/`denied`; the caller never invokes it for `withdrawn`. Source + * is the resolve operator action, so a mutation replay dedups on it. */ +export async function notifyReconsiderationOutcome( + deps: NotifyDeps, + input: { actionId: string; uri: string; cid?: string; outcome: ReconsiderationNoticeOutcome }, +): Promise { + const target = contactTargetFromUri(input.uri); + if (!target) return; + await runTrigger( + deps, + { type: "operator", id: input.actionId }, + target, + reconsiderationOutcomeNoticeContent(deps, input), + ); +} + /** * Rebuild a NOTICE's public content from its source row, for the retry sweep. * Nothing about the notice is persisted (plaintext minimization), so a retry @@ -321,11 +368,30 @@ export async function resolveNoticeForSource( return overrideRetractNoticeContent(deps, { uri, cid }); case "takedown": return emergencyNoticeContent(deps, { uri, neg: operatorActionNeg(action.metadataJson) }); + case "reconsideration-resolve": { + const outcome = reconsiderationNoticeOutcome(action.metadataJson); + return outcome ? reconsiderationOutcomeNoticeContent(deps, { uri, cid, outcome }) : null; + } default: return null; } } +/** The notifying outcome a resolve action stored in its metadata, or null when it + * is `withdrawn` (no notice) or unreadable — the sweep then abandons the row. */ +function reconsiderationNoticeOutcome(metadataJson: string): ReconsiderationNoticeOutcome | null { + try { + const parsed: unknown = JSON.parse(metadataJson); + if (typeof parsed === "object" && parsed !== null) { + const outcome = (parsed as { outcome?: unknown }).outcome; + if (outcome === "granted" || outcome === "denied") return outcome; + } + } catch { + return null; + } + return null; +} + /** `getAssessment` throws `TypeError` only for a malformed id — which a stored * `source_id` never is — so that maps to "no notice" (null). Any OTHER error is a * transient read failure: it must PROPAGATE, not abandon the row, so the sweep diff --git a/apps/labeler/src/operational-events.ts b/apps/labeler/src/operational-events.ts index a26ad00471..5f40783ab9 100644 --- a/apps/labeler/src/operational-events.ts +++ b/apps/labeler/src/operational-events.ts @@ -11,7 +11,9 @@ export type OperationalEventType = | "automation-paused" | "automation-resumed" | "dead-letter-retried" - | "dead-letter-quarantined"; + | "dead-letter-quarantined" + | "reconsideration-opened" + | "reconsideration-resolved"; export type OperationalEventSeverity = "critical" | "high" | "info"; @@ -53,6 +55,14 @@ export interface OperationalEventInsert { * `issued_labels` row) inserts neither the event nor its outbox row. */ gateOnIssuedLabelActionKey?: string; + /** + * Gates the event on this reconsideration having been resolved by this action: + * `EXISTS (SELECT 1 FROM reconsiderations WHERE id = ? AND outcome_action_id = ?)`. + * Batched after the resolve UPDATE (which guards on `state = 'open'`), so a + * losing concurrent resolve — whose UPDATE matched zero rows — inserts no + * phantom resolved-event while its audit row still commits for replay. + */ + gateOnResolvedReconsideration?: { reconsiderationId: string; actionId: string }; } export interface OutboxInsert { @@ -160,6 +170,22 @@ export function buildOperationalEventInsert( .bind(...values, input.gateOnIssuedLabelActionKey); } + if (input.gateOnResolvedReconsideration !== undefined) { + return db + .prepare( + `INSERT INTO operational_events (${columns}) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ? + WHERE EXISTS ( + SELECT 1 FROM reconsiderations WHERE id = ? AND outcome_action_id = ? + )`, + ) + .bind( + ...values, + input.gateOnResolvedReconsideration.reconsiderationId, + input.gateOnResolvedReconsideration.actionId, + ); + } + if (input.gateOnIssuedLabelActionId !== undefined) { return db .prepare( diff --git a/apps/labeler/src/operator-actions.ts b/apps/labeler/src/operator-actions.ts index 24e1293d47..548b640bdc 100644 --- a/apps/labeler/src/operator-actions.ts +++ b/apps/labeler/src/operator-actions.ts @@ -16,7 +16,10 @@ export type OperatorActionType = | "pause-issuance" | "resume-issuance" | "dlq-retry" - | "dlq-quarantine"; + | "dlq-quarantine" + | "reconsideration-open" + | "reconsideration-note" + | "reconsideration-resolve"; export interface StoredOperatorAction { id: string; diff --git a/apps/labeler/src/reconsiderations.ts b/apps/labeler/src/reconsiderations.ts new file mode 100644 index 0000000000..66aef1f4c2 --- /dev/null +++ b/apps/labeler/src/reconsiderations.ts @@ -0,0 +1,343 @@ +import { ulid } from "ulidx"; + +import type { OperatorRole } from "./access-auth.js"; + +/** The resolution vocabulary (plan W10.6): `granted` (assessment revised in the + * publisher's favour), `denied` (assessment upheld), `withdrawn` (requester + * withdrew / duplicate / moot). Only `granted`/`denied` fire an outcome notice. */ +export type ReconsiderationOutcome = "granted" | "denied" | "withdrawn"; + +export type ReconsiderationState = "open" | "resolved"; + +export interface StoredReconsideration { + id: string; + subjectUri: string; + subjectCid: string; + triggeringAssessmentId: string; + state: ReconsiderationState; + outcome: ReconsiderationOutcome | null; + openedById: string; + openedByEmail: string | null; + openedByCommonName: string | null; + openedByRole: OperatorRole; + openedAt: string; + openedAtEpochMs: number; + resolvedById: string | null; + resolvedByEmail: string | null; + resolvedByCommonName: string | null; + resolvedAt: string | null; + resolvedAtEpochMs: number | null; + outcomeActionId: string | null; +} + +export interface StoredReconsiderationNote { + id: string; + reconsiderationId: string; + authorId: string; + authorEmail: string | null; + authorCommonName: string | null; + authorRole: OperatorRole; + note: string; + createdAt: string; + createdAtEpochMs: number; +} + +interface ReconsiderationRow { + id: string; + subject_uri: string; + subject_cid: string; + triggering_assessment_id: string; + state: ReconsiderationState; + outcome: ReconsiderationOutcome | null; + opened_by_id: string; + opened_by_email: string | null; + opened_by_common_name: string | null; + opened_by_role: OperatorRole; + opened_at: string; + opened_at_epoch_ms: number; + resolved_by_id: string | null; + resolved_by_email: string | null; + resolved_by_common_name: string | null; + resolved_at: string | null; + resolved_at_epoch_ms: number | null; + outcome_action_id: string | null; +} + +interface ReconsiderationNoteRow { + id: string; + reconsideration_id: string; + author_id: string; + author_email: string | null; + author_common_name: string | null; + author_role: OperatorRole; + note: string; + created_at: string; + created_at_epoch_ms: number; +} + +export interface ReconsiderationInsert { + id: string; + subjectUri: string; + subjectCid: string; + triggeringAssessmentId: string; + openedById: string; + openedByEmail: string | null; + openedByCommonName: string | null; + openedByRole: OperatorRole; + openedAt: string; + openedAtEpochMs: number; +} + +export interface ReconsiderationNoteInsert { + id: string; + reconsiderationId: string; + authorId: string; + authorEmail: string | null; + authorCommonName: string | null; + authorRole: OperatorRole; + note: string; + createdAt: string; + createdAtEpochMs: number; +} + +export interface ReconsiderationResolveUpdate { + id: string; + outcome: ReconsiderationOutcome; + resolvedById: string; + resolvedByEmail: string | null; + resolvedByCommonName: string | null; + resolvedAt: string; + resolvedAtEpochMs: number; + outcomeActionId: string; +} + +export function newReconsiderationId(): string { + return `rcn_${ulid()}`; +} + +export function newReconsiderationNoteId(): string { + return `rcnn_${ulid()}`; +} + +/** + * Insert for one case, always born `open` with a null outcome. A concurrent + * second open for the same subject violates `idx_reconsiderations_open_subject` + * and aborts the enclosing `db.batch` (see {@link isOpenReconsiderationConflict}). + * Batched with its first note + the operational event + the operator_actions row + * by `commitMutation`; never run alone. + */ +export function buildReconsiderationInsert( + db: D1Database, + input: ReconsiderationInsert, +): D1PreparedStatement { + return db + .prepare( + `INSERT INTO reconsiderations + (id, subject_uri, subject_cid, triggering_assessment_id, state, outcome, + opened_by_id, opened_by_email, opened_by_common_name, opened_by_role, + opened_at, opened_at_epoch_ms) + VALUES (?, ?, ?, ?, 'open', NULL, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + input.id, + input.subjectUri, + input.subjectCid, + input.triggeringAssessmentId, + input.openedById, + input.openedByEmail, + input.openedByCommonName, + input.openedByRole, + input.openedAt, + input.openedAtEpochMs, + ); +} + +/** Insert for one append-only private note. Batched with the operator_actions + * row by `commitMutation`; never run alone. */ +export function buildReconsiderationNoteInsert( + db: D1Database, + input: ReconsiderationNoteInsert, +): D1PreparedStatement { + return db + .prepare( + `INSERT INTO reconsideration_notes + (id, reconsideration_id, author_id, author_email, author_common_name, + author_role, note, created_at, created_at_epoch_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + input.id, + input.reconsiderationId, + input.authorId, + input.authorEmail, + input.authorCommonName, + input.authorRole, + input.note, + input.createdAt, + input.createdAtEpochMs, + ); +} + +/** + * Effect statement for a resolve. The `state = 'open'` predicate makes a second + * concurrent resolve a zero-row UPDATE: the row flips exactly once and + * `outcome_action_id` records the winner, so a loser that also committed its + * audit row is detected post-commit and skips its outcome notice. Batched with + * the operator_actions row + an optional final note + the operational event by + * `commitMutation`. + */ +export function buildReconsiderationResolveUpdate( + db: D1Database, + input: ReconsiderationResolveUpdate, +): D1PreparedStatement { + return db + .prepare( + `UPDATE reconsiderations + SET state = 'resolved', outcome = ?, resolved_by_id = ?, resolved_by_email = ?, + resolved_by_common_name = ?, resolved_at = ?, resolved_at_epoch_ms = ?, + outcome_action_id = ? + WHERE id = ? AND state = 'open'`, + ) + .bind( + input.outcome, + input.resolvedById, + input.resolvedByEmail, + input.resolvedByCommonName, + input.resolvedAt, + input.resolvedAtEpochMs, + input.outcomeActionId, + input.id, + ); +} + +const RECONSIDERATION_COLUMNS = `id, subject_uri, subject_cid, triggering_assessment_id, state, + outcome, opened_by_id, opened_by_email, opened_by_common_name, opened_by_role, + opened_at, opened_at_epoch_ms, resolved_by_id, resolved_by_email, resolved_by_common_name, + resolved_at, resolved_at_epoch_ms, outcome_action_id`; + +/** The single open case for a subject, or null. Backed by the partial unique + * index, so at most one row can match. */ +export async function getOpenCaseForSubject( + db: D1Database, + subjectUri: string, + subjectCid: string, +): Promise { + const row = await db + .prepare( + `SELECT ${RECONSIDERATION_COLUMNS} FROM reconsiderations + WHERE subject_uri = ? AND subject_cid = ? AND state = 'open'`, + ) + .bind(subjectUri, subjectCid) + .first(); + return row ? rowToStoredReconsideration(row) : null; +} + +export async function getReconsiderationById( + db: D1Database, + id: string, +): Promise { + const row = await db + .prepare(`SELECT ${RECONSIDERATION_COLUMNS} FROM reconsiderations WHERE id = ?`) + .bind(id) + .first(); + return row ? rowToStoredReconsideration(row) : null; +} + +/** + * Case page, newest first, exclusive keyset on `(opened_at_epoch_ms, id)` over + * `idx_reconsiderations_opened`. Fetches `limit + 1` so the caller detects a next + * page without a trailing COUNT — matching `getOperatorActionsPage`. + */ +export async function getReconsiderationsPage( + db: D1Database, + keyset: { createdAt: string; id: string } | null, + limit: number, +): Promise { + const bindings: (string | number)[] = []; + let where = ""; + if (keyset !== null) { + const epochMs = Date.parse(keyset.createdAt); + where = `WHERE (opened_at_epoch_ms < ? OR (opened_at_epoch_ms = ? AND id < ?))`; + bindings.push(epochMs, epochMs, keyset.id); + } + bindings.push(limit + 1); + const rows = await db + .prepare( + `SELECT ${RECONSIDERATION_COLUMNS} FROM reconsiderations ${where} + ORDER BY opened_at_epoch_ms DESC, id DESC + LIMIT ?`, + ) + .bind(...bindings) + .all(); + return (rows.results ?? []).map(rowToStoredReconsideration); +} + +/** Notes for one case, oldest first (the order an operator reads the thread). */ +export async function getNotesForReconsideration( + db: D1Database, + reconsiderationId: string, +): Promise { + const rows = await db + .prepare( + `SELECT id, reconsideration_id, author_id, author_email, author_common_name, + author_role, note, created_at, created_at_epoch_ms + FROM reconsideration_notes + WHERE reconsideration_id = ? + ORDER BY created_at_epoch_ms ASC, id ASC`, + ) + .bind(reconsiderationId) + .all(); + return (rows.results ?? []).map(rowToStoredReconsiderationNote); +} + +/** + * True when `error` is the D1 UNIQUE violation on the open-case partial index. + * `commitMutation` rethrows any non-idempotency-key violation, so the open + * handler catches this around its commit to report a duplicate-open 409 rather + * than a 500 on the race between its pre-check and the batch. + */ +export function isOpenReconsiderationConflict(error: unknown): boolean { + return ( + error instanceof Error && + error.message.includes( + "UNIQUE constraint failed: reconsiderations.subject_uri, reconsiderations.subject_cid", + ) + ); +} + +function rowToStoredReconsideration(row: ReconsiderationRow): StoredReconsideration { + return { + id: row.id, + subjectUri: row.subject_uri, + subjectCid: row.subject_cid, + triggeringAssessmentId: row.triggering_assessment_id, + state: row.state, + outcome: row.outcome, + openedById: row.opened_by_id, + openedByEmail: row.opened_by_email, + openedByCommonName: row.opened_by_common_name, + openedByRole: row.opened_by_role, + openedAt: row.opened_at, + openedAtEpochMs: row.opened_at_epoch_ms, + resolvedById: row.resolved_by_id, + resolvedByEmail: row.resolved_by_email, + resolvedByCommonName: row.resolved_by_common_name, + resolvedAt: row.resolved_at, + resolvedAtEpochMs: row.resolved_at_epoch_ms, + outcomeActionId: row.outcome_action_id, + }; +} + +function rowToStoredReconsiderationNote(row: ReconsiderationNoteRow): StoredReconsiderationNote { + return { + id: row.id, + reconsiderationId: row.reconsideration_id, + authorId: row.author_id, + authorEmail: row.author_email, + authorCommonName: row.author_common_name, + authorRole: row.author_role, + note: row.note, + createdAt: row.created_at, + createdAtEpochMs: row.created_at_epoch_ms, + }; +} diff --git a/apps/labeler/test/console-reconsiderations.test.ts b/apps/labeler/test/console-reconsiderations.test.ts new file mode 100644 index 0000000000..354838e27c --- /dev/null +++ b/apps/labeler/test/console-reconsiderations.test.ts @@ -0,0 +1,833 @@ +import { applyD1Migrations, env } from "cloudflare:test"; +import { generateKeyPair, SignJWT } from "jose"; +import { beforeAll, describe, expect, it } from "vitest"; + +import type { AccessKeyResolver } from "../src/access-auth.js"; +import { AggregatorClient } from "../src/aggregator-client.js"; +import { computeRunKey, initialTriggerId } from "../src/assessment-lifecycle.js"; +import { createAssessmentRun, createSubject } from "../src/assessment-store.js"; +import { handleConsoleApi, type ConsoleApiDeps } from "../src/console-api.js"; +import { handleConsoleMutation, type ConsoleMutationDeps } from "../src/console-mutation-api.js"; +import { OPERATOR_REQUEST_HEADER } from "../src/mutation-guard.js"; +import { + confirmContact, + ensureContact, + hashConfirmToken, + recipientHash, + recordConfirmSent, +} from "../src/notification-contacts.js"; +import type { ConfirmationPayload, NoticePayload, SendResult } from "../src/notification-send.js"; +import { resolveNoticeForSource, type NotifyDeps } from "../src/notification-triggers.js"; + +const TEAM_DOMAIN = "https://example-team.cloudflareaccess.com"; +const AUDIENCE = "test-audience"; +const ORIGIN = "https://labeler.example.com"; +const LABELER_DID = "did:web:labels.emdashcms.com"; +const LABELER_SERVICE_URL = "https://labels.emdashcms.com"; +const PUBLISHER_DID = "did:plc:publisher000000000000000000"; +const PUBLISHER_EMAIL = "security@publisher.example"; +const RECON_URL = "https://emdashcms.com/plugin-moderation/reconsideration"; +const PEPPER = "recon-pepper"; +const CID = "bafkreif4oaymum54i5qefbwoblrt5zasfjhpyhyvacpseqtehi3queew5m"; +const PRIVATE_NOTE = "PRIVATE-INTERNAL-secret-exploit-detail-do-not-leak"; + +const CONFIG = { + labelerDid: LABELER_DID, + signingKeyVersion: "v1", + serviceUrl: LABELER_SERVICE_URL, + signingPublicKeyMultibase: "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ", +}; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; +} +const testEnv = env as unknown as TestEnv; + +let resolver: AccessKeyResolver; +let signKey: CryptoKey; +let reviewerToken: string; +let keySeq = 0; + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); + const pair = await generateKeyPair("RS256"); + signKey = pair.privateKey; + resolver = (async () => pair.publicKey) as AccessKeyResolver; + reviewerToken = await mintToken({ email: "reviewer@example.com" }); +}); + +async function mintToken(claims: Record): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ sub: "user-sub-1", ...claims }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer(TEAM_DOMAIN) + .setAudience(AUDIENCE) + .setIssuedAt(now) + .setExpirationTime(now + 3600) + .sign(signKey); +} + +interface RecordingSender { + confirmations: ConfirmationPayload[]; + notices: NoticePayload[]; + sendConfirmation(p: ConfirmationPayload): Promise; + sendNotice(p: NoticePayload): Promise; +} + +function recordingSender(): RecordingSender { + const confirmations: ConfirmationPayload[] = []; + const notices: NoticePayload[] = []; + return { + confirmations, + notices, + sendConfirmation: async (p) => { + confirmations.push(p); + return { ok: true, providerId: "p" }; + }, + sendNotice: async (p) => { + notices.push(p); + return { ok: true, providerId: "p" }; + }, + }; +} + +/** Aggregator that surfaces the publisher's security email and no verification + * claims, so a notice takes the double-opt-in path against a confirmed contact. */ +function aggregator(): AggregatorClient { + const fetcher = { + fetch: async (url: string) => { + if (url.includes("getPublisherVerification")) + return Response.json({ did: PUBLISHER_DID, verifications: [], labels: [] }); + if (url.includes("getPublisher")) + return Response.json({ + did: PUBLISHER_DID, + profile: { contact: [{ kind: "security", email: PUBLISHER_EMAIL }] }, + }); + return new Response(JSON.stringify({ error: "NotFound" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + }, + } as unknown as Fetcher; + return new AggregatorClient(fetcher); +} + +function notifyDeps(sender: RecordingSender): NotifyDeps { + return { + db: testEnv.DB, + aggregator: aggregator(), + sender, + pepper: PEPPER, + serviceUrl: LABELER_SERVICE_URL, + reconsiderationUrl: RECON_URL, + }; +} + +/** A mutation-deps whose `defer` collects the post-commit promises so a test can + * await the deferred notice before asserting on the sender. */ +function mutationDeps(overrides: Partial = {}): { + deps: ConsoleMutationDeps; + settle: () => Promise; +} { + const deferred: Promise[] = []; + const deps: ConsoleMutationDeps = { + db: testEnv.DB, + accessConfig: { + teamDomain: TEAM_DOMAIN, + audience: AUDIENCE, + admins: ["admin@example.com"], + reviewers: ["reviewer@example.com"], + }, + keys: resolver, + config: CONFIG, + createSigner: () => Promise.reject(new Error("no signer needed")), + now: () => new Date(), + afterCommit: async () => {}, + defer: (work) => { + deferred.push(work); + }, + sendDiscoveryJob: async () => {}, + ...overrides, + }; + return { deps, settle: async () => void (await Promise.allSettled(deferred)) }; +} + +function readDeps(): ConsoleApiDeps { + return { + db: testEnv.DB, + config: { + teamDomain: TEAM_DOMAIN, + audience: AUDIENCE, + admins: ["admin@example.com"], + reviewers: ["reviewer@example.com"], + }, + keys: resolver, + expectedOrigin: ORIGIN, + labelerDid: LABELER_DID, + jetstreamConnected: async () => true, + }; +} + +function post(path: string, body: unknown): Request { + const headers = new Headers(); + headers.set(OPERATOR_REQUEST_HEADER, "1"); + headers.set("Content-Type", "application/json"); + headers.set("Cf-Access-Jwt-Assertion", reviewerToken); + return new Request(`${ORIGIN}${path}`, { method: "POST", headers, body: JSON.stringify(body) }); +} + +function getReq(path: string): Request { + const headers = new Headers(); + headers.set(OPERATOR_REQUEST_HEADER, "1"); + headers.set("Cf-Access-Jwt-Assertion", reviewerToken); + return new Request(`${ORIGIN}${path}`, { method: "GET", headers }); +} + +function nextKey(): string { + keySeq += 1; + return `recon-key-${keySeq.toString().padStart(6, "0")}`; +} + +function releaseUri(rkey: string): string { + return `at://${PUBLISHER_DID}/com.emdashcms.experimental.package.release/${rkey}`; +} + +async function bodyData(response: Response): Promise { + const parsed = (await response.json()) as { data: T }; + return parsed.data; +} + +async function bodyError(response: Response): Promise<{ code: string; message: string }> { + const parsed = (await response.json()) as { error: { code: string; message: string } }; + return parsed.error; +} + +async function countRows(sql: string, ...binds: unknown[]): Promise { + const row = await testEnv.DB.prepare(sql) + .bind(...binds) + .first<{ n: number }>(); + return row?.n ?? 0; +} + +/** Seeds a subject + one assessment run, returning its id + uri. */ +async function seedRun(rkey: string, cid = CID): Promise<{ id: string; uri: string }> { + const uri = releaseUri(rkey); + await createSubject(testEnv.DB, { + uri, + cid, + did: PUBLISHER_DID, + collection: "com.emdashcms.experimental.package.release", + rkey, + now: new Date("2026-07-08T08:00:00.000Z"), + }); + const triggerId = initialTriggerId(cid); + const runKey = await computeRunKey({ + uri, + cid, + policyVersion: "v1", + modelId: "m", + promptHash: "p", + scannerSetVersion: "v1", + triggerId, + }); + const { assessment } = await createAssessmentRun(testEnv.DB, { + runKey, + uri, + cid, + trigger: "initial", + triggerId, + policyVersion: "v1", + coverageJson: "{}", + now: new Date("2026-07-08T08:05:00.000Z"), + }); + return { id: assessment.id, uri }; +} + +/** Confirms a contact for the publisher email so the notice sends rather than + * kicking off a double-opt-in confirmation. */ +async function seedConfirmedContact(): Promise { + const hash = await recipientHash(PEPPER, PUBLISHER_EMAIL); + await ensureContact(testEnv.DB, hash, "2026-07-16T00:00:00.000Z"); + const th = await hashConfirmToken("seed"); + await recordConfirmSent(testEnv.DB, hash, th, 1_000); + await confirmContact(testEnv.DB, hash, th, "2026-07-16T00:00:01.000Z"); +} + +/** + * Wraps `db` so the FIRST `getReconsiderationById` SELECT returns `snapshot` (a + * stale `open` view) and then passes through. Reproduces a concurrent resolve + * whose pre-check read the case as open before the winner's guarded UPDATE + * landed: the loser's own UPDATE no-ops while its audit row still commits. + */ +function staleOpenOnce(db: D1Database, snapshot: Record): D1Database { + let used = false; + return new Proxy(db, { + get(target, prop, receiver) { + if (prop === "prepare") { + return (sql: string) => { + const stmt = target.prepare(sql); + if (used || !(sql.includes("FROM reconsiderations") && sql.includes("WHERE id = ?"))) + return stmt; + return new Proxy(stmt, { + get(s, p) { + if (p === "bind") + return (...args: unknown[]) => { + const bound = s.bind(...args); + return new Proxy(bound, { + get(b, bp) { + if (bp === "first") + return async () => { + used = true; + return snapshot; + }; + const value = Reflect.get(b, bp); + return typeof value === "function" ? value.bind(b) : value; + }, + }); + }; + const value = Reflect.get(s, p); + return typeof value === "function" ? value.bind(s) : value; + }, + }); + }; + } + const value = Reflect.get(target, prop, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }) as unknown as D1Database; +} + +async function openCase( + assessmentId: string, + deps: ConsoleMutationDeps, + note = "publisher wrote in about this release", +): Promise<{ response: Response; reconsiderationId: string }> { + const response = await handleConsoleMutation( + post("/admin/api/reconsiderations/open", { + assessmentId, + note, + reason: "opening a case", + idempotencyKey: nextKey(), + }), + deps, + ); + if (response.status !== 200) return { response, reconsiderationId: "" }; + const data = await bodyData<{ reconsiderationId: string }>(response.clone()); + return { response, reconsiderationId: data.reconsiderationId }; +} + +describe("reconsideration open", () => { + it("creates the case, its first note, the operational event, and the audit row", async () => { + const { id, uri } = await seedRun("open-basic"); + const { deps } = mutationDeps(); + const { response, reconsiderationId } = await openCase(id, deps); + expect(response.status).toBe(200); + expect(reconsiderationId).toMatch(/^rcn_/); + + const cases = await testEnv.DB.prepare( + `SELECT state, outcome, subject_uri, subject_cid, triggering_assessment_id, opened_by_email, opened_by_role + FROM reconsiderations WHERE id = ?`, + ) + .bind(reconsiderationId) + .first(); + expect(cases).toMatchObject({ + state: "open", + outcome: null, + subject_uri: uri, + subject_cid: CID, + triggering_assessment_id: id, + opened_by_email: "reviewer@example.com", + opened_by_role: "reviewer", + }); + expect( + await countRows( + `SELECT COUNT(*) n FROM reconsideration_notes WHERE reconsideration_id = ?`, + reconsiderationId, + ), + ).toBe(1); + expect( + await countRows( + `SELECT COUNT(*) n FROM operational_events WHERE event_type = 'reconsideration-opened' AND subject_uri = ?`, + uri, + ), + ).toBe(1); + expect( + await countRows( + `SELECT COUNT(*) n FROM operator_actions WHERE action = 'reconsideration-open' AND subject_uri = ?`, + uri, + ), + ).toBe(1); + }); + + it("409s a second open for a subject that already has an open case", async () => { + const { id } = await seedRun("open-dup"); + const { deps } = mutationDeps(); + const first = await openCase(id, deps); + expect(first.response.status).toBe(200); + + const second = await openCase(id, deps); + expect(second.response.status).toBe(409); + expect((await bodyError(second.response)).code).toBe("RECONSIDERATION_OPEN_EXISTS"); + expect( + await countRows( + `SELECT COUNT(*) n FROM reconsiderations WHERE subject_uri = ? AND state = 'open'`, + releaseUri("open-dup"), + ), + ).toBe(1); + }); + + it("404s an unknown assessment id", async () => { + const { deps } = mutationDeps(); + const response = await handleConsoleMutation( + post("/admin/api/reconsiderations/open", { + assessmentId: "asmt_00000000000000000000000000", + note: "no such run", + reason: "opening", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(response.status).toBe(404); + }); + + it("replays the stored descriptor without a second case", async () => { + const { id } = await seedRun("open-replay"); + const { deps } = mutationDeps(); + const body = { + assessmentId: id, + note: "once", + reason: "opening", + idempotencyKey: nextKey(), + }; + const first = await handleConsoleMutation(post("/admin/api/reconsiderations/open", body), deps); + const firstText = await first.text(); + const second = await handleConsoleMutation( + post("/admin/api/reconsiderations/open", body), + deps, + ); + expect(await second.text()).toBe(firstText); + expect( + await countRows( + `SELECT COUNT(*) n FROM reconsiderations WHERE subject_uri = ?`, + releaseUri("open-replay"), + ), + ).toBe(1); + }); +}); + +describe("reconsideration note", () => { + it("appends a note to an open case", async () => { + const { id } = await seedRun("note-open"); + const { deps } = mutationDeps(); + const { reconsiderationId } = await openCase(id, deps); + + const response = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/note`, { + note: "second note", + reason: "adding context", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(response.status).toBe(200); + expect( + await countRows( + `SELECT COUNT(*) n FROM reconsideration_notes WHERE reconsideration_id = ?`, + reconsiderationId, + ), + ).toBe(2); + }); + + it("404s a note on an unknown case", async () => { + const { deps } = mutationDeps(); + const response = await handleConsoleMutation( + post("/admin/api/reconsiderations/rcn_00000000000000000000000000/note", { + note: "orphan", + reason: "adding", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(response.status).toBe(404); + }); + + it("allows a note on a resolved case for post-hoc audit", async () => { + const { id } = await seedRun("note-resolved"); + const { deps } = mutationDeps(); + const { reconsiderationId } = await openCase(id, deps); + const resolved = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "withdrawn", + reason: "moot", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(resolved.status).toBe(200); + + const response = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/note`, { + note: "post-hoc", + reason: "audit", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(response.status).toBe(200); + }); +}); + +describe("reconsideration resolve", () => { + it("sets outcome, state, and provenance and fires a notice for granted", async () => { + await seedConfirmedContact(); + const { id, uri } = await seedRun("resolve-granted"); + const sender = recordingSender(); + const { deps, settle } = mutationDeps({ notify: notifyDeps(sender) }); + const { reconsiderationId } = await openCase(id, deps, PRIVATE_NOTE); + + const response = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "granted", + note: `${PRIVATE_NOTE} — resolving`, + reason: "reviewer granted after rerun", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(response.status).toBe(200); + await settle(); + + const row = await testEnv.DB.prepare( + `SELECT state, outcome, resolved_by_email, outcome_action_id FROM reconsiderations WHERE id = ?`, + ) + .bind(reconsiderationId) + .first(); + const descriptor = await bodyData<{ actionId: string }>(response); + expect(row).toMatchObject({ + state: "resolved", + outcome: "granted", + resolved_by_email: "reviewer@example.com", + outcome_action_id: descriptor.actionId, + }); + expect( + await countRows( + `SELECT COUNT(*) n FROM operational_events WHERE event_type = 'reconsideration-resolved' AND action_id = ?`, + descriptor.actionId, + ), + ).toBe(1); + + // The notice went out and its copy contains NONE of the private-note text. + expect(sender.notices).toHaveLength(1); + const notice = sender.notices[0]!; + expect(notice.subject).toBe("Your reconsideration request was granted"); + expect(notice.assessmentUrl).toContain(encodeURIComponent(uri)); + expect(notice.reconsiderationUrl).toBe(RECON_URL); + expect(JSON.stringify(notice)).not.toContain(PRIVATE_NOTE); + }); + + it("fires a notice for denied", async () => { + await seedConfirmedContact(); + const { id } = await seedRun("resolve-denied"); + const sender = recordingSender(); + const { deps, settle } = mutationDeps({ notify: notifyDeps(sender) }); + const { reconsiderationId } = await openCase(id, deps); + + const response = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "denied", + reason: "assessment upheld", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(response.status).toBe(200); + await settle(); + expect(sender.notices).toHaveLength(1); + expect(sender.notices[0]!.subject).toBe("Your reconsideration request was reviewed"); + }); + + it("fires NO notice for withdrawn", async () => { + await seedConfirmedContact(); + const { id } = await seedRun("resolve-withdrawn"); + const sender = recordingSender(); + const { deps, settle } = mutationDeps({ notify: notifyDeps(sender) }); + const { reconsiderationId } = await openCase(id, deps); + + const response = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "withdrawn", + reason: "requester withdrew", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(response.status).toBe(200); + await settle(); + expect(sender.notices).toHaveLength(0); + expect(sender.confirmations).toHaveLength(0); + }); + + it("409s a double-resolve", async () => { + const { id } = await seedRun("resolve-double"); + const { deps } = mutationDeps(); + const { reconsiderationId } = await openCase(id, deps); + + const first = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "denied", + reason: "first resolve", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(first.status).toBe(200); + + const second = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "granted", + reason: "second resolve", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(second.status).toBe(409); + expect((await bodyError(second)).code).toBe("RECONSIDERATION_RESOLVED"); + }); + + it("rejects an unknown outcome", async () => { + const { id } = await seedRun("resolve-badoutcome"); + const { deps } = mutationDeps(); + const { reconsiderationId } = await openCase(id, deps); + const response = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "maybe", + reason: "bad", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(response.status).toBe(400); + }); + + it("lets a fresh case open after resolution", async () => { + const { id } = await seedRun("resolve-reopen"); + const { deps } = mutationDeps(); + const first = await openCase(id, deps); + await handleConsoleMutation( + post(`/admin/api/reconsiderations/${first.reconsiderationId}/resolve`, { + outcome: "denied", + reason: "upheld", + idempotencyKey: nextKey(), + }), + deps, + ); + const second = await openCase(id, deps); + expect(second.response.status).toBe(200); + expect(second.reconsiderationId).not.toBe(first.reconsiderationId); + }); + + it("does not re-notify when a losing concurrent resolve's key is replayed", async () => { + await seedConfirmedContact(); + const { id, uri } = await seedRun("resolve-concurrent-loser"); + const sender = recordingSender(); + const { deps, settle } = mutationDeps({ notify: notifyDeps(sender) }); + const { reconsiderationId } = await openCase(id, deps); + + // The open snapshot both concurrent resolves' pre-checks would have read. + const openSnapshot = await testEnv.DB.prepare(`SELECT * FROM reconsiderations WHERE id = ?`) + .bind(reconsiderationId) + .first(); + expect(openSnapshot?.state).toBe("open"); + + // Winner A resolves granted and notifies. + const winner = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "granted", + reason: "A wins", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(winner.status).toBe(200); + await settle(); + const winnerActionId = (await bodyData<{ actionId: string }>(winner)).actionId; + expect(sender.notices).toHaveLength(1); + expect(sender.notices[0]!.subject).toBe("Your reconsideration request was granted"); + + // Loser B: its pre-check sees the stale open snapshot, so it reaches commit; + // its guarded UPDATE no-ops (case already resolved) but its audit row + stored + // descriptor persist with outcome=denied. Fresh path must not notify (it lost). + const loserBody = { outcome: "denied", reason: "B loses", idempotencyKey: nextKey() }; + const loserDeps = mutationDeps({ notify: notifyDeps(sender) }); + const loser = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, loserBody), + { ...loserDeps.deps, db: staleOpenOnce(testEnv.DB, openSnapshot!) }, + ); + expect(loser.status).toBe(200); + await loserDeps.settle(); + const loserDescriptor = await bodyData<{ actionId: string; outcome: string }>(loser); + expect(loserDescriptor.outcome).toBe("denied"); + // The case still records A's win, and no second notice fired. + const caseRow = await testEnv.DB.prepare( + `SELECT outcome, outcome_action_id FROM reconsiderations WHERE id = ?`, + ) + .bind(reconsiderationId) + .first<{ outcome: string; outcome_action_id: string }>(); + expect(caseRow?.outcome).toBe("granted"); + expect(caseRow?.outcome_action_id).not.toBe(loserDescriptor.actionId); + expect(sender.notices).toHaveLength(1); + // Exactly one resolved-event, the winner's — the loser's is gated out. + const events = await testEnv.DB.prepare( + `SELECT action_id FROM operational_events WHERE event_type = 'reconsideration-resolved' AND subject_uri = ?`, + ) + .bind(uri) + .all<{ action_id: string }>(); + expect(events.results).toHaveLength(1); + expect(events.results![0]!.action_id).toBe(winnerActionId); + + // Replaying B's key must NOT fire a second (contradictory 'denied') notice. + const replayDeps = mutationDeps({ notify: notifyDeps(sender) }); + const replay = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, loserBody), + replayDeps.deps, + ); + expect(replay.status).toBe(200); + await replayDeps.settle(); + expect(sender.notices).toHaveLength(1); + expect(sender.notices[0]!.subject).toBe("Your reconsideration request was granted"); + }); +}); + +describe("resolveNoticeForSource sweep parity", () => { + it("re-renders content identical to the live granted notice", async () => { + await seedConfirmedContact(); + const { id } = await seedRun("sweep-parity"); + const sender = recordingSender(); + const { deps, settle } = mutationDeps({ notify: notifyDeps(sender) }); + const { reconsiderationId } = await openCase(id, deps, PRIVATE_NOTE); + const response = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "granted", + reason: "granted", + idempotencyKey: nextKey(), + }), + deps, + ); + await settle(); + const descriptor = await bodyData<{ actionId: string }>(response); + + const rebuilt = await resolveNoticeForSource( + notifyDeps(sender), + "operator", + descriptor.actionId, + ); + expect(rebuilt).not.toBeNull(); + const live = sender.notices[0]!; + expect(rebuilt).toMatchObject({ + subject: live.subject, + publicSummary: live.publicSummary, + effect: live.effect, + assessmentUrl: live.assessmentUrl, + reconsiderationUrl: live.reconsiderationUrl, + }); + expect(JSON.stringify(rebuilt)).not.toContain(PRIVATE_NOTE); + }); + + it("re-renders null for a withdrawn resolve (sweep abandons the row)", async () => { + const { id } = await seedRun("sweep-withdrawn"); + const sender = recordingSender(); + const { deps } = mutationDeps({ notify: notifyDeps(sender) }); + const { reconsiderationId } = await openCase(id, deps); + const response = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "withdrawn", + reason: "moot", + idempotencyKey: nextKey(), + }), + deps, + ); + const descriptor = await bodyData<{ actionId: string }>(response); + expect( + await resolveNoticeForSource(notifyDeps(sender), "operator", descriptor.actionId), + ).toBeNull(); + }); +}); + +describe("reconsideration read API", () => { + it("lists cases newest-first and returns a case with its note thread", async () => { + const { id } = await seedRun("read-case"); + const { deps } = mutationDeps(); + const { reconsiderationId } = await openCase(id, deps, "first note"); + await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/note`, { + note: "second note", + reason: "context", + idempotencyKey: nextKey(), + }), + deps, + ); + + const list = await handleConsoleApi(getReq("/admin/api/reconsiderations"), readDeps()); + expect(list.status).toBe(200); + const listBody = await bodyData<{ items: { id: string; state: string }[] }>(list); + expect(listBody.items.some((c) => c.id === reconsiderationId && c.state === "open")).toBe(true); + + const detail = await handleConsoleApi( + getReq(`/admin/api/reconsiderations/${reconsiderationId}`), + readDeps(), + ); + expect(detail.status).toBe(200); + const detailBody = await bodyData<{ + reconsideration: { id: string; state: string }; + notes: { note: string }[]; + }>(detail); + expect(detailBody.reconsideration.id).toBe(reconsiderationId); + expect(detailBody.notes.map((n) => n.note)).toEqual(["first note", "second note"]); + }); + + it("404s an unknown case", async () => { + const response = await handleConsoleApi( + getReq("/admin/api/reconsiderations/rcn_00000000000000000000000000"), + readDeps(), + ); + expect(response.status).toBe(404); + }); +}); + +describe("reconsideration schema invariants", () => { + function insertCase(id: string, uri: string, triggeringId: string, columns: string, values: string) { + return testEnv.DB.prepare( + `INSERT INTO reconsiderations + (id, subject_uri, subject_cid, triggering_assessment_id, + opened_by_id, opened_by_role, opened_at, opened_at_epoch_ms, ${columns}) + VALUES (?, ?, ?, ?, 'op', 'reviewer', '2026-07-16T00:00:00.000Z', 1, ${values})`, + ).bind(id, uri, CID, triggeringId); + } + + it("rejects a resolved row missing its resolve provenance", async () => { + const { id: triggeringId, uri } = await seedRun("schema-partial-resolved"); + await expect( + insertCase( + "rcn_schema_partial_res", + uri, + triggeringId, + "state, outcome, resolved_at, resolved_at_epoch_ms, resolved_by_id", + "'resolved', 'granted', '2026-07-16T01:00:00.000Z', 2, 'op'", + ).run(), + ).rejects.toThrow(); + }); + + it("rejects an open row carrying resolve provenance", async () => { + const { id: triggeringId, uri } = await seedRun("schema-open-provenance"); + await expect( + insertCase( + "rcn_schema_open_prov", + uri, + triggeringId, + "state, resolved_by_id", + "'open', 'op'", + ).run(), + ).rejects.toThrow(); + }); +}); From 3cbae51c6095f1fd0730f97e416d71279902dd93 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Fri, 17 Jul 2026 05:57:25 +0000 Subject: [PATCH 101/137] style: format --- apps/labeler/test/console-reconsiderations.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/labeler/test/console-reconsiderations.test.ts b/apps/labeler/test/console-reconsiderations.test.ts index 354838e27c..87c1dd49e5 100644 --- a/apps/labeler/test/console-reconsiderations.test.ts +++ b/apps/labeler/test/console-reconsiderations.test.ts @@ -796,7 +796,13 @@ describe("reconsideration read API", () => { }); describe("reconsideration schema invariants", () => { - function insertCase(id: string, uri: string, triggeringId: string, columns: string, values: string) { + function insertCase( + id: string, + uri: string, + triggeringId: string, + columns: string, + values: string, + ) { return testEnv.DB.prepare( `INSERT INTO reconsiderations (id, subject_uri, subject_cid, triggering_assessment_id, From 7c0b753533bc7780971961d2d028deaed096b342 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 07:05:28 +0100 Subject: [PATCH 102/137] feat(labeler): reconsideration operator console UI (W10.6 client) (#2087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-facing surface for the reconsideration workflow over the slice-1 server API. Console-only; no server changes. - Reconsiderations list route + case-detail route (header, private note thread oldest-first, Add note, and Resolve while open) modelled on the audit-log/dead-letter list and the AssessmentDetail dialog + invalidateKeys pattern. - Open/Note/Resolve dialogs reuse the shared consoleApiFetch/postAction CSRF + error helpers and the per-open ulid idempotency key, matching the existing action dialogs. Resolve offers granted/denied/withdrawn with inline help that withdrawn sends no notice. - "Open reconsideration" action on AssessmentDetail: attempts the open for the assessment's subject and surfaces the server 409 (an open case already exists) inline — no by-subject lookup added. Resolve of an already-resolved case surfaces its 409 inline too. - Sidebar nav entry, router + test-harness wiring, fixtures, and component coverage (render, dialogs, empty/error/not-found, both 409s, axe, keyboard). Plain-English strings matching the console's existing convention; RTL-safe logical classes. Co-authored-by: Claude Opus 4.8 --- apps/labeler/console/src/api/client.test.ts | 127 ++++++++++++ apps/labeler/console/src/api/client.ts | 85 ++++++++ apps/labeler/console/src/api/types.ts | 115 +++++++++++ .../OpenReconsiderationDialog.test.tsx | 79 +++++++ .../components/OpenReconsiderationDialog.tsx | 108 ++++++++++ .../src/components/ReconsiderationBadges.tsx | 39 ++++ .../ReconsiderationDialogs.test.tsx | 182 ++++++++++++++++ .../components/ReconsiderationNoteDialog.tsx | 101 +++++++++ .../ReconsiderationResolveDialog.tsx | 137 +++++++++++++ .../console/src/components/Sidebar.tsx | 9 +- apps/labeler/console/src/fixtures/index.ts | 6 + .../console/src/fixtures/reconsiderations.ts | 112 ++++++++++ apps/labeler/console/src/reconsiderations.ts | 12 ++ apps/labeler/console/src/router.tsx | 4 + .../console/src/routes/AssessmentDetail.tsx | 12 ++ .../src/routes/ReconsiderationDetail.tsx | 194 ++++++++++++++++++ .../console/src/routes/Reconsiderations.tsx | 117 +++++++++++ apps/labeler/console/src/test/axe.test.tsx | 44 +++- apps/labeler/console/src/test/harness.tsx | 4 + .../console/src/test/keyboard.test.tsx | 26 +++ .../src/test/reconsiderations.test.tsx | 74 +++++++ 21 files changed, 1585 insertions(+), 2 deletions(-) create mode 100644 apps/labeler/console/src/components/OpenReconsiderationDialog.test.tsx create mode 100644 apps/labeler/console/src/components/OpenReconsiderationDialog.tsx create mode 100644 apps/labeler/console/src/components/ReconsiderationBadges.tsx create mode 100644 apps/labeler/console/src/components/ReconsiderationDialogs.test.tsx create mode 100644 apps/labeler/console/src/components/ReconsiderationNoteDialog.tsx create mode 100644 apps/labeler/console/src/components/ReconsiderationResolveDialog.tsx create mode 100644 apps/labeler/console/src/fixtures/reconsiderations.ts create mode 100644 apps/labeler/console/src/reconsiderations.ts create mode 100644 apps/labeler/console/src/routes/ReconsiderationDetail.tsx create mode 100644 apps/labeler/console/src/routes/Reconsiderations.tsx create mode 100644 apps/labeler/console/src/test/reconsiderations.test.tsx diff --git a/apps/labeler/console/src/api/client.test.ts b/apps/labeler/console/src/api/client.test.ts index 182e52ae70..6cea961108 100644 --- a/apps/labeler/console/src/api/client.test.ts +++ b/apps/labeler/console/src/api/client.test.ts @@ -48,6 +48,27 @@ describe("fixture client", () => { expect(ids).toEqual(ids.toSorted((a, b) => b - a)); expect(page.items.some((d) => d.status === "new")).toBe(true); }); + + it("lists reconsiderations with an open and a resolved case", async () => { + const page = await apiClient.listReconsiderations(); + expect(page.items.length).toBeGreaterThan(0); + expect(page.items.some((r) => r.state === "open")).toBe(true); + expect(page.items.some((r) => r.state === "resolved" && r.outcome !== null)).toBe(true); + }); + + it("returns a reconsideration case with its oldest-first note thread", async () => { + const [first] = (await apiClient.listReconsiderations()).items; + expect(first).toBeDefined(); + const detail = await apiClient.getReconsideration(first!.id); + expect(detail?.reconsideration.id).toBe(first!.id); + expect(detail!.notes.length).toBeGreaterThan(0); + const timestamps = detail!.notes.map((n) => Date.parse(n.createdAt)); + expect(timestamps).toEqual(timestamps.toSorted((a, b) => a - b)); + }); + + it("returns null for an unknown reconsideration id", async () => { + expect(await apiClient.getReconsideration("recon_missing")).toBeNull(); + }); }); describe("fetch client label actions", () => { @@ -279,4 +300,110 @@ describe("fetch client label actions", () => { }), ).rejects.toThrow("key already used"); }); + + it("GETs the reconsideration list", async () => { + stubFetch(() => Response.json({ data: { items: [{ id: "recon_1", state: "open" }] } })); + const page = await fetchClient.listReconsiderations({ cursor: "c1", limit: 25 }); + expect(page.items).toEqual([{ id: "recon_1", state: "open" }]); + const url = calls[0]!.url; + expect(url).toContain("/admin/api/reconsiderations?"); + expect(url).toContain("cursor=c1"); + expect(url).toContain("limit=25"); + }); + + it("GETs one reconsideration, mapping 404 to null", async () => { + stubFetch(() => new Response(null, { status: 404 })); + expect(await fetchClient.getReconsideration("recon_missing")).toBeNull(); + expect(calls[0]!.url).toBe("/admin/api/reconsiderations/recon_missing"); + }); + + it("POSTs an open with the CSRF header, JSON content type, and threaded key", async () => { + stubFetch(() => Response.json({ data: { actionId: "oact_1", reconsiderationId: "recon_1" } })); + const result = await fetchClient.openReconsideration({ + assessmentId: "asmt_target", + note: "publisher appealed", + reason: "opening", + idempotencyKey: input.idempotencyKey, + }); + expect(result).toMatchObject({ reconsiderationId: "recon_1" }); + const call = calls[0]!; + expect(call.url).toBe("/admin/api/reconsiderations/open"); + expect(call.init.method).toBe("POST"); + const headers = new Headers(call.init.headers); + expect(headers.get("X-EmDash-Request")).toBe("1"); + expect(headers.get("Content-Type")).toBe("application/json"); + expect(JSON.parse(call.init.body as string)).toMatchObject({ + assessmentId: "asmt_target", + note: "publisher appealed", + reason: "opening", + idempotencyKey: input.idempotencyKey, + }); + }); + + it("POSTs a note to the case note route", async () => { + stubFetch(() => Response.json({ data: { actionId: "oact_1", noteId: "rnote_1" } })); + await fetchClient.addReconsiderationNote("recon_1", { + note: "another note", + reason: "context", + idempotencyKey: input.idempotencyKey, + }); + expect(calls[0]!.url).toBe("/admin/api/reconsiderations/recon_1/note"); + expect(calls[0]!.init.method).toBe("POST"); + expect(JSON.parse(calls[0]!.init.body as string)).toMatchObject({ note: "another note" }); + }); + + it("POSTs a resolve to the resolve route with the outcome", async () => { + stubFetch(() => Response.json({ data: { actionId: "oact_1", outcome: "granted" } })); + await fetchClient.resolveReconsideration("recon_1", { + outcome: "granted", + reason: "false positive", + idempotencyKey: input.idempotencyKey, + }); + expect(calls[0]!.url).toBe("/admin/api/reconsiderations/recon_1/resolve"); + expect(calls[0]!.init.method).toBe("POST"); + expect(JSON.parse(calls[0]!.init.body as string).outcome).toBe("granted"); + }); + + it("surfaces the server 409 open-exists message", async () => { + stubFetch(() => + Response.json( + { + error: { + code: "RECONSIDERATION_OPEN_EXISTS", + message: "An open reconsideration already exists for this subject", + }, + }, + { status: 409 }, + ), + ); + await expect( + fetchClient.openReconsideration({ + assessmentId: "asmt_target", + note: "n", + reason: "r", + idempotencyKey: input.idempotencyKey, + }), + ).rejects.toThrow("An open reconsideration already exists for this subject"); + }); + + it("surfaces the server 409 already-resolved message on a resolve", async () => { + stubFetch(() => + Response.json( + { + error: { + code: "RECONSIDERATION_RESOLVED", + message: "Reconsideration is already resolved", + }, + }, + { status: 409 }, + ), + ); + await expect( + fetchClient.resolveReconsideration("recon_1", { + outcome: "denied", + reason: "r", + idempotencyKey: input.idempotencyKey, + }), + ).rejects.toThrow("Reconsideration is already resolved"); + }); }); diff --git a/apps/labeler/console/src/api/client.ts b/apps/labeler/console/src/api/client.ts index a0ac5069f1..768c53e57f 100644 --- a/apps/labeler/console/src/api/client.ts +++ b/apps/labeler/console/src/api/client.ts @@ -4,6 +4,8 @@ import { FIXTURE_FINDINGS_BY_ASSESSMENT, FIXTURE_LABELS_BY_ASSESSMENT, FIXTURE_OPERATOR_ACTIONS, + FIXTURE_RECONSIDERATION_DETAIL, + FIXTURE_RECONSIDERATIONS, FIXTURE_SUBJECT_HISTORY, FIXTURE_SYSTEM_STATUS, } from "../fixtures/index.js"; @@ -25,6 +27,7 @@ import type { ListAssessmentsParams, ListAuditLogParams, ListDeadLettersParams, + ListReconsiderationsParams, OperatorAction, OverrideActionInput, OverrideEffectPreviewParams, @@ -32,6 +35,14 @@ import type { OverrideRetractResult, OperatorFinding, Page, + ReconsiderationDetail, + ReconsiderationNoteInput, + ReconsiderationNoteResult, + ReconsiderationOpenInput, + ReconsiderationOpenResult, + ReconsiderationResolveInput, + ReconsiderationResolveResult, + ReconsiderationView, RerunResult, SubjectHistoryView, SubjectLabel, @@ -54,6 +65,8 @@ export interface LabelerConsoleClient { getSubjectLabels(uri: string, cid?: string): Promise; listAuditLog(params?: ListAuditLogParams): Promise>; listDeadLetters(params?: ListDeadLettersParams): Promise>; + listReconsiderations(params?: ListReconsiderationsParams): Promise>; + getReconsideration(id: string): Promise; getSystemStatus(): Promise; whoami(): Promise; previewEffect(params: EffectPreviewParams): Promise; @@ -72,6 +85,15 @@ export interface LabelerConsoleClient { resumeAutomation(input: AutomationToggleInput): Promise; retryDeadLetter(id: number, input: DeadLetterActionInput): Promise; quarantineDeadLetter(id: number, input: DeadLetterActionInput): Promise; + openReconsideration(input: ReconsiderationOpenInput): Promise; + addReconsiderationNote( + id: string, + input: ReconsiderationNoteInput, + ): Promise; + resolveReconsideration( + id: string, + input: ReconsiderationResolveInput, + ): Promise; } /** The admin-only emergency endpoints, keyed by action and direction. */ @@ -182,6 +204,18 @@ export function createFetchClient(): LabelerConsoleClient { const response = await consoleApiFetch(`/dead-letters?${search.toString()}`); return parseJson(response, "Failed to load dead letters"); }, + async listReconsiderations(params = {}) { + const search = new URLSearchParams(); + if (params.cursor) search.set("cursor", params.cursor); + if (params.limit) search.set("limit", String(params.limit)); + const response = await consoleApiFetch(`/reconsiderations?${search.toString()}`); + return parseJson(response, "Failed to load reconsiderations"); + }, + async getReconsideration(id) { + const response = await consoleApiFetch(`/reconsiderations/${encodeURIComponent(id)}`); + if (response.status === 404) return null; + return parseJson(response, "Failed to load reconsideration"); + }, async getSystemStatus() { const response = await consoleApiFetch("/status"); return parseJson(response, "Failed to load system status"); @@ -259,6 +293,23 @@ export function createFetchClient(): LabelerConsoleClient { "Failed to quarantine dead letter", ); }, + async openReconsideration(input) { + return postAction("/reconsiderations/open", input, "Failed to open reconsideration"); + }, + async addReconsiderationNote(id, input) { + return postAction( + `/reconsiderations/${encodeURIComponent(id)}/note`, + input, + "Failed to add note", + ); + }, + async resolveReconsideration(id, input) { + return postAction( + `/reconsiderations/${encodeURIComponent(id)}/resolve`, + input, + "Failed to resolve reconsideration", + ); + }, }; } @@ -294,6 +345,12 @@ export function createFixtureClient(): LabelerConsoleClient { async listDeadLetters() { return { items: [...FIXTURE_DEAD_LETTERS] }; }, + async listReconsiderations() { + return { items: [...FIXTURE_RECONSIDERATIONS] }; + }, + async getReconsideration(id) { + return FIXTURE_RECONSIDERATION_DETAIL[id] ?? null; + }, async getSystemStatus() { return FIXTURE_SYSTEM_STATUS; }, @@ -411,6 +468,34 @@ export function createFixtureClient(): LabelerConsoleClient { cts: new Date().toISOString(), }; }, + async openReconsideration(input) { + return { + actionId: "oact_fixture", + reconsiderationId: "recon_fixture", + uri: "at://did:plc:x/com.emdashcms.experimental.package.release/rk1", + cid: "bafyfixture", + triggeringAssessmentId: input.assessmentId, + cts: new Date().toISOString(), + }; + }, + async addReconsiderationNote(id) { + return { + actionId: "oact_fixture", + reconsiderationId: id, + noteId: "rnote_fixture", + cts: new Date().toISOString(), + }; + }, + async resolveReconsideration(id, input) { + return { + actionId: "oact_fixture", + reconsiderationId: id, + outcome: input.outcome, + uri: "at://did:plc:x/com.emdashcms.experimental.package.release/rk1", + cid: "bafyfixture", + cts: new Date().toISOString(), + }; + }, }; } diff --git a/apps/labeler/console/src/api/types.ts b/apps/labeler/console/src/api/types.ts index bd65ecb5dc..4b66e0241a 100644 --- a/apps/labeler/console/src/api/types.ts +++ b/apps/labeler/console/src/api/types.ts @@ -394,6 +394,116 @@ export interface DeadLetterActionResult { cts: string; } +/** A reconsideration case's lifecycle state (mirrors the server's + * `ReconsiderationState`). A case opens `open` and terminates `resolved`. */ +export type ReconsiderationState = "open" | "resolved"; + +/** The terminal decision on a resolved case (mirrors the server's + * `ReconsiderationOutcome`). `withdrawn` fires no publisher notice. */ +export type ReconsiderationOutcome = "granted" | "denied" | "withdrawn"; + +/** + * A reconsideration case from `GET /admin/api/reconsiderations` — mirrors the + * server's `serializeReconsideration`. Every field is operator-only (Access-edge + * + reviewer gated), including the actor provenance. Humans carry an email, + * service tokens a common name. `outcome`/`resolvedBy*`/`resolvedAt` are non-null + * only once resolved. */ +export interface ReconsiderationView { + id: string; + subjectUri: string; + subjectCid: string; + triggeringAssessmentId: string; + state: ReconsiderationState; + outcome: ReconsiderationOutcome | null; + openedById: string; + openedByEmail: string | null; + openedByCommonName: string | null; + openedByRole: OperatorRole; + openedAt: string; + resolvedById: string | null; + resolvedByEmail: string | null; + resolvedByCommonName: string | null; + resolvedAt: string | null; + outcomeActionId: string | null; +} + +/** A private reconsideration note (mirrors `serializeReconsiderationNote`). The + * `note` text is operator-only and never enters publisher notice copy. */ +export interface ReconsiderationNoteView { + id: string; + reconsiderationId: string; + authorId: string; + authorEmail: string | null; + authorCommonName: string | null; + authorRole: OperatorRole; + note: string; + createdAt: string; +} + +/** One case plus its private note thread (oldest-first) from + * `GET /admin/api/reconsiderations/:id`. */ +export interface ReconsiderationDetail { + reconsideration: ReconsiderationView; + notes: ReconsiderationNoteView[]; +} + +/** Body for `POST /admin/api/reconsiderations/open`. `note` is required + * non-empty (≤10000 chars); `idempotencyKey` is minted client-side (ULID) per + * dialog open and reused across retries so a network retry replays rather than + * double-opens. */ +export interface ReconsiderationOpenInput { + assessmentId: string; + note: string; + reason: string; + idempotencyKey: string; +} + +/** Body for `POST /admin/api/reconsiderations/:id/note`. Allowed in any state. */ +export interface ReconsiderationNoteInput { + note: string; + reason: string; + idempotencyKey: string; +} + +/** Body for `POST /admin/api/reconsiderations/:id/resolve`. `note` is an + * optional final note; the server rejects a resolve of an already-resolved case + * with a 409 `RECONSIDERATION_RESOLVED`. */ +export interface ReconsiderationResolveInput { + outcome: ReconsiderationOutcome; + note?: string; + reason: string; + idempotencyKey: string; +} + +/** Idempotent open result — the new case's id and subject, plus the triggering + * assessment it was opened from. */ +export interface ReconsiderationOpenResult { + actionId: string; + reconsiderationId: string; + uri: string; + cid: string; + triggeringAssessmentId: string; + cts: string; +} + +/** Idempotent note result — the appended note's id. */ +export interface ReconsiderationNoteResult { + actionId: string; + reconsiderationId: string; + noteId: string; + cts: string; +} + +/** Idempotent resolve result — the case's terminal outcome and subject. */ +export interface ReconsiderationResolveResult { + actionId: string; + reconsiderationId: string; + outcome: ReconsiderationOutcome; + uri: string; + cid: string; + cts: string; +} + export interface ListAssessmentsParams { state?: PublicAssessmentState; cursor?: string; @@ -409,3 +519,8 @@ export interface ListDeadLettersParams { cursor?: string; limit?: number; } + +export interface ListReconsiderationsParams { + cursor?: string; + limit?: number; +} diff --git a/apps/labeler/console/src/components/OpenReconsiderationDialog.test.tsx b/apps/labeler/console/src/components/OpenReconsiderationDialog.test.tsx new file mode 100644 index 0000000000..893bd13396 --- /dev/null +++ b/apps/labeler/console/src/components/OpenReconsiderationDialog.test.tsx @@ -0,0 +1,79 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { apiClient } from "../api/client.js"; +import { ASSESSMENT_GAMMA } from "../fixtures/index.js"; +import { renderRoute } from "../test/harness.js"; + +vi.mock("../api/client.js", async () => { + const actual = await vi.importActual("../api/client.js"); + return { ...actual, apiClient: actual.createFixtureClient() }; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const DETAIL_PATH = `/assessments/${ASSESSMENT_GAMMA.id}`; + +async function openTheDialog(): Promise { + renderRoute(DETAIL_PATH); + fireEvent.click(await screen.findByRole("button", { name: "Open reconsideration" })); + return screen.findByRole("alertdialog"); +} + +describe("OpenReconsiderationDialog (via AssessmentDetail)", () => { + it("opens a case for this assessment and navigates to the new case detail", async () => { + const open = vi.spyOn(apiClient, "openReconsideration").mockResolvedValue({ + actionId: "oact_1", + reconsiderationId: "recon_new", + uri: ASSESSMENT_GAMMA.uri, + cid: ASSESSMENT_GAMMA.cid, + triggeringAssessmentId: ASSESSMENT_GAMMA.id, + cts: "", + }); + const dialog = await openTheDialog(); + + fireEvent.change( + within(dialog).getByPlaceholderText("Why this assessment is being reconsidered"), + { target: { value: "publisher appealed the block" } }, + ); + fireEvent.change(within(dialog).getByPlaceholderText("Why this case is being opened"), { + target: { value: "opening for review" }, + }); + fireEvent.click(within(dialog).getByRole("button", { name: "Open reconsideration" })); + + await waitFor(() => { + expect(open).toHaveBeenCalledWith( + expect.objectContaining({ + assessmentId: ASSESSMENT_GAMMA.id, + note: "publisher appealed the block", + reason: "opening for review", + }), + ); + }); + // Navigation lands on the new case; getReconsideration("recon_new") is absent + // from the fixtures, so the detail resolves not-found — proof we navigated. + expect(await screen.findByText("Reconsideration not found.")).toBeTruthy(); + }); + + it("surfaces the server 409 open-exists message inline", async () => { + vi.spyOn(apiClient, "openReconsideration").mockRejectedValue( + new Error("An open reconsideration already exists for this subject"), + ); + const dialog = await openTheDialog(); + + fireEvent.change( + within(dialog).getByPlaceholderText("Why this assessment is being reconsidered"), + { target: { value: "note" } }, + ); + fireEvent.change(within(dialog).getByPlaceholderText("Why this case is being opened"), { + target: { value: "reason" }, + }); + fireEvent.click(within(dialog).getByRole("button", { name: "Open reconsideration" })); + + expect( + await within(dialog).findByText("An open reconsideration already exists for this subject"), + ).toBeTruthy(); + }); +}); diff --git a/apps/labeler/console/src/components/OpenReconsiderationDialog.tsx b/apps/labeler/console/src/components/OpenReconsiderationDialog.tsx new file mode 100644 index 0000000000..39b3ad56e4 --- /dev/null +++ b/apps/labeler/console/src/components/OpenReconsiderationDialog.tsx @@ -0,0 +1,108 @@ +import { Button, Dialog, InputArea } from "@cloudflare/kumo"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { ulid } from "ulidx"; + +import { apiClient } from "../api/client.js"; + +interface OpenReconsiderationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The assessment whose subject the case is opened for. */ + assessmentId: string; + subjectUri: string; + invalidateKeys: readonly (readonly unknown[])[]; +} + +/** + * Opens a reconsideration case for this assessment's subject, with a required + * first note and reason. There is no by-subject lookup — a subject that already + * has an open case surfaces the server's 409 inline. On success it navigates to + * the new case detail. The idempotency key is minted per open and reused across + * retries so a network retry replays rather than double-opens. + */ +export function OpenReconsiderationDialog({ + open, + onOpenChange, + assessmentId, + subjectUri, + invalidateKeys, +}: OpenReconsiderationDialogProps) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const [note, setNote] = useState(""); + const [reason, setReason] = useState(""); + const [idempotencyKey, setIdempotencyKey] = useState(""); + + useEffect(() => { + if (!open) return; + setIdempotencyKey(ulid()); + setNote(""); + setReason(""); + }, [open]); + + const mutation = useMutation({ + mutationFn: () => apiClient.openReconsideration({ assessmentId, note, reason, idempotencyKey }), + onSuccess: async (result) => { + await Promise.all( + invalidateKeys.map((queryKey) => queryClient.invalidateQueries({ queryKey })), + ); + onOpenChange(false); + await navigate({ to: "/reconsiderations/$id", params: { id: result.reconsiderationId } }); + }, + }); + + const canSubmit = note.trim().length > 0 && reason.trim().length > 0 && !mutation.isPending; + + return ( + + + Open reconsideration + + {subjectUri} + + +

+ Opens a case to reconsider this release's assessment. A subject can have only one open + case at a time. +

+ + + + + + {mutation.isError && ( +

+ {mutation.error instanceof Error ? mutation.error.message : "Action failed"} +

+ )} + +
+ +
+
+
+ ); +} diff --git a/apps/labeler/console/src/components/ReconsiderationBadges.tsx b/apps/labeler/console/src/components/ReconsiderationBadges.tsx new file mode 100644 index 0000000000..f2829fe46f --- /dev/null +++ b/apps/labeler/console/src/components/ReconsiderationBadges.tsx @@ -0,0 +1,39 @@ +import { Badge } from "@cloudflare/kumo"; + +import type { ReconsiderationOutcome, ReconsiderationState } from "../api/types.js"; + +type BadgeVariant = "neutral" | "info" | "success" | "warning" | "error"; + +const STATE_VARIANT: Record = { + open: "warning", + resolved: "neutral", +}; + +const STATE_LABEL: Record = { + open: "Open", + resolved: "Resolved", +}; + +const OUTCOME_VARIANT: Record = { + granted: "success", + denied: "error", + withdrawn: "neutral", +}; + +const OUTCOME_LABEL: Record = { + granted: "Granted", + denied: "Denied", + withdrawn: "Withdrawn", +}; + +export function ReconsiderationStateBadge({ state }: { state: ReconsiderationState }) { + return ( + + {STATE_LABEL[state]} + + ); +} + +export function ReconsiderationOutcomeBadge({ outcome }: { outcome: ReconsiderationOutcome }) { + return {OUTCOME_LABEL[outcome]}; +} diff --git a/apps/labeler/console/src/components/ReconsiderationDialogs.test.tsx b/apps/labeler/console/src/components/ReconsiderationDialogs.test.tsx new file mode 100644 index 0000000000..abff0106bf --- /dev/null +++ b/apps/labeler/console/src/components/ReconsiderationDialogs.test.tsx @@ -0,0 +1,182 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { apiClient } from "../api/client.js"; +import { renderWithClient } from "../test/harness.js"; +import { ReconsiderationNoteDialog } from "./ReconsiderationNoteDialog.js"; +import { ReconsiderationResolveDialog } from "./ReconsiderationResolveDialog.js"; + +vi.mock("../api/client.js", async () => { + const actual = await vi.importActual("../api/client.js"); + return { ...actual, apiClient: actual.createFixtureClient() }; +}); + +const RECON_ID = "recon_test"; +const SUBJECT_URI = "at://did:plc:x/com.emdashcms.experimental.package.release/rk1"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("ReconsiderationNoteDialog", () => { + it("threads the note and reason, keeping submit disabled until both are set", async () => { + const addNote = vi + .spyOn(apiClient, "addReconsiderationNote") + .mockResolvedValue({ + actionId: "oact_1", + reconsiderationId: RECON_ID, + noteId: "rnote_1", + cts: "", + }); + renderWithClient( + {}} + reconsiderationId={RECON_ID} + subjectUri={SUBJECT_URI} + invalidateKeys={[]} + />, + ); + + const submit = screen.getByRole("button", { name: "Add note" }) as HTMLButtonElement; + expect(submit.disabled).toBe(true); + + fireEvent.change(screen.getByPlaceholderText("A private note for the case thread"), { + target: { value: "publisher contests the finding" }, + }); + expect(submit.disabled).toBe(true); + fireEvent.change(screen.getByPlaceholderText("Why this note is being recorded"), { + target: { value: "recording the dispute" }, + }); + expect(submit.disabled).toBe(false); + fireEvent.click(submit); + + await waitFor(() => { + expect(addNote).toHaveBeenCalledWith( + RECON_ID, + expect.objectContaining({ + note: "publisher contests the finding", + reason: "recording the dispute", + }), + ); + }); + expect(addNote.mock.calls[0]![1].idempotencyKey.length).toBeGreaterThan(0); + }); + + it("surfaces the server error message on failure", async () => { + vi.spyOn(apiClient, "addReconsiderationNote").mockRejectedValue(new Error("boom")); + renderWithClient( + {}} + reconsiderationId={RECON_ID} + subjectUri={SUBJECT_URI} + invalidateKeys={[]} + />, + ); + fireEvent.change(screen.getByPlaceholderText("A private note for the case thread"), { + target: { value: "note" }, + }); + fireEvent.change(screen.getByPlaceholderText("Why this note is being recorded"), { + target: { value: "reason" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Add note" })); + expect(await screen.findByText("boom")).toBeTruthy(); + }); +}); + +describe("ReconsiderationResolveDialog", () => { + it("resolves with the default granted outcome and reason, threading an idempotency key", async () => { + const resolve = vi.spyOn(apiClient, "resolveReconsideration").mockResolvedValue({ + actionId: "oact_1", + reconsiderationId: RECON_ID, + outcome: "granted", + uri: SUBJECT_URI, + cid: "bafy", + cts: "", + }); + renderWithClient( + {}} + reconsiderationId={RECON_ID} + subjectUri={SUBJECT_URI} + invalidateKeys={[]} + />, + ); + + const submit = screen.getByRole("button", { name: "Resolve" }) as HTMLButtonElement; + expect(submit.disabled).toBe(true); + fireEvent.change(screen.getByPlaceholderText("Why this outcome was reached"), { + target: { value: "finding confirmed as false positive" }, + }); + expect(submit.disabled).toBe(false); + fireEvent.click(submit); + + await waitFor(() => { + expect(resolve).toHaveBeenCalledWith( + RECON_ID, + expect.objectContaining({ + outcome: "granted", + reason: "finding confirmed as false positive", + }), + ); + }); + // No final note was entered, so the optional field is omitted from the body. + expect(resolve.mock.calls[0]![1].note).toBeUndefined(); + }); + + it("includes the optional final note when entered", async () => { + const resolve = vi.spyOn(apiClient, "resolveReconsideration").mockResolvedValue({ + actionId: "oact_1", + reconsiderationId: RECON_ID, + outcome: "granted", + uri: SUBJECT_URI, + cid: "bafy", + cts: "", + }); + renderWithClient( + {}} + reconsiderationId={RECON_ID} + subjectUri={SUBJECT_URI} + invalidateKeys={[]} + />, + ); + fireEvent.change(screen.getByPlaceholderText("A closing note for the case thread"), { + target: { value: "closing note" }, + }); + fireEvent.change(screen.getByPlaceholderText("Why this outcome was reached"), { + target: { value: "reason" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Resolve" })); + + await waitFor(() => { + expect(resolve).toHaveBeenCalledWith( + RECON_ID, + expect.objectContaining({ note: "closing note" }), + ); + }); + }); + + it("surfaces the server 409 already-resolved message inline", async () => { + vi.spyOn(apiClient, "resolveReconsideration").mockRejectedValue( + new Error("Reconsideration is already resolved"), + ); + renderWithClient( + {}} + reconsiderationId={RECON_ID} + subjectUri={SUBJECT_URI} + invalidateKeys={[]} + />, + ); + fireEvent.change(screen.getByPlaceholderText("Why this outcome was reached"), { + target: { value: "reason" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Resolve" })); + expect(await screen.findByText("Reconsideration is already resolved")).toBeTruthy(); + }); +}); diff --git a/apps/labeler/console/src/components/ReconsiderationNoteDialog.tsx b/apps/labeler/console/src/components/ReconsiderationNoteDialog.tsx new file mode 100644 index 0000000000..496f7cf3e9 --- /dev/null +++ b/apps/labeler/console/src/components/ReconsiderationNoteDialog.tsx @@ -0,0 +1,101 @@ +import { Button, Dialog, InputArea } from "@cloudflare/kumo"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { ulid } from "ulidx"; + +import { apiClient } from "../api/client.js"; + +interface ReconsiderationNoteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + reconsiderationId: string; + /** The case subject, shown for context. */ + subjectUri: string; + /** TanStack Query keys to invalidate on success so the thread re-renders. */ + invalidateKeys: readonly (readonly unknown[])[]; +} + +/** + * Appends one private note to a reconsideration case. A note is allowed in any + * state (a resolved case still accepts post-hoc audit notes). A required note + * body and reason gate submit; the idempotency key is minted per open and reused + * across retries so a network retry replays rather than double-appends. + */ +export function ReconsiderationNoteDialog({ + open, + onOpenChange, + reconsiderationId, + subjectUri, + invalidateKeys, +}: ReconsiderationNoteDialogProps) { + const queryClient = useQueryClient(); + const [note, setNote] = useState(""); + const [reason, setReason] = useState(""); + const [idempotencyKey, setIdempotencyKey] = useState(""); + + useEffect(() => { + if (!open) return; + setIdempotencyKey(ulid()); + setNote(""); + setReason(""); + }, [open]); + + const mutation = useMutation({ + mutationFn: () => + apiClient.addReconsiderationNote(reconsiderationId, { note, reason, idempotencyKey }), + onSuccess: async () => { + await Promise.all( + invalidateKeys.map((queryKey) => queryClient.invalidateQueries({ queryKey })), + ); + onOpenChange(false); + }, + }); + + const canSubmit = note.trim().length > 0 && reason.trim().length > 0 && !mutation.isPending; + + return ( + + + Add note + + {subjectUri} + + + + + + + {mutation.isError && ( +

+ {mutation.error instanceof Error ? mutation.error.message : "Action failed"} +

+ )} + +
+ +
+
+
+ ); +} diff --git a/apps/labeler/console/src/components/ReconsiderationResolveDialog.tsx b/apps/labeler/console/src/components/ReconsiderationResolveDialog.tsx new file mode 100644 index 0000000000..2c9bbf7aaf --- /dev/null +++ b/apps/labeler/console/src/components/ReconsiderationResolveDialog.tsx @@ -0,0 +1,137 @@ +import { Button, Dialog, InputArea, Select } from "@cloudflare/kumo"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { ulid } from "ulidx"; + +import { apiClient } from "../api/client.js"; +import type { ReconsiderationOutcome } from "../api/types.js"; + +interface ReconsiderationResolveDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + reconsiderationId: string; + /** The case subject, shown for context. */ + subjectUri: string; + invalidateKeys: readonly (readonly unknown[])[]; +} + +const OUTCOMES: readonly ReconsiderationOutcome[] = ["granted", "denied", "withdrawn"]; + +const OUTCOME_LABEL: Record = { + granted: "Granted", + denied: "Denied", + withdrawn: "Withdrawn", +}; + +const OUTCOME_HELP: Record = { + granted: "The reconsideration succeeds — the publisher is notified of the granted outcome.", + denied: "The reconsideration is refused — the publisher is notified of the denied outcome.", + withdrawn: "The case is closed with no decision. No publisher notice is sent.", +}; + +/** + * Resolves an open reconsideration: an outcome (granted / denied / withdrawn), + * an optional final note, and a required reason. A `granted` / `denied` resolve + * fires the publisher outcome notice; `withdrawn` notifies nothing. The server + * rejects a resolve of an already-resolved case with a 409, surfaced inline. + */ +export function ReconsiderationResolveDialog({ + open, + onOpenChange, + reconsiderationId, + subjectUri, + invalidateKeys, +}: ReconsiderationResolveDialogProps) { + const queryClient = useQueryClient(); + const [outcome, setOutcome] = useState("granted"); + const [note, setNote] = useState(""); + const [reason, setReason] = useState(""); + const [idempotencyKey, setIdempotencyKey] = useState(""); + + useEffect(() => { + if (!open) return; + setIdempotencyKey(ulid()); + setOutcome("granted"); + setNote(""); + setReason(""); + }, [open]); + + const mutation = useMutation({ + mutationFn: () => + apiClient.resolveReconsideration(reconsiderationId, { + outcome, + ...(note.trim().length > 0 ? { note } : {}), + reason, + idempotencyKey, + }), + onSuccess: async () => { + await Promise.all( + invalidateKeys.map((queryKey) => queryClient.invalidateQueries({ queryKey })), + ); + onOpenChange(false); + }, + }); + + const canSubmit = reason.trim().length > 0 && !mutation.isPending; + + return ( + + + Resolve reconsideration + + {subjectUri} + + + +

{OUTCOME_HELP[outcome]}

+ + + + + + {mutation.isError && ( +

+ {mutation.error instanceof Error ? mutation.error.message : "Action failed"} +

+ )} + +
+ +
+
+
+ ); +} diff --git a/apps/labeler/console/src/components/Sidebar.tsx b/apps/labeler/console/src/components/Sidebar.tsx index ad4cb5d0b5..b6a782f65f 100644 --- a/apps/labeler/console/src/components/Sidebar.tsx +++ b/apps/labeler/console/src/components/Sidebar.tsx @@ -1,5 +1,11 @@ import { Sidebar as KumoSidebar, useSidebar } from "@cloudflare/kumo"; -import { ClockCounterClockwise, Envelope, ShieldCheck, SquaresFour } from "@phosphor-icons/react"; +import { + ClockCounterClockwise, + Envelope, + Scales, + ShieldCheck, + SquaresFour, +} from "@phosphor-icons/react"; import { useLocation } from "@tanstack/react-router"; import * as React from "react"; @@ -43,6 +49,7 @@ export function SidebarNav() { { to: "/", label: "Dashboard", icon: SquaresFour }, { to: "/assessments", label: "Assessments", icon: ShieldCheck }, { to: "/dead-letters", label: "Dead letters", icon: Envelope }, + { to: "/reconsiderations", label: "Reconsiderations", icon: Scales }, { to: "/audit", label: "Audit log", icon: ClockCounterClockwise }, ]; diff --git a/apps/labeler/console/src/fixtures/index.ts b/apps/labeler/console/src/fixtures/index.ts index 7a0ef33fa1..630b3a9eb4 100644 --- a/apps/labeler/console/src/fixtures/index.ts +++ b/apps/labeler/console/src/fixtures/index.ts @@ -17,6 +17,12 @@ export { export { FIXTURE_DEAD_LETTERS } from "./dead-letters.js"; export { FIXTURE_LABELS_BY_ASSESSMENT } from "./labels.js"; export { FIXTURE_OPERATOR_ACTIONS } from "./operator-actions.js"; +export { + FIXTURE_RECONSIDERATION_DETAIL, + FIXTURE_RECONSIDERATIONS, + RECONSIDERATION_BETA_GRANTED, + RECONSIDERATION_GAMMA_OPEN, +} from "./reconsiderations.js"; export { FIXTURE_SUBJECT_HISTORY } from "./subject-history.js"; export { FIXTURE_SUBJECTS, diff --git a/apps/labeler/console/src/fixtures/reconsiderations.ts b/apps/labeler/console/src/fixtures/reconsiderations.ts new file mode 100644 index 0000000000..9eceb27d50 --- /dev/null +++ b/apps/labeler/console/src/fixtures/reconsiderations.ts @@ -0,0 +1,112 @@ +import type { + ReconsiderationDetail, + ReconsiderationNoteView, + ReconsiderationView, +} from "../api/types.js"; +import { ASSESSMENT_BETA, ASSESSMENT_GAMMA } from "./assessments.js"; +import { SUBJECT_BETA, SUBJECT_GAMMA } from "./subjects.js"; + +/** An open case for the blocked gamma release — a publisher has asked the + * reviewers to reconsider the malware finding. */ +export const RECONSIDERATION_GAMMA_OPEN: ReconsiderationView = { + id: "recon_01J9ZM8R3N5U9W1Y3T5X7Z9C2D", + subjectUri: SUBJECT_GAMMA.uri, + subjectCid: SUBJECT_GAMMA.cid, + triggeringAssessmentId: ASSESSMENT_GAMMA.id, + state: "open", + outcome: null, + openedById: "access|8f2c1a90-6b3d-4e57-9a12-77c0de9b4a11", + openedByEmail: "reviewer@emdashcms.com", + openedByCommonName: null, + openedByRole: "reviewer", + openedAt: "2026-07-13T10:05:00.000Z", + resolvedById: null, + resolvedByEmail: null, + resolvedByCommonName: null, + resolvedAt: null, + outcomeActionId: null, +}; + +/** A resolved (granted) case for the warned beta release. */ +export const RECONSIDERATION_BETA_GRANTED: ReconsiderationView = { + id: "recon_01J9YJ4G6P8R0T2V4X6Z8B0D3E", + subjectUri: SUBJECT_BETA.uri, + subjectCid: SUBJECT_BETA.cid, + triggeringAssessmentId: ASSESSMENT_BETA.id, + state: "resolved", + outcome: "granted", + openedById: "access|8f2c1a90-6b3d-4e57-9a12-77c0de9b4a11", + openedByEmail: "reviewer@emdashcms.com", + openedByCommonName: null, + openedByRole: "reviewer", + openedAt: "2026-07-12T09:00:00.000Z", + resolvedById: "access|2a7d4c31-9e02-4b18-b5f6-1c8e0a4f2b90", + resolvedByEmail: "admin@emdashcms.com", + resolvedByCommonName: null, + resolvedAt: "2026-07-12T15:30:00.000Z", + outcomeActionId: "oact_01J9YK5H7Q9S1U3W5Y7A9C1E4F", +}; + +const GAMMA_NOTES: ReconsiderationNoteView[] = [ + { + id: "rnote_01J9ZM9A0B2C4E6G8J0M2P4R6T", + reconsiderationId: RECONSIDERATION_GAMMA_OPEN.id, + authorId: RECONSIDERATION_GAMMA_OPEN.openedById, + authorEmail: "reviewer@emdashcms.com", + authorCommonName: null, + authorRole: "reviewer", + note: "Publisher disputes the malware flag; says the payload is a security-research proof-of-concept.", + createdAt: "2026-07-13T10:05:00.000Z", + }, + { + id: "rnote_01J9ZMB1C3D5F7H9K1N3Q5S7U", + reconsiderationId: RECONSIDERATION_GAMMA_OPEN.id, + authorId: "access|2a7d4c31-9e02-4b18-b5f6-1c8e0a4f2b90", + authorEmail: "admin@emdashcms.com", + authorCommonName: null, + authorRole: "admin", + note: "Re-running the scanner against the pinned artifact before deciding.", + createdAt: "2026-07-13T11:20:00.000Z", + }, +]; + +const BETA_NOTES: ReconsiderationNoteView[] = [ + { + id: "rnote_01J9YJ5B2D4F6H8K0M2P4R6T8V", + reconsiderationId: RECONSIDERATION_BETA_GRANTED.id, + authorId: RECONSIDERATION_BETA_GRANTED.openedById, + authorEmail: "reviewer@emdashcms.com", + authorCommonName: null, + authorRole: "reviewer", + note: "Low-quality packaging warning contested — the manifest was fixed in a follow-up release.", + createdAt: "2026-07-12T09:00:00.000Z", + }, + { + id: "rnote_01J9YK6C3E5G7J9L1N3Q5S7U9W", + reconsiderationId: RECONSIDERATION_BETA_GRANTED.id, + authorId: "access|2a7d4c31-9e02-4b18-b5f6-1c8e0a4f2b90", + authorEmail: "admin@emdashcms.com", + authorCommonName: null, + authorRole: "admin", + note: "Confirmed the warning no longer applies. Granting.", + createdAt: "2026-07-12T15:30:00.000Z", + }, +]; + +/** Newest first, matching the labeler's `getReconsiderationsPage` ordering. */ +export const FIXTURE_RECONSIDERATIONS: readonly ReconsiderationView[] = [ + RECONSIDERATION_GAMMA_OPEN, + RECONSIDERATION_BETA_GRANTED, +]; + +/** Case detail (case + oldest-first note thread) keyed by case id. */ +export const FIXTURE_RECONSIDERATION_DETAIL: Record = { + [RECONSIDERATION_GAMMA_OPEN.id]: { + reconsideration: RECONSIDERATION_GAMMA_OPEN, + notes: GAMMA_NOTES, + }, + [RECONSIDERATION_BETA_GRANTED.id]: { + reconsideration: RECONSIDERATION_BETA_GRANTED, + notes: BETA_NOTES, + }, +}; diff --git a/apps/labeler/console/src/reconsiderations.ts b/apps/labeler/console/src/reconsiderations.ts new file mode 100644 index 0000000000..7b2d671597 --- /dev/null +++ b/apps/labeler/console/src/reconsiderations.ts @@ -0,0 +1,12 @@ +/** + * Presentation helper for reconsideration actor provenance. Humans carry an + * email, service tokens a common name; both fall back to the raw Access subject + * id so a row always renders someone. + */ +export function reconsiderationActorName(actor: { + email: string | null; + commonName: string | null; + id: string; +}): string { + return actor.email ?? actor.commonName ?? actor.id; +} diff --git a/apps/labeler/console/src/router.tsx b/apps/labeler/console/src/router.tsx index bc3e5ebebd..02dd55e3f1 100644 --- a/apps/labeler/console/src/router.tsx +++ b/apps/labeler/console/src/router.tsx @@ -7,6 +7,8 @@ import { auditLogRoute } from "./routes/AuditLog.js"; import { dashboardRoute } from "./routes/Dashboard.js"; import { deadLetterQueueRoute } from "./routes/DeadLetterQueue.js"; import { notFoundRoute } from "./routes/NotFound.js"; +import { reconsiderationDetailRoute } from "./routes/ReconsiderationDetail.js"; +import { reconsiderationsRoute } from "./routes/Reconsiderations.js"; import { rootRoute, shellRoute } from "./routes/root.js"; import { subjectHistoryRoute } from "./routes/SubjectHistory.js"; @@ -17,6 +19,8 @@ const shellRoutes = shellRoute.addChildren([ subjectHistoryRoute, auditLogRoute, deadLetterQueueRoute, + reconsiderationsRoute, + reconsiderationDetailRoute, notFoundRoute, ]); diff --git a/apps/labeler/console/src/routes/AssessmentDetail.tsx b/apps/labeler/console/src/routes/AssessmentDetail.tsx index 8a5f90321f..788955094b 100644 --- a/apps/labeler/console/src/routes/AssessmentDetail.tsx +++ b/apps/labeler/console/src/routes/AssessmentDetail.tsx @@ -8,6 +8,7 @@ import type { IssuableLabel } from "../api/types.js"; import { AssessmentActionDialog } from "../components/AssessmentActionDialog.js"; import { FindingCard } from "../components/FindingCard.js"; import { LabelActionDialog } from "../components/LabelActionDialog.js"; +import { OpenReconsiderationDialog } from "../components/OpenReconsiderationDialog.js"; import { OverrideDialog } from "../components/OverrideDialog.js"; import { QueryError } from "../components/QueryError.js"; import { StateBadge } from "../components/StateBadge.js"; @@ -76,6 +77,7 @@ function AssessmentDetail() { const [rerunOpen, setRerunOpen] = useState(false); const [overrideOpen, setOverrideOpen] = useState(false); const [overrideRetractOpen, setOverrideRetractOpen] = useState(false); + const [openReconOpen, setOpenReconOpen] = useState(false); if (isAssessmentError) { return ; @@ -165,6 +167,9 @@ function AssessmentDetail() { + {activeBlocks.length > 0 && ( + {isOpen && ( + + )} +
+ )} + +
+

Notes

+ {notes.length === 0 ? ( +

No notes on this case.

+ ) : ( +
+ {notes.map((note) => ( + + ))} +
+ )} +
+ + {canAct && ( + <> + + + + )} +
+ ); +} + +export const reconsiderationDetailRoute = createRoute({ + getParentRoute: () => shellRoute, + path: "/reconsiderations/$id", + component: ReconsiderationDetail, +}); diff --git a/apps/labeler/console/src/routes/Reconsiderations.tsx b/apps/labeler/console/src/routes/Reconsiderations.tsx new file mode 100644 index 0000000000..ea180ba6c8 --- /dev/null +++ b/apps/labeler/console/src/routes/Reconsiderations.tsx @@ -0,0 +1,117 @@ +import { LayerCard, Loader, Table } from "@cloudflare/kumo"; +import { useQuery } from "@tanstack/react-query"; +import { createRoute, Link } from "@tanstack/react-router"; + +import { apiClient } from "../api/client.js"; +import { QueryError } from "../components/QueryError.js"; +import { + ReconsiderationOutcomeBadge, + ReconsiderationStateBadge, +} from "../components/ReconsiderationBadges.js"; +import { reconsiderationActorName } from "../reconsiderations.js"; +import { shellRoute } from "./root.js"; + +function Reconsiderations() { + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["reconsiderations"], + queryFn: () => apiClient.listReconsiderations(), + }); + + return ( +
+

Reconsiderations

+

+ Cases where a publisher has asked reviewers to reconsider an assessment. Open a case from an + assessment; resolve it granted, denied, or withdrawn. +

+ + {isError ? ( + + ) : ( + + {isLoading || !data ? ( +
+ +
+ ) : data.items.length === 0 ? ( +
No reconsiderations.
+ ) : ( + + + + Subject + State + Outcome + Opened + Resolved + + + + {data.items.map((recon) => ( + + + + {recon.subjectUri.split("/").pop()} + + + + + + + {recon.outcome ? ( + + ) : ( + + )} + + + + {reconsiderationActorName({ + email: recon.openedByEmail, + commonName: recon.openedByCommonName, + id: recon.openedById, + })} + + + {new Date(recon.openedAt).toLocaleString()} + + + + {recon.resolvedAt ? ( + <> + + {reconsiderationActorName({ + email: recon.resolvedByEmail, + commonName: recon.resolvedByCommonName, + id: recon.resolvedById ?? "", + })} + + + {new Date(recon.resolvedAt).toLocaleString()} + + + ) : ( + + )} + + + ))} + +
+ )} +
+ )} +
+ ); +} + +export const reconsiderationsRoute = createRoute({ + getParentRoute: () => shellRoute, + path: "/reconsiderations", + component: Reconsiderations, +}); diff --git a/apps/labeler/console/src/test/axe.test.tsx b/apps/labeler/console/src/test/axe.test.tsx index 4065568cd4..60e0a9e0c2 100644 --- a/apps/labeler/console/src/test/axe.test.tsx +++ b/apps/labeler/console/src/test/axe.test.tsx @@ -9,7 +9,9 @@ import { DeadLetterActions } from "../components/DeadLetterActions.js"; import { EmergencyActionDialog } from "../components/EmergencyActionDialog.js"; import { LabelActionDialog } from "../components/LabelActionDialog.js"; import { OverrideDialog } from "../components/OverrideDialog.js"; -import { ASSESSMENT_GAMMA, SUBJECT_ALPHA } from "../fixtures/index.js"; +import { ReconsiderationNoteDialog } from "../components/ReconsiderationNoteDialog.js"; +import { ReconsiderationResolveDialog } from "../components/ReconsiderationResolveDialog.js"; +import { ASSESSMENT_GAMMA, RECONSIDERATION_GAMMA_OPEN, SUBJECT_ALPHA } from "../fixtures/index.js"; import { RELEASE_ISSUABLE_LABELS } from "../labels.js"; import { ADMIN_IDENTITY, renderRoute, renderWithClient } from "./harness.js"; @@ -79,6 +81,20 @@ describe("axe: routes", () => { expect(await axe(container)).toHaveNoViolations(); }); + it("Reconsiderations", async () => { + const { container } = renderRoute("/reconsiderations"); + await screen.findByRole("heading", { name: "Reconsiderations", level: 1 }); + await screen.findByRole("table"); + expect(await axe(container)).toHaveNoViolations(); + }); + + it("ReconsiderationDetail", async () => { + const { container } = renderRoute(`/reconsiderations/${RECONSIDERATION_GAMMA_OPEN.id}`); + await screen.findByRole("heading", { name: "Reconsideration", level: 1 }); + await screen.findByRole("button", { name: "Resolve" }); + expect(await axe(container)).toHaveNoViolations(); + }); + it("NotFound", async () => { const { container } = renderRoute("/no-such-page"); await screen.findByRole("heading", { name: "Not found", level: 1 }); @@ -160,4 +176,30 @@ describe("axe: dialogs", () => { await within(panel).findByText("Retry dead letter"); expect(await axe(panel)).toHaveNoViolations(); }); + + it("ReconsiderationNoteDialog", async () => { + renderWithClient( + {}} + reconsiderationId="recon_1" + subjectUri={RELEASE_URI} + invalidateKeys={[]} + />, + ); + await expectDialogClean("alertdialog"); + }); + + it("ReconsiderationResolveDialog", async () => { + renderWithClient( + {}} + reconsiderationId="recon_1" + subjectUri={RELEASE_URI} + invalidateKeys={[]} + />, + ); + await expectDialogClean("alertdialog"); + }); }); diff --git a/apps/labeler/console/src/test/harness.tsx b/apps/labeler/console/src/test/harness.tsx index 5a2ac92bd8..c93c96d5ca 100644 --- a/apps/labeler/console/src/test/harness.tsx +++ b/apps/labeler/console/src/test/harness.tsx @@ -11,6 +11,8 @@ import { auditLogRoute } from "../routes/AuditLog.js"; import { dashboardRoute } from "../routes/Dashboard.js"; import { deadLetterQueueRoute } from "../routes/DeadLetterQueue.js"; import { notFoundRoute } from "../routes/NotFound.js"; +import { reconsiderationDetailRoute } from "../routes/ReconsiderationDetail.js"; +import { reconsiderationsRoute } from "../routes/Reconsiderations.js"; import { rootRoute, shellRoute } from "../routes/root.js"; import { subjectHistoryRoute } from "../routes/SubjectHistory.js"; @@ -22,6 +24,8 @@ const routeTree = rootRoute.addChildren([ subjectHistoryRoute, auditLogRoute, deadLetterQueueRoute, + reconsiderationsRoute, + reconsiderationDetailRoute, notFoundRoute, ]), ]); diff --git a/apps/labeler/console/src/test/keyboard.test.tsx b/apps/labeler/console/src/test/keyboard.test.tsx index 28bf871732..4c944707f4 100644 --- a/apps/labeler/console/src/test/keyboard.test.tsx +++ b/apps/labeler/console/src/test/keyboard.test.tsx @@ -10,6 +10,8 @@ import { DeadLetterActions } from "../components/DeadLetterActions.js"; import { EmergencyActionDialog } from "../components/EmergencyActionDialog.js"; import { LabelActionDialog } from "../components/LabelActionDialog.js"; import { OverrideDialog } from "../components/OverrideDialog.js"; +import { ReconsiderationNoteDialog } from "../components/ReconsiderationNoteDialog.js"; +import { ReconsiderationResolveDialog } from "../components/ReconsiderationResolveDialog.js"; import { RELEASE_ISSUABLE_LABELS } from "../labels.js"; import { renderWithClient } from "./harness.js"; @@ -115,6 +117,30 @@ const CASES: { /> ), }, + { + name: "ReconsiderationNoteDialog", + role: "alertdialog", + build: (props) => ( + + ), + }, + { + name: "ReconsiderationResolveDialog", + role: "alertdialog", + build: (props) => ( + + ), + }, ]; describe("keyboard: dialog focus management", () => { diff --git a/apps/labeler/console/src/test/reconsiderations.test.tsx b/apps/labeler/console/src/test/reconsiderations.test.tsx new file mode 100644 index 0000000000..d4ff16742c --- /dev/null +++ b/apps/labeler/console/src/test/reconsiderations.test.tsx @@ -0,0 +1,74 @@ +import { screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { apiClient } from "../api/client.js"; +import { RECONSIDERATION_BETA_GRANTED, RECONSIDERATION_GAMMA_OPEN } from "../fixtures/index.js"; +import { renderRoute, REVIEWER_IDENTITY } from "./harness.js"; + +vi.mock("../api/client.js", async () => { + const actual = await vi.importActual("../api/client.js"); + return { ...actual, apiClient: actual.createFixtureClient() }; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Reconsiderations list", () => { + it("renders a case per row with its state and outcome, linking to the detail", async () => { + renderRoute("/reconsiderations"); + await screen.findByRole("heading", { name: "Reconsiderations", level: 1 }); + const table = await screen.findByRole("table"); + + const openLink = within(table).getByRole("link", { + name: RECONSIDERATION_GAMMA_OPEN.subjectUri.split("/").pop(), + }); + expect(openLink.getAttribute("href")).toContain( + `/reconsiderations/${RECONSIDERATION_GAMMA_OPEN.id}`, + ); + expect(within(table).getByText("Open")).toBeTruthy(); + expect(within(table).getByText("Granted")).toBeTruthy(); + }); + + it("shows the empty state when there are no cases", async () => { + vi.spyOn(apiClient, "listReconsiderations").mockResolvedValue({ items: [] }); + renderRoute("/reconsiderations"); + await screen.findByRole("heading", { name: "Reconsiderations", level: 1 }); + expect(await screen.findByText("No reconsiderations.")).toBeTruthy(); + }); +}); + +describe("Reconsideration detail", () => { + it("shows the note thread and offers Resolve for an open case", async () => { + vi.spyOn(apiClient, "whoami").mockResolvedValue(REVIEWER_IDENTITY); + renderRoute(`/reconsiderations/${RECONSIDERATION_GAMMA_OPEN.id}`); + await screen.findByRole("heading", { name: "Reconsideration", level: 1 }); + + expect(await screen.findByRole("button", { name: "Add note" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Resolve" })).toBeTruthy(); + expect(screen.getByText(/Publisher disputes the malware flag/)).toBeTruthy(); + // The triggering assessment cross-links to its detail. + const link = screen.getByRole("link", { + name: RECONSIDERATION_GAMMA_OPEN.triggeringAssessmentId, + }); + expect(link.getAttribute("href")).toContain( + `/assessments/${RECONSIDERATION_GAMMA_OPEN.triggeringAssessmentId}`, + ); + }); + + it("hides Resolve for a resolved case and shows its outcome", async () => { + vi.spyOn(apiClient, "whoami").mockResolvedValue(REVIEWER_IDENTITY); + renderRoute(`/reconsiderations/${RECONSIDERATION_BETA_GRANTED.id}`); + await screen.findByRole("heading", { name: "Reconsideration", level: 1 }); + + await screen.findByRole("button", { name: "Add note" }); + expect(screen.queryByRole("button", { name: "Resolve" })).toBeNull(); + expect(screen.getByText("Granted")).toBeTruthy(); + }); + + it("renders a not-found state for an unknown case id", async () => { + vi.spyOn(apiClient, "getReconsideration").mockResolvedValue(null); + renderRoute("/reconsiderations/recon_missing"); + expect(await screen.findByText("Reconsideration not found.")).toBeTruthy(); + }); +}); From 32a286c32f8f38476dadf7c103dbedc25c2cf46e Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Fri, 17 Jul 2026 06:08:08 +0000 Subject: [PATCH 103/137] style: format --- .../src/components/ReconsiderationDialogs.test.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/labeler/console/src/components/ReconsiderationDialogs.test.tsx b/apps/labeler/console/src/components/ReconsiderationDialogs.test.tsx index abff0106bf..a4dceb6ca9 100644 --- a/apps/labeler/console/src/components/ReconsiderationDialogs.test.tsx +++ b/apps/labeler/console/src/components/ReconsiderationDialogs.test.tsx @@ -20,14 +20,12 @@ afterEach(() => { describe("ReconsiderationNoteDialog", () => { it("threads the note and reason, keeping submit disabled until both are set", async () => { - const addNote = vi - .spyOn(apiClient, "addReconsiderationNote") - .mockResolvedValue({ - actionId: "oact_1", - reconsiderationId: RECON_ID, - noteId: "rnote_1", - cts: "", - }); + const addNote = vi.spyOn(apiClient, "addReconsiderationNote").mockResolvedValue({ + actionId: "oact_1", + reconsiderationId: RECON_ID, + noteId: "rnote_1", + cts: "", + }); renderWithClient( Date: Fri, 17 Jul 2026 08:04:12 +0100 Subject: [PATCH 104/137] feat(labeler): prolonged-error publisher notice via reconciliation cron (W10.5 follow-up) (#2088) * feat(labeler): prolonged-error publisher notice via reconciliation cron (W10.5 follow-up) The sixth notification event. An assessment stuck in the terminal error state that stays the live, unsuperseded run escalates in two stages from the reconciliation cron: at 24h a high-severity assessment-prolonged-error operational_event so operators can triage an infra-vs-publisher cause, at 72h a neutral, actionable publisher notice through the W10.5 double-opt-in pipeline. - migration 0010: assessment_error_escalations tracking table makes each stage fire-once across the 5-minute cron ticks. - the notice reuses source_type 'issuance' keyed on the errored assessment id (an errored run never produces a block/warn notice, so no key collision and no notifications CHECK rebuild); resolveNoticeForSource routes error-state to the prolonged-error content for sweep parity. The finalization path stays silent on error (assessmentNoticeContent still returns null), so only the cron's 72h stage notifies. - supersession halts the ladder when a newer run exists for the same (uri,cid); deleted subjects are skipped; the scan drops fully-escalated rows so a mass-error backlog never starves newer errors. - the operator-alert insert is gated on the escalation still being un-alerted so overlapping cron ticks cannot double-alert; the publisher stage is idempotent via the notifications source claim. - notice carries no private detail; the operational_event payload is the public assessment id + cid only. Co-Authored-By: Claude Opus 4.8 * feat(labeler): index the prolonged-error scan and retry on transient send failure Addresses the emdashbot review on #2088. - Add a partial index idx_assessments_error_completed on assessments(completed_at_epoch_ms) WHERE state='error' so the 5-minute cron scan is index-backed instead of full-scanning error rows. - runTrigger now reports whether the trigger reached a terminal outcome (sent / confirmation / undeliverable / dedup hit) versus threw a transient error before any notifications row was claimed. The prolonged-error cron stamps publisher_notified_at only on a terminal outcome, so a transient pre-claim failure (aggregator read or D1 write throwing) leaves the row in the scan window to retry next tick rather than being permanently silenced. An unparseable subject URI counts as terminal (never retried). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../0010_assessment_error_escalations.sql | 36 ++ .../src/assessment-error-escalations.ts | 183 +++++++ apps/labeler/src/constants.ts | 15 + apps/labeler/src/index.ts | 19 + apps/labeler/src/notification-triggers.ts | 63 ++- apps/labeler/src/operational-events.ts | 31 +- apps/labeler/src/prolonged-error.ts | 74 +++ apps/labeler/test/prolonged-error.test.ts | 509 ++++++++++++++++++ 8 files changed, 926 insertions(+), 4 deletions(-) create mode 100644 apps/labeler/migrations/0010_assessment_error_escalations.sql create mode 100644 apps/labeler/src/assessment-error-escalations.ts create mode 100644 apps/labeler/src/prolonged-error.ts create mode 100644 apps/labeler/test/prolonged-error.test.ts diff --git a/apps/labeler/migrations/0010_assessment_error_escalations.sql b/apps/labeler/migrations/0010_assessment_error_escalations.sql new file mode 100644 index 0000000000..bca6e2d818 --- /dev/null +++ b/apps/labeler/migrations/0010_assessment_error_escalations.sql @@ -0,0 +1,36 @@ +-- Prolonged-error escalation tracking (plan W10.5 follow-up, ratified +-- 2026-07-17). Drives the two-stage escalation ladder the reconciliation cron +-- applies to an assessment stuck in the terminal `error` state (the labeler +-- failed to assess, transient retries exhausted) that no newer run has +-- superseded: at 24h an operator alert (an `operational_events` row) so +-- operators can triage an infra-vs-publisher cause, then at 72h the publisher +-- notice if the error is still the live run. +-- +-- One row per escalated assessment makes each stage fire-once across the +-- 5-minute cron ticks: the cron upserts the row, raises the operator alert once +-- when `operator_alerted_at_epoch_ms` is null, and sends the publisher notice +-- once past 72h when `publisher_notified_at_epoch_ms` is null. MUTABLE — the two +-- mark columns flip from null to stamped — so no immutability trigger, mirroring +-- the `notifications`/`notification_outbox` mutable-outbox tables. +-- +-- Timestamp columns queries order/compare on carry an integer `*_epoch_ms` +-- sibling (RFC 3339 strings compare incorrectly across timezone offsets in SQL), +-- matching 0003-0008. + +CREATE TABLE assessment_error_escalations ( + assessment_id TEXT PRIMARY KEY REFERENCES assessments(id), + subject_uri TEXT NOT NULL, + subject_cid TEXT NOT NULL, + operator_alerted_at_epoch_ms INTEGER, + publisher_notified_at_epoch_ms INTEGER, + created_at TEXT NOT NULL, + created_at_epoch_ms INTEGER NOT NULL +); + +-- The escalation scan filters `state = 'error' AND completed_at_epoch_ms <= ?` +-- and orders by `completed_at_epoch_ms`; the existing `idx_assessments_state_created` +-- covers `(state, created_at_epoch_ms)`, not `completed_at`. Partial on the error +-- state keeps this compact (errors are a small minority) and matches the cron's +-- filter and order exactly, so a 5-minute tick seeks instead of full-scanning. +CREATE INDEX idx_assessments_error_completed + ON assessments(completed_at_epoch_ms) WHERE state = 'error'; diff --git a/apps/labeler/src/assessment-error-escalations.ts b/apps/labeler/src/assessment-error-escalations.ts new file mode 100644 index 0000000000..f29b7c1666 --- /dev/null +++ b/apps/labeler/src/assessment-error-escalations.ts @@ -0,0 +1,183 @@ +/** + * Persistence for the prolonged-error escalation ladder (plan W10.5 follow-up). + * `assessment_error_escalations` (migration 0010) tracks, per errored + * assessment, whether the 24h operator alert and the 72h publisher notice have + * already fired — the tracking table that makes each stage idempotent across the + * 5-minute reconciliation cron ticks. + * + * The escalation query deliberately does NOT reuse the pointer-based + * `isSuperseded` (an `error` run never moves the `current_assessments` pointer, + * spec §10): supersession here means simply "a newer run exists for the same + * `(uri, cid)`", detected by `created_at_epoch_ms`. + */ + +import { PROLONGED_ERROR_OPERATOR_THRESHOLD_MS, PROLONGED_ERROR_SCAN_BATCH } from "./constants.js"; + +export interface EscalatableError { + id: string; + uri: string; + cid: string; + completedAtEpochMs: number; +} + +export interface AssessmentErrorEscalation { + assessmentId: string; + subjectUri: string; + subjectCid: string; + operatorAlertedAtEpochMs: number | null; + publisherNotifiedAtEpochMs: number | null; + createdAtEpochMs: number; +} + +interface EscalatableErrorRow { + id: string; + uri: string; + cid: string; + completed_at_epoch_ms: number; +} + +interface EscalationRow { + assessment_id: string; + subject_uri: string; + subject_cid: string; + operator_alerted_at_epoch_ms: number | null; + publisher_notified_at_epoch_ms: number | null; + created_at_epoch_ms: number; +} + +/** + * Terminal `error` assessments whose `completed_at` is at least the operator + * threshold (24h) old and that no newer run has superseded — the candidate set + * for the escalation ladder. Skips subjects tombstoned at the source (a deleted + * release warrants no publisher chase) and rows whose escalation is already + * complete (both marks stamped), so a fully-escalated row leaves the + * `completed_at`-ordered window and newer errors behind it are reached during a + * backlog. Capped at {@link PROLONGED_ERROR_SCAN_BATCH}; a full page is logged so + * a persistent backlog is visible. + */ +export async function findEscalatableErrors( + db: D1Database, + now: Date, +): Promise { + const operatorBefore = now.getTime() - PROLONGED_ERROR_OPERATOR_THRESHOLD_MS; + const rows = await db + .prepare( + `SELECT a.id, a.uri, a.cid, a.completed_at_epoch_ms + FROM assessments a + JOIN subjects s ON s.uri = a.uri AND s.cid = a.cid + LEFT JOIN assessment_error_escalations e ON e.assessment_id = a.id + WHERE a.state = 'error' + AND a.completed_at_epoch_ms IS NOT NULL + AND a.completed_at_epoch_ms <= ? + AND s.deleted_at IS NULL + AND ( + e.assessment_id IS NULL + OR e.operator_alerted_at_epoch_ms IS NULL + OR e.publisher_notified_at_epoch_ms IS NULL + ) + AND NOT EXISTS ( + SELECT 1 FROM assessments b + WHERE b.uri = a.uri AND b.cid = a.cid AND b.created_at_epoch_ms > a.created_at_epoch_ms + ) + ORDER BY a.completed_at_epoch_ms ASC + LIMIT ?`, + ) + .bind(operatorBefore, PROLONGED_ERROR_SCAN_BATCH) + .all(); + const results = rows.results ?? []; + if (results.length >= PROLONGED_ERROR_SCAN_BATCH) + console.error("[labeler] prolonged-error escalation scan hit the batch cap", { + cap: PROLONGED_ERROR_SCAN_BATCH, + }); + return results.map((row) => ({ + id: row.id, + uri: row.uri, + cid: row.cid, + completedAtEpochMs: row.completed_at_epoch_ms, + })); +} + +/** Guarantee a tracking row exists for an errored assessment, leaving both mark + * columns null. Idempotent: a repeated cron tick keeps the first row's marks. */ +export async function ensureEscalationRow( + db: D1Database, + input: { assessmentId: string; subjectUri: string; subjectCid: string; now: Date }, +): Promise { + await db + .prepare( + `INSERT INTO assessment_error_escalations + (assessment_id, subject_uri, subject_cid, created_at, created_at_epoch_ms) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(assessment_id) DO NOTHING`, + ) + .bind( + input.assessmentId, + input.subjectUri, + input.subjectCid, + input.now.toISOString(), + input.now.getTime(), + ) + .run(); +} + +export async function getEscalation( + db: D1Database, + assessmentId: string, +): Promise { + const row = await db + .prepare( + `SELECT assessment_id, subject_uri, subject_cid, + operator_alerted_at_epoch_ms, publisher_notified_at_epoch_ms, created_at_epoch_ms + FROM assessment_error_escalations WHERE assessment_id = ?`, + ) + .bind(assessmentId) + .first(); + return row + ? { + assessmentId: row.assessment_id, + subjectUri: row.subject_uri, + subjectCid: row.subject_cid, + operatorAlertedAtEpochMs: row.operator_alerted_at_epoch_ms, + publisherNotifiedAtEpochMs: row.publisher_notified_at_epoch_ms, + createdAtEpochMs: row.created_at_epoch_ms, + } + : null; +} + +/** + * The statement that stamps `operator_alerted_at`, guarded on it still being + * null. Returned rather than executed so the caller can batch it atomically with + * the operational-event insert — a crash can then never leave the alert raised + * without the mark (which would re-alert on the next tick). + */ +export function buildMarkOperatorAlerted( + db: D1Database, + assessmentId: string, + now: Date, +): D1PreparedStatement { + return db + .prepare( + `UPDATE assessment_error_escalations + SET operator_alerted_at_epoch_ms = ? + WHERE assessment_id = ? AND operator_alerted_at_epoch_ms IS NULL`, + ) + .bind(now.getTime(), assessmentId); +} + +/** Stamp `publisher_notified_at`, guarded on it still being null. Runs after the + * notice trigger returns; the notice's own `(issuance, id)` dedup makes a crash + * between send and mark self-heal (the next tick re-sends nothing, then marks). */ +export async function markPublisherNotified( + db: D1Database, + assessmentId: string, + now: Date, +): Promise { + await db + .prepare( + `UPDATE assessment_error_escalations + SET publisher_notified_at_epoch_ms = ? + WHERE assessment_id = ? AND publisher_notified_at_epoch_ms IS NULL`, + ) + .bind(now.getTime(), assessmentId) + .run(); +} diff --git a/apps/labeler/src/constants.ts b/apps/labeler/src/constants.ts index 2dce10d4dc..66721e7395 100644 --- a/apps/labeler/src/constants.ts +++ b/apps/labeler/src/constants.ts @@ -40,3 +40,18 @@ export const NOTIFICATION_MAX_SEND_ATTEMPTS = 5; export const NOTIFICATION_STUCK_PENDING_MS = 15 * 60 * 1000; export const NOTIFICATION_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; export const NOTIFICATION_SWEEP_BATCH = 200; + +/** + * Prolonged-error escalation thresholds (plan W10.5 follow-up). Versioned as a + * set: bump together if the ladder is retuned. A terminal `error` assessment + * (retries exhausted) that no newer run has superseded escalates in two stages + * measured from `completed_at_epoch_ms`: at {@link PROLONGED_ERROR_OPERATOR_THRESHOLD_MS} + * (24h) an operator alert so operators can triage an infra-vs-publisher cause; + * at {@link PROLONGED_ERROR_PUBLISHER_THRESHOLD_MS} (72h) the publisher notice, + * only if the error is still live. {@link PROLONGED_ERROR_SCAN_BATCH} caps one + * cron pass's scan of escalatable errors. + */ +export const PROLONGED_ERROR_ESCALATION_VERSION = 1; +export const PROLONGED_ERROR_OPERATOR_THRESHOLD_MS = 24 * 60 * 60 * 1000; +export const PROLONGED_ERROR_PUBLISHER_THRESHOLD_MS = 72 * 60 * 60 * 1000; +export const PROLONGED_ERROR_SCAN_BATCH = 200; diff --git a/apps/labeler/src/index.ts b/apps/labeler/src/index.ts index d9c7bd0962..13b31063d6 100644 --- a/apps/labeler/src/index.ts +++ b/apps/labeler/src/index.ts @@ -13,6 +13,7 @@ import { didDocumentResponse, policyDocumentResponse } from "./identity.js"; import { handleNotificationRequest, isNotificationPath } from "./notification-endpoints.js"; import { runNotificationSweep } from "./notification-sweep.js"; import { createNotifyDeps, type NotifyDeps } from "./notification-triggers.js"; +import { runProlongedErrorEscalation } from "./prolonged-error.js"; import { queryLabels } from "./query-labels.js"; import { reconcileAssessments } from "./reconciliation.js"; import { createRuntimeSigner, getRuntimeSigningSecret } from "./signing-runtime.js"; @@ -124,6 +125,24 @@ export default { } })(), ); + + // Prolonged-error escalation (plan W10.5 follow-up): 24h operator alert then + // 72h publisher notice for an errored, unsuperseded run. Its own branch with + // the non-throwing deps builder, so a notify-deps failure skips the pass + // rather than disturbing the sweep or reconciliation passes above. + ctx.waitUntil( + (async () => { + try { + const deps = await safeCreateNotifyDeps(env); + if (!deps) return; + await runProlongedErrorEscalation(deps, new Date()); + } catch (err) { + console.error("[labeler] prolonged-error escalation failed", { + error: err instanceof Error ? err.message : String(err), + }); + } + })(), + ); }, }; diff --git a/apps/labeler/src/notification-triggers.ts b/apps/labeler/src/notification-triggers.ts index 7fc48107a5..6726f89789 100644 --- a/apps/labeler/src/notification-triggers.ts +++ b/apps/labeler/src/notification-triggers.ts @@ -231,6 +231,26 @@ function reconsiderationOutcomeNoticeContent( }; } +/** Prolonged-error notice for an assessment stuck in `error` past the 72h + * threshold (plan W10.5 follow-up). Neutral and actionable: it states the + * labeler could not complete its own assessment, that no label change is + * implied, and gives the publisher the one thing they can act on — checking the + * release's artifact URL is reachable. Carries NO findings or private detail. */ +function prolongedErrorNoticeContent( + urls: NoticeUrls, + input: { uri: string; cid: string }, +): NoticeContent { + return { + subject: "We couldn't complete the security assessment of your plugin release", + publicSummary: + "The automated security assessment of this release repeatedly failed to complete.", + effect: + "No label change is implied by this failure. If the release's artifact URL is unavailable or has changed, please verify it is reachable so the assessment can complete.", + assessmentUrl: assessmentUrl(urls.serviceUrl, input.uri, input.cid), + reconsiderationUrl: urls.reconsiderationUrl, + }; +} + // ── Live trigger entry points ─────────────────────────────────────────────── /** Automated block/warning notice from a finalized assessment run. Source is the @@ -326,6 +346,30 @@ export async function notifyReconsiderationOutcome( ); } +/** + * Prolonged-error publisher notice, fired by the reconciliation cron's 72h stage + * (plan W10.5 follow-up) — never at finalization (`assessmentNoticeContent` + * stays null for `error`, so `notifyAssessmentOutcome` no-ops on it). Source is + * the errored assessment id: an errored run never produces a block/warn notice, + * so the `(issuance, id)` key never collides with a finalization notice, and the + * claim dedups a crash-retry between the send and the escalation-row mark. + */ +export async function notifyProlongedError( + deps: NotifyDeps, + assessment: Assessment, +): Promise { + const target = contactTargetFromUri(assessment.uri); + // An unparseable URI is terminal, not transient (the URI never changes), so it + // counts as processed — the cron marks it rather than re-attempting forever. + if (!target) return true; + return runTrigger( + deps, + { type: "issuance", id: assessment.id }, + target, + prolongedErrorNoticeContent(deps, { uri: assessment.uri, cid: assessment.cid }), + ); +} + /** * Rebuild a NOTICE's public content from its source row, for the retry sweep. * Nothing about the notice is persisted (plaintext minimization), so a retry @@ -341,7 +385,10 @@ export async function resolveNoticeForSource( ): Promise { if (sourceType === "issuance") { const assessment = await loadAssessmentSafe(deps.db, sourceId); - return assessment ? assessmentNoticeContent(deps, assessment) : null; + if (!assessment) return null; + if (assessment.state === "error") + return prolongedErrorNoticeContent(deps, { uri: assessment.uri, cid: assessment.cid }); + return assessmentNoticeContent(deps, assessment); } const action = await getOperatorActionById(deps.db, sourceId); if (!action || action.subjectUri === null) return null; @@ -423,18 +470,26 @@ function operatorActionNeg(metadataJson: string): boolean { * Dedup, resolve verification (fail-closed), send, swallow+log. Shared by every * trigger so the dedup and verified-skip policy is applied uniformly and a * notification failure can never escape into the label path. + * + * Returns whether the trigger reached a TERMINAL outcome — a dedup hit or a + * normal `sendNotification` return (sent, confirmation-sent, undeliverable, or a + * claimed-then-failed row the sweep now owns) — versus a thrown TRANSIENT error + * (an aggregator read or a pre-claim D1 write that failed before any row was + * claimed). The prolonged-error cron uses this to decide whether to stamp its + * fire-once mark: a transient failure returns `false` so the next tick retries + * instead of being silently swallowed. Fire-and-forget callers ignore it. */ async function runTrigger( deps: NotifyDeps, source: NotificationSource, target: ContactTarget, notice: NoticeContent, -): Promise { +): Promise { const now = deps.now ?? (() => new Date()); try { if (await sourceAlreadyProcessed(deps.db, source)) { logTrigger(source, target.did, "deduped"); - return; + return true; } const verifiedPublisher = await isVerifiedPublisher(deps.aggregator, target.did, now()); const ctx: SendContext = { @@ -449,6 +504,7 @@ async function runTrigger( const request: NotificationRequest = { source, target, notice }; const outcome = await sendNotification(ctx, request); logTrigger(source, target.did, outcome.status); + return true; } catch (error) { console.error("[notifications] trigger failed", { sourceType: source.type, @@ -456,6 +512,7 @@ async function runTrigger( did: target.did, error: error instanceof Error ? error.message : String(error), }); + return false; } } diff --git a/apps/labeler/src/operational-events.ts b/apps/labeler/src/operational-events.ts index 5f40783ab9..557170cb76 100644 --- a/apps/labeler/src/operational-events.ts +++ b/apps/labeler/src/operational-events.ts @@ -13,7 +13,8 @@ export type OperationalEventType = | "dead-letter-retried" | "dead-letter-quarantined" | "reconsideration-opened" - | "reconsideration-resolved"; + | "reconsideration-resolved" + | "assessment-prolonged-error"; export type OperationalEventSeverity = "critical" | "high" | "info"; @@ -26,6 +27,11 @@ export type OperationalEventSeverity = "critical" | "high" | "info"; */ export interface OperationalEventPayload { reason?: string; + /** The escalated run and its release CID for `assessment-prolonged-error`. + * Both are public identifiers (the assessment id the public API returns, the + * record's content hash) — no findings or private detail. */ + assessmentId?: string; + cid?: string; } export interface OperationalEventInsert { @@ -63,6 +69,16 @@ export interface OperationalEventInsert { * phantom resolved-event while its audit row still commits for replay. */ gateOnResolvedReconsideration?: { reconsiderationId: string; actionId: string }; + /** + * When set, the INSERT is gated on the `assessment_error_escalations` row for + * this assessment still being un-alerted. Batched atomically with the + * `operator_alerted_at` mark, it makes the prolonged-error operator alert + * at-most-once even under overlapping cron ticks: a second pass that read the + * row unalerted before the first committed still inserts no event, because its + * EXISTS sees the committed mark (the app-level null read alone cannot — cron + * ticks are not serialized against each other). + */ + gateOnUnalertedEscalation?: { assessmentId: string }; } export interface OutboxInsert { @@ -196,6 +212,19 @@ export function buildOperationalEventInsert( .bind(...values, input.gateOnIssuedLabelActionId); } + if (input.gateOnUnalertedEscalation !== undefined) { + return db + .prepare( + `INSERT INTO operational_events (${columns}) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ? + WHERE EXISTS ( + SELECT 1 FROM assessment_error_escalations + WHERE assessment_id = ? AND operator_alerted_at_epoch_ms IS NULL + )`, + ) + .bind(...values, input.gateOnUnalertedEscalation.assessmentId); + } + return db .prepare( `INSERT INTO operational_events (${columns}) diff --git a/apps/labeler/src/prolonged-error.ts b/apps/labeler/src/prolonged-error.ts new file mode 100644 index 0000000000..982468e265 --- /dev/null +++ b/apps/labeler/src/prolonged-error.ts @@ -0,0 +1,74 @@ +/** + * The reconciliation cron's prolonged-error escalation pass (plan W10.5 + * follow-up). For each terminal `error` assessment that has stayed the live, + * unsuperseded run past the thresholds, it walks the two-stage ladder: + * + * - 24h: raise a `assessment-prolonged-error` operational_event (severity + * high, no operator action) so operators can triage an infra-vs-publisher + * cause. The event insert and the `operator_alerted_at` mark commit in one + * `db.batch`, so a crash can never raise the alert without recording it and + * re-alert on the next tick. + * - 72h: send the publisher notice, then stamp `publisher_notified_at`. The + * notice's own `(issuance, id)` dedup makes a crash between send and mark + * self-heal — the next tick re-sends nothing and stamps the mark. + * + * Each stage is fire-once across the 5-minute ticks via + * `assessment_error_escalations` (migration 0010). The pass takes {@link NotifyDeps} + * because the 72h stage needs the sender; the cron builds it with + * `safeCreateNotifyDeps` and skips the pass when it is unavailable. + */ + +import { + buildMarkOperatorAlerted, + ensureEscalationRow, + findEscalatableErrors, + getEscalation, + markPublisherNotified, +} from "./assessment-error-escalations.js"; +import { getAssessment } from "./assessment-store.js"; +import { PROLONGED_ERROR_PUBLISHER_THRESHOLD_MS } from "./constants.js"; +import type { NotifyDeps } from "./notification-triggers.js"; +import { notifyProlongedError } from "./notification-triggers.js"; +import { buildOperationalEventInsert, newOperationalEventId } from "./operational-events.js"; + +export async function runProlongedErrorEscalation(deps: NotifyDeps, now: Date): Promise { + const escalatable = await findEscalatableErrors(deps.db, now); + for (const error of escalatable) { + await ensureEscalationRow(deps.db, { + assessmentId: error.id, + subjectUri: error.uri, + subjectCid: error.cid, + now, + }); + const escalation = await getEscalation(deps.db, error.id); + if (!escalation) continue; + + if (escalation.operatorAlertedAtEpochMs === null) { + const eventInsert = buildOperationalEventInsert(deps.db, { + id: newOperationalEventId(), + eventType: "assessment-prolonged-error", + severity: "high", + actionId: null, + subjectUri: error.uri, + payload: { assessmentId: error.id, cid: error.cid }, + now, + gateOnUnalertedEscalation: { assessmentId: error.id }, + }); + await deps.db.batch([eventInsert, buildMarkOperatorAlerted(deps.db, error.id, now)]); + } + + if ( + escalation.publisherNotifiedAtEpochMs === null && + now.getTime() - error.completedAtEpochMs >= PROLONGED_ERROR_PUBLISHER_THRESHOLD_MS + ) { + const assessment = await getAssessment(deps.db, error.id); + if (!assessment) continue; + // Only stamp the fire-once mark when the trigger reached a terminal + // outcome. A transient failure (aggregator read / pre-claim D1 write threw + // before any notifications row was claimed) returns false — leave the mark + // null so the next tick retries rather than silently dropping the notice. + const processed = await notifyProlongedError(deps, assessment); + if (processed) await markPublisherNotified(deps.db, error.id, now); + } + } +} diff --git a/apps/labeler/test/prolonged-error.test.ts b/apps/labeler/test/prolonged-error.test.ts new file mode 100644 index 0000000000..e1db207959 --- /dev/null +++ b/apps/labeler/test/prolonged-error.test.ts @@ -0,0 +1,509 @@ +import { applyD1Migrations, env } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { AggregatorClient } from "../src/aggregator-client.js"; +import { + buildMarkOperatorAlerted, + ensureEscalationRow, + findEscalatableErrors, + getEscalation, +} from "../src/assessment-error-escalations.js"; +import { computeRunKey, initialTriggerId, intelTriggerId } from "../src/assessment-lifecycle.js"; +import type { Assessment } from "../src/assessment-store.js"; +import { + buildFinalizationStatements, + createAssessmentRun, + createSubject, + deleteSubject, + transitionAssessmentState, +} from "../src/assessment-store.js"; +import { + confirmContact, + ensureContact, + hashConfirmToken, + recipientHash, + recordConfirmSent, +} from "../src/notification-contacts.js"; +import type { ConfirmationPayload, NoticePayload, SendResult } from "../src/notification-send.js"; +import { + notifyAssessmentOutcome, + resolveNoticeForSource, + type NotifyDeps, +} from "../src/notification-triggers.js"; +import { buildOperationalEventInsert, newOperationalEventId } from "../src/operational-events.js"; +import { runProlongedErrorEscalation } from "../src/prolonged-error.js"; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; +} +const testEnv = env as unknown as TestEnv; +const db = () => testEnv.DB; + +const PEPPER = "prolonged-pepper"; +const SERVICE = "https://labels.example"; +const RECON = "https://recon.example/reconsider"; +const SRC = "did:plc:labeler000000000000000000"; +const PUBLISHER_DID = "did:plc:publisher000000000000000000"; +const NOW = new Date("2026-07-17T00:00:00.000Z"); +const H = 60 * 60 * 1000; + +let counter = 0; +const uniq = (p: string) => `${p}-${++counter}`; + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); +}); + +function release(): { uri: string; cid: string } { + counter++; + return { + uri: `at://${PUBLISHER_DID}/com.emdashcms.experimental.package.release/pe-${counter}:1.0.0`, + cid: `bafkreipe${counter}0000000000000000000000000000000000000000000`, + }; +} + +async function subject(target: { uri: string; cid: string }): Promise { + await createSubject(db(), { + uri: target.uri, + cid: target.cid, + did: PUBLISHER_DID, + collection: "com.emdashcms.experimental.package.release", + rkey: target.uri.split("/").at(-1)!, + }); +} + +/** Drives a run all the way to the terminal `error` state, controlling both the + * `created_at` (supersession ordering) and `completed_at` (escalation timing). */ +async function errorAssessment( + target: { uri: string; cid: string }, + opts: { createdAt: Date; completedAt: Date; triggerId?: string }, +): Promise { + const triggerId = opts.triggerId ?? initialTriggerId(target.cid); + const runKey = await computeRunKey({ + uri: target.uri, + cid: target.cid, + policyVersion: "v1", + modelId: "m", + promptHash: "p", + scannerSetVersion: "v1", + triggerId, + }); + const { assessment } = await createAssessmentRun(db(), { + runKey, + uri: target.uri, + cid: target.cid, + trigger: "initial", + triggerId, + policyVersion: "v1", + coverageJson: "{}", + now: opts.createdAt, + }); + const id = assessment.id; + await transitionAssessmentState(db(), { + id, + from: "observed", + to: "verifying", + now: opts.createdAt, + }); + await transitionAssessmentState(db(), { + id, + from: "verifying", + to: "pending", + now: opts.createdAt, + }); + await transitionAssessmentState(db(), { + id, + from: "pending", + to: "running", + now: opts.createdAt, + }); + const fin = buildFinalizationStatements(db(), { + assessmentId: id, + fromState: "running", + toState: "error", + src: SRC, + uri: target.uri, + cid: target.cid, + now: opts.completedAt, + }); + await db().batch(fin.statements); + return id; +} + +interface RecordingSender { + confirmations: ConfirmationPayload[]; + notices: NoticePayload[]; + sendConfirmation(p: ConfirmationPayload): Promise; + sendNotice(p: NoticePayload): Promise; +} + +function recordingSender(result: SendResult = { ok: true, providerId: "p" }): RecordingSender { + const confirmations: ConfirmationPayload[] = []; + const notices: NoticePayload[] = []; + return { + confirmations, + notices, + sendConfirmation: async (p) => (confirmations.push(p), result), + sendNotice: async (p) => (notices.push(p), result), + }; +} + +function aggregatorFor(email?: string): AggregatorClient { + const fetcher = { + fetch: async (url: string) => { + if (url.includes("getPublisherVerification")) + return Response.json({ did: PUBLISHER_DID, verifications: [], labels: [] }); + if (url.includes("getPublisher") && email !== undefined) + return Response.json({ + did: PUBLISHER_DID, + profile: { contact: [{ kind: "security", email }] }, + }); + return new Response(JSON.stringify({ error: "NotFound" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + }, + } as unknown as Fetcher; + return new AggregatorClient(fetcher); +} + +/** An aggregator whose reads THROW — drives a transient pre-claim failure in + * `resolvePublisherContact` (which propagates rather than returning `none`). */ +function throwingAggregator(): AggregatorClient { + return new AggregatorClient({ + fetch: async () => { + throw new Error("aggregator down"); + }, + } as unknown as Fetcher); +} + +function deps(sender: RecordingSender, aggregator: AggregatorClient): NotifyDeps { + return { + db: db(), + aggregator, + sender, + pepper: PEPPER, + serviceUrl: SERVICE, + reconsiderationUrl: RECON, + now: () => NOW, + }; +} + +async function seedConfirmed(email: string): Promise { + const hash = await recipientHash(PEPPER, email); + await ensureContact(db(), hash, "2026-07-16T00:00:00.000Z"); + const th = await hashConfirmToken("seed"); + await recordConfirmSent(db(), hash, th, 1_000); + await confirmContact(db(), hash, th, "2026-07-16T00:00:01.000Z"); +} + +async function operatorAlertCount(uri: string): Promise { + const row = await db() + .prepare( + `SELECT COUNT(*) AS n FROM operational_events + WHERE event_type = 'assessment-prolonged-error' AND subject_uri = ?`, + ) + .bind(uri) + .first<{ n: number }>(); + return row?.n ?? 0; +} + +async function notificationRows(sourceId: string): Promise<{ kind: string; state: string }[]> { + const r = await db() + .prepare(`SELECT kind, state FROM notifications WHERE source_id = ?`) + .bind(sourceId) + .all<{ kind: string; state: string }>(); + return r.results ?? []; +} + +describe("operator alert stage (24h)", () => { + it("fires an operator alert once the error is 24h old, not before", async () => { + const fresh = release(); + await subject(fresh); + await errorAssessment(fresh, { + createdAt: new Date(NOW.getTime() - 30 * H), + completedAt: new Date(NOW.getTime() - 23 * H), + }); + + await runProlongedErrorEscalation(deps(recordingSender(), aggregatorFor()), NOW); + expect(await operatorAlertCount(fresh.uri)).toBe(0); + + const stale = release(); + await subject(stale); + await errorAssessment(stale, { + createdAt: new Date(NOW.getTime() - 30 * H), + completedAt: new Date(NOW.getTime() - 25 * H), + }); + + await runProlongedErrorEscalation(deps(recordingSender(), aggregatorFor()), NOW); + expect(await operatorAlertCount(stale.uri)).toBe(1); + }); + + it("records the alert as severity high with a public-safe payload", async () => { + const target = release(); + await subject(target); + const id = await errorAssessment(target, { + createdAt: new Date(NOW.getTime() - 30 * H), + completedAt: new Date(NOW.getTime() - 25 * H), + }); + + await runProlongedErrorEscalation(deps(recordingSender(), aggregatorFor()), NOW); + + const row = await db() + .prepare( + `SELECT severity, action_id, payload_json FROM operational_events + WHERE event_type = 'assessment-prolonged-error' AND subject_uri = ?`, + ) + .bind(target.uri) + .first<{ severity: string; action_id: string | null; payload_json: string }>(); + expect(row?.severity).toBe("high"); + expect(row?.action_id).toBeNull(); + const payload = JSON.parse(row?.payload_json ?? "{}") as Record; + expect(payload).toEqual({ assessmentId: id, cid: target.cid }); + }); + + it("is idempotent across repeated cron ticks (exactly one event)", async () => { + const target = release(); + await subject(target); + await errorAssessment(target, { + createdAt: new Date(NOW.getTime() - 30 * H), + completedAt: new Date(NOW.getTime() - 25 * H), + }); + + await runProlongedErrorEscalation(deps(recordingSender(), aggregatorFor()), NOW); + await runProlongedErrorEscalation(deps(recordingSender(), aggregatorFor()), NOW); + await runProlongedErrorEscalation(deps(recordingSender(), aggregatorFor()), NOW); + + expect(await operatorAlertCount(target.uri)).toBe(1); + }); + + it("inserts exactly one operator event when two overlapping passes both read it unalerted", async () => { + const target = release(); + await subject(target); + const id = await errorAssessment(target, { + createdAt: new Date(NOW.getTime() - 30 * H), + completedAt: new Date(NOW.getTime() - 25 * H), + }); + await ensureEscalationRow(db(), { + assessmentId: id, + subjectUri: target.uri, + subjectCid: target.cid, + now: NOW, + }); + // Both passes observe operator_alerted null (cron ticks are not serialized), + // then each commits its gated event+mark batch. The in-batch EXISTS gate — not + // the stale app-level read — must admit only the first. + expect((await getEscalation(db(), id))?.operatorAlertedAtEpochMs).toBeNull(); + expect((await getEscalation(db(), id))?.operatorAlertedAtEpochMs).toBeNull(); + const commitAlertBatch = () => + db().batch([ + buildOperationalEventInsert(db(), { + id: newOperationalEventId(), + eventType: "assessment-prolonged-error", + severity: "high", + actionId: null, + subjectUri: target.uri, + payload: { assessmentId: id, cid: target.cid }, + now: NOW, + gateOnUnalertedEscalation: { assessmentId: id }, + }), + buildMarkOperatorAlerted(db(), id, NOW), + ]); + + await commitAlertBatch(); + await commitAlertBatch(); + + expect(await operatorAlertCount(target.uri)).toBe(1); + }); + + it("drops a fully-escalated row from the scan window so newer errors are reached", async () => { + const done = release(); + await subject(done); + const doneId = await errorAssessment(done, { + createdAt: new Date(NOW.getTime() - 100 * H), + completedAt: new Date(NOW.getTime() - 73 * H), + }); + const email = uniq("win") + "@x.test"; + await seedConfirmed(email); + await runProlongedErrorEscalation(deps(recordingSender(), aggregatorFor(email)), NOW); + + // Both marks are now set, so the oldest (by completed_at) row leaves the window. + const escalation = await getEscalation(db(), doneId); + expect(escalation?.operatorAlertedAtEpochMs).not.toBeNull(); + expect(escalation?.publisherNotifiedAtEpochMs).not.toBeNull(); + expect((await findEscalatableErrors(db(), NOW)).some((e) => e.id === doneId)).toBe(false); + + // A newer error behind it in completed_at order is still reached. + const fresh = release(); + await subject(fresh); + const freshId = await errorAssessment(fresh, { + createdAt: new Date(NOW.getTime() - 30 * H), + completedAt: new Date(NOW.getTime() - 25 * H), + }); + const found = await findEscalatableErrors(db(), NOW); + expect(found.some((e) => e.id === freshId)).toBe(true); + expect(found.some((e) => e.id === doneId)).toBe(false); + }); + + it("does not escalate a superseded error at all", async () => { + const target = release(); + await subject(target); + await errorAssessment(target, { + createdAt: new Date(NOW.getTime() - 100 * H), + completedAt: new Date(NOW.getTime() - 80 * H), + }); + // A newer run for the SAME (uri, cid) supersedes the error. + await errorAssessment(target, { + createdAt: new Date(NOW.getTime() - 10 * H), + completedAt: new Date(NOW.getTime() - 5 * H), + triggerId: intelTriggerId("corpus-v2"), + }); + + const sender = recordingSender(); + await runProlongedErrorEscalation(deps(sender, aggregatorFor("s@x.test")), NOW); + + expect(await operatorAlertCount(target.uri)).toBe(0); + expect(sender.notices).toHaveLength(0); + }); + + it("does not escalate an error whose subject was deleted at the source", async () => { + const target = release(); + await subject(target); + await errorAssessment(target, { + createdAt: new Date(NOW.getTime() - 100 * H), + completedAt: new Date(NOW.getTime() - 80 * H), + }); + await deleteSubject(db(), { uri: target.uri, cid: target.cid }); + + const sender = recordingSender(); + await runProlongedErrorEscalation(deps(sender, aggregatorFor("s@x.test")), NOW); + + expect(await operatorAlertCount(target.uri)).toBe(0); + expect(sender.notices).toHaveLength(0); + }); +}); + +describe("publisher notice stage (72h)", () => { + it("does not notify the publisher before 72h", async () => { + const target = release(); + await subject(target); + const id = await errorAssessment(target, { + createdAt: new Date(NOW.getTime() - 80 * H), + completedAt: new Date(NOW.getTime() - 71 * H), + }); + const email = uniq("early") + "@x.test"; + await seedConfirmed(email); + const sender = recordingSender(); + + await runProlongedErrorEscalation(deps(sender, aggregatorFor(email)), NOW); + + expect(await operatorAlertCount(target.uri)).toBe(1); + expect(sender.notices).toHaveLength(0); + expect(await notificationRows(id)).toHaveLength(0); + }); + + it("notifies a confirmed publisher once past 72h, exactly once across ticks", async () => { + const target = release(); + await subject(target); + const id = await errorAssessment(target, { + createdAt: new Date(NOW.getTime() - 100 * H), + completedAt: new Date(NOW.getTime() - 73 * H), + }); + const email = uniq("late") + "@x.test"; + await seedConfirmed(email); + const sender = recordingSender(); + const d = deps(sender, aggregatorFor(email)); + + await runProlongedErrorEscalation(d, NOW); + await runProlongedErrorEscalation(d, NOW); + + expect(sender.notices).toHaveLength(1); + expect(sender.notices[0]).toMatchObject({ + to: email, + subject: expect.stringContaining("couldn't complete"), + }); + expect(await notificationRows(id)).toEqual([{ kind: "notice", state: "sent" }]); + }); + + it("gates an unconfirmed publisher through double opt-in (confirmation only)", async () => { + const target = release(); + await subject(target); + const id = await errorAssessment(target, { + createdAt: new Date(NOW.getTime() - 100 * H), + completedAt: new Date(NOW.getTime() - 73 * H), + }); + const email = uniq("unconf") + "@x.test"; + const sender = recordingSender(); + + await runProlongedErrorEscalation(deps(sender, aggregatorFor(email)), NOW); + + expect(sender.notices).toHaveLength(0); + expect(sender.confirmations).toHaveLength(1); + expect(await notificationRows(id)).toEqual([{ kind: "confirmation", state: "sent" }]); + }); + + it("does not mark on a transient pre-claim failure, and retries on the next tick", async () => { + const target = release(); + await subject(target); + const id = await errorAssessment(target, { + createdAt: new Date(NOW.getTime() - 100 * H), + completedAt: new Date(NOW.getTime() - 73 * H), + }); + const email = uniq("retry") + "@x.test"; + await seedConfirmed(email); + + // Tick 1: the aggregator read throws inside resolvePublisherContact, before any + // notifications row is claimed. The trigger swallows it and returns false, so + // nothing is claimed and publisher_notified_at stays null. + await runProlongedErrorEscalation(deps(recordingSender(), throwingAggregator()), NOW); + expect((await getEscalation(db(), id))?.publisherNotifiedAtEpochMs).toBeNull(); + expect(await notificationRows(id)).toHaveLength(0); + + // Tick 2: the aggregator is healthy → the notice is claimed and sent, and the + // mark is stamped. A transient failure retried rather than being swallowed. + const sender = recordingSender(); + await runProlongedErrorEscalation(deps(sender, aggregatorFor(email)), NOW); + expect(sender.notices).toHaveLength(1); + expect(await notificationRows(id)).toEqual([{ kind: "notice", state: "sent" }]); + expect((await getEscalation(db(), id))?.publisherNotifiedAtEpochMs).not.toBeNull(); + }); +}); + +describe("sweep parity and finalization invariant", () => { + it("resolveNoticeForSource re-renders the prolonged-error content for an errored run", async () => { + const target = release(); + await subject(target); + const id = await errorAssessment(target, { + createdAt: new Date(NOW.getTime() - 100 * H), + completedAt: new Date(NOW.getTime() - 73 * H), + }); + + const notice = await resolveNoticeForSource( + deps(recordingSender(), aggregatorFor()), + "issuance", + id, + ); + + expect(notice).not.toBeNull(); + expect(notice?.subject).toContain("couldn't complete"); + expect(notice?.assessmentUrl.startsWith(SERVICE)).toBe(true); + }); + + it("notifyAssessmentOutcome never notifies on an error assessment (finalization stays silent)", async () => { + const sender = recordingSender(); + const assessment = { + id: uniq("asmt"), + uri: `at://${PUBLISHER_DID}/com.emdashcms.experimental.package.release/fin1`, + cid: "bafyerr", + state: "error", + publicSummary: "irrelevant", + } as unknown as Assessment; + + await notifyAssessmentOutcome(deps(sender, aggregatorFor("s@x.test")), assessment); + + expect(sender.notices).toHaveLength(0); + expect(sender.confirmations).toHaveLength(0); + expect(await notificationRows(assessment.id)).toHaveLength(0); + }); +}); From f161aa5ec396aff8ff34285abec1e1b960a4cae8 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 09:50:21 +0100 Subject: [PATCH 105/137] docs(labeler-plan): cut capability analysis + scanners from deterministic surface Ratified 2026-07-17: plugins are self-contained (no deps) and the workerd sandbox enforces declared access at runtime (globalOutbound routes all plugin fetch through the capability + allowedHosts-checked backing service), so SBOM/dependency/malware scanning is inapplicable and static capability extraction is redundant. Deterministic analysis reduces to integrity/bundle validation (already in the acquire stage); the deterministic + dependency orchestrator stages are removed. Dissolves the scanner-tooling-selection gate. Co-Authored-By: Claude Opus 4.8 --- .../plugin-registry-labelling-service/implementation-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index e1debccf25..843ad9fd69 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -963,6 +963,8 @@ Dependencies: `W7.1`. ### `W7.4` Implement deterministic security checks +Decision (2026-07-17, ratified): **the deterministic-analysis surface is cut to integrity/bundle validation only; capability analysis (W7.5) and all SBOM/dependency/malware/advisory scanning are NOT built, and the `deterministic` + `dependency` orchestrator stages are removed.** Rationale: (1) **plugins are self-contained with no external dependencies** — SBOM, dependency-graph, and advisory scanning have nothing to analyze, and a dependency-CVE is not a moderation signal anyway (over-moderation / Dependabot's job). (2) **The plugin runtime ENFORCES declared access**, so static undeclared-access / capability extraction is redundant: plugins execute in a dynamically-loaded workerd sandbox whose `globalOutbound` routes ALL outbound `fetch()` (raw global fetch included) through the backing service (`packages/workerd/src/sandbox/capnp.ts`, `globalOutbound = "emdash-backing"`), and `packages/cloudflare/src/sandbox/bridge-http.ts` checks the `network:request` capability + the `allowedHosts` allowlist on every request and every redirect hop (SSRF-hardened), throwing on an undeclared host; every other host resource (content/media/users/email) reaches the plugin only through the capability-gated backing-service API, never a raw binding. A plugin declaring "no network" but calling `fetch('evil.com')` is blocked at runtime regardless of its source, so statically detecting the attempt catches nothing the sandbox does not already stop — and the code-AI adapter is the better judge of malicious *intent* on top of that. Signature-based AV is additionally a poor fit (novel obfuscated source-level JS, infeasible in workerd). The only residual "intelligence" idea with distinct value — a curated known-bad artifact-checksum denylist — mostly overlaps the existing cross-publisher checksum-reuse detection in the history stage and is deferred (do-it-now-or-not-at-all: not now). **Consequence:** the deterministic half of the pipeline is just integrity/bundle validation, which already emits real `artifact-integrity-failure` / `invalid-bundle` findings from the acquire stage (#2069); the W7.4 forbidden-archive/executable-payload and metadata-consistency checks fold into that acquire-stage validation rather than a separate stage. W7.5 and the capability-mismatch portion of W7.6 are struck. This dissolves the "Select dependency/malware scanners, SBOM tooling" gate — there is nothing to select. + Adapters/findings for: - Declared-vs-actual capability analysis (feeding `undeclared-access`). From 05d7be0c16c6468498398e0af2109e49b643b4d2 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Fri, 17 Jul 2026 08:52:33 +0000 Subject: [PATCH 106/137] style: format --- .../plugin-registry-labelling-service/implementation-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 843ad9fd69..61bd97a9a9 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -963,7 +963,7 @@ Dependencies: `W7.1`. ### `W7.4` Implement deterministic security checks -Decision (2026-07-17, ratified): **the deterministic-analysis surface is cut to integrity/bundle validation only; capability analysis (W7.5) and all SBOM/dependency/malware/advisory scanning are NOT built, and the `deterministic` + `dependency` orchestrator stages are removed.** Rationale: (1) **plugins are self-contained with no external dependencies** — SBOM, dependency-graph, and advisory scanning have nothing to analyze, and a dependency-CVE is not a moderation signal anyway (over-moderation / Dependabot's job). (2) **The plugin runtime ENFORCES declared access**, so static undeclared-access / capability extraction is redundant: plugins execute in a dynamically-loaded workerd sandbox whose `globalOutbound` routes ALL outbound `fetch()` (raw global fetch included) through the backing service (`packages/workerd/src/sandbox/capnp.ts`, `globalOutbound = "emdash-backing"`), and `packages/cloudflare/src/sandbox/bridge-http.ts` checks the `network:request` capability + the `allowedHosts` allowlist on every request and every redirect hop (SSRF-hardened), throwing on an undeclared host; every other host resource (content/media/users/email) reaches the plugin only through the capability-gated backing-service API, never a raw binding. A plugin declaring "no network" but calling `fetch('evil.com')` is blocked at runtime regardless of its source, so statically detecting the attempt catches nothing the sandbox does not already stop — and the code-AI adapter is the better judge of malicious *intent* on top of that. Signature-based AV is additionally a poor fit (novel obfuscated source-level JS, infeasible in workerd). The only residual "intelligence" idea with distinct value — a curated known-bad artifact-checksum denylist — mostly overlaps the existing cross-publisher checksum-reuse detection in the history stage and is deferred (do-it-now-or-not-at-all: not now). **Consequence:** the deterministic half of the pipeline is just integrity/bundle validation, which already emits real `artifact-integrity-failure` / `invalid-bundle` findings from the acquire stage (#2069); the W7.4 forbidden-archive/executable-payload and metadata-consistency checks fold into that acquire-stage validation rather than a separate stage. W7.5 and the capability-mismatch portion of W7.6 are struck. This dissolves the "Select dependency/malware scanners, SBOM tooling" gate — there is nothing to select. +Decision (2026-07-17, ratified): **the deterministic-analysis surface is cut to integrity/bundle validation only; capability analysis (W7.5) and all SBOM/dependency/malware/advisory scanning are NOT built, and the `deterministic` + `dependency` orchestrator stages are removed.** Rationale: (1) **plugins are self-contained with no external dependencies** — SBOM, dependency-graph, and advisory scanning have nothing to analyze, and a dependency-CVE is not a moderation signal anyway (over-moderation / Dependabot's job). (2) **The plugin runtime ENFORCES declared access**, so static undeclared-access / capability extraction is redundant: plugins execute in a dynamically-loaded workerd sandbox whose `globalOutbound` routes ALL outbound `fetch()` (raw global fetch included) through the backing service (`packages/workerd/src/sandbox/capnp.ts`, `globalOutbound = "emdash-backing"`), and `packages/cloudflare/src/sandbox/bridge-http.ts` checks the `network:request` capability + the `allowedHosts` allowlist on every request and every redirect hop (SSRF-hardened), throwing on an undeclared host; every other host resource (content/media/users/email) reaches the plugin only through the capability-gated backing-service API, never a raw binding. A plugin declaring "no network" but calling `fetch('evil.com')` is blocked at runtime regardless of its source, so statically detecting the attempt catches nothing the sandbox does not already stop — and the code-AI adapter is the better judge of malicious _intent_ on top of that. Signature-based AV is additionally a poor fit (novel obfuscated source-level JS, infeasible in workerd). The only residual "intelligence" idea with distinct value — a curated known-bad artifact-checksum denylist — mostly overlaps the existing cross-publisher checksum-reuse detection in the history stage and is deferred (do-it-now-or-not-at-all: not now). **Consequence:** the deterministic half of the pipeline is just integrity/bundle validation, which already emits real `artifact-integrity-failure` / `invalid-bundle` findings from the acquire stage (#2069); the W7.4 forbidden-archive/executable-payload and metadata-consistency checks fold into that acquire-stage validation rather than a separate stage. W7.5 and the capability-mismatch portion of W7.6 are struck. This dissolves the "Select dependency/malware scanners, SBOM tooling" gate — there is nothing to select. Adapters/findings for: From 9ca84438ab4f707e40ad719b67ff6ea1616e317b Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 11:04:12 +0100 Subject: [PATCH 107/137] docs(labeler-plan): record production-stage-wiring design (W7/W8 follow-on) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three create*Stage wrappers over the built analyzeCode/analyzeImages/ analyzeHistory adapters following the createAcquireStage closure idiom; buildStages assembles them and the PROD deploy gate lifts. All bindings (AI/AGGREGATOR/DB/EVIDENCE) already exist; coverage merges into coverage_json; the deterministic+dependency stages are removed. The finalization label-leak guard is already closed by #2072 — no new guard needed. Co-Authored-By: Claude Opus 4.8 --- .../plugin-registry-labelling-service/implementation-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 61bd97a9a9..6e5c159dd7 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1095,6 +1095,8 @@ Prompt-calibration outcomes (2026-07-15, adversary-reviewed): the adapter system Gate 4 passes. A complete automated run can propose and issue the expected labels while preserving the signer boundary. +Production-stage wiring (2026-07-17): the built analysis adapters are connected into the production orchestrator, lifting the `import.meta.env.PROD` deploy gate in `assessment-workflow.ts` `buildStages()`. Three `create*Stage` wrappers follow the `createAcquireStage(options)` closure idiom — each captures its deps + the shared `AcquisitionHolder` at construction and returns a `StageAdapter` that reads `holder.result` (the acquired, decoded `{path,content}` file map + parsed `declaredAccess`) and `ctx.assessment`: `createCodeAiStage` builds `CodeAnalysisInput` and calls `analyzeCode(input, {ai: env.AI, policy, promptVersion})`; `createImageAiStage` builds `ImageAnalysisInput` from the bundle's binary files and calls `analyzeImages(input, {ai: env.AI, ...})`; `createHistoryStage` closes over `env.DB` + a `PublisherVerificationReader` over the `AGGREGATOR` service binding and calls `analyzeHistory(db, assessment, opts)`. `buildStages(env, holder, policy)` assembles them; all required bindings (`AI`, `AGGREGATOR`, `DB`, `EVIDENCE`) already exist in `wrangler.jsonc` — no new binding. The AI adapters' `coverage` (`complete`/`partial`) + `droppedFiles` are merged into the assessment's `coverage_json` (already an updatable field via `updateAssessment.coverageJson`). The dead `deterministic` and `dependency` stages are removed from `OrchestratorStages`/`STAGE_ORDER`/`stubStages` (per the analysis-scope cut above); the W7.4 integrity/metadata checks remain in the acquire stage. **The finalization label-leak window the orchestrator comment flagged for "the real-stage wiring" is ALREADY closed** — #2072 gates every issuance statement on `requireAssessmentState: toState` with the whole batch all-or-nothing against the run→toState CAS and a signing-free currency re-check; the stale comment is updated, no new guard needed. Best-effort contract preserved: `analyzeHistory` swallows its own errors (context-only, never a label); a code/image adapter `ModelTransientError` propagates as a `StageTransientError` so the run retries rather than finalizing on a flaky model call. + ## Workstream W9: Operator Authentication and Console ### `W9.1` Implement Access JWT verification From 6bd42fccfcb16eb1409667838869c81cc5beb01f Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 11:14:05 +0100 Subject: [PATCH 108/137] =?UTF-8?q?docs(labeler-plan):=20slice=20stage=20w?= =?UTF-8?q?iring=20=E2=80=94=20acquire=20consumer=20+=20gate-lift=20deferr?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoping found the acquire stage has no production consumer (createAcquireStage is never constructed with real deps; its SSRF egress + release resolver don't exist). Lifting the deploy gate without a real acquire would sign assessment-passed for unscanned subjects. Slice A: the three AI/history stage wrappers + coverage + dead-stage removal + tests, gate untouched. Slice B (security-critical): the acquire consumer + SSRF egress + buildStages assembly + gate-lift. Co-Authored-By: Claude Opus 4.8 --- .../plugin-registry-labelling-service/implementation-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 6e5c159dd7..d0700bb979 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1095,7 +1095,7 @@ Prompt-calibration outcomes (2026-07-15, adversary-reviewed): the adapter system Gate 4 passes. A complete automated run can propose and issue the expected labels while preserving the signer boundary. -Production-stage wiring (2026-07-17): the built analysis adapters are connected into the production orchestrator, lifting the `import.meta.env.PROD` deploy gate in `assessment-workflow.ts` `buildStages()`. Three `create*Stage` wrappers follow the `createAcquireStage(options)` closure idiom — each captures its deps + the shared `AcquisitionHolder` at construction and returns a `StageAdapter` that reads `holder.result` (the acquired, decoded `{path,content}` file map + parsed `declaredAccess`) and `ctx.assessment`: `createCodeAiStage` builds `CodeAnalysisInput` and calls `analyzeCode(input, {ai: env.AI, policy, promptVersion})`; `createImageAiStage` builds `ImageAnalysisInput` from the bundle's binary files and calls `analyzeImages(input, {ai: env.AI, ...})`; `createHistoryStage` closes over `env.DB` + a `PublisherVerificationReader` over the `AGGREGATOR` service binding and calls `analyzeHistory(db, assessment, opts)`. `buildStages(env, holder, policy)` assembles them; all required bindings (`AI`, `AGGREGATOR`, `DB`, `EVIDENCE`) already exist in `wrangler.jsonc` — no new binding. The AI adapters' `coverage` (`complete`/`partial`) + `droppedFiles` are merged into the assessment's `coverage_json` (already an updatable field via `updateAssessment.coverageJson`). The dead `deterministic` and `dependency` stages are removed from `OrchestratorStages`/`STAGE_ORDER`/`stubStages` (per the analysis-scope cut above); the W7.4 integrity/metadata checks remain in the acquire stage. **The finalization label-leak window the orchestrator comment flagged for "the real-stage wiring" is ALREADY closed** — #2072 gates every issuance statement on `requireAssessmentState: toState` with the whole batch all-or-nothing against the run→toState CAS and a signing-free currency re-check; the stale comment is updated, no new guard needed. Best-effort contract preserved: `analyzeHistory` swallows its own errors (context-only, never a label); a code/image adapter `ModelTransientError` propagates as a `StageTransientError` so the run retries rather than finalizing on a flaky model call. +Production-stage wiring (2026-07-17): the built analysis adapters are connected into the production orchestrator. **SLICED — the deploy gate does NOT lift yet.** Scoping found the `acquire` stage (the first stage; produces the bundle every later stage reads) has NO production consumer: `createAcquireStage` has zero non-test callers, and its SSRF-hardened egress deps (`resolveHostname`, `fetch`) and its `resolveTarget` (assessment → verified release → `AcquisitionTarget`) are never constructed in production. Without acquire wired, `holder.result` is never set, the AI stages no-op, and a prod run would sign `assessment-passed` for UNSCANNED subjects — exactly what the `import.meta.env.PROD` gate in `buildStages()` prevents. So: **slice A (this)** builds the three `create*Stage` wrappers + coverage seam + dead-stage removal + the finalization-comment fix + tests (with a test-injected acquire), and leaves `buildStages`/the gate/`executeAssessmentInstance` UNTOUCHED. **slice B (next, security-critical, needs its own design + ratification)** builds the acquire consumer — release-resolution + the SSRF-hardened egress (`resolveHostname`/`fetch` implementation; approach TBD: DoH resolver vs egress-proxy binding) — then assembles all four stages in `buildStages(env, holder, config, policy)`, wires `executeAssessmentInstance`, and lifts the gate. Only when a real acquire runs is it safe to lift. Three `create*Stage` wrappers follow the `createAcquireStage(options)` closure idiom — each captures its deps + the shared `AcquisitionHolder` at construction and returns a `StageAdapter` that reads `holder.result` (the acquired, decoded `{path,content}` file map + parsed `declaredAccess`) and `ctx.assessment`: `createCodeAiStage` builds `CodeAnalysisInput` and calls `analyzeCode(input, {ai: env.AI, policy, promptVersion})`; `createImageAiStage` builds `ImageAnalysisInput` from the bundle's binary files and calls `analyzeImages(input, {ai: env.AI, ...})`; `createHistoryStage` closes over `env.DB` + a `PublisherVerificationReader` over the `AGGREGATOR` service binding and calls `analyzeHistory(db, assessment, opts)`. `buildStages(env, holder, policy)` assembles them; all required bindings (`AI`, `AGGREGATOR`, `DB`, `EVIDENCE`) already exist in `wrangler.jsonc` — no new binding. The AI adapters' `coverage` (`complete`/`partial`) + `droppedFiles` are merged into the assessment's `coverage_json` (already an updatable field via `updateAssessment.coverageJson`). The dead `deterministic` and `dependency` stages are removed from `OrchestratorStages`/`STAGE_ORDER`/`stubStages` (per the analysis-scope cut above); the W7.4 integrity/metadata checks remain in the acquire stage. **The finalization label-leak window the orchestrator comment flagged for "the real-stage wiring" is ALREADY closed** — #2072 gates every issuance statement on `requireAssessmentState: toState` with the whole batch all-or-nothing against the run→toState CAS and a signing-free currency re-check; the stale comment is updated, no new guard needed. Best-effort contract preserved: `analyzeHistory` swallows its own errors (context-only, never a label); a code/image adapter `ModelTransientError` propagates as a `StageTransientError` so the run retries rather than finalizing on a flaky model call. ## Workstream W9: Operator Authentication and Console From c5021aca0e6a001666bf6a58d8fe35d5b955d090 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 11:17:08 +0100 Subject: [PATCH 109/137] docs(labeler-plan): fold image-metadata extractor into slice B Second missing bundle-production producer: no image-metadata extractor (bytes -> ImageAnalysisImage) exists, symmetric to acquire producing decoded code files. It belongs in slice B with acquire; createImageAiStage over a non-existent extractor would be a stub. Slice A wires only code + history (inputs exist today); imageAi stays a deferred stub. Co-Authored-By: Claude Opus 4.8 --- .../plugin-registry-labelling-service/implementation-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index d0700bb979..deb4a5b00b 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1095,7 +1095,7 @@ Prompt-calibration outcomes (2026-07-15, adversary-reviewed): the adapter system Gate 4 passes. A complete automated run can propose and issue the expected labels while preserving the signer boundary. -Production-stage wiring (2026-07-17): the built analysis adapters are connected into the production orchestrator. **SLICED — the deploy gate does NOT lift yet.** Scoping found the `acquire` stage (the first stage; produces the bundle every later stage reads) has NO production consumer: `createAcquireStage` has zero non-test callers, and its SSRF-hardened egress deps (`resolveHostname`, `fetch`) and its `resolveTarget` (assessment → verified release → `AcquisitionTarget`) are never constructed in production. Without acquire wired, `holder.result` is never set, the AI stages no-op, and a prod run would sign `assessment-passed` for UNSCANNED subjects — exactly what the `import.meta.env.PROD` gate in `buildStages()` prevents. So: **slice A (this)** builds the three `create*Stage` wrappers + coverage seam + dead-stage removal + the finalization-comment fix + tests (with a test-injected acquire), and leaves `buildStages`/the gate/`executeAssessmentInstance` UNTOUCHED. **slice B (next, security-critical, needs its own design + ratification)** builds the acquire consumer — release-resolution + the SSRF-hardened egress (`resolveHostname`/`fetch` implementation; approach TBD: DoH resolver vs egress-proxy binding) — then assembles all four stages in `buildStages(env, holder, config, policy)`, wires `executeAssessmentInstance`, and lifts the gate. Only when a real acquire runs is it safe to lift. Three `create*Stage` wrappers follow the `createAcquireStage(options)` closure idiom — each captures its deps + the shared `AcquisitionHolder` at construction and returns a `StageAdapter` that reads `holder.result` (the acquired, decoded `{path,content}` file map + parsed `declaredAccess`) and `ctx.assessment`: `createCodeAiStage` builds `CodeAnalysisInput` and calls `analyzeCode(input, {ai: env.AI, policy, promptVersion})`; `createImageAiStage` builds `ImageAnalysisInput` from the bundle's binary files and calls `analyzeImages(input, {ai: env.AI, ...})`; `createHistoryStage` closes over `env.DB` + a `PublisherVerificationReader` over the `AGGREGATOR` service binding and calls `analyzeHistory(db, assessment, opts)`. `buildStages(env, holder, policy)` assembles them; all required bindings (`AI`, `AGGREGATOR`, `DB`, `EVIDENCE`) already exist in `wrangler.jsonc` — no new binding. The AI adapters' `coverage` (`complete`/`partial`) + `droppedFiles` are merged into the assessment's `coverage_json` (already an updatable field via `updateAssessment.coverageJson`). The dead `deterministic` and `dependency` stages are removed from `OrchestratorStages`/`STAGE_ORDER`/`stubStages` (per the analysis-scope cut above); the W7.4 integrity/metadata checks remain in the acquire stage. **The finalization label-leak window the orchestrator comment flagged for "the real-stage wiring" is ALREADY closed** — #2072 gates every issuance statement on `requireAssessmentState: toState` with the whole batch all-or-nothing against the run→toState CAS and a signing-free currency re-check; the stale comment is updated, no new guard needed. Best-effort contract preserved: `analyzeHistory` swallows its own errors (context-only, never a label); a code/image adapter `ModelTransientError` propagates as a `StageTransientError` so the run retries rather than finalizing on a flaky model call. +Production-stage wiring (2026-07-17): the built analysis adapters are connected into the production orchestrator. **SLICED — the deploy gate does NOT lift yet.** Scoping found the `acquire` stage (the first stage; produces the bundle every later stage reads) has NO production consumer: `createAcquireStage` has zero non-test callers, and its SSRF-hardened egress deps (`resolveHostname`, `fetch`) and its `resolveTarget` (assessment → verified release → `AcquisitionTarget`) are never constructed in production. Without acquire wired, `holder.result` is never set, the AI stages no-op, and a prod run would sign `assessment-passed` for UNSCANNED subjects — exactly what the `import.meta.env.PROD` gate in `buildStages()` prevents. A second missing producer surfaced with the same shape: the IMAGE stage needs an image-metadata extractor (raw `bundle.files` bytes → `ImageAnalysisImage` with mime/sha256/base64/width/height/kind) that does not exist — symmetric to how acquire produces the decoded code `.files` (AcquiredArtifact documents "binary files (images) remain available on bundle.files"). It is bundle-production, so it folds into slice B with acquire; building `createImageAiStage` over a non-existent extractor would be a stub-in-disguise. The fault line is **inputs-that-exist-today vs bundle-production-completion**. So: **slice A (this)** wires only the two stages whose inputs already exist — `createCodeAiStage` (code `.files` + `declaredAccessToCapabilities`) and `createHistoryStage` (own D1 + the AGGREGATOR verification read) — plus the code-stage coverage seam, removal of the dead `deterministic`/`dependency` stages, the finalization-comment fix, and tests (with a test-injected acquire); `imageAi` stays a stub (deferred, not dead), and `buildStages`/the gate/`executeAssessmentInstance` are UNTOUCHED. **slice B (next, security-critical, needs its own design + ratification)** completes bundle production: the acquire consumer — release-resolution + the SSRF-hardened egress (`resolveHostname`/`fetch` implementation; approach TBD: DoH resolver vs egress-proxy binding) — AND the deterministic image-metadata extractor, then `createImageAiStage` (thin, over the extended holder), assembles all four stages in `buildStages(env, holder, config, policy)`, wires `executeAssessmentInstance`, and lifts the gate. Only when a real acquire runs is it safe to lift. Three `create*Stage` wrappers follow the `createAcquireStage(options)` closure idiom — each captures its deps + the shared `AcquisitionHolder` at construction and returns a `StageAdapter` that reads `holder.result` (the acquired, decoded `{path,content}` file map + parsed `declaredAccess`) and `ctx.assessment`: `createCodeAiStage` builds `CodeAnalysisInput` and calls `analyzeCode(input, {ai: env.AI, policy, promptVersion})`; `createImageAiStage` builds `ImageAnalysisInput` from the bundle's binary files and calls `analyzeImages(input, {ai: env.AI, ...})`; `createHistoryStage` closes over `env.DB` + a `PublisherVerificationReader` over the `AGGREGATOR` service binding and calls `analyzeHistory(db, assessment, opts)`. `buildStages(env, holder, policy)` assembles them; all required bindings (`AI`, `AGGREGATOR`, `DB`, `EVIDENCE`) already exist in `wrangler.jsonc` — no new binding. The AI adapters' `coverage` (`complete`/`partial`) + `droppedFiles` are merged into the assessment's `coverage_json` (already an updatable field via `updateAssessment.coverageJson`). The dead `deterministic` and `dependency` stages are removed from `OrchestratorStages`/`STAGE_ORDER`/`stubStages` (per the analysis-scope cut above); the W7.4 integrity/metadata checks remain in the acquire stage. **The finalization label-leak window the orchestrator comment flagged for "the real-stage wiring" is ALREADY closed** — #2072 gates every issuance statement on `requireAssessmentState: toState` with the whole batch all-or-nothing against the run→toState CAS and a signing-free currency re-check; the stale comment is updated, no new guard needed. Best-effort contract preserved: `analyzeHistory` swallows its own errors (context-only, never a label); a code/image adapter `ModelTransientError` propagates as a `StageTransientError` so the run retries rather than finalizing on a flaky model call. ## Workstream W9: Operator Authentication and Console From 0da771a748b1917ef5c0a5040b42d07b8916d990 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 11:26:02 +0100 Subject: [PATCH 110/137] docs(labeler-plan): record capabilities-only declared-access mapping + calibration flag createCodeAiStage passes declaredAccessToCapabilities(...).capabilities (capabilities-only, allowedHosts excluded), matching what the calibration actually ran. Flags that the production declaredAccess vocabulary differs from the retired fixtures' legacy capabilities vocabulary and must be re-validated by the planned calibration sweep before enforcement launch. Co-Authored-By: Claude Opus 4.8 --- .../plugin-registry-labelling-service/implementation-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index deb4a5b00b..d3029e3f2c 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1095,7 +1095,7 @@ Prompt-calibration outcomes (2026-07-15, adversary-reviewed): the adapter system Gate 4 passes. A complete automated run can propose and issue the expected labels while preserving the signer boundary. -Production-stage wiring (2026-07-17): the built analysis adapters are connected into the production orchestrator. **SLICED — the deploy gate does NOT lift yet.** Scoping found the `acquire` stage (the first stage; produces the bundle every later stage reads) has NO production consumer: `createAcquireStage` has zero non-test callers, and its SSRF-hardened egress deps (`resolveHostname`, `fetch`) and its `resolveTarget` (assessment → verified release → `AcquisitionTarget`) are never constructed in production. Without acquire wired, `holder.result` is never set, the AI stages no-op, and a prod run would sign `assessment-passed` for UNSCANNED subjects — exactly what the `import.meta.env.PROD` gate in `buildStages()` prevents. A second missing producer surfaced with the same shape: the IMAGE stage needs an image-metadata extractor (raw `bundle.files` bytes → `ImageAnalysisImage` with mime/sha256/base64/width/height/kind) that does not exist — symmetric to how acquire produces the decoded code `.files` (AcquiredArtifact documents "binary files (images) remain available on bundle.files"). It is bundle-production, so it folds into slice B with acquire; building `createImageAiStage` over a non-existent extractor would be a stub-in-disguise. The fault line is **inputs-that-exist-today vs bundle-production-completion**. So: **slice A (this)** wires only the two stages whose inputs already exist — `createCodeAiStage` (code `.files` + `declaredAccessToCapabilities`) and `createHistoryStage` (own D1 + the AGGREGATOR verification read) — plus the code-stage coverage seam, removal of the dead `deterministic`/`dependency` stages, the finalization-comment fix, and tests (with a test-injected acquire); `imageAi` stays a stub (deferred, not dead), and `buildStages`/the gate/`executeAssessmentInstance` are UNTOUCHED. **slice B (next, security-critical, needs its own design + ratification)** completes bundle production: the acquire consumer — release-resolution + the SSRF-hardened egress (`resolveHostname`/`fetch` implementation; approach TBD: DoH resolver vs egress-proxy binding) — AND the deterministic image-metadata extractor, then `createImageAiStage` (thin, over the extended holder), assembles all four stages in `buildStages(env, holder, config, policy)`, wires `executeAssessmentInstance`, and lifts the gate. Only when a real acquire runs is it safe to lift. Three `create*Stage` wrappers follow the `createAcquireStage(options)` closure idiom — each captures its deps + the shared `AcquisitionHolder` at construction and returns a `StageAdapter` that reads `holder.result` (the acquired, decoded `{path,content}` file map + parsed `declaredAccess`) and `ctx.assessment`: `createCodeAiStage` builds `CodeAnalysisInput` and calls `analyzeCode(input, {ai: env.AI, policy, promptVersion})`; `createImageAiStage` builds `ImageAnalysisInput` from the bundle's binary files and calls `analyzeImages(input, {ai: env.AI, ...})`; `createHistoryStage` closes over `env.DB` + a `PublisherVerificationReader` over the `AGGREGATOR` service binding and calls `analyzeHistory(db, assessment, opts)`. `buildStages(env, holder, policy)` assembles them; all required bindings (`AI`, `AGGREGATOR`, `DB`, `EVIDENCE`) already exist in `wrangler.jsonc` — no new binding. The AI adapters' `coverage` (`complete`/`partial`) + `droppedFiles` are merged into the assessment's `coverage_json` (already an updatable field via `updateAssessment.coverageJson`). The dead `deterministic` and `dependency` stages are removed from `OrchestratorStages`/`STAGE_ORDER`/`stubStages` (per the analysis-scope cut above); the W7.4 integrity/metadata checks remain in the acquire stage. **The finalization label-leak window the orchestrator comment flagged for "the real-stage wiring" is ALREADY closed** — #2072 gates every issuance statement on `requireAssessmentState: toState` with the whole batch all-or-nothing against the run→toState CAS and a signing-free currency re-check; the stale comment is updated, no new guard needed. Best-effort contract preserved: `analyzeHistory` swallows its own errors (context-only, never a label); a code/image adapter `ModelTransientError` propagates as a `StageTransientError` so the run retries rather than finalizing on a flaky model call. +Production-stage wiring (2026-07-17): the built analysis adapters are connected into the production orchestrator. **SLICED — the deploy gate does NOT lift yet.** Scoping found the `acquire` stage (the first stage; produces the bundle every later stage reads) has NO production consumer: `createAcquireStage` has zero non-test callers, and its SSRF-hardened egress deps (`resolveHostname`, `fetch`) and its `resolveTarget` (assessment → verified release → `AcquisitionTarget`) are never constructed in production. Without acquire wired, `holder.result` is never set, the AI stages no-op, and a prod run would sign `assessment-passed` for UNSCANNED subjects — exactly what the `import.meta.env.PROD` gate in `buildStages()` prevents. A second missing producer surfaced with the same shape: the IMAGE stage needs an image-metadata extractor (raw `bundle.files` bytes → `ImageAnalysisImage` with mime/sha256/base64/width/height/kind) that does not exist — symmetric to how acquire produces the decoded code `.files` (AcquiredArtifact documents "binary files (images) remain available on bundle.files"). It is bundle-production, so it folds into slice B with acquire; building `createImageAiStage` over a non-existent extractor would be a stub-in-disguise. The fault line is **inputs-that-exist-today vs bundle-production-completion**. So: **slice A (this)** wires only the two stages whose inputs already exist — `createCodeAiStage` (code `.files` + `declaredAccessToCapabilities`) and `createHistoryStage` (own D1 + the AGGREGATOR verification read) — plus the code-stage coverage seam, removal of the dead `deterministic`/`dependency` stages, the finalization-comment fix, and tests (with a test-injected acquire); `imageAi` stays a stub (deferred, not dead), and `buildStages`/the gate/`executeAssessmentInstance` are UNTOUCHED. **Declared-access mapping decision:** `createCodeAiStage` passes `declaredAccessToCapabilities(bundle.declaredAccess).capabilities` — CAPABILITIES-ONLY, allowedHosts excluded — matching the calibration input shape (the fixture loader passes `manifest.capabilities` and drops `allowedHosts`; the model avoids `undeclared-access` via the declared capability, not the host, and the runtime enforces allowedHosts so host-level access is not a moderation signal). **Calibration-fidelity flag:** the production declared vocabulary from `declaredAccessToCapabilities` (`content:read`, `network:request`) differs from the retired fixtures' legacy `manifest.capabilities` vocabulary (`read:content`, `network:fetch`); the planned post-severity-amendment calibration sweep MUST re-validate the model against the production vocabulary + the capabilities-only input before enforcement launch. **slice B (next, security-critical, needs its own design + ratification)** completes bundle production: the acquire consumer — release-resolution + the SSRF-hardened egress (`resolveHostname`/`fetch` implementation; approach TBD: DoH resolver vs egress-proxy binding) — AND the deterministic image-metadata extractor, then `createImageAiStage` (thin, over the extended holder), assembles all four stages in `buildStages(env, holder, config, policy)`, wires `executeAssessmentInstance`, and lifts the gate. Only when a real acquire runs is it safe to lift. Three `create*Stage` wrappers follow the `createAcquireStage(options)` closure idiom — each captures its deps + the shared `AcquisitionHolder` at construction and returns a `StageAdapter` that reads `holder.result` (the acquired, decoded `{path,content}` file map + parsed `declaredAccess`) and `ctx.assessment`: `createCodeAiStage` builds `CodeAnalysisInput` and calls `analyzeCode(input, {ai: env.AI, policy, promptVersion})`; `createImageAiStage` builds `ImageAnalysisInput` from the bundle's binary files and calls `analyzeImages(input, {ai: env.AI, ...})`; `createHistoryStage` closes over `env.DB` + a `PublisherVerificationReader` over the `AGGREGATOR` service binding and calls `analyzeHistory(db, assessment, opts)`. `buildStages(env, holder, policy)` assembles them; all required bindings (`AI`, `AGGREGATOR`, `DB`, `EVIDENCE`) already exist in `wrangler.jsonc` — no new binding. The AI adapters' `coverage` (`complete`/`partial`) + `droppedFiles` are merged into the assessment's `coverage_json` (already an updatable field via `updateAssessment.coverageJson`). The dead `deterministic` and `dependency` stages are removed from `OrchestratorStages`/`STAGE_ORDER`/`stubStages` (per the analysis-scope cut above); the W7.4 integrity/metadata checks remain in the acquire stage. **The finalization label-leak window the orchestrator comment flagged for "the real-stage wiring" is ALREADY closed** — #2072 gates every issuance statement on `requireAssessmentState: toState` with the whole batch all-or-nothing against the run→toState CAS and a signing-free currency re-check; the stale comment is updated, no new guard needed. Best-effort contract preserved: `analyzeHistory` swallows its own errors (context-only, never a label); a code/image adapter `ModelTransientError` propagates as a `StageTransientError` so the run retries rather than finalizing on a flaky model call. ## Workstream W9: Operator Authentication and Console From 98c3a47e52350ba381f2dfd81f7d9ec75d072981 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 11:55:46 +0100 Subject: [PATCH 111/137] docs(labeler-plan): carry stage-wiring adversary items into slice B Release-record description threading (empty description leaves misleading- metadata/privacy-risk near-inert; second calibration-input divergence) and the coverage-on-error semantics call, both slice-B concerns per the pass. Co-Authored-By: Claude Opus 4.8 --- .../plugin-registry-labelling-service/implementation-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index d3029e3f2c..0e56eb85c9 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1095,7 +1095,7 @@ Prompt-calibration outcomes (2026-07-15, adversary-reviewed): the adapter system Gate 4 passes. A complete automated run can propose and issue the expected labels while preserving the signer boundary. -Production-stage wiring (2026-07-17): the built analysis adapters are connected into the production orchestrator. **SLICED — the deploy gate does NOT lift yet.** Scoping found the `acquire` stage (the first stage; produces the bundle every later stage reads) has NO production consumer: `createAcquireStage` has zero non-test callers, and its SSRF-hardened egress deps (`resolveHostname`, `fetch`) and its `resolveTarget` (assessment → verified release → `AcquisitionTarget`) are never constructed in production. Without acquire wired, `holder.result` is never set, the AI stages no-op, and a prod run would sign `assessment-passed` for UNSCANNED subjects — exactly what the `import.meta.env.PROD` gate in `buildStages()` prevents. A second missing producer surfaced with the same shape: the IMAGE stage needs an image-metadata extractor (raw `bundle.files` bytes → `ImageAnalysisImage` with mime/sha256/base64/width/height/kind) that does not exist — symmetric to how acquire produces the decoded code `.files` (AcquiredArtifact documents "binary files (images) remain available on bundle.files"). It is bundle-production, so it folds into slice B with acquire; building `createImageAiStage` over a non-existent extractor would be a stub-in-disguise. The fault line is **inputs-that-exist-today vs bundle-production-completion**. So: **slice A (this)** wires only the two stages whose inputs already exist — `createCodeAiStage` (code `.files` + `declaredAccessToCapabilities`) and `createHistoryStage` (own D1 + the AGGREGATOR verification read) — plus the code-stage coverage seam, removal of the dead `deterministic`/`dependency` stages, the finalization-comment fix, and tests (with a test-injected acquire); `imageAi` stays a stub (deferred, not dead), and `buildStages`/the gate/`executeAssessmentInstance` are UNTOUCHED. **Declared-access mapping decision:** `createCodeAiStage` passes `declaredAccessToCapabilities(bundle.declaredAccess).capabilities` — CAPABILITIES-ONLY, allowedHosts excluded — matching the calibration input shape (the fixture loader passes `manifest.capabilities` and drops `allowedHosts`; the model avoids `undeclared-access` via the declared capability, not the host, and the runtime enforces allowedHosts so host-level access is not a moderation signal). **Calibration-fidelity flag:** the production declared vocabulary from `declaredAccessToCapabilities` (`content:read`, `network:request`) differs from the retired fixtures' legacy `manifest.capabilities` vocabulary (`read:content`, `network:fetch`); the planned post-severity-amendment calibration sweep MUST re-validate the model against the production vocabulary + the capabilities-only input before enforcement launch. **slice B (next, security-critical, needs its own design + ratification)** completes bundle production: the acquire consumer — release-resolution + the SSRF-hardened egress (`resolveHostname`/`fetch` implementation; approach TBD: DoH resolver vs egress-proxy binding) — AND the deterministic image-metadata extractor, then `createImageAiStage` (thin, over the extended holder), assembles all four stages in `buildStages(env, holder, config, policy)`, wires `executeAssessmentInstance`, and lifts the gate. Only when a real acquire runs is it safe to lift. Three `create*Stage` wrappers follow the `createAcquireStage(options)` closure idiom — each captures its deps + the shared `AcquisitionHolder` at construction and returns a `StageAdapter` that reads `holder.result` (the acquired, decoded `{path,content}` file map + parsed `declaredAccess`) and `ctx.assessment`: `createCodeAiStage` builds `CodeAnalysisInput` and calls `analyzeCode(input, {ai: env.AI, policy, promptVersion})`; `createImageAiStage` builds `ImageAnalysisInput` from the bundle's binary files and calls `analyzeImages(input, {ai: env.AI, ...})`; `createHistoryStage` closes over `env.DB` + a `PublisherVerificationReader` over the `AGGREGATOR` service binding and calls `analyzeHistory(db, assessment, opts)`. `buildStages(env, holder, policy)` assembles them; all required bindings (`AI`, `AGGREGATOR`, `DB`, `EVIDENCE`) already exist in `wrangler.jsonc` — no new binding. The AI adapters' `coverage` (`complete`/`partial`) + `droppedFiles` are merged into the assessment's `coverage_json` (already an updatable field via `updateAssessment.coverageJson`). The dead `deterministic` and `dependency` stages are removed from `OrchestratorStages`/`STAGE_ORDER`/`stubStages` (per the analysis-scope cut above); the W7.4 integrity/metadata checks remain in the acquire stage. **The finalization label-leak window the orchestrator comment flagged for "the real-stage wiring" is ALREADY closed** — #2072 gates every issuance statement on `requireAssessmentState: toState` with the whole batch all-or-nothing against the run→toState CAS and a signing-free currency re-check; the stale comment is updated, no new guard needed. Best-effort contract preserved: `analyzeHistory` swallows its own errors (context-only, never a label); a code/image adapter `ModelTransientError` propagates as a `StageTransientError` so the run retries rather than finalizing on a flaky model call. +Production-stage wiring (2026-07-17): the built analysis adapters are connected into the production orchestrator. **SLICED — the deploy gate does NOT lift yet.** Scoping found the `acquire` stage (the first stage; produces the bundle every later stage reads) has NO production consumer: `createAcquireStage` has zero non-test callers, and its SSRF-hardened egress deps (`resolveHostname`, `fetch`) and its `resolveTarget` (assessment → verified release → `AcquisitionTarget`) are never constructed in production. Without acquire wired, `holder.result` is never set, the AI stages no-op, and a prod run would sign `assessment-passed` for UNSCANNED subjects — exactly what the `import.meta.env.PROD` gate in `buildStages()` prevents. A second missing producer surfaced with the same shape: the IMAGE stage needs an image-metadata extractor (raw `bundle.files` bytes → `ImageAnalysisImage` with mime/sha256/base64/width/height/kind) that does not exist — symmetric to how acquire produces the decoded code `.files` (AcquiredArtifact documents "binary files (images) remain available on bundle.files"). It is bundle-production, so it folds into slice B with acquire; building `createImageAiStage` over a non-existent extractor would be a stub-in-disguise. The fault line is **inputs-that-exist-today vs bundle-production-completion**. So: **slice A (this)** wires only the two stages whose inputs already exist — `createCodeAiStage` (code `.files` + `declaredAccessToCapabilities`) and `createHistoryStage` (own D1 + the AGGREGATOR verification read) — plus the code-stage coverage seam, removal of the dead `deterministic`/`dependency` stages, the finalization-comment fix, and tests (with a test-injected acquire); `imageAi` stays a stub (deferred, not dead), and `buildStages`/the gate/`executeAssessmentInstance` are UNTOUCHED. **Declared-access mapping decision:** `createCodeAiStage` passes `declaredAccessToCapabilities(bundle.declaredAccess).capabilities` — CAPABILITIES-ONLY, allowedHosts excluded — matching the calibration input shape (the fixture loader passes `manifest.capabilities` and drops `allowedHosts`; the model avoids `undeclared-access` via the declared capability, not the host, and the runtime enforces allowedHosts so host-level access is not a moderation signal). **Calibration-fidelity flag:** the production declared vocabulary from `declaredAccessToCapabilities` (`content:read`, `network:request`) differs from the retired fixtures' legacy `manifest.capabilities` vocabulary (`read:content`, `network:fetch`); the planned post-severity-amendment calibration sweep MUST re-validate the model against the production vocabulary + the capabilities-only input before enforcement launch. **slice B (next, security-critical, needs its own design + ratification)** completes bundle production: the acquire consumer — release-resolution + the SSRF-hardened egress (`resolveHostname`/`fetch` implementation; approach TBD: DoH resolver vs egress-proxy binding) — AND the deterministic image-metadata extractor, then `createImageAiStage` (thin, over the extended holder), assembles all four stages in `buildStages(env, holder, config, policy)`, wires `executeAssessmentInstance`, and lifts the gate. Only when a real acquire runs is it safe to lift. Adversary items carried into slice B: (1) **thread the release record's description into `CodeAiStageOptions`** — the bundle manifest has no name/description, so the code stage currently passes `description: ""`, leaving misleading-metadata/privacy-risk (both rooted in stated purpose) near-inert; the release record is in hand during acquire, so slice B threads its description through, and the calibration sweep must run against the real-description input (flagged as the second calibration-input divergence in the stage's comment); (2) once a post-codeAi stage can transient-exhaust (the image stage), an `error` finalization can carry `coverage.code: "complete"` from the earlier successful code pass — decide then whether that's the intended "describes what WAS analyzed" semantics or should be cleared on error. Three `create*Stage` wrappers follow the `createAcquireStage(options)` closure idiom — each captures its deps + the shared `AcquisitionHolder` at construction and returns a `StageAdapter` that reads `holder.result` (the acquired, decoded `{path,content}` file map + parsed `declaredAccess`) and `ctx.assessment`: `createCodeAiStage` builds `CodeAnalysisInput` and calls `analyzeCode(input, {ai: env.AI, policy, promptVersion})`; `createImageAiStage` builds `ImageAnalysisInput` from the bundle's binary files and calls `analyzeImages(input, {ai: env.AI, ...})`; `createHistoryStage` closes over `env.DB` + a `PublisherVerificationReader` over the `AGGREGATOR` service binding and calls `analyzeHistory(db, assessment, opts)`. `buildStages(env, holder, policy)` assembles them; all required bindings (`AI`, `AGGREGATOR`, `DB`, `EVIDENCE`) already exist in `wrangler.jsonc` — no new binding. The AI adapters' `coverage` (`complete`/`partial`) + `droppedFiles` are merged into the assessment's `coverage_json` (already an updatable field via `updateAssessment.coverageJson`). The dead `deterministic` and `dependency` stages are removed from `OrchestratorStages`/`STAGE_ORDER`/`stubStages` (per the analysis-scope cut above); the W7.4 integrity/metadata checks remain in the acquire stage. **The finalization label-leak window the orchestrator comment flagged for "the real-stage wiring" is ALREADY closed** — #2072 gates every issuance statement on `requireAssessmentState: toState` with the whole batch all-or-nothing against the run→toState CAS and a signing-free currency re-check; the stale comment is updated, no new guard needed. Best-effort contract preserved: `analyzeHistory` swallows its own errors (context-only, never a label); a code/image adapter `ModelTransientError` propagates as a `StageTransientError` so the run retries rather than finalizing on a flaky model call. ## Workstream W9: Operator Authentication and Console From bdb77c7d1d4e9f37e78d52d4b1915e26510039f2 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 12:03:51 +0100 Subject: [PATCH 112/137] docs(labeler-plan): ratify slice-B SSRF approach and release-resolution design DoH resolver (existing cloudflareDohResolver, fails closed) injected into the already-hardened fetchVerifiedResource; egress-proxy Worker rejected for v1 (net-new surface for a residual doubly bounded on Workers: no private network, checksum-gated bytes). Lexical-only resolution prohibited. resolveTarget reads the declared URL from the aggregator ReleaseView over the AGGREGATOR binding (getLatestRelease + listReleases scan for the pinned cid), crossCheckCoordinates guards drift. Slice B also covers the image-metadata extractor, description threading, buildStages assembly, and the gate lift. Co-Authored-By: Claude Opus 4.8 --- .../plugin-registry-labelling-service/implementation-plan.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 0e56eb85c9..0cf31d5e5d 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1095,7 +1095,9 @@ Prompt-calibration outcomes (2026-07-15, adversary-reviewed): the adapter system Gate 4 passes. A complete automated run can propose and issue the expected labels while preserving the signer boundary. -Production-stage wiring (2026-07-17): the built analysis adapters are connected into the production orchestrator. **SLICED — the deploy gate does NOT lift yet.** Scoping found the `acquire` stage (the first stage; produces the bundle every later stage reads) has NO production consumer: `createAcquireStage` has zero non-test callers, and its SSRF-hardened egress deps (`resolveHostname`, `fetch`) and its `resolveTarget` (assessment → verified release → `AcquisitionTarget`) are never constructed in production. Without acquire wired, `holder.result` is never set, the AI stages no-op, and a prod run would sign `assessment-passed` for UNSCANNED subjects — exactly what the `import.meta.env.PROD` gate in `buildStages()` prevents. A second missing producer surfaced with the same shape: the IMAGE stage needs an image-metadata extractor (raw `bundle.files` bytes → `ImageAnalysisImage` with mime/sha256/base64/width/height/kind) that does not exist — symmetric to how acquire produces the decoded code `.files` (AcquiredArtifact documents "binary files (images) remain available on bundle.files"). It is bundle-production, so it folds into slice B with acquire; building `createImageAiStage` over a non-existent extractor would be a stub-in-disguise. The fault line is **inputs-that-exist-today vs bundle-production-completion**. So: **slice A (this)** wires only the two stages whose inputs already exist — `createCodeAiStage` (code `.files` + `declaredAccessToCapabilities`) and `createHistoryStage` (own D1 + the AGGREGATOR verification read) — plus the code-stage coverage seam, removal of the dead `deterministic`/`dependency` stages, the finalization-comment fix, and tests (with a test-injected acquire); `imageAi` stays a stub (deferred, not dead), and `buildStages`/the gate/`executeAssessmentInstance` are UNTOUCHED. **Declared-access mapping decision:** `createCodeAiStage` passes `declaredAccessToCapabilities(bundle.declaredAccess).capabilities` — CAPABILITIES-ONLY, allowedHosts excluded — matching the calibration input shape (the fixture loader passes `manifest.capabilities` and drops `allowedHosts`; the model avoids `undeclared-access` via the declared capability, not the host, and the runtime enforces allowedHosts so host-level access is not a moderation signal). **Calibration-fidelity flag:** the production declared vocabulary from `declaredAccessToCapabilities` (`content:read`, `network:request`) differs from the retired fixtures' legacy `manifest.capabilities` vocabulary (`read:content`, `network:fetch`); the planned post-severity-amendment calibration sweep MUST re-validate the model against the production vocabulary + the capabilities-only input before enforcement launch. **slice B (next, security-critical, needs its own design + ratification)** completes bundle production: the acquire consumer — release-resolution + the SSRF-hardened egress (`resolveHostname`/`fetch` implementation; approach TBD: DoH resolver vs egress-proxy binding) — AND the deterministic image-metadata extractor, then `createImageAiStage` (thin, over the extended holder), assembles all four stages in `buildStages(env, holder, config, policy)`, wires `executeAssessmentInstance`, and lifts the gate. Only when a real acquire runs is it safe to lift. Adversary items carried into slice B: (1) **thread the release record's description into `CodeAiStageOptions`** — the bundle manifest has no name/description, so the code stage currently passes `description: ""`, leaving misleading-metadata/privacy-risk (both rooted in stated purpose) near-inert; the release record is in hand during acquire, so slice B threads its description through, and the calibration sweep must run against the real-description input (flagged as the second calibration-input divergence in the stage's comment); (2) once a post-codeAi stage can transient-exhaust (the image stage), an `error` finalization can carry `coverage.code: "complete"` from the earlier successful code pass — decide then whether that's the intended "describes what WAS analyzed" semantics or should be cleared on error. Three `create*Stage` wrappers follow the `createAcquireStage(options)` closure idiom — each captures its deps + the shared `AcquisitionHolder` at construction and returns a `StageAdapter` that reads `holder.result` (the acquired, decoded `{path,content}` file map + parsed `declaredAccess`) and `ctx.assessment`: `createCodeAiStage` builds `CodeAnalysisInput` and calls `analyzeCode(input, {ai: env.AI, policy, promptVersion})`; `createImageAiStage` builds `ImageAnalysisInput` from the bundle's binary files and calls `analyzeImages(input, {ai: env.AI, ...})`; `createHistoryStage` closes over `env.DB` + a `PublisherVerificationReader` over the `AGGREGATOR` service binding and calls `analyzeHistory(db, assessment, opts)`. `buildStages(env, holder, policy)` assembles them; all required bindings (`AI`, `AGGREGATOR`, `DB`, `EVIDENCE`) already exist in `wrangler.jsonc` — no new binding. The AI adapters' `coverage` (`complete`/`partial`) + `droppedFiles` are merged into the assessment's `coverage_json` (already an updatable field via `updateAssessment.coverageJson`). The dead `deterministic` and `dependency` stages are removed from `OrchestratorStages`/`STAGE_ORDER`/`stubStages` (per the analysis-scope cut above); the W7.4 integrity/metadata checks remain in the acquire stage. **The finalization label-leak window the orchestrator comment flagged for "the real-stage wiring" is ALREADY closed** — #2072 gates every issuance statement on `requireAssessmentState: toState` with the whole batch all-or-nothing against the run→toState CAS and a signing-free currency re-check; the stale comment is updated, no new guard needed. Best-effort contract preserved: `analyzeHistory` swallows its own errors (context-only, never a label); a code/image adapter `ModelTransientError` propagates as a `StageTransientError` so the run retries rather than finalizing on a flaky model call. +Production-stage wiring (2026-07-17): the built analysis adapters are connected into the production orchestrator. **SLICED — the deploy gate does NOT lift yet.** Scoping found the `acquire` stage (the first stage; produces the bundle every later stage reads) has NO production consumer: `createAcquireStage` has zero non-test callers, and its SSRF-hardened egress deps (`resolveHostname`, `fetch`) and its `resolveTarget` (assessment → verified release → `AcquisitionTarget`) are never constructed in production. Without acquire wired, `holder.result` is never set, the AI stages no-op, and a prod run would sign `assessment-passed` for UNSCANNED subjects — exactly what the `import.meta.env.PROD` gate in `buildStages()` prevents. A second missing producer surfaced with the same shape: the IMAGE stage needs an image-metadata extractor (raw `bundle.files` bytes → `ImageAnalysisImage` with mime/sha256/base64/width/height/kind) that does not exist — symmetric to how acquire produces the decoded code `.files` (AcquiredArtifact documents "binary files (images) remain available on bundle.files"). It is bundle-production, so it folds into slice B with acquire; building `createImageAiStage` over a non-existent extractor would be a stub-in-disguise. The fault line is **inputs-that-exist-today vs bundle-production-completion**. So: **slice A (this)** wires only the two stages whose inputs already exist — `createCodeAiStage` (code `.files` + `declaredAccessToCapabilities`) and `createHistoryStage` (own D1 + the AGGREGATOR verification read) — plus the code-stage coverage seam, removal of the dead `deterministic`/`dependency` stages, the finalization-comment fix, and tests (with a test-injected acquire); `imageAi` stays a stub (deferred, not dead), and `buildStages`/the gate/`executeAssessmentInstance` are UNTOUCHED. **Declared-access mapping decision:** `createCodeAiStage` passes `declaredAccessToCapabilities(bundle.declaredAccess).capabilities` — CAPABILITIES-ONLY, allowedHosts excluded — matching the calibration input shape (the fixture loader passes `manifest.capabilities` and drops `allowedHosts`; the model avoids `undeclared-access` via the declared capability, not the host, and the runtime enforces allowedHosts so host-level access is not a moderation signal). **Calibration-fidelity flag:** the production declared vocabulary from `declaredAccessToCapabilities` (`content:read`, `network:request`) differs from the retired fixtures' legacy `manifest.capabilities` vocabulary (`read:content`, `network:fetch`); the planned post-severity-amendment calibration sweep MUST re-validate the model against the production vocabulary + the capabilities-only input before enforcement launch. **slice B (next, security-critical, needs its own design + ratification)** completes bundle production: the acquire consumer — release-resolution + the SSRF-hardened egress (`resolveHostname`/`fetch` implementation; approach TBD: DoH resolver vs egress-proxy binding) — AND the deterministic image-metadata extractor, then `createImageAiStage` (thin, over the extended holder), assembles all four stages in `buildStages(env, holder, config, policy)`, wires `executeAssessmentInstance`, and lifts the gate. Only when a real acquire runs is it safe to lift. Adversary items carried into slice B: (1) **thread the release record's description into `CodeAiStageOptions`** — the bundle manifest has no name/description, so the code stage currently passes `description: ""`, leaving misleading-metadata/privacy-risk (both rooted in stated purpose) near-inert; the release record is in hand during acquire, so slice B threads its description through, and the calibration sweep must run against the real-description input (flagged as the second calibration-input divergence in the stage's comment); (2) once a post-codeAi stage can transient-exhaust (the image stage), an `error` finalization can carry `coverage.code: "complete"` from the earlier successful code pass — decide then whether that's the intended "describes what WAS analyzed" semantics or should be cleared on error. + +Slice-B design (2026-07-17, SSRF approach ratified): **`resolveHostname` = the existing `cloudflareDohResolver`** (`packages/core/src/security/ssrf.ts` — DoH against `cloudflare-dns.com/dns-query`, A+AAAA, fails closed, structurally assignable to registry-verification's `HostnameResolver`), injected into the already-hardened `fetchVerifiedResource` (which enforces HTTPS-only, credential-free URLs, per-hop redirect re-validation with per-hop re-resolution, private/reserved-IP rejection, and byte/time budgets — `ACQUISITION_FETCH_LIMITS`). The **egress-proxy Worker option was considered and rejected** for v1: it is the only complete TTL-0-rebinding defense, but it is a net-new security-critical deploy surface, and the residual is doubly bounded here — the labeler is a top-level Worker on Cloudflare's edge with no private network behind it, and fetched bytes are checksum/CID-verified against the signed release before any use, so the worst case is a blind GET (the same accepted residual `ssrf.ts` documents). Lexical-only resolution (the sandbox bridge's approach) fails the ratified "DNS hardening" control and is prohibited — the sandbox is only safe lexically because of `allowedHosts` + `globalOutbound`, neither of which applies to the labeler's arbitrary declared hosts. **Release resolution (`resolveTarget`):** the declared artifact URL is NOT stored labeler-side (the assessment row keeps only pinned `artifact_id`/`artifact_checksum`; discovery-consumer does not persist the URL) — it lives in the aggregator `ReleaseView.release` artifacts blob, so `resolveTarget` reads over the existing `AGGREGATOR` service binding (the ratified W8.4 slice-3 read-path pattern): `getLatestRelease` fast path, falling back to a `listReleases` scan for the release matching the assessment's pinned `(uri, cid)` since no by-cid ReleaseView endpoint exists; the existing `crossCheckCoordinates` guards declared-vs-pinned drift, a release absent from the aggregator classifies as the existing transient acquisition failure (aggregator lag; reconciliation owns true deletions). Slice B also builds the deterministic image-metadata extractor (bundle bytes → `ImageAnalysisImage`: mime sniff, sha256, base64, PNG/JPEG/WebP/GIF dimensions, icon-vs-screenshot classification) + `createImageAiStage`, threads the release description into the code stage (adversary item 1), assembles all four stages in `buildStages`, wires `executeAssessmentInstance`, and lifts the PROD gate — the gate-lift is valid ONLY with a real acquire running. Three `create*Stage` wrappers follow the `createAcquireStage(options)` closure idiom — each captures its deps + the shared `AcquisitionHolder` at construction and returns a `StageAdapter` that reads `holder.result` (the acquired, decoded `{path,content}` file map + parsed `declaredAccess`) and `ctx.assessment`: `createCodeAiStage` builds `CodeAnalysisInput` and calls `analyzeCode(input, {ai: env.AI, policy, promptVersion})`; `createImageAiStage` builds `ImageAnalysisInput` from the bundle's binary files and calls `analyzeImages(input, {ai: env.AI, ...})`; `createHistoryStage` closes over `env.DB` + a `PublisherVerificationReader` over the `AGGREGATOR` service binding and calls `analyzeHistory(db, assessment, opts)`. `buildStages(env, holder, policy)` assembles them; all required bindings (`AI`, `AGGREGATOR`, `DB`, `EVIDENCE`) already exist in `wrangler.jsonc` — no new binding. The AI adapters' `coverage` (`complete`/`partial`) + `droppedFiles` are merged into the assessment's `coverage_json` (already an updatable field via `updateAssessment.coverageJson`). The dead `deterministic` and `dependency` stages are removed from `OrchestratorStages`/`STAGE_ORDER`/`stubStages` (per the analysis-scope cut above); the W7.4 integrity/metadata checks remain in the acquire stage. **The finalization label-leak window the orchestrator comment flagged for "the real-stage wiring" is ALREADY closed** — #2072 gates every issuance statement on `requireAssessmentState: toState` with the whole batch all-or-nothing against the run→toState CAS and a signing-free currency re-check; the stale comment is updated, no new guard needed. Best-effort contract preserved: `analyzeHistory` swallows its own errors (context-only, never a label); a code/image adapter `ModelTransientError` propagates as a `StageTransientError` so the run retries rather than finalizing on a flaky model call. ## Workstream W9: Operator Authentication and Console From c24d1151f91a55b5a64da6bd607d2c3dd38e3abb Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 13:11:57 +0100 Subject: [PATCH 113/137] docs(labeler-plan): record slice-B adversary verdict + M1/M2 fixes Gate-lift core invariant and SSRF main path confirmed HOLD. M1: orchestrator honors an accumulated block on a later stage's transient exhaustion (finalize blocked, not discard-to-error) so a model-breaking image can't shield block-worthy code. M2: fetch.ts isForbiddenAddress extended to IPv4-compatible + NAT64 private/metadata encodings (parity with core isPrivateIp). Co-Authored-By: Claude Opus 4.8 --- .../plugin-registry-labelling-service/implementation-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 0cf31d5e5d..1dc59a16a1 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1097,7 +1097,7 @@ Gate 4 passes. A complete automated run can propose and issue the expected label Production-stage wiring (2026-07-17): the built analysis adapters are connected into the production orchestrator. **SLICED — the deploy gate does NOT lift yet.** Scoping found the `acquire` stage (the first stage; produces the bundle every later stage reads) has NO production consumer: `createAcquireStage` has zero non-test callers, and its SSRF-hardened egress deps (`resolveHostname`, `fetch`) and its `resolveTarget` (assessment → verified release → `AcquisitionTarget`) are never constructed in production. Without acquire wired, `holder.result` is never set, the AI stages no-op, and a prod run would sign `assessment-passed` for UNSCANNED subjects — exactly what the `import.meta.env.PROD` gate in `buildStages()` prevents. A second missing producer surfaced with the same shape: the IMAGE stage needs an image-metadata extractor (raw `bundle.files` bytes → `ImageAnalysisImage` with mime/sha256/base64/width/height/kind) that does not exist — symmetric to how acquire produces the decoded code `.files` (AcquiredArtifact documents "binary files (images) remain available on bundle.files"). It is bundle-production, so it folds into slice B with acquire; building `createImageAiStage` over a non-existent extractor would be a stub-in-disguise. The fault line is **inputs-that-exist-today vs bundle-production-completion**. So: **slice A (this)** wires only the two stages whose inputs already exist — `createCodeAiStage` (code `.files` + `declaredAccessToCapabilities`) and `createHistoryStage` (own D1 + the AGGREGATOR verification read) — plus the code-stage coverage seam, removal of the dead `deterministic`/`dependency` stages, the finalization-comment fix, and tests (with a test-injected acquire); `imageAi` stays a stub (deferred, not dead), and `buildStages`/the gate/`executeAssessmentInstance` are UNTOUCHED. **Declared-access mapping decision:** `createCodeAiStage` passes `declaredAccessToCapabilities(bundle.declaredAccess).capabilities` — CAPABILITIES-ONLY, allowedHosts excluded — matching the calibration input shape (the fixture loader passes `manifest.capabilities` and drops `allowedHosts`; the model avoids `undeclared-access` via the declared capability, not the host, and the runtime enforces allowedHosts so host-level access is not a moderation signal). **Calibration-fidelity flag:** the production declared vocabulary from `declaredAccessToCapabilities` (`content:read`, `network:request`) differs from the retired fixtures' legacy `manifest.capabilities` vocabulary (`read:content`, `network:fetch`); the planned post-severity-amendment calibration sweep MUST re-validate the model against the production vocabulary + the capabilities-only input before enforcement launch. **slice B (next, security-critical, needs its own design + ratification)** completes bundle production: the acquire consumer — release-resolution + the SSRF-hardened egress (`resolveHostname`/`fetch` implementation; approach TBD: DoH resolver vs egress-proxy binding) — AND the deterministic image-metadata extractor, then `createImageAiStage` (thin, over the extended holder), assembles all four stages in `buildStages(env, holder, config, policy)`, wires `executeAssessmentInstance`, and lifts the gate. Only when a real acquire runs is it safe to lift. Adversary items carried into slice B: (1) **thread the release record's description into `CodeAiStageOptions`** — the bundle manifest has no name/description, so the code stage currently passes `description: ""`, leaving misleading-metadata/privacy-risk (both rooted in stated purpose) near-inert; the release record is in hand during acquire, so slice B threads its description through, and the calibration sweep must run against the real-description input (flagged as the second calibration-input divergence in the stage's comment); (2) once a post-codeAi stage can transient-exhaust (the image stage), an `error` finalization can carry `coverage.code: "complete"` from the earlier successful code pass — decide then whether that's the intended "describes what WAS analyzed" semantics or should be cleared on error. -Slice-B design (2026-07-17, SSRF approach ratified): **`resolveHostname` = the existing `cloudflareDohResolver`** (`packages/core/src/security/ssrf.ts` — DoH against `cloudflare-dns.com/dns-query`, A+AAAA, fails closed, structurally assignable to registry-verification's `HostnameResolver`), injected into the already-hardened `fetchVerifiedResource` (which enforces HTTPS-only, credential-free URLs, per-hop redirect re-validation with per-hop re-resolution, private/reserved-IP rejection, and byte/time budgets — `ACQUISITION_FETCH_LIMITS`). The **egress-proxy Worker option was considered and rejected** for v1: it is the only complete TTL-0-rebinding defense, but it is a net-new security-critical deploy surface, and the residual is doubly bounded here — the labeler is a top-level Worker on Cloudflare's edge with no private network behind it, and fetched bytes are checksum/CID-verified against the signed release before any use, so the worst case is a blind GET (the same accepted residual `ssrf.ts` documents). Lexical-only resolution (the sandbox bridge's approach) fails the ratified "DNS hardening" control and is prohibited — the sandbox is only safe lexically because of `allowedHosts` + `globalOutbound`, neither of which applies to the labeler's arbitrary declared hosts. **Release resolution (`resolveTarget`):** the declared artifact URL is NOT stored labeler-side (the assessment row keeps only pinned `artifact_id`/`artifact_checksum`; discovery-consumer does not persist the URL) — it lives in the aggregator `ReleaseView.release` artifacts blob, so `resolveTarget` reads over the existing `AGGREGATOR` service binding (the ratified W8.4 slice-3 read-path pattern): `getLatestRelease` fast path, falling back to a `listReleases` scan for the release matching the assessment's pinned `(uri, cid)` since no by-cid ReleaseView endpoint exists; the existing `crossCheckCoordinates` guards declared-vs-pinned drift, a release absent from the aggregator classifies as the existing transient acquisition failure (aggregator lag; reconciliation owns true deletions). Slice B also builds the deterministic image-metadata extractor (bundle bytes → `ImageAnalysisImage`: mime sniff, sha256, base64, PNG/JPEG/WebP/GIF dimensions, icon-vs-screenshot classification) + `createImageAiStage`, threads the release description into the code stage (adversary item 1), assembles all four stages in `buildStages`, wires `executeAssessmentInstance`, and lifts the PROD gate — the gate-lift is valid ONLY with a real acquire running. Three `create*Stage` wrappers follow the `createAcquireStage(options)` closure idiom — each captures its deps + the shared `AcquisitionHolder` at construction and returns a `StageAdapter` that reads `holder.result` (the acquired, decoded `{path,content}` file map + parsed `declaredAccess`) and `ctx.assessment`: `createCodeAiStage` builds `CodeAnalysisInput` and calls `analyzeCode(input, {ai: env.AI, policy, promptVersion})`; `createImageAiStage` builds `ImageAnalysisInput` from the bundle's binary files and calls `analyzeImages(input, {ai: env.AI, ...})`; `createHistoryStage` closes over `env.DB` + a `PublisherVerificationReader` over the `AGGREGATOR` service binding and calls `analyzeHistory(db, assessment, opts)`. `buildStages(env, holder, policy)` assembles them; all required bindings (`AI`, `AGGREGATOR`, `DB`, `EVIDENCE`) already exist in `wrangler.jsonc` — no new binding. The AI adapters' `coverage` (`complete`/`partial`) + `droppedFiles` are merged into the assessment's `coverage_json` (already an updatable field via `updateAssessment.coverageJson`). The dead `deterministic` and `dependency` stages are removed from `OrchestratorStages`/`STAGE_ORDER`/`stubStages` (per the analysis-scope cut above); the W7.4 integrity/metadata checks remain in the acquire stage. **The finalization label-leak window the orchestrator comment flagged for "the real-stage wiring" is ALREADY closed** — #2072 gates every issuance statement on `requireAssessmentState: toState` with the whole batch all-or-nothing against the run→toState CAS and a signing-free currency re-check; the stale comment is updated, no new guard needed. Best-effort contract preserved: `analyzeHistory` swallows its own errors (context-only, never a label); a code/image adapter `ModelTransientError` propagates as a `StageTransientError` so the run retries rather than finalizing on a flaky model call. +Slice-B design (2026-07-17, SSRF approach ratified): **`resolveHostname` = the existing `cloudflareDohResolver`** (`packages/core/src/security/ssrf.ts` — DoH against `cloudflare-dns.com/dns-query`, A+AAAA, fails closed, structurally assignable to registry-verification's `HostnameResolver`), injected into the already-hardened `fetchVerifiedResource` (which enforces HTTPS-only, credential-free URLs, per-hop redirect re-validation with per-hop re-resolution, private/reserved-IP rejection, and byte/time budgets — `ACQUISITION_FETCH_LIMITS`). The **egress-proxy Worker option was considered and rejected** for v1: it is the only complete TTL-0-rebinding defense, but it is a net-new security-critical deploy surface, and the residual is doubly bounded here — the labeler is a top-level Worker on Cloudflare's edge with no private network behind it, and fetched bytes are checksum/CID-verified against the signed release before any use, so the worst case is a blind GET (the same accepted residual `ssrf.ts` documents). Lexical-only resolution (the sandbox bridge's approach) fails the ratified "DNS hardening" control and is prohibited — the sandbox is only safe lexically because of `allowedHosts` + `globalOutbound`, neither of which applies to the labeler's arbitrary declared hosts. **Release resolution (`resolveTarget`):** the declared artifact URL is NOT stored labeler-side (the assessment row keeps only pinned `artifact_id`/`artifact_checksum`; discovery-consumer does not persist the URL) — it lives in the aggregator `ReleaseView.release` artifacts blob, so `resolveTarget` reads over the existing `AGGREGATOR` service binding (the ratified W8.4 slice-3 read-path pattern): `getLatestRelease` fast path, falling back to a `listReleases` scan for the release matching the assessment's pinned `(uri, cid)` since no by-cid ReleaseView endpoint exists; the existing `crossCheckCoordinates` guards declared-vs-pinned drift, a release absent from the aggregator classifies as the existing transient acquisition failure (aggregator lag; reconciliation owns true deletions). Slice B also builds the deterministic image-metadata extractor (bundle bytes → `ImageAnalysisImage`: mime sniff, sha256, base64, PNG/JPEG/WebP/GIF dimensions, icon-vs-screenshot classification) + `createImageAiStage`, threads the release description into the code stage (adversary item 1; sourced from the package PROFILE via `getPackage`, not the ReleaseView — best-effort, never fails acquisition), assembles all four stages in `buildStages`, wires `executeAssessmentInstance`, and lifts the PROD gate — the gate-lift is valid ONLY with a real acquire running. Adversary pass (2026-07-17) confirmed the gate-lift core invariant HOLDS (no path to a false `passed`) and the SSRF main path HOLDS; two MED fixes folded in: **(M1)** a later stage's transient-exhaustion must not discard a block a prior stage already found — the orchestrator, on transient exhaustion, resolves the accumulated findings and finalizes `blocked` if they already block (a confirmed block is monotonic; no later stage can lift it), else `error` (incomplete analysis never concludes `passed`/`warned`); without this, a plugin pairing block-worthy code with a vision-model-breaking image escapes the block into `error` and stays installable across reconciliation reruns. **(M2)** `registry-verification`'s `fetch.ts isForbiddenAddress` is extended to reject IPv4-compatible (`::7f00:1`) and NAT64 (`64:ff9b::`) private/metadata encodings it previously missed (parity with core's `isPrivateIp`); strictly more-restrictive, additive, changeset'd — closes an SSRF gap the labeler's DoH-resolver composition exposes and that self-hosted Node egress (no platform firewall backstop) needs. L1: automated-label provenance (`model_id`/`prompt_hash` provisional) is on the calibration-launch checklist, not a gate blocker. Three `create*Stage` wrappers follow the `createAcquireStage(options)` closure idiom — each captures its deps + the shared `AcquisitionHolder` at construction and returns a `StageAdapter` that reads `holder.result` (the acquired, decoded `{path,content}` file map + parsed `declaredAccess`) and `ctx.assessment`: `createCodeAiStage` builds `CodeAnalysisInput` and calls `analyzeCode(input, {ai: env.AI, policy, promptVersion})`; `createImageAiStage` builds `ImageAnalysisInput` from the bundle's binary files and calls `analyzeImages(input, {ai: env.AI, ...})`; `createHistoryStage` closes over `env.DB` + a `PublisherVerificationReader` over the `AGGREGATOR` service binding and calls `analyzeHistory(db, assessment, opts)`. `buildStages(env, holder, policy)` assembles them; all required bindings (`AI`, `AGGREGATOR`, `DB`, `EVIDENCE`) already exist in `wrangler.jsonc` — no new binding. The AI adapters' `coverage` (`complete`/`partial`) + `droppedFiles` are merged into the assessment's `coverage_json` (already an updatable field via `updateAssessment.coverageJson`). The dead `deterministic` and `dependency` stages are removed from `OrchestratorStages`/`STAGE_ORDER`/`stubStages` (per the analysis-scope cut above); the W7.4 integrity/metadata checks remain in the acquire stage. **The finalization label-leak window the orchestrator comment flagged for "the real-stage wiring" is ALREADY closed** — #2072 gates every issuance statement on `requireAssessmentState: toState` with the whole batch all-or-nothing against the run→toState CAS and a signing-free currency re-check; the stale comment is updated, no new guard needed. Best-effort contract preserved: `analyzeHistory` swallows its own errors (context-only, never a label); a code/image adapter `ModelTransientError` propagates as a `StageTransientError` so the run retries rather than finalizing on a flaky model call. ## Workstream W9: Operator Authentication and Console From 5d8f77503abbd1cd64615709894927bef27d2afb Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 13:25:07 +0100 Subject: [PATCH 114/137] feat(labeler): code-AI and history stage wiring + dead-stage removal (slice A) (#2090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(labeler): code-AI and history stage wiring + dead-stage removal (slice A) Adapts the built analyzeCode/analyzeHistory analysis modules to the orchestrator's StageAdapter contract, following the createAcquireStage closure idiom. Pure additions this slice: buildStages, the production deploy gate, and executeAssessmentInstance are untouched — the gate stays down until the acquire consumer (release resolution + SSRF-hardened egress + image-metadata extraction) lands and the pipeline can genuinely run end-to-end. - assessment-stages.ts: createCodeAiStage (decoded bundle files + capabilities-only declared surface matching the calibration input shape; ModelTransientError converted to StageTransientError so a flaky model retries and can never finalize a spurious pass) and createHistoryStage (thin over the best-effort analyzeHistory), plus the per-run CoverageAccumulator and serializeCoverage. - assessment-orchestrator.ts: the deterministic and dependency stages are removed (ratified cut: plugins are dependency-free and the sandbox enforces declared access, so those stages have no work); a resolveCoverageJson seam stamps coverage atomically with the finalization CAS; the stale label-leak comment now reflects the requireAssessmentState gate that already closes the window (#2072). - tests: full runAssessment integration over a fake AiBinding/aggregator and real bundle fixtures — block/warn/clean outcomes, transient exhaustion finalizing error not passed, partial coverage propagation, acquire-failure short-circuit, and history best-effort. Co-Authored-By: Claude Opus 4.8 * docs(labeler): align orchestrator docblock with the reduced stage set The module comment still listed the removed deterministic/dependency stages as pending real adapters. Addresses the emdashbot review suggestion on #2090. Co-Authored-By: Claude Opus 4.8 * docs(labeler): drop dead stage names from buildStages comment + test label Addresses the two prose-drift suggestions in the emdashbot re-review of #2090: the buildStages docblock and a migrated test describe block still named the removed deterministic/dependency stages. Co-Authored-By: Claude Opus 4.8 * docs(labeler): update deploy-gate comment + error to name the acquire-consumer slice Last prose-drift item from the emdashbot re-review of #2090: the DEPLOY GATE comment and the production-gate error message still cited the W7/W8 plan codes. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- apps/labeler/package.json | 1 + apps/labeler/src/assessment-orchestrator.ts | 45 +- apps/labeler/src/assessment-stages.ts | 149 ++++++ apps/labeler/src/assessment-workflow.ts | 9 +- .../test/assessment-orchestrator.test.ts | 39 +- apps/labeler/test/assessment-stages.test.ts | 431 ++++++++++++++++++ pnpm-lock.yaml | 3 + 7 files changed, 628 insertions(+), 49 deletions(-) create mode 100644 apps/labeler/src/assessment-stages.ts create mode 100644 apps/labeler/test/assessment-stages.test.ts diff --git a/apps/labeler/package.json b/apps/labeler/package.json index e63b4ed135..c95a1b245e 100644 --- a/apps/labeler/package.json +++ b/apps/labeler/package.json @@ -30,6 +30,7 @@ "@atcute/lexicons": "catalog:", "@atcute/repo": "catalog:", "@atcute/xrpc-server": "catalog:", + "@emdash-cms/plugin-types": "workspace:*", "@emdash-cms/registry-lexicons": "workspace:*", "@emdash-cms/registry-moderation": "workspace:*", "@emdash-cms/registry-verification": "workspace:*", diff --git a/apps/labeler/src/assessment-orchestrator.ts b/apps/labeler/src/assessment-orchestrator.ts index a2d8e7f218..7c71034252 100644 --- a/apps/labeler/src/assessment-orchestrator.ts +++ b/apps/labeler/src/assessment-orchestrator.ts @@ -7,9 +7,9 @@ * run executes as one Workflow instance whose id is the run's runKey, so a * redelivered discovery event dedups onto the same instance rather than starting * a duplicate (see assessment-dispatch.ts). The Workflow constructs this - * orchestrator per run and calls `runAssessment`. Until W7/W8 supply the real stage adapters - * (acquire, deterministic validation, dependency/SBOM, code/metadata AI, image - * AI), that wiring runs `stubStages` — every stage resolves with no findings, so + * orchestrator per run and calls `runAssessment`. Until the acquire-consumer + * slice assembles the real stage adapters (acquire, code/metadata AI, image + * AI, history), that wiring runs `stubStages` — every stage resolves with no findings, so * `resolvePolicyOutcome` returns `passed` and finalization issues a real signed * `assessment-passed` label for EVERY subject (not merely clearing its own * `assessment-pending`). That is a hard deploy gate — see the DEPLOY GATE note @@ -79,8 +79,6 @@ export type StageAdapter = (ctx: StageContext) => Promise Promise; retryDelayMs?: number; + /** Resolves the `coverage_json` to stamp on the finalized row, called once + * at finalization. The stage-wiring layer builds this over the coverage its + * AI stages accumulate (`assessment-stages.ts`); `undefined` (or an undefined + * return) leaves the stored value unchanged. */ + resolveCoverageJson?: () => string | undefined; } export class AssessmentOrchestrator { @@ -125,6 +121,7 @@ export class AssessmentOrchestrator { private readonly maxStageRetries: number; private readonly sleep: (ms: number) => Promise; private readonly retryDelayMs: number; + private readonly resolveCoverageJson: (() => string | undefined) | undefined; constructor(opts: AssessmentOrchestratorOptions) { this.db = opts.db; @@ -136,6 +133,7 @@ export class AssessmentOrchestrator { this.maxStageRetries = opts.maxStageRetries ?? 2; this.sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); this.retryDelayMs = opts.retryDelayMs ?? 0; + this.resolveCoverageJson = opts.resolveCoverageJson; } async runAssessment(runId: string): Promise { @@ -193,15 +191,14 @@ export class AssessmentOrchestrator { // CID-superseded subject finalizes as `stale` — no labels, pointer // untouched. // - // This read narrows but does NOT close the window: `finalize` signs each - // label (an async round-trip) between here and `db.batch`, so a delete or - // a cancel landing in that window still commits labels — the finalization - // CAS guards its own row, but the issuance statements carry no - // assessment-state guard. The Workflow instance id (the runKey) dedups only - // redelivery of the same run (assessment-dispatch.ts) — not a delete or - // operator cancel against an in-flight run — so closing this window still - // needs an in-batch assessment-state guard on every issuance statement, - // tracked with the real-stage wiring. + // This read narrows the delete/cancel window; `finalize` closes it. Between + // here and `db.batch`, `finalize` signs each label (an async round-trip), so + // a delete or an operator cancel can still land in that window — but every + // issuance statement is gated on the run reaching `toState` + // (`requireAssessmentState`, see `finalize`) and the whole finalization batch + // is all-or-nothing against the run→toState CAS, so a race that moves the run + // out of `running` no-ops the CAS AND every label. Nothing leaks; the lost + // race raises `AssessmentFinalizationConflictError`. const current = await isSubjectCurrent(this.db, { uri: assessment.uri, cid: assessment.cid }); if (!current) { return transitionAssessmentState(this.db, { id: runId, from: "running", to: "stale", now }); @@ -250,6 +247,7 @@ export class AssessmentOrchestrator { const toState: AssessmentState = outcome?.toState ?? "error"; const positiveLabels: readonly OutcomeLabel[] = outcome?.labels ?? []; + const coverageJson = this.resolveCoverageJson?.(); const finalization = buildFinalizationStatements(this.db, { assessmentId: assessment.id, fromState: "running", @@ -258,6 +256,7 @@ export class AssessmentOrchestrator { uri: assessment.uri, cid: assessment.cid, now, + ...(coverageJson !== undefined ? { coverageJson } : {}), }); const statements = [...finalization.statements]; @@ -389,8 +388,6 @@ export class AssessmentOrchestrator { */ export const stubStages: OrchestratorStages = { acquire: () => Promise.resolve([]), - deterministic: () => Promise.resolve([]), - dependency: () => Promise.resolve([]), codeAi: () => Promise.resolve([]), imageAi: () => Promise.resolve([]), history: () => Promise.resolve([]), diff --git a/apps/labeler/src/assessment-stages.ts b/apps/labeler/src/assessment-stages.ts new file mode 100644 index 0000000000..b2650f2529 --- /dev/null +++ b/apps/labeler/src/assessment-stages.ts @@ -0,0 +1,149 @@ +/** + * Orchestrator stage wiring (plan W-production-stage-wiring). Adapts the pure + * analysis modules (`code-ai-adapter.ts`, `history-context.ts`) to the + * orchestrator's `StageAdapter` contract, following the `createAcquireStage` + * closure idiom: each factory captures its deps plus the shared + * `AcquisitionHolder` at construction and returns a `StageAdapter` that reads + * `holder.result` (the acquired bundle + decoded file set) and `ctx.assessment`. + * + * Keeping this here rather than in the adapter modules preserves those modules' + * purity (no DB, no orchestrator types) so they stay independently testable. + * + * The image stage and the whole `buildStages` assembly are deferred to the + * acquire-consumer slice, when a production bundle producer (verified + * acquisition + image-metadata extraction) exists to feed them; until then the + * orchestrator's production gate keeps stub stages from running. + */ + +import { declaredAccessToCapabilities } from "@emdash-cms/plugin-types"; + +import type { AcquisitionHolder } from "./artifact-acquisition.js"; +import type { StageAdapter } from "./assessment-orchestrator.js"; +import { StageTransientError } from "./assessment-orchestrator.js"; +import { + analyzeCode, + ModelTransientError, + type AiBinding, + type CodeAnalysisInput, + type CodeAnalysisResult, +} from "./code-ai-adapter.js"; +import { analyzeHistory, type PublisherVerificationReader } from "./history-context.js"; +import type { ModerationPolicy } from "./policy.js"; +import { parseAtUri } from "./record-verification.js"; + +/** + * Per-run coverage the AI stages accumulate as they run, serialized onto the + * finalized assessment's `coverage_json` (read by `public-assessment.ts`). + * `createCodeAiStage` records `code`; the image stage records `images` in the + * acquire-consumer slice. Shared by reference across the stages of one run. + */ +export interface CoverageAccumulator { + code?: { readonly coverage: "complete" | "partial"; readonly droppedFiles: readonly string[] }; +} + +/** Coverage axis value for an analysis that did not run this assessment (its + * input was unavailable or its stage is not yet wired). */ +const UNAVAILABLE = "unavailable"; + +/** + * Serializes the accumulated coverage into the `coverage_json` blob shape + * `public-assessment.ts` reads (`{ code, images, metadata }`, extra keys + * ignored). `images` and `metadata` stay `unavailable` until their producers + * land in the acquire-consumer slice. `droppedFiles` records which code files + * were dropped to fit the model budget when `code` is `partial`. + */ +export function serializeCoverage(coverage: CoverageAccumulator): string { + return JSON.stringify({ + code: coverage.code?.coverage ?? UNAVAILABLE, + images: UNAVAILABLE, + metadata: UNAVAILABLE, + droppedFiles: coverage.code ? [...coverage.code.droppedFiles] : [], + }); +} + +/** Per-run state: `holder` and `coverage` are shared by reference across one + * run's stages, so a factory must be constructed per assessment execution — + * reusing one across runs leaks the prior run's bundle and coverage. */ +export interface CodeAiStageOptions { + readonly holder: AcquisitionHolder; + readonly ai: AiBinding; + readonly policy: ModerationPolicy; + readonly promptVersion: string; + readonly modelId?: string; + readonly coverage: CoverageAccumulator; +} + +/** + * Builds the orchestrator's `codeAi` stage. When acquisition produced no + * bundle (a permanent deterministic finding, or a transient failure that never + * reached the holder) there is nothing to analyze, so it reports no findings. + * Otherwise it runs the code model over the decoded file set and returns its + * validated findings, recording the run's code coverage. A `ModelTransientError` + * (flaky model call, unparseable output) becomes a `StageTransientError` so the + * orchestrator retries the stage rather than aborting the run. + */ +export function createCodeAiStage(options: CodeAiStageOptions): StageAdapter { + return async (ctx) => { + const acquired = options.holder.result; + if (!acquired) return []; + + // The declared surface is passed capabilities-only, matching the calibration + // input shape; the production declaredAccess vocabulary (from + // declaredAccessToCapabilities) differs from the legacy fixture vocabulary and + // must be re-validated by the calibration sweep. Second calibration-input + // divergence for that sweep: `description` is empty below — the bundle + // manifest carries no name/description, and the misleading-metadata and + // privacy-risk categories are rooted in the plugin's stated purpose, so they + // run near-inert until the acquire-consumer slice threads the release + // record's description through these options. + const { capabilities } = declaredAccessToCapabilities(acquired.bundle.declaredAccess); + const input: CodeAnalysisInput = { + files: acquired.files, + declaredAccess: capabilities, + metadata: { + name: acquired.bundle.manifest.id, + description: "", + publisherDid: parseAtUri(ctx.assessment.uri).did, + version: acquired.bundle.manifest.version, + }, + }; + + let result: CodeAnalysisResult; + try { + result = await analyzeCode(input, { + ai: options.ai, + policy: options.policy, + promptVersion: options.promptVersion, + ...(options.modelId !== undefined ? { modelId: options.modelId } : {}), + }); + } catch (err) { + if (err instanceof ModelTransientError) throw new StageTransientError(err.message); + throw err; + } + + options.coverage.code = { coverage: result.coverage, droppedFiles: result.droppedFiles }; + return result.findings; + }; +} + +export interface HistoryStageOptions { + readonly db: D1Database; + /** The labeler's own DID (`src`) whose active manual labels count as context. */ + readonly src: string; + /** Read-only aggregator surface for the publisher's verification state. */ + readonly aggregator?: PublisherVerificationReader; +} + +/** + * Builds the orchestrator's `history` stage over `analyzeHistory`. History is + * operator-only context that never becomes a label and `analyzeHistory` is + * best-effort (it swallows its own errors and returns `[]`), so this wrapper + * adds no error handling of its own. + */ +export function createHistoryStage(options: HistoryStageOptions): StageAdapter { + return (ctx) => + analyzeHistory(options.db, ctx.assessment, { + src: options.src, + ...(options.aggregator !== undefined ? { aggregator: options.aggregator } : {}), + }); +} diff --git a/apps/labeler/src/assessment-workflow.ts b/apps/labeler/src/assessment-workflow.ts index 1c80229cef..6115acbbb5 100644 --- a/apps/labeler/src/assessment-workflow.ts +++ b/apps/labeler/src/assessment-workflow.ts @@ -9,7 +9,8 @@ * signed `assessment-passed` label for EVERY subject — an unconditional "this is * safe" attestation over unscanned content. This shell must NOT reach an * enforcing or label-consuming production deployment until the real analysis - * stages (W7/W8) land; shipping it live before then would vouch for everything. + * stages land in the acquire-consumer slice; shipping it live before then would + * vouch for everything. * * The whole run executes in one `step.do`, not one step per stage: the * orchestrator accumulates stage findings in memory and the acquire stage @@ -114,8 +115,8 @@ async function notifyOutcome(env: Env, assessment: Assessment): Promise { /** * The orchestrator's stage adapters, built per instance execution. Real - * adapters attach here in the W7/W8 follow-on — acquire (via the aggregator - * client), deterministic/dependency/AI scanning, and publisher history; today + * adapters attach here in the acquire-consumer follow-on — acquire (via the + * aggregator client), code/metadata AI, image AI, and publisher history; today * the run executes the exported stub stages. Building them per execution rather * than sharing module-scope state keeps per-run state — notably the acquire * stage's `AcquisitionHolder` — isolated to this instance, never shared across @@ -129,7 +130,7 @@ function buildStages(): OrchestratorStages { // at runtime. Remove once real stages are wired here. if (import.meta.env.PROD) throw new Error( - "AssessmentWorkflow has only stub stages in a production build — refusing to issue assessment-passed for unscanned subjects. Wire the real analysis stages (W7/W8) before deploying.", + "AssessmentWorkflow has only stub stages in a production build — refusing to issue assessment-passed for unscanned subjects. Wire the real analysis stages (the acquire-consumer slice) before deploying.", ); return stubStages; } diff --git a/apps/labeler/test/assessment-orchestrator.test.ts b/apps/labeler/test/assessment-orchestrator.test.ts index 8e07bb4869..3a732ddcd7 100644 --- a/apps/labeler/test/assessment-orchestrator.test.ts +++ b/apps/labeler/test/assessment-orchestrator.test.ts @@ -234,8 +234,7 @@ describe("AssessmentOrchestrator: warning findings", () => { const run = await pendingRun({ name: "warn", cidValue: await cid("warn") }); const stages: OrchestratorStages = { ...stubStages, - deterministic: () => - Promise.resolve([finding({ category: "obfuscated-code", severity: "medium" })]), + codeAi: () => Promise.resolve([finding({ category: "obfuscated-code", severity: "medium" })]), }; const orchestrator = await buildOrchestrator(stages); @@ -294,8 +293,7 @@ describe("AssessmentOrchestrator: warning findings", () => { await transitionAssessmentState(testEnv.DB, { id: first.id, from: "verifying", to: "pending" }); const warningStages: OrchestratorStages = { ...stubStages, - deterministic: () => - Promise.resolve([finding({ category: "obfuscated-code", severity: "medium" })]), + codeAi: () => Promise.resolve([finding({ category: "obfuscated-code", severity: "medium" })]), }; const firstResult = await (await buildOrchestrator(warningStages)).runAssessment(first.id); expect(firstResult.state).toBe("warned"); @@ -386,8 +384,7 @@ describe("AssessmentOrchestrator: warning findings", () => { await transitionAssessmentState(testEnv.DB, { id: first.id, from: "verifying", to: "pending" }); const warningStages: OrchestratorStages = { ...stubStages, - deterministic: () => - Promise.resolve([finding({ category: "obfuscated-code", severity: "medium" })]), + codeAi: () => Promise.resolve([finding({ category: "obfuscated-code", severity: "medium" })]), }; const firstResult = await (await buildOrchestrator(warningStages)).runAssessment(first.id); expect(firstResult.state).toBe("warned"); @@ -422,8 +419,7 @@ describe("AssessmentOrchestrator: warning findings", () => { }); const blockingStages: OrchestratorStages = { ...stubStages, - deterministic: () => - Promise.resolve([finding({ category: "malware", severity: "critical" })]), + codeAi: () => Promise.resolve([finding({ category: "malware", severity: "critical" })]), }; const secondResult = await (await buildOrchestrator(blockingStages)).runAssessment(second.id); expect(secondResult.state).toBe("blocked"); @@ -457,12 +453,12 @@ describe("AssessmentOrchestrator: warning findings", () => { }); }); -describe("AssessmentOrchestrator: permanent deterministic failure", () => { +describe("AssessmentOrchestrator: permanent blocking finding", () => { it("finalizes blocked with the mapped automated-block label", async () => { const run = await pendingRun({ name: "blocked", cidValue: await cid("blocked") }); const stages: OrchestratorStages = { ...stubStages, - deterministic: () => + codeAi: () => Promise.resolve([ finding({ category: "artifact-integrity-failure", severity: "critical" }), ]), @@ -484,7 +480,7 @@ describe("AssessmentOrchestrator: permanent deterministic failure", () => { const run = await pendingRun({ name: "multi-block", cidValue: await cid("multi-block") }); const stages: OrchestratorStages = { ...stubStages, - deterministic: () => + codeAi: () => Promise.resolve([ finding({ category: "malware", severity: "critical" }), finding({ category: "supply-chain-compromise", severity: "critical" }), @@ -511,9 +507,11 @@ describe("AssessmentOrchestrator: permanent deterministic failure", () => { const run = await pendingRun({ name: "block-and-warn", cidValue: await cid("block-and-warn") }); const stages: OrchestratorStages = { ...stubStages, - deterministic: () => - Promise.resolve([finding({ category: "malware", severity: "critical" })]), - codeAi: () => Promise.resolve([finding({ category: "obfuscated-code", severity: "medium" })]), + codeAi: () => + Promise.resolve([ + finding({ category: "malware", severity: "critical" }), + finding({ category: "obfuscated-code", severity: "medium" }), + ]), }; const orchestrator = await buildOrchestrator(stages); @@ -625,7 +623,7 @@ describe("AssessmentOrchestrator: deleted or superseded subject", () => { // entry currency check passed, before finalize's re-check. const stages: OrchestratorStages = { ...stubStages, - deterministic: async () => { + codeAi: async () => { await deleteSubject(testEnv.DB, { uri: run.uri, cid: run.cid }); return []; }, @@ -671,7 +669,7 @@ describe("AssessmentOrchestrator: invalid findings", () => { }); const stages: OrchestratorStages = { ...stubStages, - deterministic: () => + codeAi: () => Promise.resolve([finding({ category: "not-a-real-label", severity: "critical" })]), }; const orchestrator = await buildOrchestrator(stages); @@ -690,7 +688,7 @@ describe("AssessmentOrchestrator: invalid findings", () => { }); const stages: OrchestratorStages = { ...stubStages, - deterministic: () => + codeAi: () => Promise.resolve([ finding({ category: "obfuscated-code", @@ -719,7 +717,7 @@ describe("AssessmentOrchestrator: resume from running", () => { if (attempts === 1) throw new Error("stage crashed post-transition"); return Promise.resolve([]); }; - const orchestrator = await buildOrchestrator({ ...stubStages, deterministic: flaky }); + const orchestrator = await buildOrchestrator({ ...stubStages, codeAi: flaky }); await expect(orchestrator.runAssessment(run.id)).rejects.toThrow( "stage crashed post-transition", @@ -756,7 +754,7 @@ describe("AssessmentOrchestrator: cancel racing finalization", () => { }; const orchestrator = await buildOrchestrator({ ...stubStages, - deterministic: cancelDuringStage, + codeAi: cancelDuringStage, }); await expect(orchestrator.runAssessment(run.id)).rejects.toBeInstanceOf( @@ -861,8 +859,7 @@ describe("AssessmentOrchestrator: supersession negation provenance (decision 6)" await transitionAssessmentState(testEnv.DB, { id: first.id, from: "verifying", to: "pending" }); const blockingStages: OrchestratorStages = { ...stubStages, - deterministic: () => - Promise.resolve([finding({ category: "malware", severity: "critical" })]), + codeAi: () => Promise.resolve([finding({ category: "malware", severity: "critical" })]), }; const firstResult = await (await buildOrchestrator(blockingStages)).runAssessment(first.id); expect(firstResult.state).toBe("blocked"); diff --git a/apps/labeler/test/assessment-stages.test.ts b/apps/labeler/test/assessment-stages.test.ts new file mode 100644 index 0000000000..15d7af1a79 --- /dev/null +++ b/apps/labeler/test/assessment-stages.test.ts @@ -0,0 +1,431 @@ +import { CODEC_RAW, create as createCid, toString as cidToString } from "@atcute/cid"; +import { + createLabelSigner, + type LabelDidDocument, + type LabelSigner, +} from "@emdash-cms/registry-moderation"; +import { applyD1Migrations, env } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { + createAcquireStage, + type AcquisitionHolder, + type AcquisitionTarget, +} from "../src/artifact-acquisition.js"; +import { computeRunKey, initialTriggerId } from "../src/assessment-lifecycle.js"; +import { + AssessmentOrchestrator, + stubStages, + type OrchestratorStages, +} from "../src/assessment-orchestrator.js"; +import { + createCodeAiStage, + createHistoryStage, + serializeCoverage, + type CoverageAccumulator, +} from "../src/assessment-stages.js"; +import { + createAssessmentRun, + createSubject, + transitionAssessmentState, +} from "../src/assessment-store.js"; +import type { AiBinding, AiRunInputs } from "../src/code-ai-adapter.js"; +import type { PublisherVerificationReader } from "../src/history-context.js"; +import { MODERATION_POLICY } from "../src/policy.js"; +import { initializeSigningState } from "../src/signing-rotation.js"; +import { canonicalBundle, checksumOf, file } from "./bundle-fixture.js"; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; +} + +const testEnv = env as unknown as TestEnv; +const LABELER_DID = "did:web:labels.emdashcms.com"; +const PUBLISHER_DID = "did:plc:publisher000000000000000000"; +const PRIVATE_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE"; +const MULTIKEY = "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ"; +const PROMPT_VERSION = "prompt-test-v1"; +const config = { labelerDid: LABELER_DID, signingKeyVersion: "v1" }; + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); + await initializeSigningState(testEnv.DB, { + issuerDid: LABELER_DID, + keyVersion: "v1", + publicKeyMultibase: MULTIKEY, + }); +}); + +function document(): LabelDidDocument { + return { + id: LABELER_DID, + verificationMethod: [ + { + id: "#atproto_label", + type: "Multikey", + controller: LABELER_DID, + publicKeyMultibase: MULTIKEY, + }, + ], + }; +} + +async function signer(): Promise { + return createLabelSigner({ + issuerDid: LABELER_DID, + privateKey: PRIVATE_KEY, + resolveDid: async () => document(), + }); +} + +async function cid(seed: string): Promise { + const bytes = new TextEncoder().encode(seed); + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + return cidToString(await createCid(CODEC_RAW, new Uint8Array(buffer))); +} + +function releaseUri(name: string): string { + return `at://${PUBLISHER_DID}/com.emdashcms.experimental.package.release/${name}:1.0.0`; +} + +async function pendingRun(name: string): Promise<{ id: string; uri: string; cid: string }> { + const cidValue = await cid(name); + const uri = releaseUri(name); + await createSubject(testEnv.DB, { + uri, + cid: cidValue, + did: PUBLISHER_DID, + collection: "com.emdashcms.experimental.package.release", + rkey: `${name}:1.0.0`, + }); + const triggerId = initialTriggerId(cidValue); + const runKey = await computeRunKey({ + uri, + cid: cidValue, + policyVersion: MODERATION_POLICY.policyVersion, + modelId: "unassigned", + promptHash: "unassigned", + scannerSetVersion: "unassigned", + triggerId, + }); + const { assessment } = await createAssessmentRun(testEnv.DB, { + runKey, + uri, + cid: cidValue, + trigger: "initial", + triggerId, + policyVersion: MODERATION_POLICY.policyVersion, + coverageJson: "{}", + }); + await transitionAssessmentState(testEnv.DB, { + id: assessment.id, + from: "observed", + to: "verifying", + }); + await transitionAssessmentState(testEnv.DB, { + id: assessment.id, + from: "verifying", + to: "pending", + }); + return { id: assessment.id, uri, cid: cidValue }; +} + +async function buildOrchestrator( + stages: OrchestratorStages, + overrides: { coverage?: CoverageAccumulator; maxStageRetries?: number } = {}, +): Promise { + return new AssessmentOrchestrator({ + db: testEnv.DB, + config, + signer: await signer(), + policy: MODERATION_POLICY, + stages, + sleep: () => Promise.resolve(), + ...(overrides.coverage !== undefined + ? { resolveCoverageJson: () => serializeCoverage(overrides.coverage!) } + : {}), + ...(overrides.maxStageRetries !== undefined + ? { maxStageRetries: overrides.maxStageRetries } + : {}), + }); +} + +function fakeAi(run: (model: string, inputs: AiRunInputs) => Promise): AiBinding { + return { run }; +} + +function findingResponse(findings: unknown[]): { response: string } { + return { response: JSON.stringify({ findings }) }; +} + +const nullAggregator: PublisherVerificationReader = { getPublisherVerification: async () => null }; + +const BLOCK_FINDING = { + category: "malware", + severity: "critical", + title: "malware detected", + publicSummary: "the plugin ships a malicious payload", + privateDetail: "backend.js drops an obfuscated dropper", + affectedFiles: ["backend.js"], +}; + +const WARN_FINDING = { + category: "obfuscated-code", + severity: "medium", + title: "obfuscated payload", + publicSummary: "the code appears obfuscated", + privateDetail: "backend.js base64-decodes a runtime string", + affectedFiles: ["backend.js"], +}; + +function targetFor(checksum: string): (assessment: { uri: string }) => Promise { + return async () => ({ + url: "https://cdn.example.test/plugin.tgz", + checksum, + slug: "test-plugin", + version: "1.0.0", + }); +} + +/** Test-injected acquire stage that serves `bytes` and pins `checksum`. When + * `checksum` matches the bytes it publishes the bundle to `holder`; a mismatch + * yields the permanent artifact-integrity-failure finding and leaves `holder` + * empty — the two shapes the AI stages branch on. */ +function acquireStage(bytes: Uint8Array, checksum: string, holder: AcquisitionHolder) { + return createAcquireStage({ + deps: { + fetch: async () => new Response(bytes), + resolveHostname: async () => ["203.0.113.5"], + }, + resolveTarget: targetFor(checksum), + holder, + }); +} + +async function labelNeg(uri: string, cidValue: string, val: string): Promise { + const row = await testEnv.DB.prepare( + `SELECT neg FROM issued_labels WHERE uri = ? AND cid = ? AND val = ? ORDER BY sequence DESC LIMIT 1`, + ) + .bind(uri, cidValue, val) + .first<{ neg: number }>(); + return row?.neg; +} + +describe("analysis stages: code AI stage", () => { + it("blocks and issues the mapped label when the code model returns an automated-block finding", async () => { + const run = await pendingRun("code-block"); + const bytes = await canonicalBundle(); + const holder: AcquisitionHolder = {}; + const coverage: CoverageAccumulator = {}; + const ai = fakeAi(() => Promise.resolve(findingResponse([BLOCK_FINDING]))); + const stages: OrchestratorStages = { + ...stubStages, + acquire: acquireStage(bytes, await checksumOf(bytes), holder), + codeAi: createCodeAiStage({ + holder, + ai, + policy: MODERATION_POLICY, + promptVersion: PROMPT_VERSION, + coverage, + }), + history: createHistoryStage({ db: testEnv.DB, src: LABELER_DID, aggregator: nullAggregator }), + }; + + const result = await (await buildOrchestrator(stages, { coverage })).runAssessment(run.id); + + expect(result.state).toBe("blocked"); + expect(await labelNeg(run.uri, run.cid, "malware")).toBe(0); + expect(await labelNeg(run.uri, run.cid, "assessment-passed")).toBeUndefined(); + }); + + it("passes on a clean model response and records complete code coverage", async () => { + const run = await pendingRun("code-clean"); + const bytes = await canonicalBundle(); + const holder: AcquisitionHolder = {}; + const coverage: CoverageAccumulator = {}; + const ai = fakeAi(() => Promise.resolve(findingResponse([]))); + const stages: OrchestratorStages = { + ...stubStages, + acquire: acquireStage(bytes, await checksumOf(bytes), holder), + codeAi: createCodeAiStage({ + holder, + ai, + policy: MODERATION_POLICY, + promptVersion: PROMPT_VERSION, + coverage, + }), + history: createHistoryStage({ db: testEnv.DB, src: LABELER_DID }), + }; + + const result = await (await buildOrchestrator(stages, { coverage })).runAssessment(run.id); + + expect(result.state).toBe("passed"); + expect(await labelNeg(run.uri, run.cid, "assessment-passed")).toBe(0); + expect(JSON.parse(result.coverageJson)).toMatchObject({ + code: "complete", + images: "unavailable", + metadata: "unavailable", + droppedFiles: [], + }); + }); + + it("warns and issues the warning label alongside assessment-passed", async () => { + const run = await pendingRun("code-warn"); + const bytes = await canonicalBundle(); + const holder: AcquisitionHolder = {}; + const coverage: CoverageAccumulator = {}; + const ai = fakeAi(() => Promise.resolve(findingResponse([WARN_FINDING]))); + const stages: OrchestratorStages = { + ...stubStages, + acquire: acquireStage(bytes, await checksumOf(bytes), holder), + codeAi: createCodeAiStage({ + holder, + ai, + policy: MODERATION_POLICY, + promptVersion: PROMPT_VERSION, + coverage, + }), + history: createHistoryStage({ db: testEnv.DB, src: LABELER_DID }), + }; + + const result = await (await buildOrchestrator(stages, { coverage })).runAssessment(run.id); + + expect(result.state).toBe("warned"); + expect(await labelNeg(run.uri, run.cid, "obfuscated-code")).toBe(0); + expect(await labelNeg(run.uri, run.cid, "assessment-passed")).toBe(0); + }); + + it("finalizes error, never a spurious pass, when the model call keeps failing transiently", async () => { + const run = await pendingRun("code-transient"); + const bytes = await canonicalBundle(); + const holder: AcquisitionHolder = {}; + const coverage: CoverageAccumulator = {}; + let calls = 0; + const ai = fakeAi(() => { + calls += 1; + return Promise.reject(new Error("model overloaded")); + }); + const stages: OrchestratorStages = { + ...stubStages, + acquire: acquireStage(bytes, await checksumOf(bytes), holder), + codeAi: createCodeAiStage({ + holder, + ai, + policy: MODERATION_POLICY, + promptVersion: PROMPT_VERSION, + coverage, + }), + history: createHistoryStage({ db: testEnv.DB, src: LABELER_DID }), + }; + + const result = await ( + await buildOrchestrator(stages, { coverage, maxStageRetries: 1 }) + ).runAssessment(run.id); + + expect(result.state).toBe("error"); + expect(calls).toBe(2); + expect(await labelNeg(run.uri, run.cid, "assessment-error")).toBe(0); + expect(await labelNeg(run.uri, run.cid, "assessment-passed")).toBeUndefined(); + }); + + it("records partial coverage and the dropped file when the model input budget drops a file", async () => { + const run = await pendingRun("code-partial"); + // Two files, each under the per-file bundle cap (128 KiB) but together over + // the model's 200k-char input budget, so the code adapter drops the larger. + const bytes = await canonicalBundle([ + file("big-a.js", "a".repeat(125_000)), + file("big-b.js", "b".repeat(120_000)), + ]); + const holder: AcquisitionHolder = {}; + const coverage: CoverageAccumulator = {}; + const ai = fakeAi(() => Promise.resolve(findingResponse([]))); + const stages: OrchestratorStages = { + ...stubStages, + acquire: acquireStage(bytes, await checksumOf(bytes), holder), + codeAi: createCodeAiStage({ + holder, + ai, + policy: MODERATION_POLICY, + promptVersion: PROMPT_VERSION, + coverage, + }), + history: createHistoryStage({ db: testEnv.DB, src: LABELER_DID }), + }; + + const result = await (await buildOrchestrator(stages, { coverage })).runAssessment(run.id); + + expect(result.state).toBe("passed"); + const parsed = JSON.parse(result.coverageJson); + expect(parsed.code).toBe("partial"); + expect(parsed.droppedFiles).toContain("big-a.js"); + }); +}); + +describe("analysis stages: acquire produced no bundle", () => { + it("no-ops the code stage and finalizes on the deterministic acquire finding", async () => { + const run = await pendingRun("acquire-permanent"); + const served = await canonicalBundle([file("tampered.js", "steal();")]); + const pinned = await checksumOf(await canonicalBundle()); + const holder: AcquisitionHolder = {}; + const coverage: CoverageAccumulator = {}; + let modelCalled = false; + const ai = fakeAi(() => { + modelCalled = true; + return Promise.resolve(findingResponse([])); + }); + const stages: OrchestratorStages = { + ...stubStages, + acquire: acquireStage(served, pinned, holder), + codeAi: createCodeAiStage({ + holder, + ai, + policy: MODERATION_POLICY, + promptVersion: PROMPT_VERSION, + coverage, + }), + history: createHistoryStage({ db: testEnv.DB, src: LABELER_DID }), + }; + + const result = await (await buildOrchestrator(stages, { coverage })).runAssessment(run.id); + + expect(result.state).toBe("blocked"); + expect(modelCalled).toBe(false); + expect(holder.result).toBeUndefined(); + expect(await labelNeg(run.uri, run.cid, "artifact-integrity-failure")).toBe(0); + expect(JSON.parse(result.coverageJson).code).toBe("unavailable"); + }); +}); + +describe("analysis stages: history stage is best-effort", () => { + it("does not fail the run when the history stage's own lookup throws internally", async () => { + const run = await pendingRun("history-throws"); + const bytes = await canonicalBundle(); + const holder: AcquisitionHolder = {}; + const coverage: CoverageAccumulator = {}; + const ai = fakeAi(() => Promise.resolve(findingResponse([]))); + const brokenDb = { + prepare() { + throw new Error("history db unavailable"); + }, + } as unknown as D1Database; + const stages: OrchestratorStages = { + ...stubStages, + acquire: acquireStage(bytes, await checksumOf(bytes), holder), + codeAi: createCodeAiStage({ + holder, + ai, + policy: MODERATION_POLICY, + promptVersion: PROMPT_VERSION, + coverage, + }), + history: createHistoryStage({ db: brokenDb, src: LABELER_DID }), + }; + + const result = await (await buildOrchestrator(stages, { coverage })).runAssessment(run.id); + + expect(result.state).toBe("passed"); + expect(await labelNeg(run.uri, run.cid, "assessment-passed")).toBe(0); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 354987e5bf..aa8dac924a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -444,6 +444,9 @@ importers: '@atcute/xrpc-server': specifier: 'catalog:' version: 2.0.0(@atcute/cid@2.4.1)(@atcute/lexicons@2.0.0)(typescript@6.0.3) + '@emdash-cms/plugin-types': + specifier: workspace:* + version: link:../../packages/plugin-types '@emdash-cms/registry-lexicons': specifier: workspace:* version: link:../../packages/registry-lexicons From 0bb7e7cd8e04f0658efc655fcd66d5a0e15f7fed Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 14:25:09 +0100 Subject: [PATCH 115/137] feat(labeler): acquire consumer, SSRF egress, image stage, and gate lift (slice B) (#2091) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(labeler): acquire consumer, SSRF egress, image stage, and gate lift (slice B) Makes the labeler perform a real production assessment. The acquire stage now resolves an assessment's pinned release from the aggregator, fetches its declared artifact under an SSRF-hardened egress, and publishes the verified bundle; the code, image, and history stages run over it; and the production deploy gate lifts — a `passed` outcome now means a genuine clean scan. - artifact-egress.ts: the production {fetch, resolveHostname} for acquisition — resolveHostname is the DoH resolver (fails closed), fetch is a bare globalThis.fetch pass-through so fetchVerifiedResource keeps full control of redirects/budgets. No recursion: the DoH call uses its own fetch to a fixed host. - release-resolution.ts: resolveTarget reads the pinned release's declared URL + checksum from the aggregator ReleaseView (getLatestRelease, falling back to a listReleases scan for the pinned cid), sets the pinned coordinates so acquisition's crossCheckCoordinates raises a permanent integrity finding on drift; aggregator-miss classifies transient. Threads the package description (best-effort) into the code stage. - image-metadata.ts: deterministic bundle-bytes -> ImageAnalysisImage — magic-byte mime, header dimensions, sha256 + base64, caps enforced before base64 materialization; malformed images are skipped, never thrown. - createImageAiStage + real image coverage; buildStages assembles the four real stages over injected deps; executeAssessmentInstance builds the per-run holder/coverage and asserts required bindings; the import.meta.env.PROD gate is removed (safe only because every stage is now real — acquire returns no finding iff it published a verified bundle). - orchestrator: a later stage's transient exhaustion no longer discards a block an earlier stage already found (finalize blocked on partial coverage; clean/warn-incomplete still -> error), closing an indefinite block-evasion. - registry-verification: the verified-fetch SSRF guard rejects IPv4-compatible and NAT64 IPv6 encodings of private/metadata addresses. - Exposes emdash/security/ssrf for the DoH resolver. Co-Authored-By: Claude Opus 4.8 * fix(labeler): cap publisher description so it can't suppress a block A publisher-controlled package description was threaded into the AI prompts uncapped; a long enough one pushes the fixed prompt overhead past MAX_MODEL_INPUT_CHARS, and the adapters throw a plain TypeError (not the ModelTransientError the stage converts). That aborts the run and leaves it stuck 'running', so the subject's block/warn label never finalizes — a publisher could evade a block by padding their description. Cap the description at ingestion (4096 chars, truncated) so it can never blow the budget; the adapters' behavior is unchanged. Also updates the stale orchestrator module comment that still described the lifted stub-stage deploy gate. Addresses the emdashbot review on #2091. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/core-ssrf-doh-export.md | 5 + .changeset/registry-verification-ipv6-ssrf.md | 5 + apps/labeler/package.json | 1 + apps/labeler/src/artifact-acquisition.ts | 9 + apps/labeler/src/artifact-egress.ts | 45 +++ apps/labeler/src/assessment-orchestrator.ts | 44 ++- apps/labeler/src/assessment-stages.ts | 114 +++++- apps/labeler/src/assessment-workflow.ts | 143 ++++++-- apps/labeler/src/discovery-consumer.ts | 4 +- apps/labeler/src/image-metadata.ts | 327 ++++++++++++++++++ apps/labeler/src/release-resolution.ts | 181 ++++++++++ apps/labeler/test/artifact-egress.test.ts | 123 +++++++ .../test/assessment-orchestrator.test.ts | 70 ++++ apps/labeler/test/assessment-stages.test.ts | 230 ++++++++++++ apps/labeler/test/assessment-workflow.test.ts | 290 +++++++++++++--- apps/labeler/test/image-metadata.test.ts | 218 ++++++++++++ apps/labeler/test/release-resolution.test.ts | 230 ++++++++++++ packages/core/package.json | 4 + packages/core/tsdown.config.ts | 2 + packages/registry-verification/src/fetch.ts | 18 +- .../registry-verification/tests/fetch.test.ts | 20 ++ pnpm-lock.yaml | 3 + 22 files changed, 1973 insertions(+), 113 deletions(-) create mode 100644 .changeset/core-ssrf-doh-export.md create mode 100644 .changeset/registry-verification-ipv6-ssrf.md create mode 100644 apps/labeler/src/artifact-egress.ts create mode 100644 apps/labeler/src/image-metadata.ts create mode 100644 apps/labeler/src/release-resolution.ts create mode 100644 apps/labeler/test/artifact-egress.test.ts create mode 100644 apps/labeler/test/image-metadata.test.ts create mode 100644 apps/labeler/test/release-resolution.test.ts diff --git a/.changeset/core-ssrf-doh-export.md b/.changeset/core-ssrf-doh-export.md new file mode 100644 index 0000000000..78d17abe28 --- /dev/null +++ b/.changeset/core-ssrf-doh-export.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Adds a `emdash/security/ssrf` subpath export exposing the `cloudflareDohResolver` DNS-over-HTTPS resolver and the SSRF URL/address validation helpers for reuse in Workers that fetch untrusted URLs. diff --git a/.changeset/registry-verification-ipv6-ssrf.md b/.changeset/registry-verification-ipv6-ssrf.md new file mode 100644 index 0000000000..a4dd018e66 --- /dev/null +++ b/.changeset/registry-verification-ipv6-ssrf.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-verification": patch +--- + +Rejects additional IPv6-encoded private/metadata addresses in the verified-fetch SSRF guard: deprecated IPv4-compatible (`::a.b.c.d`) and NAT64 (`64:ff9b::a.b.c.d`) forms that embed a private or link-local IPv4 address are now resolved to their embedded address and blocked. diff --git a/apps/labeler/package.json b/apps/labeler/package.json index c95a1b245e..052b42a097 100644 --- a/apps/labeler/package.json +++ b/apps/labeler/package.json @@ -34,6 +34,7 @@ "@emdash-cms/registry-lexicons": "workspace:*", "@emdash-cms/registry-moderation": "workspace:*", "@emdash-cms/registry-verification": "workspace:*", + "emdash": "workspace:*", "jose": "^6.1.3", "ulidx": "^2.4.1" }, diff --git a/apps/labeler/src/artifact-acquisition.ts b/apps/labeler/src/artifact-acquisition.ts index 0d830270fe..bb2e0283b7 100644 --- a/apps/labeler/src/artifact-acquisition.ts +++ b/apps/labeler/src/artifact-acquisition.ts @@ -123,6 +123,11 @@ export interface AcquisitionTarget { * declaration when present. */ readonly pinnedChecksum?: string | null; readonly pinnedArtifactId?: string | null; + /** The release's advertised human description (the publisher's package-profile + * description), for the code/image stages' stated-purpose analysis. Best-effort + * — absent when the profile is unindexed. Carried through to the acquired + * artifact; never an integrity input. */ + readonly description?: string; } export interface AcquiredArtifact { @@ -133,6 +138,9 @@ export interface AcquiredArtifact { readonly files: readonly CodeAnalysisFile[]; /** The checksum-verified compressed bundle bytes. */ readonly bytes: Uint8Array; + /** The release's advertised description, carried from the target for the + * analysis stages' stated-purpose input. Absent when unindexed. */ + readonly description?: string; } export type AcquisitionResult = @@ -312,6 +320,7 @@ async function verifyAndUnpack( bundle: bundle.value, files: fileSet.files, bytes, + ...(target.description !== undefined ? { description: target.description } : {}), }, }; } diff --git a/apps/labeler/src/artifact-egress.ts b/apps/labeler/src/artifact-egress.ts new file mode 100644 index 0000000000..4f9c0dc3b3 --- /dev/null +++ b/apps/labeler/src/artifact-egress.ts @@ -0,0 +1,45 @@ +/** + * Production SSRF egress for artifact acquisition (plan W7.2, Slice-B design + * 2026-07-17). Constructs the `{ fetch, resolveHostname }` pair + * `fetchVerifiedResource` injects into every declared-URL artifact download. + * + * The security posture lives entirely in `fetchVerifiedResource` + * (`@emdash-cms/registry-verification`): HTTPS-only, credential-free URLs, + * manual per-hop redirect re-validation with per-hop hostname re-resolution, + * private/reserved-IP rejection, and byte/time budgets. This module supplies + * only the two injected primitives and adds NOTHING to the request — no + * ambient credentials, no header injection, no redirect following of its own. + * + * - `resolveHostname` is the ratified `cloudflareDohResolver` (DoH against a + * fixed trusted host, A+AAAA, fails closed). `fetchVerifiedResource` checks + * every returned address against the private/reserved blocklist before each + * hop, so a rebinding or private-IP host is rejected before any fetch. + * - `fetch` is a thin pass-through over `globalThis.fetch`. It forwards the + * method/redirect/signal `fetchVerifiedResource` sets verbatim and never + * overrides them, so the hardened posture is preserved. + * + * No recursion into the SSRF-checked path: `cloudflareDohResolver` reaches its + * fixed DoH host with its own plain `globalThis.fetch`, never through the + * `fetch` this module returns, so resolving a hostname does not itself trigger + * another resolve. + */ + +import type { FetchImplementation, HostnameResolver } from "@emdash-cms/registry-verification"; +import { cloudflareDohResolver } from "emdash/security/ssrf"; + +export interface ArtifactEgress { + readonly fetch: FetchImplementation; + readonly resolveHostname: HostnameResolver; +} + +/** + * Forwards `fetchVerifiedResource`'s pre-built request unchanged. Wrapping + * `globalThis.fetch` in an arrow rather than passing the bare reference keeps + * the call bound to the global and makes the "no added headers/credentials" + * contract explicit at the call site. + */ +const passthroughFetch: FetchImplementation = (input, init) => globalThis.fetch(input, init); + +export function createArtifactEgress(): ArtifactEgress { + return { fetch: passthroughFetch, resolveHostname: cloudflareDohResolver }; +} diff --git a/apps/labeler/src/assessment-orchestrator.ts b/apps/labeler/src/assessment-orchestrator.ts index 7c71034252..72f5fff6fa 100644 --- a/apps/labeler/src/assessment-orchestrator.ts +++ b/apps/labeler/src/assessment-orchestrator.ts @@ -7,13 +7,10 @@ * run executes as one Workflow instance whose id is the run's runKey, so a * redelivered discovery event dedups onto the same instance rather than starting * a duplicate (see assessment-dispatch.ts). The Workflow constructs this - * orchestrator per run and calls `runAssessment`. Until the acquire-consumer - * slice assembles the real stage adapters (acquire, code/metadata AI, image - * AI, history), that wiring runs `stubStages` — every stage resolves with no findings, so - * `resolvePolicyOutcome` returns `passed` and finalization issues a real signed - * `assessment-passed` label for EVERY subject (not merely clearing its own - * `assessment-pending`). That is a hard deploy gate — see the DEPLOY GATE note - * in assessment-workflow.ts. + * orchestrator per run and calls `runAssessment` with the real stage adapters + * that `assessment-workflow.ts` `buildStages` assembles (acquire, code AI, image + * AI, history). `stubStages` remains exported only for tests that need an + * empty-findings pass; production never runs it. */ import type { LabelSigner } from "@emdash-cms/registry-moderation"; @@ -174,18 +171,25 @@ export class AssessmentOrchestrator { } } + // Validate and resolve the findings gathered so far even on transient + // exhaustion: a stage that already returned a BLOCKING finding must not be + // discarded because a LATER stage failed transiently. A block is monotonic — + // no unfinished stage can lift it, only add labels — so finalizing `blocked` + // on partial coverage is correct and safe, and it stops a crafted input that + // reliably exhausts a later stage from suppressing a real block on every + // rerun. Anything short of an already-confirmed block on an incomplete run + // still finalizes `error` (below) and retries — an unrun stage might have + // found a block. + // // validateFindings throws on a malformed finding; uncaught, it aborts the // run (assessment stays `running`, like any non-transient stage error). // Resolution and persistence below read validatedFindings, never the raw // stage output. - let validatedFindings: NormalizedFinding[] = []; - if (!transientExhausted) { - const resolvableEvidenceIds = await listEvidenceObjectIds(this.db, assessment.id); - validatedFindings = validateFindings(findings, { - allowedCategories: allowedFindingCategories(this.policy), - resolvableEvidenceIds, - }); - } + const resolvableEvidenceIds = await listEvidenceObjectIds(this.db, assessment.id); + const validatedFindings = validateFindings(findings, { + allowedCategories: allowedFindingCategories(this.policy), + resolvableEvidenceIds, + }); // Re-read subject currency immediately before finalizing: a deleted or // CID-superseded subject finalizes as `stale` — no labels, pointer @@ -204,9 +208,15 @@ export class AssessmentOrchestrator { return transitionAssessmentState(this.db, { id: runId, from: "running", to: "stale", now }); } + const resolved = resolvePolicyOutcome(validatedFindings, this.policy); + // On transient exhaustion, honor an already-confirmed block; every other + // incomplete outcome (clean or warn) is an `error` to retry, never a + // `passed`/`warned` finalized over an unrun stage. const outcome = transientExhausted - ? null - : resolvePolicyOutcome(validatedFindings, this.policy); + ? resolved.toState === "blocked" + ? resolved + : null + : resolved; return this.finalize(assessment, outcome, now); } diff --git a/apps/labeler/src/assessment-stages.ts b/apps/labeler/src/assessment-stages.ts index b2650f2529..bdf7bfd318 100644 --- a/apps/labeler/src/assessment-stages.ts +++ b/apps/labeler/src/assessment-stages.ts @@ -8,11 +8,8 @@ * * Keeping this here rather than in the adapter modules preserves those modules' * purity (no DB, no orchestrator types) so they stay independently testable. - * - * The image stage and the whole `buildStages` assembly are deferred to the - * acquire-consumer slice, when a production bundle producer (verified - * acquisition + image-metadata extraction) exists to feed them; until then the - * orchestrator's production gate keeps stub stages from running. + * `assessment-workflow.ts` `buildStages` assembles these factories with the + * acquire stage into the run's `OrchestratorStages`. */ import { declaredAccessToCapabilities } from "@emdash-cms/plugin-types"; @@ -28,6 +25,13 @@ import { type CodeAnalysisResult, } from "./code-ai-adapter.js"; import { analyzeHistory, type PublisherVerificationReader } from "./history-context.js"; +import { + analyzeImages, + type ImageAiBinding, + type ImageAnalysisInput, + type ImageAnalysisResult, +} from "./image-ai-adapter.js"; +import { extractBundleImages } from "./image-metadata.js"; import type { ModerationPolicy } from "./policy.js"; import { parseAtUri } from "./record-verification.js"; @@ -39,25 +43,36 @@ import { parseAtUri } from "./record-verification.js"; */ export interface CoverageAccumulator { code?: { readonly coverage: "complete" | "partial"; readonly droppedFiles: readonly string[] }; + images?: { + readonly coverage: "complete" | "partial" | "not-present"; + readonly droppedImages: readonly string[]; + }; } /** Coverage axis value for an analysis that did not run this assessment (its - * input was unavailable or its stage is not yet wired). */ + * input was unavailable — e.g. acquisition produced no bundle, or the stage + * transient-exhausted before completing). */ const UNAVAILABLE = "unavailable"; /** * Serializes the accumulated coverage into the `coverage_json` blob shape * `public-assessment.ts` reads (`{ code, images, metadata }`, extra keys - * ignored). `images` and `metadata` stay `unavailable` until their producers - * land in the acquire-consumer slice. `droppedFiles` records which code files - * were dropped to fit the model budget when `code` is `partial`. + * ignored). `code`/`images` reflect what each AI stage recorded this run; + * `metadata` stays `unavailable` (metadata analysis folds into the code stage, + * with no separate coverage producer). An axis its stage left unset serializes + * `unavailable` — this is how a post-code transient exhaustion honestly reports + * `code: complete, images: unavailable` (the image stage never completed). The + * `images` axis also carries `not-present` for a bundle with no image files. + * `droppedFiles`/`droppedImages` record what each stage dropped to fit its + * model budget. */ export function serializeCoverage(coverage: CoverageAccumulator): string { return JSON.stringify({ code: coverage.code?.coverage ?? UNAVAILABLE, - images: UNAVAILABLE, + images: coverage.images?.coverage ?? UNAVAILABLE, metadata: UNAVAILABLE, droppedFiles: coverage.code ? [...coverage.code.droppedFiles] : [], + droppedImages: coverage.images ? [...coverage.images.droppedImages] : [], }); } @@ -90,19 +105,19 @@ export function createCodeAiStage(options: CodeAiStageOptions): StageAdapter { // The declared surface is passed capabilities-only, matching the calibration // input shape; the production declaredAccess vocabulary (from // declaredAccessToCapabilities) differs from the legacy fixture vocabulary and - // must be re-validated by the calibration sweep. Second calibration-input - // divergence for that sweep: `description` is empty below — the bundle - // manifest carries no name/description, and the misleading-metadata and - // privacy-risk categories are rooted in the plugin's stated purpose, so they - // run near-inert until the acquire-consumer slice threads the release - // record's description through these options. + // must be re-validated by the calibration sweep. `description` is the release's + // advertised profile description resolved during acquisition — the stated + // purpose the misleading-metadata and privacy-risk categories key on; the + // calibration sweep must run against this real-description input (its second + // input divergence). Absent when the profile is unindexed, leaving those + // categories near-inert for that run only. const { capabilities } = declaredAccessToCapabilities(acquired.bundle.declaredAccess); const input: CodeAnalysisInput = { files: acquired.files, declaredAccess: capabilities, metadata: { name: acquired.bundle.manifest.id, - description: "", + description: acquired.description ?? "", publisherDid: parseAtUri(ctx.assessment.uri).did, version: acquired.bundle.manifest.version, }, @@ -126,6 +141,71 @@ export function createCodeAiStage(options: CodeAiStageOptions): StageAdapter { }; } +export interface ImageAiStageOptions { + readonly holder: AcquisitionHolder; + readonly ai: ImageAiBinding; + readonly policy: ModerationPolicy; + readonly promptVersion: string; + readonly modelId?: string; + readonly coverage: CoverageAccumulator; +} + +/** + * Builds the orchestrator's `imageAi` stage. With no acquired bundle there is + * nothing to analyze. Otherwise the deterministic extractor turns the bundle's + * binary files into the vision adapter's image set: no image files records + * `not-present` and skips the model; any images run `analyzeImages`. Image + * coverage is `partial` when the extractor skipped an unreadable/over-cap image + * OR the adapter dropped one for its input budget. A `ModelTransientError` + * becomes a `StageTransientError` so the orchestrator retries rather than + * finalizing on a flaky model call. + */ +export function createImageAiStage(options: ImageAiStageOptions): StageAdapter { + return async (ctx) => { + const acquired = options.holder.result; + if (!acquired) return []; + + const { images, skipped } = await extractBundleImages(acquired.bundle.files); + if (images.length === 0) { + options.coverage.images = + skipped.length > 0 + ? { coverage: "partial", droppedImages: skipped } + : { coverage: "not-present", droppedImages: [] }; + return []; + } + + const input: ImageAnalysisInput = { + images, + metadata: { + name: acquired.bundle.manifest.id, + description: acquired.description ?? "", + publisherDid: parseAtUri(ctx.assessment.uri).did, + version: acquired.bundle.manifest.version, + }, + }; + + let result: ImageAnalysisResult; + try { + result = await analyzeImages(input, { + ai: options.ai, + policy: options.policy, + promptVersion: options.promptVersion, + ...(options.modelId !== undefined ? { modelId: options.modelId } : {}), + }); + } catch (err) { + if (err instanceof ModelTransientError) throw new StageTransientError(err.message); + throw err; + } + + const partial = skipped.length > 0 || result.coverage === "partial"; + options.coverage.images = { + coverage: partial ? "partial" : "complete", + droppedImages: [...skipped, ...result.droppedImages], + }; + return result.findings; + }; +} + export interface HistoryStageOptions { readonly db: D1Database; /** The labeler's own DID (`src`) whose active manual labels count as context. */ diff --git a/apps/labeler/src/assessment-workflow.ts b/apps/labeler/src/assessment-workflow.ts index 6115acbbb5..db0dbeb68a 100644 --- a/apps/labeler/src/assessment-workflow.ts +++ b/apps/labeler/src/assessment-workflow.ts @@ -5,12 +5,16 @@ * it through the orchestrator's stages and atomic finalization inside a single * durable step. * - * DEPLOY GATE: with `stubStages`, a run finalizes `passed` and issues a real - * signed `assessment-passed` label for EVERY subject — an unconditional "this is - * safe" attestation over unscanned content. This shell must NOT reach an - * enforcing or label-consuming production deployment until the real analysis - * stages land in the acquire-consumer slice; shipping it live before then would - * vouch for everything. + * PROD safety (the lifted deploy gate): `buildStages` now assembles four REAL + * stages — acquire (SSRF-hardened verified fetch + aggregator release + * resolution), code AI, image AI, and publisher history. A `passed` outcome is + * therefore only ever reached after a real acquire produced a checksum-verified + * bundle that the AI stages analyzed clean; there is no stub path that would + * sign `assessment-passed` over unscanned content. The invariant that makes + * this safe: the acquire stage returns no findings ONLY when it has published a + * verified bundle to the holder (a permanent failure returns a blocking + * finding, a transient one throws), so a run cannot finalize `passed` with the + * holder empty. `buildStages` fails loudly if a required binding is missing. * * The whole run executes in one `step.do`, not one step per stage: the * orchestrator accumulates stage findings in memory and the acquire stage @@ -20,24 +24,33 @@ * finalization intact. The tradeoff is coarse resume granularity: a mid-run * eviction re-runs the whole step. `executeAssessmentInstance` makes that * idempotent — a terminal row short-circuits, and `runAssessment` resumes a row - * left `running` by a crashed attempt. Finer, per-stage durable resume lands - * with the real-stage wiring. + * left `running` by a crashed attempt. */ import { WorkflowEntrypoint } from "cloudflare:workers"; import type { WorkflowEvent, WorkflowStep } from "cloudflare:workers"; +import { AggregatorClient } from "./aggregator-client.js"; +import { createAcquireStage, type AcquisitionHolder } from "./artifact-acquisition.js"; +import { createArtifactEgress, type ArtifactEgress } from "./artifact-egress.js"; import type { AssessmentWorkflowParams } from "./assessment-dispatch.js"; import { TERMINAL_STATES, type AssessmentState } from "./assessment-lifecycle.js"; +import { AssessmentOrchestrator, type OrchestratorStages } from "./assessment-orchestrator.js"; import { - AssessmentOrchestrator, - stubStages, - type OrchestratorStages, -} from "./assessment-orchestrator.js"; + createCodeAiStage, + createHistoryStage, + createImageAiStage, + serializeCoverage, + type CoverageAccumulator, +} from "./assessment-stages.js"; import { getAssessment, type Assessment } from "./assessment-store.js"; -import { getLabelerIdentityConfig } from "./config.js"; +import type { AiBinding } from "./code-ai-adapter.js"; +import { getLabelerIdentityConfig, type LabelerConfig } from "./config.js"; +import type { PublisherVerificationReader } from "./history-context.js"; +import type { ImageAiBinding } from "./image-ai-adapter.js"; import { createNotifyDeps, notifyAssessmentOutcome } from "./notification-triggers.js"; -import { MODERATION_POLICY } from "./policy.js"; +import { MODERATION_POLICY, type ModerationPolicy } from "./policy.js"; +import { createReleaseResolver, type ReleaseReader } from "./release-resolution.js"; import { createRuntimeSigner, getRuntimeSigningSecret } from "./signing-runtime.js"; const RUN_STEP_CONFIG = { @@ -80,14 +93,31 @@ export async function executeAssessmentInstance( return existing.state; } + assertRequiredBindings(env); const config = await getLabelerIdentityConfig(env); const versioned = await createRuntimeSigner(config, getRuntimeSigningSecret(env)); + // Per-run state shared by reference across this run's stages: the acquire + // stage publishes its verified bundle to `holder`; the AI stages read it and + // record into `coverage`, which `resolveCoverageJson` serializes at finalization. + const holder: AcquisitionHolder = {}; + const coverage: CoverageAccumulator = {}; + const aggregator = new AggregatorClient(env.AGGREGATOR); const orchestrator = new AssessmentOrchestrator({ db: env.DB, config, signer: versioned.signer, policy: MODERATION_POLICY, - stages: buildStages(), + stages: buildStages({ + holder, + coverage, + config, + policy: MODERATION_POLICY, + db: env.DB, + egress: createArtifactEgress(), + aggregator, + ai: env.AI, + }), + resolveCoverageJson: () => serializeCoverage(coverage), }); const finalized = await orchestrator.runAssessment(assessmentId); await notifyOutcome(env, finalized); @@ -113,24 +143,75 @@ async function notifyOutcome(env: Env, assessment: Assessment): Promise { } } +/** Prompt version stamped into AI-stage findings and their prompt hash. The + * calibration sweep re-validates the production prompt before enforcement + * launch (see the calibration-fidelity flags in `assessment-stages.ts`). */ +export const ASSESSMENT_PROMPT_VERSION = "1"; + +export interface BuildStagesInput { + readonly holder: AcquisitionHolder; + readonly coverage: CoverageAccumulator; + readonly config: LabelerConfig; + readonly policy: ModerationPolicy; + readonly db: D1Database; + /** SSRF-hardened egress for the acquire stage's declared-URL fetch. */ + readonly egress: ArtifactEgress; + /** Aggregator reads: release resolution (acquire) and publisher verification + * (history). `AggregatorClient` satisfies both. */ + readonly aggregator: ReleaseReader & PublisherVerificationReader; + /** Workers AI binding for both AI stages. */ + readonly ai: AiBinding & ImageAiBinding; + readonly promptVersion?: string; +} + /** - * The orchestrator's stage adapters, built per instance execution. Real - * adapters attach here in the acquire-consumer follow-on — acquire (via the - * aggregator client), code/metadata AI, image AI, and publisher history; today - * the run executes the exported stub stages. Building them per execution rather - * than sharing module-scope state keeps per-run state — notably the acquire - * stage's `AcquisitionHolder` — isolated to this instance, never shared across - * concurrent subjects. + * Assembles the orchestrator's four real stage adapters for one run. Built per + * execution rather than sharing module-scope state so per-run state — the + * acquire stage's `AcquisitionHolder` and the AI stages' `CoverageAccumulator` + * — stays isolated to this instance, never shared across concurrent subjects. + * `acquire` fetches the release's declared artifact under the SSRF-hardened + * egress and publishes the verified bundle; `codeAi`/`imageAi` read it; `history` + * runs last as best-effort operator context. Pure over its injected deps (no + * `env`, no network), so tests drive it with fakes; `executeAssessmentInstance` + * supplies the production deps and enforces binding presence. */ -function buildStages(): OrchestratorStages { - // Mechanical enforcement of the DEPLOY GATE above: a production build must not - // run stub stages (which would sign `assessment-passed` for every unscanned - // subject). `import.meta.env.PROD` is a Vite compile-time constant — true in - // `vite build`, false in dev and the vitest pool — so this cannot be spoofed - // at runtime. Remove once real stages are wired here. - if (import.meta.env.PROD) +export function buildStages(input: BuildStagesInput): OrchestratorStages { + const promptVersion = input.promptVersion ?? ASSESSMENT_PROMPT_VERSION; + return { + acquire: createAcquireStage({ + deps: input.egress, + resolveTarget: createReleaseResolver(input.aggregator), + holder: input.holder, + }), + codeAi: createCodeAiStage({ + holder: input.holder, + ai: input.ai, + policy: input.policy, + promptVersion, + coverage: input.coverage, + }), + imageAi: createImageAiStage({ + holder: input.holder, + ai: input.ai, + policy: input.policy, + promptVersion, + coverage: input.coverage, + }), + history: createHistoryStage({ + db: input.db, + src: input.config.labelerDid, + aggregator: input.aggregator, + }), + }; +} + +/** Fails loudly when a binding the run cannot proceed without is absent, so a + * misconfigured deployment errors before any stage runs rather than silently + * scanning nothing and finalizing `passed`. */ +function assertRequiredBindings(env: Env): void { + const missing = (["AI", "AGGREGATOR", "DB"] as const).filter((name) => !env[name]); + if (missing.length > 0) throw new Error( - "AssessmentWorkflow has only stub stages in a production build — refusing to issue assessment-passed for unscanned subjects. Wire the real analysis stages (the acquire-consumer slice) before deploying.", + `AssessmentWorkflow is missing required bindings: ${missing.join(", ")} — refusing to run an assessment that cannot fetch, scan, or persist.`, ); - return stubStages; } diff --git a/apps/labeler/src/discovery-consumer.ts b/apps/labeler/src/discovery-consumer.ts index f109804cc5..ee42c02e59 100644 --- a/apps/labeler/src/discovery-consumer.ts +++ b/apps/labeler/src/discovery-consumer.ts @@ -7,8 +7,8 @@ * (assessment-dispatch.ts), so a redelivered discovery event dedups onto the * same instance while a later re-assessment (distinct trigger → distinct * runKey) gets its own. `AssessmentWorkflow` (assessment-workflow.ts) then - * drives `pending → running → finalization` through the orchestrator (still - * stub stages until W7/W8 land real stage adapters). + * drives `pending → running → finalization` through the orchestrator's real + * acquire → code → image → history stages. * * Error policy mirrors the aggregator's records-consumer with one * difference (spec §9.1): a forged or unverifiable event is ALWAYS a dead diff --git a/apps/labeler/src/image-metadata.ts b/apps/labeler/src/image-metadata.ts new file mode 100644 index 0000000000..4a9d1ca01c --- /dev/null +++ b/apps/labeler/src/image-metadata.ts @@ -0,0 +1,327 @@ +/** + * Deterministic image-metadata extractor (plan W8.3 / Slice-B design + * 2026-07-17). Turns the raw binary files carried on a validated plugin + * bundle (`ValidatedBundleFile[]` — `{ path, bytes }`) into the + * `ImageAnalysisImage[]` the vision adapter consumes: MIME sniffed from magic + * bytes (never the filename extension), SHA-256 + base64 of the exact bytes, + * pixel dimensions parsed from the format header, and an advisory + * icon/screenshot classification. + * + * Pure and total: every parser bounds-checks each read and returns `null` on a + * truncated or malformed header rather than throwing. An image the extractor + * cannot process (unreadable dimensions, over-cap size/count) is reported as + * `skipped`, degrading image coverage to `partial`; a crafted or corrupt file + * never crashes the assessment run. Non-image files (code, unknown binaries) + * are ignored entirely — they are not part of the image surface. + * + * Caps mirror the vision adapter's (`MAX_IMAGES`, `MAX_IMAGE_BYTES`, + * `MAX_TOTAL_IMAGE_BYTES`), enforced here against the predicted base64 length + * so an over-cap image is dropped BEFORE its base64 is materialised — the + * adapter measures `dataBase64.length`, so predicting it keeps the two in step + * and bounds this module's own memory use on a bundle stuffed with images. + */ + +import type { ValidatedBundleFile } from "@emdash-cms/registry-verification"; + +import { + MAX_IMAGE_BYTES, + MAX_IMAGES, + MAX_TOTAL_IMAGE_BYTES, + type ImageAnalysisImage, +} from "./image-ai-adapter.js"; + +export interface ExtractedBundleImages { + readonly images: readonly ImageAnalysisImage[]; + /** Paths of files that sniffed as a supported image but could not be turned + * into an analysable image (unreadable dimensions, or dropped to fit the + * count/size caps). Drives `partial` image coverage. */ + readonly skipped: readonly string[]; +} + +/** Whichever dimension is smaller for an icon; larger, or non-square, reads as + * a screenshot. Advisory only — `kind` is context shown to the vision model, + * never a security boundary — so a coarse rule is sufficient. */ +const ICON_MAX_DIMENSION = 512; +const ICON_MIN_ASPECT = 0.8; +const ICON_MAX_ASPECT = 1.25; + +/** + * Extracts the analysable image set from a validated bundle's files, in tar + * order. The optional `iconPath` names the bundle-relative path the manifest + * declares as its icon, forcing that file's classification; absent (the plugin + * manifest carries no icon field today), classification falls back to + * dimensions/aspect. + */ +export async function extractBundleImages( + files: readonly ValidatedBundleFile[], + iconPath?: string, +): Promise { + const images: ImageAnalysisImage[] = []; + const skipped: string[] = []; + let totalBase64 = 0; + + for (const file of files) { + const mime = detectImageMime(file.bytes); + if (mime === null) continue; // not an image — not part of the image surface + + // From here the file IS a supported image, so any reason it cannot be + // analysed (unreadable header, over a cap) records it as skipped, degrading + // coverage, rather than silently dropping it. + const dims = parseImageDimensions(mime, file.bytes); + if (!dims) { + skipped.push(file.path); + continue; + } + + const base64Length = predictedBase64Length(file.bytes.length); + if ( + base64Length > MAX_IMAGE_BYTES || + images.length >= MAX_IMAGES || + totalBase64 + base64Length > MAX_TOTAL_IMAGE_BYTES + ) { + skipped.push(file.path); + continue; + } + + const sha256 = await sha256Hex(file.bytes); + images.push({ + path: file.path, + mime, + sha256, + dataBase64: toBase64(file.bytes), + width: dims.width, + height: dims.height, + kind: classifyKind(file.path, dims, iconPath), + }); + totalBase64 += base64Length; + } + + return { images, skipped }; +} + +/** Supported raster format from leading magic bytes, or `null` for a non-image + * file. Never trusts the filename extension. */ +function detectImageMime(bytes: Uint8Array): string | null { + if (isPng(bytes)) return "image/png"; + if (isJpeg(bytes)) return "image/jpeg"; + if (isGif(bytes)) return "image/gif"; + if (isWebp(bytes)) return "image/webp"; + return null; +} + +/** Intrinsic dimensions for an already-sniffed format, or `null` when the + * header is truncated/malformed — the caller records that as a skipped image. */ +function parseImageDimensions(mime: string, bytes: Uint8Array): Dimensions | null { + switch (mime) { + case "image/png": + return parsePngDimensions(bytes); + case "image/jpeg": + return parseJpegDimensions(bytes); + case "image/gif": + return parseGifDimensions(bytes); + case "image/webp": + return parseWebpDimensions(bytes); + default: + return null; + } +} + +interface Dimensions { + readonly width: number; + readonly height: number; +} + +function isPng(b: Uint8Array): boolean { + return ( + b.length >= 8 && + b[0] === 0x89 && + b[1] === 0x50 && + b[2] === 0x4e && + b[3] === 0x47 && + b[4] === 0x0d && + b[5] === 0x0a && + b[6] === 0x1a && + b[7] === 0x0a + ); +} + +function isJpeg(b: Uint8Array): boolean { + return b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff; +} + +function isGif(b: Uint8Array): boolean { + // "GIF87a" or "GIF89a". + return ( + b.length >= 6 && + b[0] === 0x47 && + b[1] === 0x49 && + b[2] === 0x46 && + b[3] === 0x38 && + (b[4] === 0x37 || b[4] === 0x39) && + b[5] === 0x61 + ); +} + +function isWebp(b: Uint8Array): boolean { + // "RIFF" .... "WEBP". + return ( + b.length >= 12 && + b[0] === 0x52 && + b[1] === 0x49 && + b[2] === 0x46 && + b[3] === 0x46 && + b[8] === 0x57 && + b[9] === 0x45 && + b[10] === 0x42 && + b[11] === 0x50 + ); +} + +/** PNG IHDR: 8-byte signature, 4-byte chunk length, 4-byte "IHDR" type, then + * width and height as big-endian uint32 at offsets 16 and 20. */ +function parsePngDimensions(b: Uint8Array): Dimensions | null { + if (b.length < 24) return null; + if (b[12] !== 0x49 || b[13] !== 0x48 || b[14] !== 0x44 || b[15] !== 0x52) return null; + const width = readU32BE(b, 16); + const height = readU32BE(b, 20); + return validDimensions(width, height); +} + +/** GIF logical screen descriptor: width and height as little-endian uint16 at + * offsets 6 and 8. */ +function parseGifDimensions(b: Uint8Array): Dimensions | null { + if (b.length < 10) return null; + return validDimensions(readU16LE(b, 6), readU16LE(b, 8)); +} + +/** JPEG: walk segment markers from offset 2 to the first Start-Of-Frame + * (SOF0–SOF15, excluding the DHT/JPG/DAC markers C4/C8/CC), whose payload is + * precision(1), height(2 BE), width(2 BE). */ +function parseJpegDimensions(b: Uint8Array): Dimensions | null { + let offset = 2; + while (offset + 1 < b.length) { + if (b[offset] !== 0xff) { + offset += 1; + continue; + } + let marker = b[offset + 1]!; + // Skip fill bytes (a run of 0xff) and standalone markers (RSTn, SOI, EOI, + // TEM) that carry no length. + while (marker === 0xff && offset + 1 < b.length) { + offset += 1; + marker = b[offset + 1]!; + } + offset += 2; + if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) + continue; + if (offset + 1 >= b.length) return null; + const segmentLength = readU16BE(b, offset); + if (segmentLength < 2) return null; + const isSof = + marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; + if (isSof) { + if (offset + 7 >= b.length) return null; + const height = readU16BE(b, offset + 3); + const width = readU16BE(b, offset + 5); + return validDimensions(width, height); + } + offset += segmentLength; + } + return null; +} + +/** WebP RIFF container: the fourCC at offset 12 selects the bitstream variant + * (VP8 lossy, VP8L lossless, VP8X extended), each carrying dimensions at a + * different offset and encoding. */ +function parseWebpDimensions(b: Uint8Array): Dimensions | null { + if (b.length < 16) return null; + const fourCc = String.fromCharCode(b[12]!, b[13]!, b[14]!, b[15]!); + if (fourCc === "VP8 ") return parseWebpLossy(b); + if (fourCc === "VP8L") return parseWebpLossless(b); + if (fourCc === "VP8X") return parseWebpExtended(b); + return null; +} + +/** Simple (lossy) WebP: after the 0x9d012a start code, 14-bit width and height + * (little-endian) at offsets 26 and 28. */ +function parseWebpLossy(b: Uint8Array): Dimensions | null { + if (b.length < 30) return null; + const width = readU16LE(b, 26) & 0x3fff; + const height = readU16LE(b, 28) & 0x3fff; + return validDimensions(width, height); +} + +/** Lossless WebP: a 0x2f signature byte at offset 20, then a 32-bit + * little-endian field packing (width-1) in bits 0–13 and (height-1) in bits + * 14–27. */ +function parseWebpLossless(b: Uint8Array): Dimensions | null { + if (b.length < 25 || b[20] !== 0x2f) return null; + const bits = readU32LE(b, 21); + const width = (bits & 0x3fff) + 1; + const height = ((bits >> 14) & 0x3fff) + 1; + return validDimensions(width, height); +} + +/** Extended WebP: canvas (width-1) and (height-1) as 24-bit little-endian + * values at offsets 24 and 27. */ +function parseWebpExtended(b: Uint8Array): Dimensions | null { + if (b.length < 30) return null; + const width = readU24LE(b, 24) + 1; + const height = readU24LE(b, 27) + 1; + return validDimensions(width, height); +} + +function classifyKind(path: string, dims: Dimensions, iconPath?: string): "icon" | "screenshot" { + if (iconPath !== undefined && path === iconPath) return "icon"; + const maxDimension = Math.max(dims.width, dims.height); + const aspect = dims.width / dims.height; + const nearSquare = aspect >= ICON_MIN_ASPECT && aspect <= ICON_MAX_ASPECT; + return maxDimension <= ICON_MAX_DIMENSION && nearSquare ? "icon" : "screenshot"; +} + +function validDimensions(width: number, height: number): Dimensions | null { + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null; + return { width, height }; +} + +function readU16BE(b: Uint8Array, o: number): number { + return (b[o]! << 8) | b[o + 1]!; +} + +function readU16LE(b: Uint8Array, o: number): number { + return b[o]! | (b[o + 1]! << 8); +} + +function readU24LE(b: Uint8Array, o: number): number { + return b[o]! | (b[o + 1]! << 8) | (b[o + 2]! << 16); +} + +function readU32BE(b: Uint8Array, o: number): number { + return (b[o]! * 0x1000000 + (b[o + 1]! << 16) + (b[o + 2]! << 8) + b[o + 3]!) >>> 0; +} + +function readU32LE(b: Uint8Array, o: number): number { + return (b[o]! + (b[o + 1]! << 8) + (b[o + 2]! << 16) + b[o + 3]! * 0x1000000) >>> 0; +} + +/** Base64 length of `n` raw bytes: 4 characters per 3-byte group, padded. */ +function predictedBase64Length(n: number): number { + return Math.ceil(n / 3) * 4; +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** Standard (padded) base64 of the raw bytes for the adapter's `data:` URL. + * Chunked so `String.fromCharCode` never receives an image-sized argument + * list, which would overflow the call stack. */ +function toBase64(bytes: Uint8Array): string { + let binary = ""; + const chunkSize = 0x8000; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + } + return btoa(binary); +} diff --git a/apps/labeler/src/release-resolution.ts b/apps/labeler/src/release-resolution.ts new file mode 100644 index 0000000000..84a067851b --- /dev/null +++ b/apps/labeler/src/release-resolution.ts @@ -0,0 +1,181 @@ +/** + * Release resolution for artifact acquisition (Slice-B design 2026-07-17). Maps + * an assessment to the `AcquisitionTarget` the acquire stage fetches, reading + * the signed release over the `AGGREGATOR` service binding (the ratified W8.4 + * slice-3 read-path pattern) — the declared artifact URL is not stored + * labeler-side, only the pinned `artifact_id`/`artifact_checksum`. + * + * The release view is addressed by `(did, package)`, both derived from the + * assessment's release URI: the DID is the URI authority and the package slug + * is the rkey prefix (a release rkey is `:`). `getLatestRelease` + * is the fast path; when its CID differs from the assessment's pinned CID (a + * newer release now leads the package) a `listReleases` scan finds the exact + * pinned release, since the aggregator exposes no by-CID release endpoint. + * + * A release the aggregator has not indexed (or not yet re-indexed for this CID) + * throws `StageTransientError`: that is aggregator lag, the existing transient + * acquisition failure — never a permanent finding, since absence is not + * evidence the plugin is bad, and reconciliation owns true deletions. The + * declared-vs-pinned checksum/id drift check is NOT done here — the target + * carries both the declared coordinates and the pinned ones so acquisition's + * `crossCheckCoordinates` raises the ratified integrity finding on drift. + */ + +import type { AggregatorDefs, AggregatorListReleases } from "@emdash-cms/registry-lexicons"; + +import type { AcquisitionTarget } from "./artifact-acquisition.js"; +import { StageTransientError } from "./assessment-orchestrator.js"; +import type { Assessment } from "./assessment-store.js"; +import { parseAtUri, RecordVerificationError } from "./record-verification.js"; + +/** Bounds the `listReleases` scan for the pinned CID. Newest-first paging means + * the pinned release is normally on an early page; a not-found within this many + * pages classifies as aggregator lag (transient), same as an absent release. */ +const MAX_RELEASE_SCAN_PAGES = 20; + +/** Cap on the publisher-supplied release description threaded into the AI + * stages' metadata. The description is best-effort context, but it is + * publisher-controlled and reaches both models' fixed prompt overhead; an + * uncapped padded description could exceed `MAX_MODEL_INPUT_CHARS` and make the + * adapter throw a non-transient error that stalls the run — a block-suppression + * vector. Truncating at ingestion (far under the model budget) closes it. */ +export const MAX_RELEASE_DESCRIPTION_CHARS = 4096; + +/** The aggregator reads this resolver needs, narrowed so tests inject a plain + * object. `AggregatorClient` satisfies it structurally. */ +export interface ReleaseReader { + getLatestRelease(did: string, pkg: string): Promise; + listReleases( + did: string, + pkg: string, + cursor?: string, + ): Promise; + getPackage(did: string, slug: string): Promise; +} + +export type ReleaseTargetResolver = (assessment: Assessment) => Promise; + +export function createReleaseResolver(aggregator: ReleaseReader): ReleaseTargetResolver { + return async (assessment) => { + const { did, pkg } = parseReleaseCoordinates(assessment.uri); + const view = await resolveReleaseView(aggregator, did, pkg, assessment.cid); + const artifact = extractPackageArtifact(view.release); + if (!artifact) { + throw new StageTransientError( + `release ${assessment.uri} is missing a package artifact declaration in the aggregator view`, + ); + } + const description = await resolveDescription(aggregator, did, view.package); + return { + url: artifact.url, + checksum: artifact.checksum, + slug: view.package, + version: view.version, + ...(artifact.artifactId !== undefined ? { artifactId: artifact.artifactId } : {}), + pinnedChecksum: assessment.artifactChecksum, + pinnedArtifactId: assessment.artifactId, + ...(description !== undefined ? { description } : {}), + }; + }; +} + +interface ReleaseCoordinates { + readonly did: string; + readonly pkg: string; +} + +function parseReleaseCoordinates(uri: string): ReleaseCoordinates { + let parsed; + try { + parsed = parseAtUri(uri); + } catch (err) { + if (err instanceof RecordVerificationError) throw new StageTransientError(err.message); + throw err; + } + const separator = parsed.rkey.indexOf(":"); + const pkg = separator === -1 ? "" : parsed.rkey.slice(0, separator); + if (pkg.length === 0) { + throw new StageTransientError(`release URI ${uri} has no package slug in its record key`); + } + return { did: parsed.did, pkg }; +} + +/** The latest release when it is the pinned CID, otherwise the pinned CID found + * by scanning `listReleases`. A miss on either path is aggregator lag. */ +async function resolveReleaseView( + aggregator: ReleaseReader, + did: string, + pkg: string, + cid: string, +): Promise { + const latest = await aggregator.getLatestRelease(did, pkg); + if (latest && latest.cid === cid) return latest; + + let cursor: string | undefined; + for (let page = 0; page < MAX_RELEASE_SCAN_PAGES; page += 1) { + const result = await aggregator.listReleases(did, pkg, cursor); + if (!result) break; + const match = result.releases.find((release) => release.cid === cid); + if (match) return match; + if (!result.cursor) break; + cursor = result.cursor; + } + + throw new StageTransientError( + `release ${did}/${pkg}@${cid} is not indexed by the aggregator yet`, + ); +} + +/** Best-effort human description from the package profile, for the code/image + * stages' stated-purpose analysis. A profile-fetch failure or an unindexed + * profile leaves it absent — the description is analysis context, never an + * integrity input, so it must not fail acquisition of a verified artifact. */ +async function resolveDescription( + aggregator: ReleaseReader, + did: string, + slug: string, +): Promise { + try { + const view = await aggregator.getPackage(did, slug); + if (!view) return undefined; + return extractProfileDescription(view.profile); + } catch { + return undefined; + } +} + +interface PackageArtifact { + readonly url: string; + readonly checksum: string; + readonly artifactId?: string; +} + +/** Reads `artifacts.package.{ url, checksum, id }` from the verbatim signed + * release record without lexicon re-validation (the aggregator already + * validated it; the fetched bytes are re-verified against `checksum` + * downstream). Returns `null` when the required fields are absent. */ +function extractPackageArtifact(release: unknown): PackageArtifact | null { + if (!isRecord(release)) return null; + const artifacts = release.artifacts; + if (!isRecord(artifacts)) return null; + const pkg = artifacts.package; + if (!isRecord(pkg)) return null; + if (typeof pkg.url !== "string" || typeof pkg.checksum !== "string") return null; + return { + url: pkg.url, + checksum: pkg.checksum, + ...(typeof pkg.id === "string" ? { artifactId: pkg.id } : {}), + }; +} + +function extractProfileDescription(profile: unknown): string | undefined { + if (!isRecord(profile)) return undefined; + if (typeof profile.description !== "string") return undefined; + const description = profile.description; + if (description.length <= MAX_RELEASE_DESCRIPTION_CHARS) return description; + return `${description.slice(0, MAX_RELEASE_DESCRIPTION_CHARS - 1)}…`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/apps/labeler/test/artifact-egress.test.ts b/apps/labeler/test/artifact-egress.test.ts new file mode 100644 index 0000000000..d8550b2a37 --- /dev/null +++ b/apps/labeler/test/artifact-egress.test.ts @@ -0,0 +1,123 @@ +/** + * SSRF egress wiring. The hardening itself lives in `fetchVerifiedResource` + * (covered by `artifact-acquisition.test.ts`); these tests prove this module + * wires the ratified DoH resolver and a credential-free pass-through fetch, and + * that the composed egress drives the resolver-gated fetch — a fake DoH keeps + * every case off the network. + */ + +import type { HostnameResolver } from "@emdash-cms/registry-verification"; +import { cloudflareDohResolver } from "emdash/security/ssrf"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { acquireArtifact, type AcquisitionDeps } from "../src/artifact-acquisition.js"; +import { createArtifactEgress } from "../src/artifact-egress.js"; +import { canonicalBundle, checksumOf } from "./bundle-fixture.js"; + +const PUBLIC_ADDRESS = "203.0.113.5"; +const PRIVATE_ADDRESS = "10.0.0.5"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function target(url = "https://cdn.example.test/plugin.tgz") { + const bytes = await canonicalBundle(); + return { url, checksum: await checksumOf(bytes), slug: "test-plugin", version: "1.0.0" }; +} + +function egressDeps(resolveHostname: HostnameResolver): AcquisitionDeps { + const { fetch } = createArtifactEgress(); + return { fetch, resolveHostname }; +} + +describe("createArtifactEgress: wiring", () => { + it("resolves hostnames with the ratified cloudflareDohResolver", () => { + expect(createArtifactEgress().resolveHostname).toBe(cloudflareDohResolver); + }); + + it("forwards to globalThis.fetch without adding headers or credentials", async () => { + const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(new Uint8Array())); + const { fetch } = createArtifactEgress(); + const url = new URL("https://cdn.example.test/plugin.tgz"); + const init = { + method: "GET", + redirect: "manual", + signal: new AbortController().signal, + } as const; + + await fetch(url, init); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(url, init); + const [, forwardedInit] = spy.mock.calls[0]!; + expect(forwardedInit).toBe(init); + expect((forwardedInit as RequestInit).headers).toBeUndefined(); + }); +}); + +describe("createArtifactEgress: composed SSRF enforcement", () => { + it("fetches through the pass-through when the host resolves public", async () => { + const bytes = await canonicalBundle(); + const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(bytes)); + + const result = await acquireArtifact( + egressDeps(async () => [PUBLIC_ADDRESS]), + await target(), + ); + + expect(result.success).toBe(true); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("rejects a host that resolves to a private address before any fetch is made", async () => { + const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(new Uint8Array())); + + const result = await acquireArtifact( + egressDeps(async () => [PRIVATE_ADDRESS]), + await target(), + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ kind: "policy-rejection", code: "HOST_REJECTED" }); + expect(spy).not.toHaveBeenCalled(); + }); + + it("fails closed and does not fetch when DoH resolves no addresses", async () => { + const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(new Uint8Array())); + + const result = await acquireArtifact( + egressDeps(async () => []), + await target(), + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ code: "HOST_REJECTED" }); + expect(spy).not.toHaveBeenCalled(); + }); + + it("re-resolves each redirect hop and rejects a redirect into a private host", async () => { + const spy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(null, { + status: 302, + headers: { location: "https://internal.example.test/plugin.tgz" }, + }), + ) + .mockResolvedValue(new Response(new Uint8Array())); + const resolveHostname: HostnameResolver = async (hostname) => + hostname === "internal.example.test" ? [PRIVATE_ADDRESS] : [PUBLIC_ADDRESS]; + + const result = await acquireArtifact(egressDeps(resolveHostname), await target()); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ kind: "policy-rejection", code: "HOST_REJECTED" }); + // The first hop was fetched; the private redirect target was rejected at + // resolution, before its own fetch. + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/labeler/test/assessment-orchestrator.test.ts b/apps/labeler/test/assessment-orchestrator.test.ts index 3a732ddcd7..ae9d1b7245 100644 --- a/apps/labeler/test/assessment-orchestrator.test.ts +++ b/apps/labeler/test/assessment-orchestrator.test.ts @@ -556,6 +556,76 @@ describe("AssessmentOrchestrator: transient exhaustion", () => { expect(pendingNeg?.neg).toBe(1); }); + it("preserves an already-confirmed block when a later stage transient-exhausts", async () => { + const run = await pendingRun({ + name: "block-preserved", + cidValue: await cid("block-preserved"), + }); + const stages: OrchestratorStages = { + ...stubStages, + codeAi: () => Promise.resolve([finding({ category: "malware", severity: "critical" })]), + imageAi: () => Promise.reject(new StageTransientError("vision model unavailable")), + }; + + const result = await ( + await buildOrchestrator(stages, { maxStageRetries: 1 }) + ).runAssessment(run.id); + + expect(result.state).toBe("blocked"); + const block = await testEnv.DB.prepare( + `SELECT neg FROM issued_labels WHERE uri = ? AND cid = ? AND val = 'malware'`, + ) + .bind(run.uri, run.cid) + .first<{ neg: number }>(); + expect(block?.neg).toBe(0); + // A confirmed block is monotonic: the run finalizes blocked, never error or a + // spurious pass, even though a later stage never completed. + const error = await testEnv.DB.prepare( + `SELECT neg FROM issued_labels WHERE uri = ? AND cid = ? AND val = 'assessment-error'`, + ) + .bind(run.uri, run.cid) + .first<{ neg: number }>(); + expect(error).toBeNull(); + const passed = await testEnv.DB.prepare( + `SELECT neg FROM issued_labels WHERE uri = ? AND cid = ? AND val = 'assessment-passed'`, + ) + .bind(run.uri, run.cid) + .first<{ neg: number }>(); + expect(passed).toBeNull(); + }); + + it("finalizes error, not warned, when only a warning preceded a later stage's exhaustion", async () => { + const run = await pendingRun({ + name: "warn-then-exhaust", + cidValue: await cid("warn-then-exhaust"), + }); + const stages: OrchestratorStages = { + ...stubStages, + codeAi: () => Promise.resolve([finding({ category: "obfuscated-code", severity: "medium" })]), + imageAi: () => Promise.reject(new StageTransientError("vision model unavailable")), + }; + + const result = await ( + await buildOrchestrator(stages, { maxStageRetries: 1 }) + ).runAssessment(run.id); + + // A warning is not monotonic — an unrun stage might have found a block — so + // an incomplete run with only a warning must retry as error, never warned. + expect(result.state).toBe("error"); + const errorLabel = await testEnv.DB.prepare( + `SELECT neg FROM issued_labels WHERE uri = ? AND cid = ? AND val = 'assessment-error'`, + ) + .bind(run.uri, run.cid) + .first<{ neg: number }>(); + expect(errorLabel?.neg).toBe(0); + const warnLabel = await testEnv.DB.prepare( + `SELECT neg FROM issued_labels WHERE uri = ? AND cid = ? AND val = 'obfuscated-code'`, + ) + .bind(run.uri, run.cid) + .first<{ neg: number }>(); + expect(warnLabel).toBeNull(); + }); + it("a second error run for the same subject leaves assessment-error active, not self-negated", async () => { const cidValue = await cid("double-error"); const flaky: OrchestratorStages = { diff --git a/apps/labeler/test/assessment-stages.test.ts b/apps/labeler/test/assessment-stages.test.ts index 15d7af1a79..3a20a22da8 100644 --- a/apps/labeler/test/assessment-stages.test.ts +++ b/apps/labeler/test/assessment-stages.test.ts @@ -21,6 +21,7 @@ import { import { createCodeAiStage, createHistoryStage, + createImageAiStage, serializeCoverage, type CoverageAccumulator, } from "../src/assessment-stages.js"; @@ -31,6 +32,7 @@ import { } from "../src/assessment-store.js"; import type { AiBinding, AiRunInputs } from "../src/code-ai-adapter.js"; import type { PublisherVerificationReader } from "../src/history-context.js"; +import type { ImageAiBinding } from "../src/image-ai-adapter.js"; import { MODERATION_POLICY } from "../src/policy.js"; import { initializeSigningState } from "../src/signing-rotation.js"; import { canonicalBundle, checksumOf, file } from "./bundle-fixture.js"; @@ -398,6 +400,234 @@ describe("analysis stages: acquire produced no bundle", () => { }); }); +function pngBytes(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(24); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); + bytes.set([0x00, 0x00, 0x00, 0x0d], 8); + bytes.set([0x49, 0x48, 0x44, 0x52], 12); + const view = new DataView(bytes.buffer); + view.setUint32(16, width); + view.setUint32(20, height); + return bytes; +} + +function imageFakeAi(run: (model: string, inputs: unknown) => Promise): ImageAiBinding { + return { run: run as ImageAiBinding["run"] }; +} + +const IMAGE_WARN_FINDING = { + category: "obfuscated-code", + severity: "medium", + title: "suspicious UI", + publicSummary: "the icon mimics a system dialog", + privateDetail: "icon.png renders a fake credential prompt", + affectedImages: ["icon.png"], +}; + +/** Acquire stage variant that pins a description onto the resolved target, so + * the AI stages receive a real stated-purpose input. */ +function acquireStageWithDescription( + bytes: Uint8Array, + checksum: string, + holder: AcquisitionHolder, + description: string, +) { + return createAcquireStage({ + deps: { + fetch: async () => new Response(bytes), + resolveHostname: async () => ["203.0.113.5"], + }, + resolveTarget: async () => ({ + url: "https://cdn.example.test/plugin.tgz", + checksum, + slug: "test-plugin", + version: "1.0.0", + description, + }), + holder, + }); +} + +describe("analysis stages: image AI stage", () => { + it("runs the vision model over extracted images and flows findings into the outcome", async () => { + const run = await pendingRun("image-warn"); + const bytes = await canonicalBundle([file("icon.png", pngBytes(128, 128))]); + const holder: AcquisitionHolder = {}; + const coverage: CoverageAccumulator = {}; + const codeAi = fakeAi(() => Promise.resolve(findingResponse([]))); + const imageAi = imageFakeAi(() => + Promise.resolve({ response: JSON.stringify({ findings: [IMAGE_WARN_FINDING] }) }), + ); + const stages: OrchestratorStages = { + ...stubStages, + acquire: acquireStage(bytes, await checksumOf(bytes), holder), + codeAi: createCodeAiStage({ + holder, + ai: codeAi, + policy: MODERATION_POLICY, + promptVersion: PROMPT_VERSION, + coverage, + }), + imageAi: createImageAiStage({ + holder, + ai: imageAi, + policy: MODERATION_POLICY, + promptVersion: PROMPT_VERSION, + coverage, + }), + history: createHistoryStage({ db: testEnv.DB, src: LABELER_DID }), + }; + + const result = await (await buildOrchestrator(stages, { coverage })).runAssessment(run.id); + + expect(result.state).toBe("warned"); + expect(await labelNeg(run.uri, run.cid, "obfuscated-code")).toBe(0); + expect(JSON.parse(result.coverageJson).images).toBe("complete"); + }); + + it("records not-present and never calls the model when the bundle has no images", async () => { + const run = await pendingRun("image-none"); + const bytes = await canonicalBundle(); + const holder: AcquisitionHolder = {}; + const coverage: CoverageAccumulator = {}; + let imageModelCalled = false; + const imageAi = imageFakeAi(() => { + imageModelCalled = true; + return Promise.resolve({ response: JSON.stringify({ findings: [] }) }); + }); + const stages: OrchestratorStages = { + ...stubStages, + acquire: acquireStage(bytes, await checksumOf(bytes), holder), + codeAi: createCodeAiStage({ + holder, + ai: fakeAi(() => Promise.resolve(findingResponse([]))), + policy: MODERATION_POLICY, + promptVersion: PROMPT_VERSION, + coverage, + }), + imageAi: createImageAiStage({ + holder, + ai: imageAi, + policy: MODERATION_POLICY, + promptVersion: PROMPT_VERSION, + coverage, + }), + history: createHistoryStage({ db: testEnv.DB, src: LABELER_DID }), + }; + + const result = await (await buildOrchestrator(stages, { coverage })).runAssessment(run.id); + + expect(result.state).toBe("passed"); + expect(imageModelCalled).toBe(false); + expect(JSON.parse(result.coverageJson).images).toBe("not-present"); + }); + + it("keeps a code-stage block when the image stage transient-exhausts (not discarded to error)", async () => { + const run = await pendingRun("image-block-preserved"); + const bytes = await canonicalBundle([file("icon.png", pngBytes(128, 128))]); + const holder: AcquisitionHolder = {}; + const coverage: CoverageAccumulator = {}; + const codeAi = fakeAi(() => Promise.resolve(findingResponse([BLOCK_FINDING]))); + const imageAi = imageFakeAi(() => Promise.reject(new Error("vision model overloaded"))); + const stages: OrchestratorStages = { + ...stubStages, + acquire: acquireStage(bytes, await checksumOf(bytes), holder), + codeAi: createCodeAiStage({ + holder, + ai: codeAi, + policy: MODERATION_POLICY, + promptVersion: PROMPT_VERSION, + coverage, + }), + imageAi: createImageAiStage({ + holder, + ai: imageAi, + policy: MODERATION_POLICY, + promptVersion: PROMPT_VERSION, + coverage, + }), + history: createHistoryStage({ db: testEnv.DB, src: LABELER_DID }), + }; + + const result = await ( + await buildOrchestrator(stages, { coverage, maxStageRetries: 1 }) + ).runAssessment(run.id); + + expect(result.state).toBe("blocked"); + expect(await labelNeg(run.uri, run.cid, "malware")).toBe(0); + expect(await labelNeg(run.uri, run.cid, "assessment-error")).toBeUndefined(); + expect(await labelNeg(run.uri, run.cid, "assessment-passed")).toBeUndefined(); + }); + + it("finalizes error on a transient image model failure and reports code complete, images unavailable", async () => { + const run = await pendingRun("image-transient"); + const bytes = await canonicalBundle([file("icon.png", pngBytes(128, 128))]); + const holder: AcquisitionHolder = {}; + const coverage: CoverageAccumulator = {}; + const imageAi = imageFakeAi(() => Promise.reject(new Error("vision model overloaded"))); + const stages: OrchestratorStages = { + ...stubStages, + acquire: acquireStage(bytes, await checksumOf(bytes), holder), + codeAi: createCodeAiStage({ + holder, + ai: fakeAi(() => Promise.resolve(findingResponse([]))), + policy: MODERATION_POLICY, + promptVersion: PROMPT_VERSION, + coverage, + }), + imageAi: createImageAiStage({ + holder, + ai: imageAi, + policy: MODERATION_POLICY, + promptVersion: PROMPT_VERSION, + coverage, + }), + history: createHistoryStage({ db: testEnv.DB, src: LABELER_DID }), + }; + + const result = await ( + await buildOrchestrator(stages, { coverage, maxStageRetries: 1 }) + ).runAssessment(run.id); + + expect(result.state).toBe("error"); + const parsed = JSON.parse(result.coverageJson); + expect(parsed.code).toBe("complete"); + expect(parsed.images).toBe("unavailable"); + }); +}); + +describe("analysis stages: release description threading", () => { + it("passes the resolved release description into the code model input", async () => { + const run = await pendingRun("desc-thread"); + const bytes = await canonicalBundle(); + const holder: AcquisitionHolder = {}; + const coverage: CoverageAccumulator = {}; + const description = "Imports your contacts and posts on your behalf."; + let capturedInput = ""; + const codeAi = fakeAi((_model, inputs) => { + capturedInput = JSON.stringify(inputs); + return Promise.resolve(findingResponse([])); + }); + const stages: OrchestratorStages = { + ...stubStages, + acquire: acquireStageWithDescription(bytes, await checksumOf(bytes), holder, description), + codeAi: createCodeAiStage({ + holder, + ai: codeAi, + policy: MODERATION_POLICY, + promptVersion: PROMPT_VERSION, + coverage, + }), + history: createHistoryStage({ db: testEnv.DB, src: LABELER_DID }), + }; + + const result = await (await buildOrchestrator(stages, { coverage })).runAssessment(run.id); + + expect(result.state).toBe("passed"); + expect(capturedInput).toContain(description); + }); +}); + describe("analysis stages: history stage is best-effort", () => { it("does not fail the run when the history stage's own lookup throws internally", async () => { const run = await pendingRun("history-throws"); diff --git a/apps/labeler/test/assessment-workflow.test.ts b/apps/labeler/test/assessment-workflow.test.ts index 27a51c1e4f..f89162cfd4 100644 --- a/apps/labeler/test/assessment-workflow.test.ts +++ b/apps/labeler/test/assessment-workflow.test.ts @@ -1,27 +1,46 @@ /** - * The assessment Workflow is a thin durable shell over `AssessmentOrchestrator` - * (whose stage execution and finalization `assessment-orchestrator.test.ts` - * already covers). Cloudflare's Workflow runtime has no local test harness and - * the entrypoint class cannot be constructed in the workers pool, so these - * tests exercise `executeAssessmentInstance` — the shell body `run` wraps in a - * durable step — directly: it must load the pending run, compose the - * orchestrator, and finalize, plus resume idempotently on a re-run. + * The assessment Workflow is a thin durable shell over `AssessmentOrchestrator`. + * Two layers are exercised here: + * + * - `executeAssessmentInstance` (the shell body `run` wraps in a durable step): + * the idempotent terminal short-circuit, the not-found guard, and the + * fail-loud binding check. + * - `buildStages` assembled end-to-end through a real orchestrator with a fake + * aggregator, fake SSRF egress, fake AI, and a bundle fixture — the lifted + * deploy gate: a `passed` outcome now means a real acquire→scan ran, and a + * declared-vs-pinned drift blocks rather than fetching the wrong artifact. + * + * Cloudflare's Workflow runtime has no local harness, so the entrypoint class + * is not constructed; both layers are driven directly. */ import { CODEC_RAW, create as createCid, toString as cidToString } from "@atcute/cid"; +import { + createLabelSigner, + type LabelDidDocument, + type LabelSigner, +} from "@emdash-cms/registry-moderation"; import { applyD1Migrations, env } from "cloudflare:test"; import { beforeAll, describe, expect, it } from "vitest"; +import type { AcquisitionHolder } from "../src/artifact-acquisition.js"; +import type { ArtifactEgress } from "../src/artifact-egress.js"; import { computeRunKey, initialTriggerId } from "../src/assessment-lifecycle.js"; +import { AssessmentOrchestrator } from "../src/assessment-orchestrator.js"; +import { serializeCoverage, type CoverageAccumulator } from "../src/assessment-stages.js"; import { createAssessmentRun, createSubject, - getAssessment, transitionAssessmentState, } from "../src/assessment-store.js"; -import { executeAssessmentInstance } from "../src/assessment-workflow.js"; +import { buildStages, executeAssessmentInstance } from "../src/assessment-workflow.js"; +import type { AiBinding } from "../src/code-ai-adapter.js"; +import type { PublisherVerificationReader } from "../src/history-context.js"; +import type { ImageAiBinding } from "../src/image-ai-adapter.js"; import { MODERATION_POLICY } from "../src/policy.js"; +import type { ReleaseReader } from "../src/release-resolution.js"; import { initializeSigningState } from "../src/signing-rotation.js"; +import { canonicalBundle, checksumOf } from "./bundle-fixture.js"; interface TestEnv { DB: D1Database; @@ -31,7 +50,9 @@ interface TestEnv { const testEnv = env as unknown as TestEnv; const LABELER_DID = "did:web:labels.emdashcms.com"; const PUBLISHER_DID = "did:plc:publisher000000000000000000"; +const PRIVATE_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE"; const MULTIKEY = "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ"; +const config = { labelerDid: LABELER_DID, signingKeyVersion: "v1" }; beforeAll(async () => { await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); @@ -42,6 +63,28 @@ beforeAll(async () => { }); }); +function document(): LabelDidDocument { + return { + id: LABELER_DID, + verificationMethod: [ + { + id: "#atproto_label", + type: "Multikey", + controller: LABELER_DID, + publicKeyMultibase: MULTIKEY, + }, + ], + }; +} + +function signer(): Promise { + return createLabelSigner({ + issuerDid: LABELER_DID, + privateKey: PRIVATE_KEY, + resolveDid: async () => document(), + }); +} + async function cid(seed: string): Promise { const bytes = new TextEncoder().encode(seed); const buffer = new ArrayBuffer(bytes.byteLength); @@ -55,7 +98,10 @@ function releaseUri(name: string): string { } /** Creates a verified subject and a `pending` assessment run. */ -async function pendingRun(name: string): Promise<{ id: string; uri: string; cid: string }> { +async function pendingRun( + name: string, + pins: { artifactChecksum?: string; artifactId?: string } = {}, +): Promise<{ id: string; uri: string; cid: string }> { const uri = releaseUri(name); const cidValue = await cid(name); await createSubject(testEnv.DB, { @@ -83,6 +129,8 @@ async function pendingRun(name: string): Promise<{ id: string; uri: string; cid: triggerId, policyVersion: MODERATION_POLICY.policyVersion, coverageJson: "{}", + ...(pins.artifactChecksum !== undefined ? { artifactChecksum: pins.artifactChecksum } : {}), + ...(pins.artifactId !== undefined ? { artifactId: pins.artifactId } : {}), }); await transitionAssessmentState(testEnv.DB, { id: assessment.id, @@ -97,53 +145,211 @@ async function pendingRun(name: string): Promise<{ id: string; uri: string; cid: return { id: assessment.id, uri, cid: cidValue }; } -const workflowEnv = env as unknown as Env; +function servingEgress(bytes: Uint8Array): ArtifactEgress { + return { fetch: async () => new Response(bytes), resolveHostname: async () => ["203.0.113.5"] }; +} + +function fakeAi(findings: unknown[]): AiBinding & ImageAiBinding { + const run = () => Promise.resolve({ response: JSON.stringify({ findings }) }); + return { run } as unknown as AiBinding & ImageAiBinding; +} + +function fakeAggregator( + runCid: string, + declaredChecksum: string, +): ReleaseReader & PublisherVerificationReader { + const view = { + cid: runCid, + did: PUBLISHER_DID, + indexedAt: "2026-07-17T00:00:00.000Z", + package: "test-plugin", + version: "1.0.0", + uri: releaseUri("test-plugin"), + release: { + $type: "com.emdashcms.experimental.package.release", + package: "test-plugin", + version: "1.0.0", + artifacts: { + package: { url: "https://cdn.example.test/plugin.tgz", checksum: declaredChecksum }, + }, + }, + }; + return { + getLatestRelease: async () => view as never, + listReleases: async () => ({ releases: [view] }) as never, + getPackage: async () => null, + getPublisherVerification: async () => null, + }; +} + +async function runFullPath( + runId: string, + runCid: string, + options: { + bundle: Uint8Array; + declaredChecksum: string; + ai?: AiBinding & ImageAiBinding; + }, +) { + const holder: AcquisitionHolder = {}; + const coverage: CoverageAccumulator = {}; + const stages = buildStages({ + holder, + coverage, + config, + policy: MODERATION_POLICY, + db: testEnv.DB, + egress: servingEgress(options.bundle), + aggregator: fakeAggregator(runCid, options.declaredChecksum), + ai: options.ai ?? fakeAi([]), + }); + const orchestrator = new AssessmentOrchestrator({ + db: testEnv.DB, + config, + signer: await signer(), + policy: MODERATION_POLICY, + stages, + sleep: () => Promise.resolve(), + resolveCoverageJson: () => serializeCoverage(coverage), + }); + return orchestrator.runAssessment(runId); +} -describe("executeAssessmentInstance", () => { - it("drives a pending run through the orchestrator to finalization (passed, stub stages)", async () => { - const run = await pendingRun("wf-happy"); +async function labelNeg(uri: string, cidValue: string, val: string): Promise { + const row = await testEnv.DB.prepare( + `SELECT neg FROM issued_labels WHERE uri = ? AND cid = ? AND val = ? ORDER BY sequence DESC LIMIT 1`, + ) + .bind(uri, cidValue, val) + .first<{ neg: number }>(); + return row?.neg; +} + +const minimalEnv = { DB: testEnv.DB } as unknown as Env; - const state = await executeAssessmentInstance(workflowEnv, run.id); +describe("buildStages: assembled production path (deploy gate lifted)", () => { + it("does not throw when assembling the four real stages", async () => { + const bytes = await canonicalBundle(); + expect(() => + buildStages({ + holder: {}, + coverage: {}, + config, + policy: MODERATION_POLICY, + db: testEnv.DB, + egress: servingEgress(bytes), + aggregator: fakeAggregator("cid", "checksum"), + ai: fakeAi([]), + }), + ).not.toThrow(); + }); - expect(state).toBe("passed"); - const finalized = await getAssessment(testEnv.DB, run.id); - expect(finalized?.state).toBe("passed"); + it("acquires, scans clean, and finalizes passed with real coverage", async () => { + const run = await pendingRun("wf-full-pass"); + const bytes = await canonicalBundle(); + const result = await runFullPath(run.id, run.cid, { + bundle: bytes, + declaredChecksum: await checksumOf(bytes), + }); - // The run's own assessment-pending is negated on finalization. - const pending = await testEnv.DB.prepare( - `SELECT l.neg FROM issued_labels l JOIN issuance_actions a ON a.id = l.action_id - WHERE l.uri = ? AND l.cid = ? AND l.val = 'assessment-pending'`, - ) - .bind(run.uri, run.cid) - .first<{ neg: number }>(); - expect(pending?.neg).toBe(1); + expect(result.state).toBe("passed"); + expect(await labelNeg(run.uri, run.cid, "assessment-passed")).toBe(0); + expect(JSON.parse(result.coverageJson)).toMatchObject({ + code: "complete", + images: "not-present", + }); }); - it("resumes idempotently: a re-run of an already-finalized assessment returns its state without re-entering the orchestrator", async () => { - const run = await pendingRun("wf-resume"); + it("blocks on a code-model block finding across the assembled stages", async () => { + const run = await pendingRun("wf-full-block"); + const bytes = await canonicalBundle(); + const result = await runFullPath(run.id, run.cid, { + bundle: bytes, + declaredChecksum: await checksumOf(bytes), + ai: fakeAi([ + { + category: "malware", + severity: "critical", + title: "malware", + publicSummary: "ships a payload", + privateDetail: "backend.js drops a dropper", + affectedFiles: ["backend.js"], + }, + ]), + }); - expect(await executeAssessmentInstance(workflowEnv, run.id)).toBe("passed"); - // A second execution (as a durable retry would do) must not throw on the - // now-terminal row — the orchestrator's pending-guard would otherwise - // reject it. - expect(await executeAssessmentInstance(workflowEnv, run.id)).toBe("passed"); + expect(result.state).toBe("blocked"); + expect(await labelNeg(run.uri, run.cid, "malware")).toBe(0); + expect(await labelNeg(run.uri, run.cid, "assessment-passed")).toBeUndefined(); }); - it("resumes a run left `running` by a crashed attempt and finalizes it", async () => { - const run = await pendingRun("wf-running-resume"); - // Simulate a prior attempt that made the pending→running CAS then crashed - // (e.g. a durable step evicted mid-finalize) before finalizing. - await transitionAssessmentState(testEnv.DB, { id: run.id, from: "pending", to: "running" }); + it("blocks on declared-vs-pinned checksum drift without fetching the wrong artifact", async () => { + const bytes = await canonicalBundle(); + const declared = await checksumOf(bytes); + const run = await pendingRun("wf-drift", { artifactChecksum: `${declared}-tampered` }); - const state = await executeAssessmentInstance(workflowEnv, run.id); + let fetched = false; + const egress: ArtifactEgress = { + fetch: async () => { + fetched = true; + return new Response(bytes); + }, + resolveHostname: async () => ["203.0.113.5"], + }; + const holder: AcquisitionHolder = {}; + const coverage: CoverageAccumulator = {}; + const stages = buildStages({ + holder, + coverage, + config, + policy: MODERATION_POLICY, + db: testEnv.DB, + egress, + aggregator: fakeAggregator(run.cid, declared), + ai: fakeAi([]), + }); + const orchestrator = new AssessmentOrchestrator({ + db: testEnv.DB, + config, + signer: await signer(), + policy: MODERATION_POLICY, + stages, + sleep: () => Promise.resolve(), + resolveCoverageJson: () => serializeCoverage(coverage), + }); + const result = await orchestrator.runAssessment(run.id); - expect(state).toBe("passed"); - expect((await getAssessment(testEnv.DB, run.id))?.state).toBe("passed"); + expect(result.state).toBe("blocked"); + expect(fetched).toBe(false); + expect(holder.result).toBeUndefined(); + expect(await labelNeg(run.uri, run.cid, "artifact-integrity-failure")).toBe(0); }); +}); +describe("executeAssessmentInstance: shell", () => { it("throws when the assessment does not exist", async () => { await expect( - executeAssessmentInstance(workflowEnv, "asmt_00000000000000000000000000"), + executeAssessmentInstance(minimalEnv, "asmt_00000000000000000000000000"), ).rejects.toThrow(/not found/); }); + + it("short-circuits idempotently on an already-finalized terminal row", async () => { + const run = await pendingRun("wf-idempotent"); + const bytes = await canonicalBundle(); + const first = await runFullPath(run.id, run.cid, { + bundle: bytes, + declaredChecksum: await checksumOf(bytes), + }); + expect(first.state).toBe("passed"); + + // A durable-step retry re-enters with the row now terminal: it must return + // the finalized state without rebuilding stages (minimalEnv has no bindings). + expect(await executeAssessmentInstance(minimalEnv, run.id)).toBe("passed"); + }); + + it("fails loudly when a required binding is missing", async () => { + const run = await pendingRun("wf-missing-binding"); + await expect(executeAssessmentInstance(minimalEnv, run.id)).rejects.toThrow( + /missing required bindings/, + ); + }); }); diff --git a/apps/labeler/test/image-metadata.test.ts b/apps/labeler/test/image-metadata.test.ts new file mode 100644 index 0000000000..d9241ab989 --- /dev/null +++ b/apps/labeler/test/image-metadata.test.ts @@ -0,0 +1,218 @@ +/** + * Deterministic image-metadata extractor. Crafted format headers exercise each + * parser; truncated/renamed/oversized inputs prove it never throws and only + * degrades coverage. + */ + +import type { ValidatedBundleFile } from "@emdash-cms/registry-verification"; +import { describe, expect, it } from "vitest"; + +import { extractBundleImages } from "../src/image-metadata.js"; + +const encoder = new TextEncoder(); + +function bundleFile(path: string, bytes: Uint8Array): ValidatedBundleFile { + return { path, bytes }; +} + +function png(width: number, height: number, extra = 0): Uint8Array { + const bytes = new Uint8Array(24 + extra); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); + bytes.set([0x00, 0x00, 0x00, 0x0d], 8); + bytes.set([0x49, 0x48, 0x44, 0x52], 12); + const view = new DataView(bytes.buffer); + view.setUint32(16, width); + view.setUint32(20, height); + return bytes; +} + +function gif(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(10); + bytes.set([0x47, 0x49, 0x46, 0x38, 0x39, 0x61], 0); + const view = new DataView(bytes.buffer); + view.setUint16(6, width, true); + view.setUint16(8, height, true); + return bytes; +} + +function jpeg(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(12); + bytes.set([0xff, 0xd8, 0xff, 0xc0, 0x00, 0x11, 0x08], 0); + const view = new DataView(bytes.buffer); + view.setUint16(7, height); + view.setUint16(9, width); + return bytes; +} + +function webpHeader(fourCc: string, length: number): Uint8Array { + const bytes = new Uint8Array(length); + bytes.set([0x52, 0x49, 0x46, 0x46], 0); // RIFF + bytes.set([0x57, 0x45, 0x42, 0x50], 8); // WEBP + bytes.set(encoder.encode(fourCc), 12); + return bytes; +} + +function webpLossy(width: number, height: number): Uint8Array { + const bytes = webpHeader("VP8 ", 30); + const view = new DataView(bytes.buffer); + view.setUint16(26, width & 0x3fff, true); + view.setUint16(28, height & 0x3fff, true); + return bytes; +} + +function webpLossless(width: number, height: number): Uint8Array { + const bytes = webpHeader("VP8L", 25); + bytes[20] = 0x2f; + const bits = ((width - 1) & 0x3fff) | (((height - 1) & 0x3fff) << 14); + new DataView(bytes.buffer).setUint32(21, bits >>> 0, true); + return bytes; +} + +function webpExtended(width: number, height: number): Uint8Array { + const bytes = webpHeader("VP8X", 30); + const w = width - 1; + const h = height - 1; + bytes[24] = w & 0xff; + bytes[25] = (w >> 8) & 0xff; + bytes[26] = (w >> 16) & 0xff; + bytes[27] = h & 0xff; + bytes[28] = (h >> 8) & 0xff; + bytes[29] = (h >> 16) & 0xff; + return bytes; +} + +describe("extractBundleImages: format parsing", () => { + it("parses PNG dimensions and mime from the IHDR header", async () => { + const { images, skipped } = await extractBundleImages([bundleFile("a.png", png(640, 480))]); + expect(skipped).toEqual([]); + expect(images).toHaveLength(1); + expect(images[0]).toMatchObject({ mime: "image/png", width: 640, height: 480 }); + }); + + it("parses JPEG dimensions from the SOF marker", async () => { + const { images } = await extractBundleImages([bundleFile("a.jpg", jpeg(800, 600))]); + expect(images[0]).toMatchObject({ mime: "image/jpeg", width: 800, height: 600 }); + }); + + it("parses GIF dimensions from the logical screen descriptor", async () => { + const { images } = await extractBundleImages([bundleFile("a.gif", gif(120, 90))]); + expect(images[0]).toMatchObject({ mime: "image/gif", width: 120, height: 90 }); + }); + + it("parses all three WebP bitstream variants", async () => { + const files = [ + bundleFile("lossy.webp", webpLossy(200, 100)), + bundleFile("lossless.webp", webpLossless(300, 150)), + bundleFile("extended.webp", webpExtended(1024, 768)), + ]; + const { images } = await extractBundleImages(files); + expect(images.map((i) => [i.mime, i.width, i.height])).toEqual([ + ["image/webp", 200, 100], + ["image/webp", 300, 150], + ["image/webp", 1024, 768], + ]); + }); +}); + +describe("extractBundleImages: magic-byte sniffing", () => { + it("sniffs mime from bytes, not the filename extension", async () => { + const { images } = await extractBundleImages([bundleFile("actually-a-png.txt", png(16, 16))]); + expect(images[0]?.mime).toBe("image/png"); + }); + + it("does not treat a text file with an image extension as an image", async () => { + const source = encoder.encode("export default function () {}\n"); + const { images, skipped } = await extractBundleImages([bundleFile("evil.png", source)]); + expect(images).toEqual([]); + expect(skipped).toEqual([]); + }); + + it("ignores non-image files entirely — not counted as images or skips", async () => { + const { images, skipped } = await extractBundleImages([ + bundleFile("manifest.json", encoder.encode("{}")), + bundleFile("icon.png", png(32, 32)), + ]); + expect(images).toHaveLength(1); + expect(skipped).toEqual([]); + }); +}); + +describe("extractBundleImages: malformed input never throws", () => { + it("skips a truncated PNG whose IHDR is incomplete", async () => { + const truncated = png(64, 64).subarray(0, 18); + const { images, skipped } = await extractBundleImages([bundleFile("broken.png", truncated)]); + expect(images).toEqual([]); + expect(skipped).toEqual(["broken.png"]); + }); + + it("skips a JPEG with no SOF marker", async () => { + const headerOnly = new Uint8Array([0xff, 0xd8, 0xff, 0xd9]); + const { images, skipped } = await extractBundleImages([bundleFile("empty.jpg", headerOnly)]); + expect(images).toEqual([]); + expect(skipped).toEqual(["empty.jpg"]); + }); + + it("skips a zero-dimension image rather than emitting it", async () => { + const { images, skipped } = await extractBundleImages([bundleFile("zero.gif", gif(0, 0))]); + expect(images).toEqual([]); + expect(skipped).toEqual(["zero.gif"]); + }); +}); + +describe("extractBundleImages: caps degrade coverage to partial", () => { + it("skips an image over the per-image byte cap", async () => { + const big = png(64, 64, 1_600_000); + const { images, skipped } = await extractBundleImages([bundleFile("huge.png", big)]); + expect(images).toEqual([]); + expect(skipped).toEqual(["huge.png"]); + }); + + it("keeps at most MAX_IMAGES and skips the overflow", async () => { + const files = Array.from({ length: 13 }, (_, i) => bundleFile(`icon-${i}.png`, png(16, 16))); + const { images, skipped } = await extractBundleImages(files); + expect(images).toHaveLength(12); + expect(skipped).toEqual(["icon-12.png"]); + }); + + it("skips images that would exceed the aggregate byte cap", async () => { + // Each image is exactly 1.2 MB raw → 1.6 MB base64; five reach the 8 MB + // aggregate cap exactly, the sixth overflows and is skipped. + const files = Array.from({ length: 6 }, (_, i) => + bundleFile(`shot-${i}.png`, png(64, 64, 1_200_000 - 24)), + ); + const { images, skipped } = await extractBundleImages(files); + expect(images).toHaveLength(5); + expect(skipped).toEqual(["shot-5.png"]); + }); +}); + +describe("extractBundleImages: classification and hashing", () => { + it("classifies a small near-square image as an icon and a large one as a screenshot", async () => { + const { images } = await extractBundleImages([ + bundleFile("icon.png", png(128, 128)), + bundleFile("shot.png", png(1280, 720)), + ]); + expect(images.find((i) => i.path === "icon.png")?.kind).toBe("icon"); + expect(images.find((i) => i.path === "shot.png")?.kind).toBe("screenshot"); + }); + + it("honours a declared manifest icon path over the dimension heuristic", async () => { + const { images } = await extractBundleImages( + [bundleFile("assets/brand.png", png(1280, 720))], + "assets/brand.png", + ); + expect(images[0]?.kind).toBe("icon"); + }); + + it("emits the SHA-256 hex and base64 of the exact bytes", async () => { + const bytes = png(16, 16); + const { images } = await extractBundleImages([bundleFile("a.png", bytes)]); + const digest = await crypto.subtle.digest("SHA-256", bytes); + const expectedHex = Array.from(new Uint8Array(digest), (b) => + b.toString(16).padStart(2, "0"), + ).join(""); + expect(images[0]?.sha256).toBe(expectedHex); + const decoded = Uint8Array.from(atob(images[0]!.dataBase64), (c) => c.charCodeAt(0)); + expect(decoded).toEqual(bytes); + }); +}); diff --git a/apps/labeler/test/release-resolution.test.ts b/apps/labeler/test/release-resolution.test.ts new file mode 100644 index 0000000000..681fe2be17 --- /dev/null +++ b/apps/labeler/test/release-resolution.test.ts @@ -0,0 +1,230 @@ +/** + * Release resolution: mapping an assessment to an `AcquisitionTarget` over the + * aggregator reads. A fake `ReleaseReader` returns canned views — no binding, + * no network. Drift detection is not asserted here (the target only carries the + * pinned coordinates; `acquireArtifact` raises the finding), but the pinned + * fields must be threaded so that check can fire. + */ + +import type { AggregatorDefs, AggregatorListReleases } from "@emdash-cms/registry-lexicons"; +import { describe, expect, it } from "vitest"; + +import { StageTransientError } from "../src/assessment-orchestrator.js"; +import type { Assessment } from "../src/assessment-store.js"; +import { + createReleaseResolver, + MAX_RELEASE_DESCRIPTION_CHARS, + type ReleaseReader, +} from "../src/release-resolution.js"; + +const PUBLISHER_DID = "did:plc:publisher000000000000000000"; +const RELEASE_URI = `at://${PUBLISHER_DID}/com.emdashcms.experimental.package.release/test-plugin:1.0.0`; +const PROFILE_URI = `at://${PUBLISHER_DID}/com.emdashcms.experimental.package.profile/test-plugin`; + +function assessment(overrides: Partial = {}): Assessment { + return { + id: "asmt_0000000000000000000000000000", + runKey: "run_0000000000000000000000000000", + uri: RELEASE_URI, + cid: "bafyPinnedCid", + artifactId: null, + artifactChecksum: null, + state: "running", + trigger: "initial", + triggerId: "trg", + policyVersion: "1", + modelId: null, + promptHash: null, + publicSummary: null, + coverageJson: "{}", + supersedesAssessmentId: null, + startedAt: null, + completedAt: null, + createdAt: "2026-07-17T00:00:00.000Z", + ...overrides, + }; +} + +function releaseView(overrides: { + cid: string; + url?: string; + checksum?: string; + artifactId?: string; + pkg?: string; + version?: string; +}): AggregatorDefs.ReleaseView { + const artifact: Record = { + url: overrides.url ?? "https://cdn.example.test/plugin.tgz", + checksum: overrides.checksum ?? "bDeclaredChecksum", + }; + if (overrides.artifactId !== undefined) artifact["id"] = overrides.artifactId; + return { + cid: overrides.cid, + did: PUBLISHER_DID, + indexedAt: "2026-07-17T00:00:00.000Z", + package: overrides.pkg ?? "test-plugin", + version: overrides.version ?? "1.0.0", + uri: RELEASE_URI, + release: { + $type: "com.emdashcms.experimental.package.release", + package: overrides.pkg ?? "test-plugin", + version: overrides.version ?? "1.0.0", + artifacts: { package: artifact }, + }, + } as AggregatorDefs.ReleaseView; +} + +function packageView(description?: string): AggregatorDefs.PackageView { + return { + cid: "bafyProfileCid", + did: PUBLISHER_DID, + indexedAt: "2026-07-17T00:00:00.000Z", + slug: "test-plugin", + uri: PROFILE_URI, + profile: { + $type: "com.emdashcms.experimental.package.profile", + ...(description !== undefined ? { description } : {}), + }, + } as AggregatorDefs.PackageView; +} + +function reader(overrides: Partial = {}): ReleaseReader { + return { + getLatestRelease: overrides.getLatestRelease ?? (async () => null), + listReleases: overrides.listReleases ?? (async () => ({ releases: [] })), + getPackage: overrides.getPackage ?? (async () => null), + }; +} + +describe("createReleaseResolver", () => { + it("uses the latest release when its CID matches the pinned CID", async () => { + const resolve = createReleaseResolver( + reader({ + getLatestRelease: async () => + releaseView({ + cid: "bafyPinnedCid", + url: "https://cdn.example.test/pkg.tgz", + artifactId: "pkg-1", + }), + }), + ); + + const targetResult = await resolve( + assessment({ artifactChecksum: "bDeclaredChecksum", artifactId: "pkg-1" }), + ); + + expect(targetResult).toMatchObject({ + url: "https://cdn.example.test/pkg.tgz", + checksum: "bDeclaredChecksum", + slug: "test-plugin", + version: "1.0.0", + artifactId: "pkg-1", + pinnedChecksum: "bDeclaredChecksum", + pinnedArtifactId: "pkg-1", + }); + }); + + it("scans listReleases for the pinned CID when the latest release differs", async () => { + let listCalls = 0; + const resolve = createReleaseResolver( + reader({ + getLatestRelease: async () => releaseView({ cid: "bafyNewerCid" }), + listReleases: async (): Promise => { + listCalls += 1; + return { + releases: [releaseView({ cid: "bafyNewerCid" }), releaseView({ cid: "bafyPinnedCid" })], + }; + }, + }), + ); + + const targetResult = await resolve(assessment()); + + expect(listCalls).toBe(1); + expect(targetResult.checksum).toBe("bDeclaredChecksum"); + }); + + it("throws a transient error when the pinned CID is not indexed (aggregator lag)", async () => { + const resolve = createReleaseResolver(reader({ getLatestRelease: async () => null })); + await expect(resolve(assessment())).rejects.toBeInstanceOf(StageTransientError); + }); + + it("throws a transient error when the listReleases scan exhausts without the pinned CID", async () => { + const resolve = createReleaseResolver( + reader({ + getLatestRelease: async () => releaseView({ cid: "bafyNewerCid" }), + listReleases: async () => ({ releases: [releaseView({ cid: "bafyOtherCid" })] }), + }), + ); + await expect(resolve(assessment())).rejects.toBeInstanceOf(StageTransientError); + }); + + it("threads the package-profile description into the target", async () => { + const resolve = createReleaseResolver( + reader({ + getLatestRelease: async () => releaseView({ cid: "bafyPinnedCid" }), + getPackage: async () => packageView("A friendly plugin that does one thing well."), + }), + ); + + const targetResult = await resolve(assessment()); + + expect(targetResult.description).toBe("A friendly plugin that does one thing well."); + }); + + it("truncates an over-cap publisher description to the ingestion cap", async () => { + const resolve = createReleaseResolver( + reader({ + getLatestRelease: async () => releaseView({ cid: "bafyPinnedCid" }), + getPackage: async () => packageView("x".repeat(MAX_RELEASE_DESCRIPTION_CHARS * 4)), + }), + ); + + const targetResult = await resolve(assessment()); + + expect(targetResult.description).toHaveLength(MAX_RELEASE_DESCRIPTION_CHARS); + expect(targetResult.description?.endsWith("…")).toBe(true); + }); + + it("leaves the description absent when the profile fetch fails, still resolving the artifact", async () => { + const resolve = createReleaseResolver( + reader({ + getLatestRelease: async () => releaseView({ cid: "bafyPinnedCid" }), + getPackage: async () => { + throw new Error("aggregator profile read failed"); + }, + }), + ); + + const targetResult = await resolve(assessment()); + + expect(targetResult.description).toBeUndefined(); + expect(targetResult.url).toBe("https://cdn.example.test/plugin.tgz"); + }); + + it("throws a transient error when the release view has no package artifact", async () => { + const resolve = createReleaseResolver( + reader({ + getLatestRelease: async () => + ({ + cid: "bafyPinnedCid", + did: PUBLISHER_DID, + indexedAt: "2026-07-17T00:00:00.000Z", + package: "test-plugin", + version: "1.0.0", + uri: RELEASE_URI, + release: { $type: "com.emdashcms.experimental.package.release", artifacts: {} }, + }) as AggregatorDefs.ReleaseView, + }), + ); + await expect(resolve(assessment())).rejects.toBeInstanceOf(StageTransientError); + }); + + it("throws a transient error when the release record key has no package slug", async () => { + const resolve = createReleaseResolver( + reader({ getLatestRelease: async () => releaseView({ cid: "bafyPinnedCid" }) }), + ); + const badUri = `at://${PUBLISHER_DID}/com.emdashcms.experimental.package.release/no-separator`; + await expect(resolve(assessment({ uri: badUri }))).rejects.toBeInstanceOf(StageTransientError); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 3af653739b..5507880738 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,6 +25,10 @@ "./plugin": { "types": "./dist/plugin-types.d.mts" }, + "./security/ssrf": { + "types": "./dist/security/ssrf.d.mts", + "default": "./dist/security/ssrf.mjs" + }, "./middleware": { "types": "./dist/astro/middleware.d.mts", "default": "./dist/astro/middleware.mjs" diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 9605a82cee..052ae5867b 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -73,6 +73,8 @@ export default defineConfig({ "src/index.ts", // Request context (ALS singleton - must be a separate entry for dedup) "src/request-context.ts", + // SSRF egress primitives (DoH resolver) — consumed by the registry labeler Worker + "src/security/ssrf.ts", // Astro integration (build-time) "src/astro/index.ts", "src/astro/middleware.ts", diff --git a/packages/registry-verification/src/fetch.ts b/packages/registry-verification/src/fetch.ts index 6c57af0517..8ce780242a 100644 --- a/packages/registry-verification/src/fetch.ts +++ b/packages/registry-verification/src/fetch.ts @@ -361,15 +361,25 @@ function isForbiddenAddress(address: string): boolean { const isPrivate = (first & 0xfe00) === 0xfc00; const isLinkLocal = (first & 0xffc0) === 0xfe80; const isMulticast = (first & 0xff00) === 0xff00; + // IPv4-mapped (::ffff:a.b.c.d), deprecated IPv4-compatible (::a.b.c.d, i.e. + // ::/96), and NAT64 (64:ff9b::a.b.c.d) all embed an IPv4 address in the low + // 32 bits. A public AAAA record of any of these forms is an SSRF vector onto + // the embedded address, so resolve it and apply the IPv4 rules. 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}`, - ); + const ipv4Compatible = ipv6.slice(0, 6).every((part) => part === 0); + const nat64 = + ipv6[0] === 0x0064 && ipv6[1] === 0xff9b && ipv6.slice(2, 6).every((part) => part === 0); + if (mappedIpv4 || ipv4Compatible || nat64) { + return isForbiddenAddress(embeddedIpv4(ipv6)); } return isUnspecified || isLoopback || isPrivate || isLinkLocal || isMulticast; } +/** Dotted-quad of the IPv4 embedded in the low 32 bits of an 8-hextet IPv6. */ +function embeddedIpv4(ipv6: number[]): string { + return `${ipv6[6]! >>> 8}.${ipv6[6]! & 0xff}.${ipv6[7]! >>> 8}.${ipv6[7]! & 0xff}`; +} + function parseIpv4(value: string): [number, number, number, number] | null { const parts = value.split("."); if (parts.length !== 4) return null; diff --git a/packages/registry-verification/tests/fetch.test.ts b/packages/registry-verification/tests/fetch.test.ts index 6fa5b382ca..f8b8518392 100644 --- a/packages/registry-verification/tests/fetch.test.ts +++ b/packages/registry-verification/tests/fetch.test.ts @@ -122,6 +122,26 @@ describe("fetchVerifiedResource", () => { expect(fetch).toHaveBeenCalledTimes(1); }); + it("rejects an IPv4-compatible IPv6 address embedding a private IPv4", async () => { + const fetch = vi.fn(); + const result = await fetchVerifiedResource("https://sneaky.example.test/artifact", { + fetch, + resolveHostname: async () => ["::7f00:1"], + }); + expect(result).toMatchObject({ success: false, error: { code: "HOST_REJECTED" } }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("rejects a NAT64 IPv6 address embedding the link-local metadata IPv4", async () => { + const fetch = vi.fn(); + const result = await fetchVerifiedResource("https://sneaky.example.test/artifact", { + fetch, + resolveHostname: async () => ["64:ff9b::a9fe:a9fe"], + }); + expect(result).toMatchObject({ success: false, error: { code: "HOST_REJECTED" } }); + expect(fetch).not.toHaveBeenCalled(); + }); + it("rejects oversize content-length and streaming bodies", async () => { const contentLength = await fetchVerifiedResource("https://example.test/file", { fetch: vi diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa8dac924a..47bd5d6a4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -456,6 +456,9 @@ importers: '@emdash-cms/registry-verification': specifier: workspace:* version: link:../../packages/registry-verification + emdash: + specifier: workspace:* + version: link:../../packages/core jose: specifier: ^6.1.3 version: 6.1.3 From d116ed93bd0f3f767e7b79cc99837a076b1a0d8b Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 15:05:59 +0100 Subject: [PATCH 116/137] docs(labeler-plan): drop W10.7 optional ATProto decision notice Ratified 2026-07-17: the conditional ATProto decision-notice record is declined. No known consumer, and labels already distribute over com.atproto.label subscription, publisher notification is the W10.5 email pipeline + reconsideration inbox, and public lookup is the assessment API. Publishing decision-level repo records adds a durable broadcast surface with retraction/reach concerns for no demand. Revisit via Discussion if a concrete transparency-feed consumer emerges. Co-Authored-By: Claude Opus 4.8 --- .../implementation-plan.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 1dc59a16a1..0bb761b5ad 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1298,11 +1298,13 @@ Decisions (2026-07-16, ratified): **Requirements 1 and 2 are already satisfied** ### `W10.7` Decide/implement optional ATProto decision notice -Only if `W0.1` ratifies it: +Decision (2026-07-17, ratified — DROPPED): the optional ATProto decision-notice record is NOT built. It was always conditional ("only if `W0.1` ratifies it"), and it is declined: it has no known consumer, and everything it would offer already exists — labels distribute over the standard `com.atproto.label` subscription (built), publisher notification is the W10.5 double-opt-in email pipeline plus the reconsideration inbox, and public lookup is the current/historical assessment API. Publishing human-decision-level records as durable, replicated, subscribable repo records would add a broadcast surface (amplifying every block into a network event, with retraction/reach and right-to-be-forgotten concerns) for no demand. If a concrete transparency-feed consumer emerges later, revisit via a Discussion. Email remains the active notification channel; the label stream remains the transparency channel. -- Publish a repository record referencing subject and public assessment. -- Make clear it is subscribable transparency, not guaranteed delivery. -- Keep email as the active notification channel. +Superseded requirements (not built): + +- ~~Publish a repository record referencing subject and public assessment.~~ +- ~~Make clear it is subscribable transparency, not guaranteed delivery.~~ +- ~~Keep email as the active notification channel.~~ (Email is the active channel regardless.) Dependencies: `W1.2`, `W3.1`, `W10.2`. From a728331ed545e9f8db6b9cc5ba9304cfe29fc27e Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 16:56:19 +0100 Subject: [PATCH 117/137] chore(labeler): drop kimi-k2.6 from the calibration matrix Excluded from serving since the first sweep (72s mean latency, timeouts, invalid structured output); kept only as a reference and not worth its run cost. glm-5.2 (code default) + kimi-k2.7-code + llama vision remain. Co-Authored-By: Claude Opus 4.8 --- apps/labeler/calibration/models.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/labeler/calibration/models.ts b/apps/labeler/calibration/models.ts index ffbaee02e9..99651f5d21 100644 --- a/apps/labeler/calibration/models.ts +++ b/apps/labeler/calibration/models.ts @@ -24,7 +24,6 @@ export interface CalibrationModel { // so a wall-clock kill costs the cheapest, fastest-to-redo calls at the tail // rather than the expensive reasoning-model data captured up front. export const MODELS: readonly CalibrationModel[] = [ - { modelId: "@cf/moonshotai/kimi-k2.6", lanes: ["code", "image"], reasoning: true }, { modelId: "@cf/moonshotai/kimi-k2.7-code", lanes: ["code", "image"] }, { modelId: "@cf/zai-org/glm-5.2", lanes: ["code"] }, { From 18001165373b3f7b19e8c93d688330cd06ffc7c5 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 16:59:29 +0100 Subject: [PATCH 118/137] docs(labeler-plan): reclassify undeclared-access as a warning (calibration finding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The severity-amended sweep's only code-lane FP was glm-5.2 correctly blocking a plugin using undeclared storage — but the runtime already throws on undeclared access (sandbox bridge.ts:300 for storage, allowedHosts/capability gates for the rest), so undeclared-access is a runtime-neutralized manifest mismatch, not an executable threat. Real over-reach is caught by the specific malicious categories. Reclassify block->warn; fix the mislabeled clean-with-images fixture to declare its storage. Co-Authored-By: Claude Opus 4.8 --- .../plugin-registry-labelling-service/implementation-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 0bb761b5ad..9114be1729 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1076,6 +1076,8 @@ Dependencies: `W1.7`, `W8.1` through `W8.4`. Decisions (2026-07-15, from the first calibration run): the "AI critical" blocking rule is amended — a model/image finding on ANY automated-block category blocks at `high` or `critical` severity, not `critical` only, and a block-category finding below `high` is never dropped: it degrades to a warning label, so any malicious-code finding always produces a visible label (the ratified principle: flag ALL suspected-malicious code; blocking is reserved for high-confidence findings). The first calibration sweep showed two independent models rating a genuine data-exfiltration sample `high`, which the critical-only gate reduced to no label at all (the finding was neither blocked nor warned — dropped entirely); prompt guidance to rate real threats `critical` is added as well, but the resolver gate is the defense because the resolver was built to distrust model severity calibration. Warning-category behavior is unchanged. The issuer's `validateAutomatedProposal` is aligned to accept `high` for automated-block values; because proposals carry no `source`, it gates uniformly at ≥`high`, so a deterministic sub-`high` block proposal — which the resolver would block — would be rejected there. Latent today (`finalize` bypasses the validator and deterministic block findings are high/critical in practice); when the issuer-hardening routes issuance through the validator, plumb `source` through the proposal to close the gap. The moderation-policy vocabulary gains three image-content automated-block categories — `hateful-imagery`, `explicit-imagery`, and `graphic-violence` (three-way split ratified over the legacy two-axis offensive/nsfw grouping) — each release-subject, cid-required, `automated`+`reviewer` issuance, with `en` locales and a `policyVersion` bump; the change is additive and safe for `queryLabels` consumers, and the image adapter's severity-guidance sentence (which steered models away from rating content-policy violations critical) is rewritten to name them as block-worthy. CSAM and other unlawful content stays OUT of the label vocabulary: no automated classification (unreliable and legally fraught); suspected cases route through operator escalation to `!takedown`, and an out-of-band legal reporting process (NCMEC-style) is a separate pre-launch work item owned by the maintainer. +Decision (2026-07-17, ratified from the severity-amended calibration sweep): **`undeclared-access` moves from an automated-BLOCK category to a WARNING** (`officialEffect` block→warn, `policyVersion` bump). Grounding: the sweep's only flagged code-lane FP was glm-5.2 blocking `clean-with-images` on a real `undeclared-access` finding (the fixture declares `storage: {}` but its code calls `ctx.storage.gallery.put`) — glm was correct, but the RUNTIME already neutralizes undeclared access: the sandbox throws `Storage collection not declared` (`packages/cloudflare/src/sandbox/bridge.ts:300`) and the equivalent network `allowedHosts` / capability gates reject undeclared network/content/media/users access, so an undeclared capability cannot execute — it is a manifest/behavior MISMATCH (the plugin's undeclared feature silently fails), not an executable threat. Genuinely malicious over-reach is already caught by the SPECIFIC malicious categories (`data-exfiltration`, `credential-harvesting`, etc.) which block on their own merits; `undeclared-access` is the residual "mismatch without other malicious signal", whose honest severity is a warning (surface the inaccuracy) not a hard block (hide from install surfaces). Hard-blocking a merely-sloppy manifest is over-enforcement. Additive-safe for `queryLabels` consumers (the label value is unchanged; only its effect softens). Consequence: the code-AI system prompt (data-driven from the policy's block∪warn category sets) now presents `undeclared-access` as a warning category, so the change must be re-validated by a confirming calibration sweep. Corpus fix folded in: `clean-with-images` genuinely declares its `gallery` storage so it is a valid clean baseline (matching its name); a dedicated `undeclared-access` fixture is a noted corpus gap for a follow-up. + ### `W8.6` Build calibration harness - Port legacy code/image audit fixtures. From 748af2d0c600cc9deaa2c854e039d899c4803f2b Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 20:43:19 +0100 Subject: [PATCH 119/137] feat(labeler): reclassify undeclared-access as a warning, not a block (#2097) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(labeler): reclassify undeclared-access as a warning, not a block The severity-amended calibration sweep flagged glm-5.2 blocking a plugin that used undeclared storage — a correct finding, but the runtime already enforces declared access (the sandbox throws 'Storage collection not declared', and the allowedHosts/capability gates reject undeclared network/content/media/users), so undeclared access can never execute. It is a manifest/behavior mismatch, not an executable threat; genuine over-reach is caught by the specific malicious categories (data-exfiltration, credential-harvesting, ...) which block on their own. Hard-blocking a merely-inaccurate manifest is over-enforcement. - moderation-policy: undeclared-access category automated-block->warning, officialEffect block->warn; policyVersion + effectiveAt bumped to 2026-07-17. The label value is unchanged, so queryLabels consumers are unaffected; only the effect softens. Resolver/adapter/console are all policy-driven — no code hardcodes the classification. - fixture: clean-with-images declares its gallery storage so it is a genuine clean baseline (its code used ctx.storage.gallery without declaring it). - tests: undeclared-access now resolves to warned at any source/severity; the deterministic-blocks-at-any-severity test moved to credential-harvesting; version-string pins updated. Co-Authored-By: Claude Opus 4.8 * fix(registry-moderation): reclassify undeclared-access as a warning consumer-side The labeler-side policy change softened undeclared-access to a warning, but the consumer-side evaluator hardcodes block-vs-warn in registry-moderation's AUTOMATED_BLOCKS/WARNINGS sets — so the aggregator, registry-client, and admin would still hard-block releases carrying the label. Move undeclared-access from AUTOMATED_BLOCKS to WARNINGS so RELEASE_BLOCK_VALUES drops it and the evaluator surfaces it as a warning (release stays eligible). Membership in RELEASE_VALUES is preserved (moved within the union), so it is not silently dropped. Addresses the emdashbot review on #2097. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/undeclared-access-warning.md | 5 ++++ .../fixtures/clean-with-images/manifest.json | 2 +- apps/labeler/docs/moderation-model.md | 4 +-- apps/labeler/fixtures/moderation-policy.json | 8 +++--- apps/labeler/test/policy-resolver.test.ts | 26 +++++++++++++++++-- apps/labeler/test/policy.test.ts | 7 ++++- apps/labeler/test/service.test.ts | 2 +- apps/labeler/test/xrpc-router.test.ts | 2 +- packages/registry-moderation/src/index.ts | 2 +- .../tests/moderation.test.ts | 12 +++++++++ 10 files changed, 57 insertions(+), 13 deletions(-) create mode 100644 .changeset/undeclared-access-warning.md diff --git a/.changeset/undeclared-access-warning.md b/.changeset/undeclared-access-warning.md new file mode 100644 index 0000000000..37b02c3943 --- /dev/null +++ b/.changeset/undeclared-access-warning.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-moderation": minor +--- + +Reclassifies the `undeclared-access` moderation label from a hard block to a warning — releases carrying it stay eligible and surface it as a warning rather than being blocked from install. diff --git a/apps/labeler/calibration/fixtures/clean-with-images/manifest.json b/apps/labeler/calibration/fixtures/clean-with-images/manifest.json index cdd892d19d..ffab455ba8 100644 --- a/apps/labeler/calibration/fixtures/clean-with-images/manifest.json +++ b/apps/labeler/calibration/fixtures/clean-with-images/manifest.json @@ -3,7 +3,7 @@ "version": "1.0.0", "capabilities": ["read:content"], "allowedHosts": [], - "storage": {}, + "storage": { "gallery": { "indexes": [] } }, "hooks": ["content:afterSave"], "routes": [], "admin": {} diff --git a/apps/labeler/docs/moderation-model.md b/apps/labeler/docs/moderation-model.md index f76ba5374e..c8768e76d9 100644 --- a/apps/labeler/docs/moderation-model.md +++ b/apps/labeler/docs/moderation-model.md @@ -11,7 +11,7 @@ Labels are grouped into categories. Each label carries: - a **`cidRule`** — `required` (targets one exact release CID), `forbidden` (URI-wide, whole record or publisher), or `optional` (either); and - **issuance modes** — who may issue it: `automated`, `reviewer`, or `admin`. -The policy document is served publicly at `/.well-known/emdash-labeler-policy.json`. The current `policyVersion` is `2026-07-15.experimental.1`. +The policy document is served publicly at `/.well-known/emdash-labeler-policy.json`. The current `policyVersion` is `2026-07-17.experimental.1`. ## Vocabulary @@ -28,7 +28,6 @@ The policy document is served publicly at `/.well-known/emdash-labeler-policy.js | `critical-vulnerability` | automated-block | block | release → required | automated, reviewer | | `artifact-integrity-failure` | automated-block | block | release → required | automated, reviewer | | `invalid-bundle` | automated-block | block | release → required | automated, reviewer | -| `undeclared-access` | automated-block | block | release → required | automated, reviewer | | `impersonation` | automated-block | block | release → required | automated, reviewer | | `hateful-imagery` | automated-block | block | release → required | automated, reviewer | | `explicit-imagery` | automated-block | block | release → required | automated, reviewer | @@ -39,6 +38,7 @@ The policy document is served publicly at `/.well-known/emdash-labeler-policy.js | `misleading-metadata` | warning | warn | release → required | automated, reviewer | | `low-quality` | warning | warn | release → required | automated, reviewer | | `broken-release` | warning | warn | release → required | automated, reviewer | +| `undeclared-access` | warning | warn | release → required | automated, reviewer | | `content-warning` | warning | warn | release → required | automated, reviewer | | `!takedown` | manual-system | redact | release / package / publisher → forbidden | admin | | `security-yanked` | manual-system | block | release → forbidden | reviewer | diff --git a/apps/labeler/fixtures/moderation-policy.json b/apps/labeler/fixtures/moderation-policy.json index f974038d4d..574a2a949b 100644 --- a/apps/labeler/fixtures/moderation-policy.json +++ b/apps/labeler/fixtures/moderation-policy.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "policyVersion": "2026-07-15.experimental.1", - "effectiveAt": "2026-07-15T00:00:00Z", + "policyVersion": "2026-07-17.experimental.1", + "effectiveAt": "2026-07-17T00:00:00Z", "labelerDid": "did:web:labels.emdashcms.com", "assessmentSchemaVersion": 1, "supportedSubjects": { @@ -219,8 +219,8 @@ }, { "value": "undeclared-access", - "category": "automated-block", - "officialEffect": "block", + "category": "warning", + "officialEffect": "warn", "subjectRules": [ { "subject": "release", "cidRule": "required", "issuanceModes": ["automated", "reviewer"] } ], diff --git a/apps/labeler/test/policy-resolver.test.ts b/apps/labeler/test/policy-resolver.test.ts index 7db371d0fd..7cfd9f1b0a 100644 --- a/apps/labeler/test/policy-resolver.test.ts +++ b/apps/labeler/test/policy-resolver.test.ts @@ -19,12 +19,12 @@ function finding(overrides: Partial & { category: string }): describe("resolvePolicyOutcome: blocking", () => { it("blocks on a deterministic finding at any severity", () => { const outcome = resolvePolicyOutcome( - [finding({ source: "deterministic", category: "undeclared-access", severity: "low" })], + [finding({ source: "deterministic", category: "credential-harvesting", severity: "low" })], MODERATION_POLICY, ); expect(outcome.toState).toBe("blocked"); expect(outcome.labels).toEqual([ - { val: "undeclared-access", findingCategory: "undeclared-access", severity: "low" }, + { val: "credential-harvesting", findingCategory: "credential-harvesting", severity: "low" }, ]); }); @@ -141,6 +141,28 @@ describe("resolvePolicyOutcome: warnings", () => { expect(outcome.toState).toBe("warned"); expect(outcome.labels.map((l) => l.val)).toEqual(["obfuscated-code", "assessment-passed"]); }); + + it("warns rather than blocks on an undeclared-access finding, at any source or severity", () => { + const modelHigh = resolvePolicyOutcome( + [finding({ source: "model", category: "undeclared-access", severity: "high" })], + MODERATION_POLICY, + ); + expect(modelHigh.toState).toBe("warned"); + expect(modelHigh.labels).toEqual([ + { val: "undeclared-access", findingCategory: "undeclared-access", severity: "high" }, + { val: "assessment-passed" }, + ]); + + const deterministicLow = resolvePolicyOutcome( + [finding({ source: "deterministic", category: "undeclared-access", severity: "low" })], + MODERATION_POLICY, + ); + expect(deterministicLow.toState).toBe("warned"); + expect(deterministicLow.labels).toEqual([ + { val: "undeclared-access", findingCategory: "undeclared-access", severity: "low" }, + { val: "assessment-passed" }, + ]); + }); }); describe("resolvePolicyOutcome: blocked outcomes still carry their warnings", () => { diff --git a/apps/labeler/test/policy.test.ts b/apps/labeler/test/policy.test.ts index 48a854cb21..1e86e03c7e 100644 --- a/apps/labeler/test/policy.test.ts +++ b/apps/labeler/test/policy.test.ts @@ -9,7 +9,7 @@ import { describe("moderation policy fixture", () => { it("loads the ratified fixture and exposes label lookups", () => { - expect(MODERATION_POLICY.policyVersion).toBe("2026-07-15.experimental.1"); + expect(MODERATION_POLICY.policyVersion).toBe("2026-07-17.experimental.1"); expect(getLabelDefinition("malware")).toMatchObject({ value: "malware", category: "automated-block", @@ -25,6 +25,11 @@ describe("moderation policy fixture", () => { expect(categories.has("malware")).toBe(true); expect(categories.has("impersonation")).toBe(true); expect(categories.has("low-quality")).toBe(false); + expect(categories.has("undeclared-access")).toBe(false); + expect(getLabelDefinition("undeclared-access")).toMatchObject({ + category: "warning", + officialEffect: "warn", + }); }); it("includes the image-content categories as automated-block and content-warning as a warning", () => { diff --git a/apps/labeler/test/service.test.ts b/apps/labeler/test/service.test.ts index 94ea5ea348..fffba274b6 100644 --- a/apps/labeler/test/service.test.ts +++ b/apps/labeler/test/service.test.ts @@ -78,7 +78,7 @@ describe("service identity", () => { expect(response.headers.get("etag")).toMatch(/^"[a-f0-9]{64}"$/); expect(await response.json()).toMatchObject({ schemaVersion: 1, - policyVersion: "2026-07-15.experimental.1", + policyVersion: "2026-07-17.experimental.1", labelerDid: LABELER_DID, assessmentSchemaVersion: 1, }); diff --git a/apps/labeler/test/xrpc-router.test.ts b/apps/labeler/test/xrpc-router.test.ts index 5f5b0b0e66..00326a7740 100644 --- a/apps/labeler/test/xrpc-router.test.ts +++ b/apps/labeler/test/xrpc-router.test.ts @@ -597,7 +597,7 @@ describe("getPolicy", () => { expect(response.headers.get("cache-control")).toBe("public, max-age=300"); expect(await response.json()).toMatchObject({ schemaVersion: 1, - policyVersion: "2026-07-15.experimental.1", + policyVersion: "2026-07-17.experimental.1", labelerDid: LABELER_DID, assessmentSchemaVersion: 1, }); diff --git a/packages/registry-moderation/src/index.ts b/packages/registry-moderation/src/index.ts index c3b1ad7c89..69b74fd827 100644 --- a/packages/registry-moderation/src/index.ts +++ b/packages/registry-moderation/src/index.ts @@ -172,7 +172,6 @@ const AUTOMATED_BLOCKS = new Set([ "critical-vulnerability", "artifact-integrity-failure", "invalid-bundle", - "undeclared-access", "impersonation", ]); @@ -184,6 +183,7 @@ const WARNINGS = new Set([ "low-quality", "broken-release", "package-disputed", + "undeclared-access", ]); const RELEASE_VALUES = new Set([ diff --git a/packages/registry-moderation/tests/moderation.test.ts b/packages/registry-moderation/tests/moderation.test.ts index 165b01ffc8..64ae1f37aa 100644 --- a/packages/registry-moderation/tests/moderation.test.ts +++ b/packages/registry-moderation/tests/moderation.test.ts @@ -197,6 +197,18 @@ describe("release moderation", () => { }); }); + it("surfaces undeclared-access as a warning, keeping the release eligible", () => { + expect(RELEASE_BLOCK_VALUES).not.toContain("undeclared-access"); + expect( + evaluate([label({ val: "assessment-passed" }), label({ val: "undeclared-access" })]), + ).toMatchObject({ + eligibility: "eligible", + blockingLabels: [], + warningLabels: ["undeclared-access"], + reasonCodes: ["eligible-assessment-pass", "warning-labels"], + }); + }); + it("suppresses only its source's automated findings with a valid override", () => { const result = evaluate([ label({ val: "assessment-passed" }), From 921716e87f37bdb793948d99b1fd76a271d79fba Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 21:19:26 +0100 Subject: [PATCH 120/137] docs(labeler-plan): ratify CLI break-glass for signing-key rotation The runbooks (#2100) surfaced that rotation has no operator interface. Matt chose a CLI break-glass command over a console route. Records the design crux: activate needs prod D1 + the signing secret to verify the deployed signer, via getPlatformProxy remote bindings, never local private-key handling. Co-Authored-By: Claude Opus 4.8 --- .../plugin-registry-labelling-service/implementation-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 9114be1729..9fa54bc5b8 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1357,6 +1357,8 @@ Dependencies: component metrics from `W2` through `W10`. ### `W11.4` Write key-management runbooks +Decision (2026-07-17, ratified): the runbooks landed in #2100 and surfaced the key gap — the signing-key rotation state machine (`signing-rotation.ts`: `beginRoutineKeyRotation`/`activateRoutineKeyRotation`/`abortRoutineKeyRotation`/`initializeSigningState`) is implemented + tested but has ZERO production callers (no console route, no CLI, no cron). Matt's decision: build a **CLI break-glass command** (not a console route) — rotation is a rare, high-stakes op that shouldn't be a routine console button; the out-of-band tool fits, and its lack of an `operator_actions` row is acceptable because `signing_events` already records rotation phase transitions + `ROTATION_*` alerts. Design crux for the build (flagged, not yet resolved): `activate` verifies the DEPLOYED signer holds the new key, so the CLI needs prod D1 AND the `LABEL_SIGNING_PRIVATE_KEY` secret to construct the signer identically to the Worker — the intended path is `getPlatformProxy` (wrangler) with REMOTE bindings for D1 + Secrets Store (net-new; not used in the repo yet), NOT local private-key handling. If activate cannot reach the deployed signer cleanly without the operator pasting the private key locally, stop and reconsider rather than ship an insecure key path. Follows the existing `scripts/keygen.mjs` node-CLI precedent. + - Begin from the ratified `W0.4` key-lifecycle contract and the implemented `W3.7` behavior. - Initial generation and custody: the offline copy of the production scalar lives in the maintainer's Keeper vault. - Routine rotation. From 9851f36e80db4e3e8a4fac875981978870e7598c Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 21:48:04 +0100 Subject: [PATCH 121/137] docs(labeler-plan): descope the rotation break-glass CLI Feasibility proved activate can't run from a CLI (can't read the write-only deployed secret; a CLI-local signer verifies the wrong principal), so a CLI could never complete a rotation. Descoped per Matt; rotation stays a documented manual procedure (runbooks). If ever warranted, a machine-authed Worker endpoint is the only correct activate interface. Co-Authored-By: Claude Opus 4.8 --- .../plugin-registry-labelling-service/implementation-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index 9fa54bc5b8..afa3857c1d 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1357,7 +1357,7 @@ Dependencies: component metrics from `W2` through `W10`. ### `W11.4` Write key-management runbooks -Decision (2026-07-17, ratified): the runbooks landed in #2100 and surfaced the key gap — the signing-key rotation state machine (`signing-rotation.ts`: `beginRoutineKeyRotation`/`activateRoutineKeyRotation`/`abortRoutineKeyRotation`/`initializeSigningState`) is implemented + tested but has ZERO production callers (no console route, no CLI, no cron). Matt's decision: build a **CLI break-glass command** (not a console route) — rotation is a rare, high-stakes op that shouldn't be a routine console button; the out-of-band tool fits, and its lack of an `operator_actions` row is acceptable because `signing_events` already records rotation phase transitions + `ROTATION_*` alerts. Design crux for the build (flagged, not yet resolved): `activate` verifies the DEPLOYED signer holds the new key, so the CLI needs prod D1 AND the `LABEL_SIGNING_PRIVATE_KEY` secret to construct the signer identically to the Worker — the intended path is `getPlatformProxy` (wrangler) with REMOTE bindings for D1 + Secrets Store (net-new; not used in the repo yet), NOT local private-key handling. If activate cannot reach the deployed signer cleanly without the operator pasting the private key locally, stop and reconsider rather than ship an insecure key path. Follows the existing `scripts/keygen.mjs` node-CLI precedent. +Decision (2026-07-17, ratified): the runbooks (#2100) surfaced that the signing-key rotation state machine (`signing-rotation.ts`: `beginRoutineKeyRotation`/`activateRoutineKeyRotation`/`abortRoutineKeyRotation`/`initializeSigningState`) is implemented + tested but has ZERO production callers. A CLI break-glass tool was scoped and then **DESCOPED (Matt, 2026-07-17)** after a feasibility pass proved `activate` fundamentally cannot run from a CLI: (1) `getPlatformProxy` remote bindings proxy resource *operations*, not secret *values*, and `LABEL_SIGNING_PRIVATE_KEY` is a write-only Worker secret that cannot be read back — so the only way to feed the CLI the key is local pasting (forbidden); (2) even with the key, a CLI-local signer check verifies the WRONG principal (it proves the CLI holds a key, not that the deployed Worker does), defeating activation's entire safety gate. Since `activate` must run inside the deployed Worker, a CLI could drive only begin/abort/status — it could never *complete* a rotation — so a bespoke CLI for a rare event is not worth it. **Launch answer: rotation stays a documented MANUAL procedure** (runbooks.md § Routine rotation / Compromise response describe the begin → deploy key → activate-in-Worker → resume ceremony). Consistent with the step-back framing that the labeler is a conservative trust/transparency layer over the runtime sandbox, not a primary security control whose every operational edge needs bespoke tooling pre-launch. If rotation frequency ever warrants self-serve, the only correct `activate` interface is a machine-authenticated Worker endpoint (option (a) from the feasibility pass) that runs `activateRoutineKeyRotation` with the in-Worker signer — revisit then. - Begin from the ratified `W0.4` key-lifecycle contract and the implemented `W3.7` behavior. - Initial generation and custody: the offline copy of the production scalar lives in the maintainer's Keeper vault. From 03625aa9dc9daff10a07b26235ec7e48347e7978 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Fri, 17 Jul 2026 20:49:23 +0000 Subject: [PATCH 122/137] style: format --- .../plugin-registry-labelling-service/implementation-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md index afa3857c1d..938291393d 100644 --- a/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md +++ b/.opencode/plans/plugin-registry-labelling-service/implementation-plan.md @@ -1357,7 +1357,7 @@ Dependencies: component metrics from `W2` through `W10`. ### `W11.4` Write key-management runbooks -Decision (2026-07-17, ratified): the runbooks (#2100) surfaced that the signing-key rotation state machine (`signing-rotation.ts`: `beginRoutineKeyRotation`/`activateRoutineKeyRotation`/`abortRoutineKeyRotation`/`initializeSigningState`) is implemented + tested but has ZERO production callers. A CLI break-glass tool was scoped and then **DESCOPED (Matt, 2026-07-17)** after a feasibility pass proved `activate` fundamentally cannot run from a CLI: (1) `getPlatformProxy` remote bindings proxy resource *operations*, not secret *values*, and `LABEL_SIGNING_PRIVATE_KEY` is a write-only Worker secret that cannot be read back — so the only way to feed the CLI the key is local pasting (forbidden); (2) even with the key, a CLI-local signer check verifies the WRONG principal (it proves the CLI holds a key, not that the deployed Worker does), defeating activation's entire safety gate. Since `activate` must run inside the deployed Worker, a CLI could drive only begin/abort/status — it could never *complete* a rotation — so a bespoke CLI for a rare event is not worth it. **Launch answer: rotation stays a documented MANUAL procedure** (runbooks.md § Routine rotation / Compromise response describe the begin → deploy key → activate-in-Worker → resume ceremony). Consistent with the step-back framing that the labeler is a conservative trust/transparency layer over the runtime sandbox, not a primary security control whose every operational edge needs bespoke tooling pre-launch. If rotation frequency ever warrants self-serve, the only correct `activate` interface is a machine-authenticated Worker endpoint (option (a) from the feasibility pass) that runs `activateRoutineKeyRotation` with the in-Worker signer — revisit then. +Decision (2026-07-17, ratified): the runbooks (#2100) surfaced that the signing-key rotation state machine (`signing-rotation.ts`: `beginRoutineKeyRotation`/`activateRoutineKeyRotation`/`abortRoutineKeyRotation`/`initializeSigningState`) is implemented + tested but has ZERO production callers. A CLI break-glass tool was scoped and then **DESCOPED (Matt, 2026-07-17)** after a feasibility pass proved `activate` fundamentally cannot run from a CLI: (1) `getPlatformProxy` remote bindings proxy resource _operations_, not secret _values_, and `LABEL_SIGNING_PRIVATE_KEY` is a write-only Worker secret that cannot be read back — so the only way to feed the CLI the key is local pasting (forbidden); (2) even with the key, a CLI-local signer check verifies the WRONG principal (it proves the CLI holds a key, not that the deployed Worker does), defeating activation's entire safety gate. Since `activate` must run inside the deployed Worker, a CLI could drive only begin/abort/status — it could never _complete_ a rotation — so a bespoke CLI for a rare event is not worth it. **Launch answer: rotation stays a documented MANUAL procedure** (runbooks.md § Routine rotation / Compromise response describe the begin → deploy key → activate-in-Worker → resume ceremony). Consistent with the step-back framing that the labeler is a conservative trust/transparency layer over the runtime sandbox, not a primary security control whose every operational edge needs bespoke tooling pre-launch. If rotation frequency ever warrants self-serve, the only correct `activate` interface is a machine-authenticated Worker endpoint (option (a) from the feasibility pass) that runs `activateRoutineKeyRotation` with the in-Worker signer — revisit then. - Begin from the ratified `W0.4` key-lifecycle contract and the implemented `W3.7` behavior. - Initial generation and custody: the offline copy of the production scalar lives in the maintainer's Keeper vault. From 00980e581e12c86956eb886c7ae08f908448d1af Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 21:58:47 +0100 Subject: [PATCH 123/137] docs(labeler): incident + key-management runbooks (W11.6, W11.4) (#2100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(labeler): incident + key-management runbooks (W11.6, W11.4) * docs(labeler): correct runbook re-signing + rerun-recovery claims Addresses the emdashbot review on #2100 — three procedures diverged from the code: - subscribeLabels does NOT lazily re-sign (only queryLabels does), so a post-rotation WebSocket replay serves retired-key signatures until a queryLabels sweep re-signs them; corrected in both the aggregator-replay and the after-key-event sections. - Re-run does not dispatch the Workflow (deferRerunTail stops at pending), so it does not recover a stuck run; the honest recovery today is a fresh discovery event. Flag updated to note reconciliation is detection-only and the rerun-dispatch gap leaves stuck runs with no automatic or manual re-drive. Co-Authored-By: Claude Opus 4.8 * docs(labeler): correct AI-error landing state and rotation verification window Second emdashbot round on #2100: - Non-transient AI adapter errors (MAX_MODEL_INPUT_CHARS overflow, validation) are not stage-caught; they leave the run stuck in 'running' after the Workflow's 3 step retries, not finalized as 'error'. Diagnosis now splits on error (transient outage) vs stuck running (input/config). - The deploy->activate rotation window is NOT fail-closed for the current key: old-key labels match the DB-active version, so queryLabels serves them as-is (200), not 503; only earlier-retired-key labels 503 while paused. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- apps/labeler/docs/runbooks.md | 296 ++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 apps/labeler/docs/runbooks.md diff --git a/apps/labeler/docs/runbooks.md b/apps/labeler/docs/runbooks.md new file mode 100644 index 0000000000..5207a3a59f --- /dev/null +++ b/apps/labeler/docs/runbooks.md @@ -0,0 +1,296 @@ +# Labeler incident runbooks + +This is the incident-response companion to [operating.md](operating.md). Where operating.md documents how a reviewer or admin _uses_ the console day to day, this document covers what to do when something is _wrong_: how you know, how to diagnose it, what the system heals on its own, and what you do when it doesn't. + +Every procedure here is grounded in the code that implements it, and names the mechanism (a function, a table, a cron pass) so you can find it. For the label vocabulary the actions manipulate, see [moderation-model.md](moderation-model.md); for identity, signing, and the ATProto surface, see [atproto.md](atproto.md). + +The console actions an operator takes during many of these incidents — override, takedown, pause/resume, dead-letter retry/quarantine, reconsideration — are documented step-by-step in [operating.md](operating.md). This document cross-references those rather than repeating the ceremony, and focuses on the incident: what is happening underneath, and when to reach for which control. + +## The shared substrate + +A handful of mechanisms recur across every runbook. Read this once. + +**D1 is the source of truth.** Every durable fact lives in D1: assessment runs (`assessments`), the append-only label log (`issued_labels`, monotonic `sequence`), the discovery forensics store (`dead_letters`), the Jetstream cursor (`ingest_state`), the signing state machine (`signing_state`, `signing_key_versions`, `signing_events`), and the audit logs (`operator_actions`, `issuance_actions`, `operational_events`). The two Durable Objects and the label subscription hold no authoritative state — they are transports over D1. This is why most recovery is "let it re-drive from D1," not "restore a component." + +**The 5-minute cron is the heartbeat.** `scheduled()` in `index.ts` (`crons: ["*/5 * * * *"]`) runs four independent, self-isolating passes every five minutes: + +1. A liveness `fetch` to the discovery Durable Object (`LabelerDiscoveryDO`), so an evicted ingestor is re-instantiated. +2. `reconcileAssessments` (`reconciliation.ts`) — surfaces stuck runs and orphaned subjects as structured logs. +3. `runNotificationSweep` (`notification-sweep.ts`) — re-drives failed/stuck publisher notices. +4. `runProlongedErrorEscalation` (`prolonged-error.ts`) — the 24h/72h escalation ladder for terminal `error` runs. + +Each pass is wrapped in its own `ctx.waitUntil` with its own try/catch, so one failing pass never disturbs the others. + +**Structured logs are the signal.** Until the W11.3 alerting layer lands, the operational signal is the `console.error`/`console.warn` lines the code emits, all prefixed `[labeler]`. Each runbook below names the exact line to watch for. Wire these into your log-based alerts. + +**The system fails closed.** The automation kill-switch and the signing layer both refuse to act when their state is unreadable or paused, rather than acting blind. A "stuck, doing nothing" labeler is the designed failure mode; a labeler issuing labels it shouldn't is not. + +--- + +# Operational runbooks + +## Jetstream outage / cursor loss + +**What it is.** The labeler discovers releases by holding a long-lived outbound WebSocket to Jetstream inside `LabelerDiscoveryDO`. `JetstreamIngestor` (`jetstream-ingestor.ts`) runs the connect → consume → enqueue → persist-cursor loop. An outage is Jetstream being unreachable; cursor loss is the `ingest_state` row being missing or wrong. + +**Signal.** `consecutiveFailures` climbs above zero on the DO's status surface (`LabelerDiscoveryDO.fetch` returns `{ cursor, consecutiveFailures }`); the log line `jetstream subscription failed` appears with a rising `consecutiveFailures`. A flatlining `cursor` with no new assessments being created is the downstream symptom. + +**Diagnose.** Inspect the cursor directly: `SELECT * FROM ingest_state WHERE source = 'jetstream'`. The cursor is a Jetstream `time_us` (microseconds since epoch). Compare it to now — a cursor hours behind means the ingestor is replaying a backlog, not stalled. Hit the DO liveness endpoint to read `consecutiveFailures`: a non-zero value means the most recent connection attempt produced no events (Jetstream unreachable, or a `wantedCollections` mismatch). + +**Automatic behavior.** The ingestor never gives up. Connection drops, parse errors, and `queue.send` rejections all increment a backoff counter and retry, with exponential backoff (1s → 60s cap, ±20% jitter). Any successful event resets the counter, so a flapping connection doesn't spiral. The cursor is persisted only _after_ each successful enqueue, so a crash replays at most the latest event; the discovery consumer's `runKey` dedup absorbs the duplicate. The 5-minute cron's liveness ping re-instantiates the DO if workerd evicted it mid-backoff during a long outage. When Jetstream recovers, the ingestor reconnects from the persisted cursor and drains the gap on its own — **no operator action is needed for a plain outage.** + +**Operator action.** + +- **Prolonged outage, no self-recovery:** confirm Jetstream itself is up (`JETSTREAM_URL` in `wrangler.jsonc` points at `jetstream2.us-east.bsky.network`). Cloudflare offers no direct "restart this DO" button; the liveness ping is the re-instantiation path, and it runs every five minutes. +- **Cursor lost / wiped (fresh deploy, region failover, dev-state reset):** the ingestor treats an empty cursor as "start from now" — production wiring supplies no `cursorFloor`, so **the labeler does not backfill the gap between the last cursor and reconnect.** Releases published during the gap will not be discovered from the stream. Catching them up is a reconciliation concern (see [Workflow stuck runs](#workflow-stuck-runs) for the orphaned-subject signal), not something discovery does automatically. This is a known limitation, not a bug. +- **Cursor stuck in the future (`FutureCursor`-style):** if `ingest_state.cursor` is ahead of Jetstream's live position (e.g. after a restore from a newer backup), the subscription will find nothing to deliver. Correct the row to a sane `time_us` and let the loop reconnect. + +> Flag: there is no console control for the Jetstream ingestor. Inspection is via D1 (`ingest_state`) and the DO liveness endpoint; recovery is the cron's re-instantiation. A "seed cursor from a floor" recovery would need the unbuilt `cursorFloor` production wiring. + +## Queue / DLQ recovery + +**What it is.** Discovery events flow DO → `emdash-labeler-discovery` queue → `processDiscoveryBatch` (`discovery-consumer.ts`). A job that can't be processed lands in the `dead_letters` D1 table, which the console reads. Two distinct paths populate it, and it's worth knowing which: + +- **Permanent verification failure** (bad signature, MST proof, CID mismatch, a still-resolving "delete"): the consumer writes a `dead_letters` row _directly_ and acks. It never retries — a forged or unverifiable event produces no public label, ever (spec §9.1). +- **Retry exhaustion:** a transient PDS failure calls `retry()`. After `max_retries: 5` (wrangler.jsonc), Cloudflare routes the message to `emdash-labeler-discovery-dlq`, whose drain consumer (`drainDiscoveryDeadLetterBatch`) writes a `dead_letters` row (reason `UNEXPECTED_ERROR`, detail `drained from DLQ`) and acks, so the Cloudflare DLQ never grows unbounded. + +Either way, the operator-visible artifact is a `dead_letters` row with `status = 'new'`. + +**Signal.** A growing `dead_letters` list in the console. In logs: `unexpected discovery consumer error`, `discovery DLQ drain: acking job`, or `discovery processMessage threw unexpectedly`. The `reason` column classifies each: `INVALID_PROOF`, `RECORD_NOT_FOUND`, `DELETE_RECORD_PRESENT`, `PDS_HTTP_ERROR`, etc. + +**Diagnose.** Read the row's `reason` and `detail`. A cluster of the same `reason` points at a systemic cause: many `PDS_HTTP_ERROR` suggests a specific PDS is down (those should have retried, so seeing them dead-lettered means the outage outlasted the retry budget); many `INVALID_PROOF` at once could indicate a malformed upstream or an attack. The full original `DiscoveryJob` (identity + operation + cid + the unverified Jetstream record) is preserved in `dead_letters.payload` so a retry can re-enqueue an identical job. + +**Automatic behavior.** None beyond the retry budget above. A dead letter is terminal until an operator acts — by design, so a genuinely-forged event stays parked rather than silently retrying forever. + +**Operator action.** Both actions are admin-only and documented in [operating.md § Dead-letter queue](operating.md#dead-letter-queue-admin): + +- **Retry** (`POST /admin/api/dead-letters/:id/retry`) re-enqueues the stored job. Use this when the underlying cause was transient and is now resolved (the PDS is back). The consumer re-fetches and re-verifies `(uri, cid)` from the PDS; its `runKey` dedup absorbs a duplicate re-drive, so a retry is safe even if the original eventually succeeded. +- **Quarantine** (`POST /admin/api/dead-letters/:id/quarantine`) is the terminal "will not retry." Use this for a confirmed forgery or a permanently-gone record. + +Both reject an already-resolved letter with `409` (the `status = 'new'` guard on `buildDeadLetterResolveUpdate`), so two operators can't double-resolve the same letter. + +## Workflow stuck runs + +**What it is.** Each discovered subject becomes an assessment run driven by one `AssessmentWorkflow` instance (`assessment-workflow.ts`), whose id is the run's `runKey`. The whole run — acquire, code AI, image AI, history, and atomic finalization — executes inside a single durable `step.do` (`assess-subject`) with `retries: { limit: 3, delay: "10 seconds", backoff: "exponential" }`. A "stuck run" is a row that has sat in a non-terminal state (`verifying`, `pending`, `running`) past a staleness threshold — the driver crashed, the dispatch never landed, or the run has been failing its durable-step retries. + +**Signal.** The reconciliation cron logs one line per stuck run: `[labeler] reconciliation: assessment run stuck` with the run's `{ id, uri, cid, state, createdAt }`. The threshold is one hour (`DEFAULT_STALE_THRESHOLD_MS`). A related line, `verified subject has no assessment run`, flags a subject that was verified but whose run-creation left no trace (see below). + +**Diagnose.** Look up the run: `SELECT id, state, created_at, updated_at FROM assessments WHERE id = ?`. The `state` tells you where it stalled: + +- `verifying` / `pending`: discovery created the row but the Workflow dispatch or the pending→running transition never completed. +- `running`: a Workflow attempt crashed after the pending→running CAS but before finalizing. `runAssessment` is written to _resume_ a `running` row on the next attempt, so a genuinely-stuck `running` row means the durable step has exhausted its retries or the instance is gone. + +**Automatic behavior — and its limit.** The Workflow itself is resilient: a mid-run eviction re-runs the whole step, and `executeAssessmentInstance` makes that idempotent (a terminal row short-circuits; a crash-left `running` row resumes). But **reconciliation only _logs_ stuck runs — it does not re-drive them.** `reconcileAssessments` surfaces the gap and returns; there is no automatic re-dispatch. This is a deliberate scope boundary (plan W6.8): detection is built, repair is manual. Treat the log line as a to-do, not a self-healing event. + +**Operator action.** + +- **A stuck `verifying`/`pending`/`running` run:** **Re-run** (`POST /admin/api/assessments/:id/rerun`, see [operating.md § Re-running an assessment](operating.md#re-running-an-assessment)) mints a fresh run and re-issues `assessment-pending`, but the current implementation stops at `pending` — its deferred tail (`deferRerunTail`) advances the row to `pending` and publishes the label, and does **not** dispatch the Cloudflare Workflow instance that would drive it to finalization (only the discovery path calls `dispatchAssessmentWorkflow`). So a re-run today gates the release with a new `assessment-pending` and leaves it stuck rather than recovering it. Until the rerun Workflow-dispatch follow-on is wired, the practical recovery for a stuck run is a fresh discovery event for the subject (which re-dispatches on the discovery path) — retry the corresponding `dead_letters` row if one exists, or wait for the publisher's next release update. +- **An orphaned subject** (`verified subject has no assessment run`): there is no console "create run for this subject" action. The practical recovery is to re-drive discovery for that subject — if a corresponding `dead_letters` row exists, retry it; otherwise the subject will be re-assessed when the publisher next updates the release. + +> Flag: reconciliation's stuck-run handling is detection-only — it logs stuck runs and orphaned subjects, it does not re-drive them. Combined with the rerun-dispatch gap above, a run stuck in `pending`/`running` has no automatic OR manual re-drive today; its only recovery is a fresh discovery event. Wiring `dispatchAssessmentWorkflow` into the rerun tail (and/or a reconciliation re-dispatch pass) is the follow-on that closes this. + +## AI outage + +**What it is.** The code and image stages call Workers AI (`env.AI`) through `code-ai-adapter.ts` / `image-ai-adapter.ts`. When a model call fails transiently — the binding is unavailable, the model times out, or it returns unparseable output — the adapter throws `ModelTransientError`. An "AI outage" is this happening broadly. + +**Signal.** A spike in runs finalizing `error` and issuing `assessment-error`, followed (after 24h) by the prolonged-error escalation (see below). In the Workflow logs, the `assess-subject` step retrying and eventually failing. + +**Diagnose — `error` vs stuck `running` splits the cause.** Only `ModelTransientError` is caught at the stage and finalized (as `error`) on exhaustion; every _other_ adapter error (the `MAX_MODEL_INPUT_CHARS` (200,000) overflow `TypeError`, validation/JSON errors) is non-transient, propagates past the orchestrator, and is NOT finalized. The Workflow's `step.do` retries the whole step 3 times, then the Workflow fails and the row is left stuck in `running` (surfaced by the reconciliation stuck-run log after 1h). So a broad spike in **`error`** runs points at a transient model/binding outage; a spike in stuck **`running`** runs points at an input-size or config problem. Check Workers AI health for the account. + +**Automatic behavior.** The retry ladder is layered and self-healing for a brief blip: + +1. `ModelTransientError` in an adapter becomes a `StageTransientError` (`assessment-stages.ts`). +2. The orchestrator retries the stage `maxStageRetries` times, then, if still failing, marks the run transient-exhausted (`assessment-orchestrator.ts`). +3. On transient exhaustion the orchestrator finalizes the run — but never as a false pass. It resolves the findings gathered _so far_: **if a prior stage already produced a blocking finding, it finalizes `blocked`** (a block is monotonic; no unrun stage can lift it). Anything short of an already-confirmed block finalizes `error` and issues `assessment-error` — an incomplete run never concludes `passed` or `warned`. +4. Above that, the Workflow's own `step.do` retries the whole step 3 times with exponential backoff. This is the only retry layer a _non_-transient error (an oversized bundle, a config error) sees — it does not become a `StageTransientError`, so after those 3 retries the Workflow fails and the row is left stuck in `running`, not finalized as `error`. + +So a short outage is absorbed by the retries and the run completes once the model recovers. A sustained outage leaves runs in `error`, gated (the release keeps its `assessment-pending`/`assessment-error` labels), which is the safe state — the labeler failing to assess never silently clears a subject. + +**Recovery.** When the model is healthy again, **re-run the affected assessments** (`POST /admin/api/assessments/:id/rerun`). There is no automatic re-drive of `error` runs; the prolonged-error ladder (below) escalates them for attention but does not retry them. For a large backlog, re-run the runs surfaced by the prolonged-error operator alerts first. + +## Artifact acquisition / mirror-fallback outage + +**What it is.** The acquire stage (`artifact-acquisition.ts`) resolves a verified release to its signed artifact, fetches it under the SSRF-hardened `fetchVerifiedResource`, verifies the bytes against the signed checksum, and unpacks the bundle. The target is resolved from the aggregator (`release-resolution.ts`) — the declared artifact URL is _not_ stored labeler-side, only the pinned `artifact_id`/`artifact_checksum`; `release-resolution` reads the signed release over the `AGGREGATOR` service binding. A "broad acquisition failure" is many runs unable to fetch or resolve. + +**The mirror seam.** The source preference is `["mirror", "declared-url"]`, but **v1 ships no mirror binding** — `deps.mirror` is absent, so the mirror source always misses and every acquisition falls through to the publisher's declared URL. There is no mirror to fall back _from_ today; "mirror-fallback" is a seam that activates when a mirror binding is injected, not a live redundancy. + +**Signal.** A spike of `error` runs (like an AI outage), driven by acquire-stage `StageTransientError`. The `privateDetail` on any resulting deterministic finding records the acquisition classification, e.g. `Acquisition failed (transient/FETCH_FAILED) from declared-url: ...`. + +**Diagnose — the classification is the key.** Acquisition sorts every failure into four categories, and the category determines whether it's a transient error or a public block: + +| Category | Cause | Disposition | +| --- | --- | --- | +| `mirror-miss` | No mirror object (always, in v1) | Retry → falls through to declared URL | +| `transient` | Network/timeout/5xx, size cap tripped mid-fetch, malformed _declared_ checksum | Retry → `assessment-error`. **Never a public block** — a transport failure is not evidence the plugin is bad (spec §9.4) | +| `permanent-mismatch` | `CHECKSUM_MISMATCH` on fetched bytes, or pinned-vs-declared `COORDINATE_MISMATCH` | Permanent blocking `artifact-integrity-failure` finding | +| `policy-rejection` | A checksum-verified bundle that is structurally invalid, or a non-UTF-8 code file | Permanent blocking `invalid-bundle` finding, or (for URL-safety refusals) retry | + +Also transient: **a release the aggregator hasn't indexed yet.** `release-resolution` throws `StageTransientError` for an absent release — that's aggregator lag, not a deletion (reconciliation owns true deletions). A broad "not indexed by the aggregator yet" spike points at the aggregator lagging or down, not the publishers' origins. + +**Automatic behavior.** Transient acquisition failures ride the same stage-retry → Workflow-retry ladder as an AI outage. A permanent integrity/bundle failure is a real block — it finalizes correctly and is not an outage to recover from. So "acquisition is failing broadly" almost always means a wave of `transient`/aggregator-lag failures, which self-heal when the cause clears. + +**Operator action.** + +- **A specific origin is down** (publisher's declared URL unreachable): nothing to do centrally — those runs sit in `error` and self-heal when the origin returns; re-run to confirm. +- **The aggregator is lagging/down** (mass "not indexed yet"): this is an aggregator incident. The labeler's runs correctly hold in `error`; recover the aggregator, then re-run the backlog. +- **A cluster of `permanent-mismatch`/`policy-rejection`** is _not_ an outage — those are genuine integrity blocks. Only treat it as an incident if you suspect a false classification (e.g. a labeler-side bug), in which case use **override** (see [operating.md § False-positive override](operating.md#false-positive-override)) on the specific release, not a blanket action. + +## Subscriber lag + +**What it is.** The labeler publishes its append-only label stream over `com.atproto.label.subscribeLabels`, served by `LabelSubscriptionDO` (`subscribe-labels.ts`). A downstream consumer (an aggregator) connects with a WebSocket and an optional `?cursor=N` to replay from sequence `N`, then follows live. "Subscriber lag" is a consumer that has fallen behind the live sequence. + +**The labeler's role is producer-side.** The lagging component is the _subscriber's_ own infrastructure; the labeler cannot push a third party to catch up. What the labeler guarantees is that the stream is always replayable from D1 by cursor, and that a slow subscriber is shed cleanly rather than allowed to build unbounded buffer. + +**Signal.** From the labeler side: sockets being closed with code `1013` (`subscriber must reconnect with a cursor`) — the backpressure cutoff. The DO returns `503 "label subscriptions are busy"` when its high- or low-priority queue hits 100 items. A downstream aggregator's own "labels behind" metric is the more direct signal, but that lives on the consumer. + +**Diagnose.** The label log is `issued_labels`, ordered by monotonic `sequence`; `SELECT MAX(sequence) FROM issued_labels` is the live head. A subscriber's lag is head minus its last-acked cursor. A subscriber that connects with a cursor _ahead_ of the head gets a `FutureCursor` error and is closed — that means the subscriber's persisted cursor is corrupt or from a different (e.g. restored) stream. + +**Automatic behavior.** The DO is built to keep a slow subscriber from harming others: each connection has a 1 MB buffered-bytes ceiling (`MAX_CONNECTION_BYTES`); a subscriber that can't keep up is closed with `1013` rather than buffering forever. Replay and live delivery are paged (`REPLAY_PAGE_SIZE = 100`) and round-robined across pending sockets so one catching-up subscriber doesn't starve the rest. D1 remains the durable log, so a reconnect always resumes exactly where the cursor left off — nothing is dropped. + +**Operator action.** Recovery is the subscriber reconnecting with its last good cursor; the labeler serves the replay from `issued_labels`. There is no labeler-side control to "speed up" a subscriber. If a subscriber is stuck on `FutureCursor`, it must reset its cursor to a valid sequence (≤ the labeler's head). For a subscriber that repeatedly hits the `1013` cutoff, the fix is on the consumer (larger read throughput / smaller processing per event); the labeler is behaving correctly by shedding it. See also [Full aggregator replay](#full-aggregator-replay) for a from-scratch rebuild. + +## Publisher reconsideration + +**What it is.** A blocked or warned publisher can contest a decision. Every public assessment view carries a `reconsiderationUrl` (from the moderation policy's `contact.reconsiderationUrl`, currently `https://emdashcms.com/plugin-moderation/reconsideration`) and the assessment's opaque id — the same id the public assessment API returns, which reveals no private finding detail. A reviewer then manages the case in the console (plan W10.6): open → note → resolve. + +**Signal.** This is not an outage — it's an operational workflow triggered by a publisher contact arriving through the reconsideration intake. Treat a spike in reconsiderations for one policy area as a signal your automation may be mis-calibrated for that class of plugin. + +**Diagnose.** Open the referenced assessment in the console to see its findings, the operator-only `privateDetail`, and the live labels. The public state the publisher sees is derived by `public-assessment.ts`; compare it against the private findings to judge the contest. + +**Operator action.** The three console mutations (admin/reviewer per the policy grants): + +- **Open** (`POST /admin/api/reconsiderations/open`) — creates one case for the subject, with a private opening note. A second open for a subject that already has an open case is a `409` (`RECONSIDERATION_OPEN_EXISTS`). +- **Note** (`POST /admin/api/reconsiderations/:id/note`) — appends a private note (max 10,000 chars). +- **Resolve** (`POST /admin/api/reconsiderations/:id/resolve`) — sets the outcome (`granted` | `denied` | `withdrawn`) and closes the case. Resolving an already-resolved case is a `409` (`RECONSIDERATION_RESOLVED`). + +If the outcome is that the block was wrong, the corrective action is a separate control: **override** the false positive (see [operating.md § False-positive override](operating.md#false-positive-override)), which permanently suppresses the automated block. Resolving the reconsideration records the decision; it does not itself move labels. + +> Flag: the reconsideration _intake_ (the `reconsiderationUrl` form/inbox at `emdashcms.com`) is maintainer-owned and lives outside this Worker. The code owns case management and the public assessment view; how a publisher's submission reaches an operator (the monitored inbox behind that URL) is an operator-owned procedure. + +## Emergency takedown / retraction + +**What it is.** The admin-only emergency actions are the hard, out-of-band controls for abuse that can't wait for assessment: a `!takedown` redaction on a release, package, or publisher, and a `publisher-compromised` marking. Their console ceremony is in [operating.md § Emergency actions](operating.md#emergency-actions-admin). This runbook covers the _incident_ decision: when to reach for them and what they do to enforcement. + +**When to use — takedown.** A takedown is a single URI-wide (or DID-wide) `!takedown` label the evaluator honors for everything beneath it — it is not fanned out into per-release labels. Reach for it when content is actively harmful and must be suppressed immediately and completely, above and beyond what a per-finding block expresses. A package- or publisher-level takedown suppresses the whole subtree in one label. + +**When to use — publisher-compromised.** Use `publisher-compromised` when a publisher's signing identity or account is believed compromised, so their releases should not be trusted pending investigation — distinct from a content-quality block on a single release. + +**What retraction restores.** Retraction is not a fresh computation — it restores the state that existed _before_ the emergency label. Retracting a takedown (`CONFIRM RETRACT`) pulls the single takedown label; the automated blocks that were live underneath **re-expose**, because they were never negated and nothing is re-issued (the takedown sat _above_ them). If there is no active label to pull, the retract returns `404 NO_ACTIVE_LABEL`. Contrast this with a false-positive override-retract, which does _not_ restore the original blocks (see [operating.md § False-positive override](operating.md#false-positive-override)) — know which you're undoing. + +**Diagnose before acting.** Confirm the subject identifier (record `rkey` for a release/package, the DID's final `:`-segment for a publisher) and that you are acting on the right subject — the two-field ceremony (identifier + exact intent phrase) exists precisely to prevent a misfire. Check the subject's current labels first so you know what a later retract will restore. + +**Audit.** Every emergency action writes an `operator_actions` row (who, reason, idempotency key) and raises an `operational_events` row (`emergency-takedown` / `publisher-compromised`, severity `critical`) for the alert pipeline. See [Audit evidence & incident communications](#audit-evidence--incident-communications). + +## Full aggregator replay + +**What it is.** A downstream aggregator rebuilding its view of this labeler's decisions from scratch — after data loss, a trust reset, or onboarding a new consumer. + +**How it works.** The label log is append-only and totally ordered by `sequence`. Negations are themselves labels (`neg: true`), so the full history — every issue and every retraction — is expressed as a forward sequence with no in-place mutation. A consumer replays the whole stream by connecting to `subscribeLabels` with `cursor=0` (or its last known good sequence) and reading forward; `LabelSubscriptionDO` pages the replay out of `issued_labels` (`REPLAY_PAGE_SIZE = 100`) and transitions the socket to live delivery once it catches the head. `queryLabels` (the pull XRPC) is the paged alternative for a bulk backfill by URI pattern. + +**Re-signing during replay — the two paths differ.** Only `queryLabels` re-signs on the fly. `queryLabels` (the pull XRPC) lazily re-signs any returned label whose `signing_key_version` differs from the active version (`resignStaleLabels`), preserving the original `cts` and keeping the prior signature in `label_signature_history` — so a `queryLabels` backfill after a key rotation verifies cleanly against the current DID document. `subscribeLabels` does **not**: `LabelSubscriptionDO.labelsAfter()` reads `issued_labels` rows verbatim and sends them as-signed, so a WebSocket replay from `cursor=0` after a rotation still serves labels under the _retired_ key until a proactive `queryLabels` sweep has re-signed them. If a `subscribeLabels` replay must verify against only the current key, drive a full `queryLabels` sweep first. (See [Historical re-signing](#historical-re-signing).) + +**Operator action.** None on the labeler beyond ensuring it's healthy — replay is a consumer-initiated read. For a replay that must verify under the current key after a rotation, have the consumer refresh its DID resolution and pull the backfill via `queryLabels` (or run a full `queryLabels` sweep before pointing it at `subscribeLabels`). If the labeler's own D1 label log has been lost, replay cannot reconstruct it (there is no separate backup restore today — see the note under [Key lifecycle & custody](#key-lifecycle--custody)); the append-only log in D1 _is_ the system of record. + +--- + +# Key-management runbooks + +These are security-critical. The signing key is the labeler's authority — anyone holding it can forge decisions under the labeler's DID. Read carefully and do not improvise ordering. + +## Key lifecycle & custody + +**What the key is.** The labeler signs labels with a P-256 key. Its identity is `did:web:labels.emdashcms.com` (host-level `did:web`), and the labeler _serves its own DID document_ (`identity.ts`, `serviceDidDocument`) exposing a single `#atproto_label` verification method whose `publicKeyMultibase` is the configured public key. + +**Where it lives (config vs. secret).** + +| Piece | Where | Consumed by | +| --- | --- | --- | +| `LABEL_SIGNING_KEY_VERSION` | `wrangler.jsonc` var (e.g. `v1`) | Config; stamped on issued labels; gates issuance against the DB active version | +| `LABEL_SIGNING_PUBLIC_KEY` | `wrangler.jsonc` var (P-256 Multikey) | The served DID document; validated canonical | +| `LABEL_SIGNING_PRIVATE_KEY` | **Secret** (Secrets Store), read via `getRuntimeSigningSecret` | Builds the runtime signer (`signing-runtime.ts`) | + +The runtime signer is built from the config public key + version and the secret private key. The DB (`signing_state`) is the _authoritative gate_: `buildIssuanceStatements` refuses to issue if the config's key version doesn't match the DB's active key version, or if the phase is `paused`. + +**Offline custody.** The production signing scalar's offline copy lives in the maintainer's Keeper vault. The custody procedure — who can open the vault, dual-control, where the recovery material sits — is maintainer-owned; this runbook records that the offline authority exists and that any rotation or compromise response must be able to reach it, but it does not (and must not) restate the vault access details. + +> Flag: **there is no backup/restore or retention tooling yet** (plan W11.5 is unbuilt). The D1 label log, signing state, and audit tables are the system of record with no automated export/restore path. Any runbook step that says "restore from backup" is a prerequisite on W11.5, not a procedure you can run today. Treat D1 durability and the offline key copy as the only recovery anchors. + +## Routine rotation + +**Goal.** Move signing from the current key to a new one with no forged-label window and no break in downstream verification. The mechanism is the `signing_state` / `signing_key_versions` state machine in `signing-rotation.ts`, combined with a config/secret deploy of the new key. + +**The invariant that makes it safe.** Issuance is gated on the DB active key version matching config, and it stops entirely while the phase is `paused`. Historical labels are re-signed to the active key lazily on query, so the served DID document only ever needs to expose _one_ key — the active one. Rotation therefore does not rely on the DID document carrying two keys at once; it relies on pausing issuance during the swap and re-signing history afterward. + +**Procedure (ordering matters).** + +1. **Begin (pause).** `beginRoutineKeyRotation(db, { rotationId, expectedActiveKeyVersion, nextKeyVersion, nextPublicKeyMultibase })` sets the phase to `paused` and records the pending key. From this point, all issuance throws `LabelIssuanceUnavailableError` — the discovery consumer _retries_ rather than dead-letters, so no in-flight label is lost; it's re-driven after resume. Manual and automated issuance are both held. +2. **Deploy the new key.** Set `LABEL_SIGNING_PUBLIC_KEY` and `LABEL_SIGNING_KEY_VERSION` to the new key (vars) and update the `LABEL_SIGNING_PRIVATE_KEY` secret, then redeploy. Now the runtime signer signs with the new key and the served DID document advertises the new public key. +3. **Activate.** `activateRoutineKeyRotation(db, { signer, keyVersion, publicKeyMultibase, rotationId })` verifies the deployed runtime signer actually produces a valid signature under the pending public key (it signs a throwaway `rotation-check` label and verifies it), then atomically retires the old key version, activates the new one, and returns the phase to `active`. **Activation is blocked while any label is still `publication_pending = 1` under the old key version** — un-notified labels must drain to subscribers first, so no in-flight publication is stranded under a retired key. Because activation _requires_ the deployed signer to hold the new key, step 3 can only succeed after step 2. +4. **Resume is implicit** — returning to `active` re-opens issuance; discovery re-drives anything it retried during the pause. + +If something is wrong with the pending key, `abortRoutineKeyRotation(db, { rotationId, expectedPendingKeyVersion })` returns the phase to `active` on the _old_ key and marks the pending version `aborted`. + +**Verification window to minimize.** Between step 2 (new key deployed and served in the DID doc) and step 3 (DB activated), the DID document advertises the new key while the DB active version is still the old one. This window is NOT fail-closed for the most recent key: `queryLabels` only re-signs (or `503`s, while paused) labels whose stored `signing_key_version` differs from the DB-active version — and during this window the DB-active key is still the old one, so labels signed under it are considered current and served as-is (`200`) with the old-key signature. A consumer verifying against the freshly-served new-key DID document will reject them. (Labels signed under an even-earlier retired key do `503` while paused, since they are stale relative to the DB-active version.) The takeaway is the same: keep the deploy→activate gap short, because during it recent labels are served with a signature that won't verify against the advertised key. + +**Rotation status & alerts.** `getSigningStatus` exposes the current phase and active/pending versions; `listSigningAlerts` returns `signing_events` alert rows. Watch for `ROTATION_ACTIVATION_MISMATCH`, `ROTATION_SIGNER_MISMATCH`, `ROTATION_ACTIVATION_RACE` — each means activation's guards rejected the attempt. + +> Flag: the rotation functions (`beginRoutineKeyRotation`, `activateRoutineKeyRotation`, `abortRoutineKeyRotation`, `initializeSigningState`) are implemented and tested but **are not wired to any operator interface** — no console route, no CLI command, no cron drives them. Invoking a production rotation today means executing these functions against the production D1 binding through operator-owned tooling (a one-off script/Worker), coordinated with the wrangler var/secret deploy. Building that operator interface is a prerequisite for a self-serve rotation. + +## Compromise response + +**When the private key may be exposed.** Speed matters, but the ordering still matters more — a wrong sequence can strand verification. + +1. **Stop issuing first — use the kill-switch, not a rotation.** Pause automation immediately (`POST /admin/api/automation/pause`, [operating.md § kill-switch](operating.md#the-automation-kill-switch-admin)) to halt automated ingestion, and if you must also stop _manual_ issuance, `beginRoutineKeyRotation` moves the signing phase to `paused` (which blocks all issuance). The kill-switch stops new automated decisions from being signed with a possibly-stolen key while you assess. +2. **Identify the compromised key.** The active version is in `signing_state.active_key_version`; `signing_key_versions` lists every version and its status. Labels signed under it carry that `signing_key_version` in `issued_labels`. +3. **Rotate to a fresh key** using the routine procedure above (begin → deploy new key/secret → activate). The compromised version is retired; new issuance uses the new key. +4. **DID update ordering.** Same as routine rotation: the new public key reaches the served DID document via the config deploy (step 2 of rotation), and activation gates on the deployed signer proving it holds the new key. Do not switch the DB active version ahead of deploying the key the signer will actually use. +5. **Historical re-signing decision.** Labels signed under the compromised key remain valid signatures — the concern is not that they're forged but that the _attacker_ could forge _new_ ones. After rotation, historical labels re-sign to the new key lazily on query (see below), so downstream verifiers converge on the new key automatically. Decide whether to force a proactive re-sign of the whole log (a bulk `queryLabels` sweep re-signs as a side effect) versus letting it happen on demand. +6. **Subscriber recovery.** Once rotated, downstream consumers verifying against the (now new-key) DID document will accept re-signed labels; a consumer that cached the old key should refresh its DID resolution. A full replay (see [Aggregator replay after a key event](#aggregator-replay-after-a-key-event)) re-serves everything under the new key. + +> Flag: the same "no wired rotation interface" limitation applies here, and it bites harder under time pressure — a compromise response depends on operator-owned tooling to drive the rotation functions against production D1. The Keeper-held offline key is what you rotate _to_; reaching it is the maintainer-owned custody procedure. + +## Issuance pause/resume + +**Two different pauses — know which you need.** + +- **The automation kill-switch** (`automation_state`, [operating.md § kill-switch](operating.md#the-automation-kill-switch-admin)) halts _automated ingestion_ only. Manual issuance and reruns stay available; discovery holds and retries paused events (`readAutomationPaused` in the discovery consumer re-drives them on resume). It fails closed — an unreadable switch means automation does not run. Use it for a discovery-side or model-side incident where you want to stop the firehose but keep operating manually. +- **The signing pause** (`signing_state.phase = 'paused'`, set by `beginRoutineKeyRotation`) halts _all_ issuance — manual and automated — because `buildIssuanceStatements` throws `LabelIssuanceUnavailableError` while paused. Use it only as part of a key rotation or compromise response, where signing itself must stop. + +**Resume.** The kill-switch resumes with `POST /admin/api/automation/resume` (a `reason` is required; no ceremony). The signing pause resumes by completing or aborting the rotation (`activateRoutineKeyRotation` / `abortRoutineKeyRotation`) — there is no standalone "unpause signing" that leaves a rotation half-done. + +## Historical re-signing + +**What it is and when it fires.** After a rotation, existing labels still carry signatures under the old key version. `queryLabels`'s `resignStaleLabels` (`query-labels.ts`) re-signs, on read, any returned label whose `signing_key_version` differs from the current active version. It: + +- signs the label afresh under the active key, **preserving the original `cts`** (the label's meaning and timestamp are unchanged — only the signature is refreshed); +- records the prior signature in `label_signature_history` (`ON CONFLICT DO NOTHING`), keeping an auditable trail of what the label was signed with before; +- updates `issued_labels` in place with the new signature, guarded on the signing state still being `active` on that exact key — a mid-flight rotation aborts the re-sign with a `503` rather than writing under the wrong key. + +**When it refuses.** Re-signing only runs when the phase is `active`. During a rotation pause, a query touching a stale label returns `503 "label signing is temporarily unavailable"` — the labeler will not serve a label it can't currently re-sign to the advertised key. If the active signing configuration doesn't match the DB state, it raises `RESIGN_CONFIGURATION_MISMATCH` and refuses. + +**Operator action.** Normally none — re-signing is automatic and lazy. To force the whole log onto the new key proactively (e.g. after a compromise, to shrink the window where old-key signatures are still served), drive a full `queryLabels` sweep across all URI patterns; each page re-signs its stale labels as a side effect. Watch for `RESIGN_SIGN_FAILED` / `RESIGN_STATE_CHANGED` alerts, which mean a re-sign hit a signing error or a concurrent state change. + +## Aggregator replay after a key event + +A downstream consumer recovering after a rotation or compromise doesn't need the old key, because historical labels re-sign to the active key lazily on `queryLabels`. But the recovery path matters — only `queryLabels` re-signs, not `subscribeLabels` (see [Full aggregator replay](#full-aggregator-replay)). A consumer that: + +1. refreshes its DID resolution for `did:web:labels.emdashcms.com` (picking up the new `#atproto_label` public key), and +2. pulls the backfill via `queryLabels` — or, if it must replay over the `subscribeLabels` WebSocket, drives a full `queryLabels` sweep FIRST so the stored rows are re-signed, + +will receive every label with a signature valid under the current key and verify cleanly. Pointing a consumer straight at `subscribeLabels` from `cursor=0` without that prior sweep replays retired-key signatures and will fail verification against the new DID document. The `cts` values are unchanged, so ordering and dedup are unaffected. The re-signing is what makes a post-rotation replay verify without the consumer ever holding a retired key. + +## Audit evidence & incident communications + +**What to capture.** Every operator action and system event is already recorded append-only — your job during an incident is to reference it, not reconstruct it: + +- **`operator_actions`** — every console mutation (who, via the Access `sub`; the action; the reason; the idempotency key). Immutable: rows are never updated or deleted ([operating.md § Audit log](operating.md#audit-log)). +- **`issuance_actions`** — automated block/warn/retraction provenance. +- **`operational_events`** — the alert-grade events: `emergency-takedown`, `publisher-compromised`, `automation-paused`/`-resumed`, `dead-letter-retried`/`-quarantined`, `reconsideration-opened`/`-resolved`, `assessment-prolonged-error`. The payload is deliberately public-safe — subject URI and label value in dedicated columns, operator reason only, **no findings, private detail, or evidence refs** — so an event can be forwarded to an alert channel without leaking exploit detail. +- **`signing_events`** — rotation transitions and signing alerts (`ISSUANCE_PAUSED`, `STALE_SIGNING_KEY`, `SIGNING_KEY_MISMATCH`, the `ROTATION_*` and `RESIGN_*` codes). +- **`dead_letters`** — discovery forensics, with the full original job payload. + +**The prolonged-error escalation as an evidence source.** A terminal `error` run that stays the live, unsuperseded assessment past 24h raises an `assessment-prolonged-error` operational event (severity `high`) so operators can triage an infra-vs-publisher cause; past 72h, if still unresolved, the publisher is notified (`prolonged-error.ts`, fire-once via `assessment_error_escalations`). During an AI or acquisition outage, these events are your queue of runs needing a re-run. Note the escalation ladder is driven by the 5-minute cron; a backlog beyond the scan batch (`PROLONGED_ERROR_SCAN_BATCH = 200`) logs `prolonged-error escalation scan hit the batch cap`. + +**Communications.** Publisher-facing communication flows through the notification pipeline (block/warn/override/retraction/takedown notices and the 72h prolonged-error notice), which is double-opt-in and carries only public summary/effect/reconsideration URL — never private detail. Operator-to-operator and external incident comms are maintainer-owned; the tables above are the factual record to draw from. + +> Flag: turning these tables and log lines into live alerts is the W11.3 alerting layer. Until it lands, the `operational_events`/`signing_events` rows and the `[labeler]` structured logs are the raw material — wire them into your own log-based alerting. From 3515c3894e785fdf4d65d49871b0d3f64a51a38b Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Fri, 17 Jul 2026 21:00:08 +0000 Subject: [PATCH 124/137] style: format --- apps/labeler/docs/runbooks.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/labeler/docs/runbooks.md b/apps/labeler/docs/runbooks.md index 5207a3a59f..449b03b9a4 100644 --- a/apps/labeler/docs/runbooks.md +++ b/apps/labeler/docs/runbooks.md @@ -118,12 +118,12 @@ So a short outage is absorbed by the retries and the run completes once the mode **Diagnose — the classification is the key.** Acquisition sorts every failure into four categories, and the category determines whether it's a transient error or a public block: -| Category | Cause | Disposition | -| --- | --- | --- | -| `mirror-miss` | No mirror object (always, in v1) | Retry → falls through to declared URL | -| `transient` | Network/timeout/5xx, size cap tripped mid-fetch, malformed _declared_ checksum | Retry → `assessment-error`. **Never a public block** — a transport failure is not evidence the plugin is bad (spec §9.4) | -| `permanent-mismatch` | `CHECKSUM_MISMATCH` on fetched bytes, or pinned-vs-declared `COORDINATE_MISMATCH` | Permanent blocking `artifact-integrity-failure` finding | -| `policy-rejection` | A checksum-verified bundle that is structurally invalid, or a non-UTF-8 code file | Permanent blocking `invalid-bundle` finding, or (for URL-safety refusals) retry | +| Category | Cause | Disposition | +| -------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `mirror-miss` | No mirror object (always, in v1) | Retry → falls through to declared URL | +| `transient` | Network/timeout/5xx, size cap tripped mid-fetch, malformed _declared_ checksum | Retry → `assessment-error`. **Never a public block** — a transport failure is not evidence the plugin is bad (spec §9.4) | +| `permanent-mismatch` | `CHECKSUM_MISMATCH` on fetched bytes, or pinned-vs-declared `COORDINATE_MISMATCH` | Permanent blocking `artifact-integrity-failure` finding | +| `policy-rejection` | A checksum-verified bundle that is structurally invalid, or a non-UTF-8 code file | Permanent blocking `invalid-bundle` finding, or (for URL-safety refusals) retry | Also transient: **a release the aggregator hasn't indexed yet.** `release-resolution` throws `StageTransientError` for an absent release — that's aggregator lag, not a deletion (reconciliation owns true deletions). A broad "not indexed by the aggregator yet" spike points at the aggregator lagging or down, not the publishers' origins. @@ -203,11 +203,11 @@ These are security-critical. The signing key is the labeler's authority — anyo **Where it lives (config vs. secret).** -| Piece | Where | Consumed by | -| --- | --- | --- | -| `LABEL_SIGNING_KEY_VERSION` | `wrangler.jsonc` var (e.g. `v1`) | Config; stamped on issued labels; gates issuance against the DB active version | -| `LABEL_SIGNING_PUBLIC_KEY` | `wrangler.jsonc` var (P-256 Multikey) | The served DID document; validated canonical | -| `LABEL_SIGNING_PRIVATE_KEY` | **Secret** (Secrets Store), read via `getRuntimeSigningSecret` | Builds the runtime signer (`signing-runtime.ts`) | +| Piece | Where | Consumed by | +| --------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `LABEL_SIGNING_KEY_VERSION` | `wrangler.jsonc` var (e.g. `v1`) | Config; stamped on issued labels; gates issuance against the DB active version | +| `LABEL_SIGNING_PUBLIC_KEY` | `wrangler.jsonc` var (P-256 Multikey) | The served DID document; validated canonical | +| `LABEL_SIGNING_PRIVATE_KEY` | **Secret** (Secrets Store), read via `getRuntimeSigningSecret` | Builds the runtime signer (`signing-runtime.ts`) | The runtime signer is built from the config public key + version and the secret private key. The DB (`signing_state`) is the _authoritative gate_: `buildIssuanceStatements` refuses to issue if the config's key version doesn't match the DB's active key version, or if the phase is `paused`. From 7c4b84760d242272ccaab296c783d70b354cc27a Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 22:35:54 +0100 Subject: [PATCH 125/137] fix(labeler): dispatch the assessment Workflow on operator rerun (#2103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runRerun minted a fresh assessment run, gated the release with assessment-pending, and advanced the run to pending in its deferred tail, but never dispatched the Cloudflare Workflow that executes it — stranding the release pending forever until the reconciliation stuck-run alert. ConsoleMutationDeps lacked the workflow binding entirely. Thread AssessmentWorkflowBinding into ConsoleMutationDeps (wired from env.ASSESSMENT_WORKFLOW), and dispatch the run in deferRerunTail after advancing to pending, keyed on the run's runKey. The operator trigger yields a fresh runKey, so the rerun gets its own instance; a re-driven tail or replay dedups onto it via the instance-id lock. --- apps/labeler/src/console-mutation-api.ts | 32 +++- apps/labeler/src/index.ts | 1 + .../test/console-assessment-mutations.test.ts | 138 ++++++++++++++++++ .../labeler/test/console-mutation-api.test.ts | 4 + .../test/console-reconsiderations.test.ts | 4 + 5 files changed, 173 insertions(+), 6 deletions(-) diff --git a/apps/labeler/src/console-mutation-api.ts b/apps/labeler/src/console-mutation-api.ts index 088c7b4253..4e869e9f94 100644 --- a/apps/labeler/src/console-mutation-api.ts +++ b/apps/labeler/src/console-mutation-api.ts @@ -21,6 +21,10 @@ import type { OperatorIdentity, OperatorRole, } from "./access-auth.js"; +import { + type AssessmentWorkflowBinding, + dispatchAssessmentWorkflow, +} from "./assessment-dispatch.js"; import { automatedIdempotencyKey, computeRunKey, @@ -162,6 +166,10 @@ export interface ConsoleMutationDeps { * cannot join the D1 batch, so it runs in the deferred tail; the discovery * consumer's `runKey` dedup absorbs a duplicate re-drive. */ sendDiscoveryJob: (job: DiscoveryJob) => Promise; + /** Dispatches a rerun's fresh assessment run to its Workflow instance (the same + * binding discovery uses). The instance id is the run's `runKey`, so a + * redelivered or replayed rerun dedups onto the same instance. */ + assessmentWorkflow: AssessmentWorkflowBinding; /** Publisher-notification deps (plan W10.5). When present, a label-affecting * operator action fires a post-commit publisher notice in the deferred tail * (never blocking or failing the label). Omitted in tests that don't exercise @@ -569,7 +577,19 @@ function deferRerunTail(deps: ConsoleMutationDeps, runId: string, pendingKey: st (async () => { try { const run = await getAssessment(deps.db, runId); - if (run) await advanceAssessmentToPending(deps.db, run, deps.now()); + if (run) { + await advanceAssessmentToPending(deps.db, run, deps.now()); + // The run's runKey is its Workflow instance id, so a re-driven tail + // (defer retry or replay branch) dedups onto the same instance. A + // dispatch failure here is logged and dropped — unlike discovery, + // which retries the queue message, this deferred tail has no retry + // lever, so a dropped dispatch strands the run `pending` until the + // reconciliation stuck-run sweep surfaces it for a manual re-trigger. + await dispatchAssessmentWorkflow(deps.assessmentWorkflow, { + runKey: run.runKey, + assessmentId: runId, + }); + } } catch (error) { console.error("[console-mutation] rerun advance failed", error); } @@ -639,11 +659,11 @@ function deferReconsiderationNotify( * `POST /admin/api/assessments/:id/rerun` — mints the immutable operator trigger * (`operator:`), creates a fresh run for the assessment's exact URI+CID * anchored to that trigger, and re-issues `assessment-pending`, all in one atomic - * batch with the audit row (spec §10/§11.2). Initial discovery now dispatches an - * assessment Workflow after `pending`; this rerun path still stops at `pending` - * (its own Workflow dispatch is a follow-on). The operator trigger yields a - * distinct `runKey`, so the rerun maps to its own Workflow instance id rather - * than colliding with the prior run's — the re-assessment is not stranded. + * batch with the audit row (spec §10/§11.2). The deferred tail then advances the + * fresh run to `pending` and dispatches its assessment Workflow, mirroring + * discovery. The operator trigger yields a distinct `runKey`, so the rerun maps + * to its own Workflow instance id rather than colliding with the prior run's — + * the re-assessment runs rather than stranding `pending`. */ async function runRerun( request: Request, diff --git a/apps/labeler/src/index.ts b/apps/labeler/src/index.ts index 13b31063d6..36cffc7efd 100644 --- a/apps/labeler/src/index.ts +++ b/apps/labeler/src/index.ts @@ -206,6 +206,7 @@ async function handleConsoleApiRequest( sendDiscoveryJob: async (job) => { await env.DISCOVERY_QUEUE.send(job); }, + assessmentWorkflow: env.ASSESSMENT_WORKFLOW, notify: await safeCreateNotifyDeps(env), }); } diff --git a/apps/labeler/test/console-assessment-mutations.test.ts b/apps/labeler/test/console-assessment-mutations.test.ts index f4fb21cb30..6521f0a3ce 100644 --- a/apps/labeler/test/console-assessment-mutations.test.ts +++ b/apps/labeler/test/console-assessment-mutations.test.ts @@ -9,6 +9,10 @@ import { generateKeyPair, SignJWT } from "jose"; import { beforeAll, describe, expect, it } from "vitest"; import type { AccessKeyResolver } from "../src/access-auth.js"; +import type { + AssessmentWorkflowBinding, + AssessmentWorkflowParams, +} from "../src/assessment-dispatch.js"; import { computeRunKey, initialTriggerId } from "../src/assessment-lifecycle.js"; import { createAssessmentRun, @@ -91,6 +95,37 @@ function testSigner() { }); } +interface FakeInstance { + id: string; + params: AssessmentWorkflowParams; +} + +/** In-memory stand-in for the assessment Workflow binding, mirroring the + * discovery consumer test's fake: `create` throws when the id is already taken + * (the run-key instance-id lock), `get` resolves it, and `createError` + * simulates an infrastructure failure. */ +class FakeAssessmentWorkflow implements AssessmentWorkflowBinding { + readonly instances = new Map(); + readonly created: FakeInstance[] = []; + createError: Error | undefined; + + create(options: { id: string; params: AssessmentWorkflowParams }): Promise<{ id: string }> { + if (this.createError) return Promise.reject(this.createError); + if (this.instances.has(options.id)) + return Promise.reject(new Error(`instance ${options.id} already exists`)); + const instance = { id: options.id, params: options.params }; + this.instances.set(options.id, instance); + this.created.push(instance); + return Promise.resolve({ id: options.id }); + } + + get(id: string): Promise<{ id: string }> { + const instance = this.instances.get(id); + if (!instance) return Promise.reject(new Error(`instance ${id} not found`)); + return Promise.resolve({ id }); + } +} + function mutationDeps(overrides: Partial = {}): ConsoleMutationDeps { return { db: testEnv.DB, @@ -109,10 +144,30 @@ function mutationDeps(overrides: Partial = {}): ConsoleMuta void work; }, sendDiscoveryJob: async () => {}, + assessmentWorkflow: new FakeAssessmentWorkflow(), ...overrides, }; } +/** Captures deferred tail work so a test can settle it and observe the rerun's + * Workflow dispatch — the default `mutationDeps` drops deferred work. */ +function captureDeferred(overrides: Partial = {}): { + deps: ConsoleMutationDeps; + workflow: FakeAssessmentWorkflow; + settle: () => Promise; +} { + const workflow = new FakeAssessmentWorkflow(); + const deferred: Promise[] = []; + const deps = mutationDeps({ + assessmentWorkflow: workflow, + defer: (work) => { + deferred.push(work); + }, + ...overrides, + }); + return { deps, workflow, settle: () => Promise.all(deferred.splice(0)) }; +} + function readDeps(overrides: Partial = {}): ConsoleApiDeps { return { db: testEnv.DB, @@ -425,6 +480,89 @@ describe("rerun", () => { ); expect(response.status).toBe(404); }); + + it("dispatches the rerun's fresh run to its own Workflow instance", async () => { + const { id } = await seedRun("rerun-dispatch"); + const { deps, workflow, settle } = captureDeferred(); + const response = await handleConsoleMutation( + post(`/admin/api/assessments/${id}/rerun`, { + confirmation: CID, + reason: "re-assess this release", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(response.status).toBe(200); + const descriptor = await bodyData<{ runId: string }>(response); + + // Dispatch is off the response path; the deferred tail carries it. + expect(workflow.created).toHaveLength(0); + await settle(); + + const run = await getAssessment(testEnv.DB, descriptor.runId); + // The tail advances the fresh run to pending, then hands it to a Workflow + // instance whose id is the run's runKey — distinct from the original run. + expect(run?.state).toBe("pending"); + expect(workflow.created).toHaveLength(1); + expect(workflow.created[0]).toMatchObject({ + id: run!.runKey, + params: { assessmentId: descriptor.runId }, + }); + }); + + it("re-dispatches the same run-key idempotently on replay", async () => { + const { id } = await seedRun("rerun-dispatch-replay"); + const workflow = new FakeAssessmentWorkflow(); + const deferred: Promise[] = []; + const deps = mutationDeps({ + assessmentWorkflow: workflow, + defer: (work) => { + deferred.push(work); + }, + }); + const body = { confirmation: CID, reason: "replay me", idempotencyKey: nextKey() }; + + const first = await handleConsoleMutation( + post(`/admin/api/assessments/${id}/rerun`, body), + deps, + ); + expect(first.status).toBe(200); + const second = await handleConsoleMutation( + post(`/admin/api/assessments/${id}/rerun`, body), + deps, + ); + expect(second.status).toBe(200); + + // Both the proceed tail and the replay tail dispatch the same runKey; the + // instance-id lock dedups the second onto the existing instance. + await Promise.all(deferred.splice(0)); + expect(workflow.created).toHaveLength(1); + }); + + it("keeps the rerun a success when the Workflow dispatch fails in the tail", async () => { + const { id } = await seedRun("rerun-dispatch-fail"); + const { deps, workflow, settle } = captureDeferred(); + workflow.createError = new Error("workflow binding unavailable"); + const response = await handleConsoleMutation( + post(`/admin/api/assessments/${id}/rerun`, { + confirmation: CID, + reason: "re-assess this release", + idempotencyKey: nextKey(), + }), + deps, + ); + // The label committed and the response is a success; a dispatch failure lives + // only in the deferred tail and must not surface or throw. + expect(response.status).toBe(200); + const descriptor = await bodyData<{ runId: string }>(response); + await expect(settle()).resolves.not.toThrow(); + + // The run advanced to pending but nothing dispatched — it is stranded until + // the reconciliation stuck-run sweep surfaces it. + const run = await getAssessment(testEnv.DB, descriptor.runId); + expect(run?.state).toBe("pending"); + expect(workflow.created).toHaveLength(0); + }); }); describe("override", () => { diff --git a/apps/labeler/test/console-mutation-api.test.ts b/apps/labeler/test/console-mutation-api.test.ts index a7ed6d7d28..d194a55d49 100644 --- a/apps/labeler/test/console-mutation-api.test.ts +++ b/apps/labeler/test/console-mutation-api.test.ts @@ -127,6 +127,10 @@ function mutationDeps(overrides: Partial = {}): ConsoleMuta void work; }, sendDiscoveryJob: async () => {}, + assessmentWorkflow: { + create: (options) => Promise.resolve({ id: options.id }), + get: (id) => Promise.resolve({ id }), + }, ...overrides, }; } diff --git a/apps/labeler/test/console-reconsiderations.test.ts b/apps/labeler/test/console-reconsiderations.test.ts index 87c1dd49e5..9b6c671e44 100644 --- a/apps/labeler/test/console-reconsiderations.test.ts +++ b/apps/labeler/test/console-reconsiderations.test.ts @@ -148,6 +148,10 @@ function mutationDeps(overrides: Partial = {}): { deferred.push(work); }, sendDiscoveryJob: async () => {}, + assessmentWorkflow: { + create: (options) => Promise.resolve({ id: options.id }), + get: (id) => Promise.resolve({ id }), + }, ...overrides, }; return { deps, settle: async () => void (await Promise.allSettled(deferred)) }; From c539e2252cc42ffba124b7d6ee188717157e5ae3 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 18 Jul 2026 07:23:06 +0100 Subject: [PATCH 126/137] fix(labeler): clear type-aware lint failures on the integration branch oxlint 1.73's stricter type-aware rules flagged 7 errors + 7 warnings in branch-local labeler code, failing the umbrella #1909 Lint check: - registry-verification bundle: Array.from over spread+map - calibration report/run: hoist regex literals to module scope - calibration io/dead-letters: isRecord guard instead of narrowing casts, annotation-typed JSON.parse, toSorted over in-place sort - calibration fixture-loader/rest-ai-binding: JSON.stringify for object interpolation, typeof-narrow the URL capture, split the optional-chain before member access Behaviour-identical; lint/typecheck/tests all clean. --- apps/labeler/calibration/fixture-loader.ts | 6 ++++-- apps/labeler/calibration/io.ts | 17 ++++++++++------- apps/labeler/calibration/report.ts | Bin 10079 -> 10121 bytes .../calibration/rest-ai-binding.test.ts | 5 +++-- apps/labeler/calibration/run.ts | 7 +++++-- apps/labeler/src/dead-letters.ts | 17 ++++++++++------- packages/registry-verification/src/bundle.ts | 2 +- 7 files changed, 33 insertions(+), 21 deletions(-) diff --git a/apps/labeler/calibration/fixture-loader.ts b/apps/labeler/calibration/fixture-loader.ts index c7f8abcd73..549ff15341 100644 --- a/apps/labeler/calibration/fixture-loader.ts +++ b/apps/labeler/calibration/fixture-loader.ts @@ -96,7 +96,9 @@ function mapLane( note: "legacy verdict 'warn'; labeler outcome depends on the severity the model assigns", }; - const mapped = legacyCategories.map((category) => LEGACY_CATEGORY_MAP[category] as string); + const mapped = legacyCategories + .map((category) => LEGACY_CATEGORY_MAP[category]) + .filter((label): label is string => label !== undefined); const blockCategories = automatedBlockCategories(policy); const blockingLabels = mapped.filter((label) => blockCategories.has(label)); if (blockingLabels.length > 0) @@ -153,7 +155,7 @@ function parseLaneExpectation(value: unknown, lane: string): LaneExpectation { // warn-positive fixture asserts a scored `warned` — see LaneExpectation. if (value.toState !== "passed" && value.toState !== "blocked" && value.toState !== "warned") throw new TypeError( - `expect.${lane}.toState must be "passed", "blocked", or "warned" (use review for legacy warn-zone priors), got: ${String(value.toState)}`, + `expect.${lane}.toState must be "passed", "blocked", or "warned" (use review for legacy warn-zone priors), got: ${JSON.stringify(value.toState)}`, ); result.toState = value.toState; } diff --git a/apps/labeler/calibration/io.ts b/apps/labeler/calibration/io.ts index 24d229a83b..942367ab5c 100644 --- a/apps/labeler/calibration/io.ts +++ b/apps/labeler/calibration/io.ts @@ -31,9 +31,8 @@ export interface LoadedFixture { function parseManifest(raw: string, name: string): FixtureManifest { const value: unknown = JSON.parse(raw); - if (typeof value !== "object" || value === null) - throw new TypeError(`fixture ${name}: manifest.json is not an object`); - const record = value as Record; + if (!isRecord(value)) throw new TypeError(`fixture ${name}: manifest.json is not an object`); + const record = value; if (typeof record.id !== "string" || typeof record.version !== "string") throw new TypeError(`fixture ${name}: manifest.json needs string id and version`); const capabilities = Array.isArray(record.capabilities) @@ -46,7 +45,7 @@ export function loadFixtures(): LoadedFixture[] { const entries = readdirSync(FIXTURES_DIR, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) - .sort(); + .toSorted(); return entries.map((name) => { const dir = join(FIXTURES_DIR, name); @@ -114,14 +113,18 @@ export function loadRun(runDir: string): LoadedRun { throw new Error( `calibration: incomplete run at ${runDir} (no run-manifest.json — sweep interrupted?)`, ); - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as RunManifest; + const manifest: RunManifest = JSON.parse(readFileSync(manifestPath, "utf8")); const records = readdirSync(runDir) .filter((file) => file.endsWith(".json") && file !== "run-manifest.json") - .sort() - .map((file) => JSON.parse(readFileSync(join(runDir, file), "utf8")) as CallRecord); + .toSorted() + .map((file): CallRecord => JSON.parse(readFileSync(join(runDir, file), "utf8"))); if (records.length !== manifest.recordCount) throw new Error( `calibration: run at ${runDir} has ${records.length} record files but manifest.recordCount is ${manifest.recordCount}`, ); return { manifest, records }; } + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/apps/labeler/calibration/report.ts b/apps/labeler/calibration/report.ts index 2ab35d5f17aa5a634e6cbb77c5e19d75556c0f90..53eb25ed9f00665efbf7c3faca2c50eb3e17698c 100644 GIT binary patch delta 66 zcmccb*Xh4Onn@u!Kd-n%!Pza|*Wbm}Cq5v^)y*?P!B#;(&LKH1Mqhuk1QRzOKbrhz IZ2=Q;0JeS;K>z>% delta 26 hcmeD5zwfs}nrX8g69*rMew;&cT8zH_=1>6>aR7302bTZ< diff --git a/apps/labeler/calibration/rest-ai-binding.test.ts b/apps/labeler/calibration/rest-ai-binding.test.ts index 30d7d2ca22..a634ca031d 100644 --- a/apps/labeler/calibration/rest-ai-binding.test.ts +++ b/apps/labeler/calibration/rest-ai-binding.test.ts @@ -25,7 +25,7 @@ describe("RestAiBinding", () => { accountId: "acct", apiToken: "tok", fetchImpl: async (url, init) => { - seenUrl = String(url); + if (typeof url === "string") seenUrl = url; seenInit = init; return jsonResponse({ result, success: true }); }, @@ -33,7 +33,8 @@ describe("RestAiBinding", () => { expect(await binding.run("@cf/x/model", RUN_INPUTS)).toEqual(result); expect(seenUrl).toBe("https://api.cloudflare.com/client/v4/accounts/acct/ai/run/@cf/x/model"); - expect((seenInit?.headers as Record).Authorization).toBe("Bearer tok"); + const headers = seenInit?.headers as Record; + expect(headers.Authorization).toBe("Bearer tok"); expect(seenInit?.body).toBe(JSON.stringify(RUN_INPUTS)); }); diff --git a/apps/labeler/calibration/run.ts b/apps/labeler/calibration/run.ts index 466c005bbd..4b1fd46ea7 100644 --- a/apps/labeler/calibration/run.ts +++ b/apps/labeler/calibration/run.ts @@ -33,6 +33,9 @@ import type { CallRecord, Lane, LoadedRun, RecordedFinding, RunManifest } from " export const CALIBRATION_PROMPT_VERSION = "w8.6-calibration.v2"; +const LICENSE_HINT_PATTERN = /licen[cs]e|agree|consent/i; +const SUBSECOND_SUFFIX = /\.\d+Z$/; + interface Credentials { readonly accountId: string; readonly apiToken: string; @@ -77,7 +80,7 @@ function recordFindings( } function licenseHint(modelId: string, message: string): void { - if (!/licen[cs]e|agree|consent/i.test(message)) return; + if (!LICENSE_HINT_PATTERN.test(message)) return; console.warn( `\n[calibration] ${modelId} appears to require a one-time license acceptance.\n` + `Accept it once (this is a deliberate human action, not auto-agreed by the harness):\n` + @@ -190,7 +193,7 @@ export async function runCalibration(label: string): Promise { } const records: CallRecord[] = []; - const timestamp = new Date().toISOString().replace(/\.\d+Z$/, "Z"); + const timestamp = new Date().toISOString().replace(SUBSECOND_SUFFIX, "Z"); const runDir = createRunDir(timestamp, label); const baseManifest = { label, diff --git a/apps/labeler/src/dead-letters.ts b/apps/labeler/src/dead-letters.ts index 6fc90d3778..4b719aa960 100644 --- a/apps/labeler/src/dead-letters.ts +++ b/apps/labeler/src/dead-letters.ts @@ -128,17 +128,20 @@ function decodeDiscoveryJob(payload: ArrayBuffer | Uint8Array | number[]): Disco } function isDiscoveryJob(value: unknown): value is DiscoveryJob { - if (typeof value !== "object" || value === null) return false; - const job = value as Record; + if (!isRecord(value)) return false; return ( - typeof job.did === "string" && - typeof job.collection === "string" && - typeof job.rkey === "string" && - typeof job.cid === "string" && - (job.operation === "create" || job.operation === "update" || job.operation === "delete") + typeof value.did === "string" && + typeof value.collection === "string" && + typeof value.rkey === "string" && + typeof value.cid === "string" && + (value.operation === "create" || value.operation === "update" || value.operation === "delete") ); } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + function rowToStored(row: DeadLetterRow): StoredDeadLetter { return { id: row.id, diff --git a/packages/registry-verification/src/bundle.ts b/packages/registry-verification/src/bundle.ts index d60acbd8e0..6e9a549896 100644 --- a/packages/registry-verification/src/bundle.ts +++ b/packages/registry-verification/src/bundle.ts @@ -106,7 +106,7 @@ export async function validatePluginBundle( manifest, declaredAccess: manifest.declaredAccess ?? {}, backend: backend.data, - files: [...files.value.values()].map((file) => ({ path: file.name, bytes: file.data })), + files: Array.from(files.value.values(), (file) => ({ path: file.name, bytes: file.data })), }; const admin = files.value.get("admin.js"); if (admin) result.admin = admin.data; From 7074e8108955a49a2b18e6d7d2d5106f1e88240a Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 18 Jul 2026 07:34:11 +0100 Subject: [PATCH 127/137] fix(labeler): assert assessment-URL origin, not prefix (clears CodeQL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL js/incomplete-url-substring-sanitization (high) flagged a test assertion doing `assessmentUrl.startsWith("https://labels.example")` — a prefix that `https://labels.example.evil.com` also satisfies. The production URL builder (assessmentUrl, notification-triggers.ts) is sound (`new URL(path, base)`); this was a test-only false positive. Parse the URL and assert origin equality instead — stronger, and clears the rule. --- apps/labeler/test/prolonged-error.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/labeler/test/prolonged-error.test.ts b/apps/labeler/test/prolonged-error.test.ts index e1db207959..4b6cfaa825 100644 --- a/apps/labeler/test/prolonged-error.test.ts +++ b/apps/labeler/test/prolonged-error.test.ts @@ -487,7 +487,7 @@ describe("sweep parity and finalization invariant", () => { expect(notice).not.toBeNull(); expect(notice?.subject).toContain("couldn't complete"); - expect(notice?.assessmentUrl.startsWith(SERVICE)).toBe(true); + expect(new URL(notice!.assessmentUrl).origin).toBe(SERVICE); }); it("notifyAssessmentOutcome never notifies on an error assessment (finalization stays silent)", async () => { From f71c5934bf427dc7aeeb9e64028c0b462fe08230 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 18 Jul 2026 19:53:03 +0100 Subject: [PATCH 128/137] fix(aggregator): rename labellers table via forward migration (#2111) Migration 0001 was edited in place to rename `labellers` -> `labelers` after shipping on main. Databases that already applied 0001 never see the edit, so they keep `labellers` while every query in src/ reads `labelers`, failing with `no such table`. Restore 0001 to main's byte-for-byte schema and add 0004 to rename the table forward. Both fresh installs and main upgrades now converge on `labelers`. Adds an upgrade test that provisions main's frozen 0001, applies the live set, and asserts the table is queryable. --- apps/aggregator/migrations/0001_init.sql | 12 +- .../0004_rename_labellers_to_labelers.sql | 12 + .../fixtures/main-migrations/0001_init.sql | 355 ++++++++++++++++++ .../main-migrations/0002_indexed_at.sql | 35 ++ .../aggregator/test/migration-upgrade.test.ts | 61 +++ apps/aggregator/test/smoke.test.ts | 8 + apps/aggregator/vitest.config.ts | 9 + 7 files changed, 486 insertions(+), 6 deletions(-) create mode 100644 apps/aggregator/migrations/0004_rename_labellers_to_labelers.sql create mode 100644 apps/aggregator/test/fixtures/main-migrations/0001_init.sql create mode 100644 apps/aggregator/test/fixtures/main-migrations/0002_indexed_at.sql create mode 100644 apps/aggregator/test/migration-upgrade.test.ts diff --git a/apps/aggregator/migrations/0001_init.sql b/apps/aggregator/migrations/0001_init.sql index 9cf42c7f29..ed3a6a1f41 100644 --- a/apps/aggregator/migrations/0001_init.sql +++ b/apps/aggregator/migrations/0001_init.sql @@ -178,14 +178,14 @@ CREATE TABLE mirrored_artifacts ( ); ------------------------------------------------------------------------------ --- Labels (populated when the labeler integration lands) +-- Labels (populated when the labeller integration lands) ------------------------------------------------------------------------------ -- Append-only label history. Every label received is written here, including -- negations. Current state is derived from latest cts per (src, uri, val) and -- projected into label_state below for hot-path lookups. CREATE TABLE labels ( - src TEXT NOT NULL, -- labeler DID + src TEXT NOT NULL, -- labeller DID uri TEXT NOT NULL, -- AT URI of subject cid TEXT, -- optional version-specific CID val TEXT NOT NULL, -- e.g. 'security:yanked', '!takedown' @@ -229,8 +229,8 @@ CREATE TABLE label_state ( CREATE INDEX idx_label_state_enforce ON label_state(uri, val, trusted) WHERE neg = 0 AND trusted = 1; --- Trusted/known labelers (operator config, edited via deployment). -CREATE TABLE labelers ( +-- Trusted/known labellers (operator config, edited via deployment). +CREATE TABLE labellers ( did TEXT PRIMARY KEY, endpoint TEXT NOT NULL, -- subscribeLabels URL signing_key TEXT NOT NULL, -- cached #atproto_label key @@ -278,9 +278,9 @@ END; ------------------------------------------------------------------------------ -- Cursor state for ingest sources (Jetstream microsecond timestamp, --- subscribeLabels seq cursors per labeler, etc.). +-- subscribeLabels seq cursors per labeller, etc.). CREATE TABLE ingest_state ( - source TEXT PRIMARY KEY, -- 'jetstream', 'labeler:did:web:labels.example.com', etc. + source TEXT PRIMARY KEY, -- 'jetstream', 'labeller:did:web:labels.example.com', etc. cursor TEXT NOT NULL, updated_at TEXT NOT NULL ); diff --git a/apps/aggregator/migrations/0004_rename_labellers_to_labelers.sql b/apps/aggregator/migrations/0004_rename_labellers_to_labelers.sql new file mode 100644 index 0000000000..9dfa867549 --- /dev/null +++ b/apps/aggregator/migrations/0004_rename_labellers_to_labelers.sql @@ -0,0 +1,12 @@ +-- Rename `labellers` -> `labelers` to match the spelling every query in +-- `src/` uses (`labeler-resolver.ts`, `labels-consumer.ts`, +-- `request-policy.ts`, `index.ts`). `0001_init.sql` shipped the table as +-- `labellers` and is immutable once applied, so the correction has to land as +-- a forward migration: databases provisioned by `0001` keep `labellers` and +-- only this migration brings them to `labelers`. +-- +-- No indexes, triggers, or views reference the table, so SQLite's +-- `ALTER TABLE ... RENAME TO` is sufficient — there are no explicitly-named +-- objects embedding the old spelling to recreate. + +ALTER TABLE labellers RENAME TO labelers; diff --git a/apps/aggregator/test/fixtures/main-migrations/0001_init.sql b/apps/aggregator/test/fixtures/main-migrations/0001_init.sql new file mode 100644 index 0000000000..b8ebc193ad --- /dev/null +++ b/apps/aggregator/test/fixtures/main-migrations/0001_init.sql @@ -0,0 +1,355 @@ +-- Frozen snapshot of `migrations/0001_init.sql` as it shipped on `main` +-- (with the `labellers` table spelling). The upgrade test in +-- `migration-upgrade.test.ts` applies this directory (main's shipped migration +-- set) to reproduce a database provisioned before the `labellers` -> +-- `labelers` rename, then applies the live migration set on top. Do not "fix" +-- the spelling here: this file must keep reproducing the historical schema. +-- +-- EmDash plugin registry aggregator: initial schema. +-- +-- Lands every table that the v1 read API + ingest pipeline + label hydration +-- + mirror tracking needs, at once on purpose: features that read these +-- tables don't need to add new ones, so this is the only DDL we expect to +-- ship while NSIDs remain experimental. + +------------------------------------------------------------------------------ +-- Records: package profiles + releases +------------------------------------------------------------------------------ + +CREATE TABLE packages ( + did TEXT NOT NULL, + slug TEXT NOT NULL, + type TEXT NOT NULL, -- 'emdash-plugin' + name TEXT, + description TEXT, + license TEXT NOT NULL, + authors TEXT NOT NULL, -- JSON array + security TEXT NOT NULL, -- JSON array + keywords TEXT, -- JSON array + sections TEXT, -- JSON map + last_updated TEXT, + -- Denormalised from latest release for query convenience. Updated on every + -- new release insert; readers never compute "latest" by sorting. + latest_version TEXT, + capabilities TEXT, -- JSON array + -- Raw signed record bytes for verification + envelope passthrough. Clients + -- re-verify the MST signature against the publisher's DID document at + -- install time. + record_blob BLOB NOT NULL, + signature_metadata TEXT, -- JSON: head CID, signing key id + verified_at TEXT NOT NULL, + PRIMARY KEY (did, slug) +); + +CREATE TABLE releases ( + did TEXT NOT NULL, + package TEXT NOT NULL, -- matches the parent profile's rkey/slug (record.package field) + version TEXT NOT NULL, -- canonical (un-percent-encoded) semver from record.version + rkey TEXT NOT NULL, -- exact rkey of the form `:` + -- Pre-computed semver-precedence-ordered string for ORDER BY. Application + -- code writes this; SQLite cannot compute semver order natively. Format + -- packs zero-padded major.minor.patch with prerelease tags compared per + -- semver precedence rules. + version_sort TEXT NOT NULL, + artifacts TEXT NOT NULL, -- JSON + requires TEXT, -- JSON + suggests TEXT, -- JSON + -- com.emdashcms.experimental.package.releaseExtension contents: + -- { declaredAccess }. The capabilities-shaped projection lives in + -- packages.capabilities for query convenience. + emdash_extension TEXT NOT NULL, + repo_url TEXT, + cts TEXT NOT NULL, -- creation timestamp from the record + record_blob BLOB NOT NULL, + signature_metadata TEXT, + verified_at TEXT NOT NULL, + tombstoned_at TEXT, -- soft delete (publisher deleted record) + PRIMARY KEY (did, package, version), + -- ON DELETE CASCADE because Jetstream events for a publisher can arrive + -- in arbitrary order under network reorder. A publisher who deletes their + -- profile (and all releases) emits the events in author-order, but the + -- profile-delete might land at the consumer before the release-deletes. + -- Without cascade, the consumer would have to either skip the profile + -- delete (leaving stale rows) or sequence retries, neither of which is + -- worth the complexity. Releases are version-immutable from a publishing + -- perspective, but a publisher is still entitled to remove their entire + -- package; cascade mirrors that intent. + FOREIGN KEY (did, package) REFERENCES packages(did, slug) ON DELETE CASCADE +); + +CREATE INDEX idx_releases_latest ON releases(did, package, version_sort DESC) WHERE tombstoned_at IS NULL; +CREATE INDEX idx_releases_cts ON releases(cts); + +-- Audit trail for rejected duplicate-version attempts. FAIR PR #77 makes +-- versions immutable: a second record at the same (did, package, version) is +-- rejected at the SQL layer and logged here for forensics. +-- +-- The UNIQUE constraint dedupes attempts by content (CID), not by raw bytes. +-- CAR bytes include the publisher's commit + MST proof which churns whenever +-- the publisher writes any other record in the same repo, so byte-equality +-- would misclassify benign retries as new attempts and bloat the table. +-- The CID is content-addressed and stable for an unchanged record. +-- +-- The consumer's INSERT uses `ON CONFLICT … DO UPDATE SET rejected_at, +-- attempted_record_blob = excluded.{rejected_at, attempted_record_blob}` so +-- the row tracks the latest attempt timestamp + the latest envelope bytes +-- (newer proofs supersede older ones in the forensics column). +CREATE TABLE release_duplicate_attempts ( + did TEXT NOT NULL, + package TEXT NOT NULL, + version TEXT NOT NULL, + -- CID of the verified record (stable for content; changes only when the + -- record itself changes). Used as the dedup key. + attempted_cid TEXT NOT NULL, + rejected_at TEXT NOT NULL, + reason TEXT NOT NULL, + -- Raw CAR bytes from the most recent attempt. Kept for forensics so + -- operators can inspect what was actually attempted even if the + -- publisher has since deleted the offending record. + attempted_record_blob BLOB NOT NULL, + UNIQUE (did, package, version, attempted_cid) +); + +-- The UNIQUE constraint creates an implicit index on +-- (did, package, version, attempted_record_blob); a separate index on the +-- (did, package, version) prefix is redundant for both lookups (the implicit +-- index handles prefix seeks) and inserts (one fewer index to maintain). + +------------------------------------------------------------------------------ +-- Publishers: identity-level publisher profiles + verification claims +------------------------------------------------------------------------------ + +-- One row per publisher DID (rkey is always literal `self`). Optional: a DID +-- may publish packages without ever publishing a publisher.profile, in which +-- case the row is absent and clients fall back to the handle. This table is +-- the canonical source for "who is publishing these packages?" — distinct from +-- `packages.authors`, which is per-package and remains authoritative for that +-- package. +CREATE TABLE publishers ( + did TEXT PRIMARY KEY, + display_name TEXT NOT NULL, -- bound by verification records — see publisher_verifications + description TEXT, + url TEXT, + contact TEXT, -- JSON array of { kind, url?, email? } + updated_at TEXT, + record_blob BLOB NOT NULL, + signature_metadata TEXT, -- JSON: head CID, signing key id + verified_at TEXT NOT NULL +); + +-- Verification claims: issuer DID vouches for subject DID as a trusted +-- publisher. The rkey is a TID, so an issuer can issue multiple claims (e.g. +-- delegated + official) and we store each as its own row. Validity is bound to +-- the subject's handle + publisher.profile.displayName at issuance time: +-- clients re-resolve those at read time and treat the claim as not in force if +-- either has changed. Ingest stores the facts; the validity check is a +-- query-time concern. +CREATE TABLE publisher_verifications ( + issuer_did TEXT NOT NULL, -- DID of the repo that wrote the record + rkey TEXT NOT NULL, -- TID + subject_did TEXT NOT NULL, + subject_handle TEXT NOT NULL, -- bound at issuance; query-time validity check compares against current + subject_display_name TEXT NOT NULL, -- bound at issuance; query-time validity check compares against current + created_at TEXT NOT NULL, + expires_at TEXT, + record_blob BLOB NOT NULL, + signature_metadata TEXT, + verified_at TEXT NOT NULL, + tombstoned_at TEXT, + PRIMARY KEY (issuer_did, rkey) +); + +-- Hot path: "show me all unexpired, non-tombstoned verifications for subject X". +-- Partial index keeps the index small by excluding tombstoned rows. +CREATE INDEX idx_publisher_verifications_subject ON publisher_verifications(subject_did) + WHERE tombstoned_at IS NULL; + +-- For periodic expiry sweeps. +CREATE INDEX idx_publisher_verifications_expires ON publisher_verifications(expires_at) + WHERE expires_at IS NOT NULL AND tombstoned_at IS NULL; + +------------------------------------------------------------------------------ +-- Mirror tracking (populated when the artifact mirror lands) +------------------------------------------------------------------------------ + +CREATE TABLE mirrored_artifacts ( + did TEXT NOT NULL, + slug TEXT NOT NULL, + version TEXT NOT NULL, + artifact_id TEXT NOT NULL, -- 'package', 'icon', etc. + r2_key TEXT NOT NULL, + bytes INTEGER NOT NULL, + content_type TEXT NOT NULL, + mirrored_at TEXT NOT NULL, + PRIMARY KEY (did, slug, version, artifact_id) +); + +------------------------------------------------------------------------------ +-- Labels (populated when the labeller integration lands) +------------------------------------------------------------------------------ + +-- Append-only label history. Every label received is written here, including +-- negations. Current state is derived from latest cts per (src, uri, val) and +-- projected into label_state below for hot-path lookups. +CREATE TABLE labels ( + src TEXT NOT NULL, -- labeller DID + uri TEXT NOT NULL, -- AT URI of subject + cid TEXT, -- optional version-specific CID + val TEXT NOT NULL, -- e.g. 'security:yanked', '!takedown' + neg INTEGER NOT NULL DEFAULT 0, + cts TEXT NOT NULL, + exp TEXT, -- optional expiry (RFC 3339) + sig BLOB NOT NULL, -- raw signature for client re-verification + ver INTEGER NOT NULL DEFAULT 1, + trusted INTEGER NOT NULL DEFAULT 0, + received_at TEXT NOT NULL, + PRIMARY KEY (src, uri, val, cts) +); + +CREATE INDEX idx_labels_subject ON labels(uri); +CREATE INDEX idx_labels_latest ON labels(src, uri, val, cts DESC); + +-- Latest-state projection: one row per (src, uri, val) holding the most recent +-- cts seen, including the neg flag and exp timestamp. Updated on every label +-- write within the same transaction. Query-time filters apply +-- `neg = 0 AND (exp IS NULL OR exp > now())` to determine whether a label is +-- currently in force. +-- +-- Why retain rows for negated/expired labels rather than deleting them: an +-- out-of-order delivery (a positive label arriving after its negation) could +-- otherwise reinsert a row we'd already retracted. Keeping the row with its +-- `cts` lets the upsert reject the older positive. +CREATE TABLE label_state ( + src TEXT NOT NULL, + uri TEXT NOT NULL, + val TEXT NOT NULL, + cid TEXT, + neg INTEGER NOT NULL DEFAULT 0, + cts TEXT NOT NULL, + exp TEXT, + trusted INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (src, uri, val) +); + +-- Hot path for hard filters (yanked, takedown, etc.) from trusted issuers. +-- Partial index keeps the index small by storing only currently-active rows. +CREATE INDEX idx_label_state_enforce ON label_state(uri, val, trusted) + WHERE neg = 0 AND trusted = 1; + +-- Trusted/known labellers (operator config, edited via deployment). +CREATE TABLE labellers ( + did TEXT PRIMARY KEY, + endpoint TEXT NOT NULL, -- subscribeLabels URL + signing_key TEXT NOT NULL, -- cached #atproto_label key + signing_key_id TEXT NOT NULL, + trusted INTEGER NOT NULL DEFAULT 0, + added_at TEXT NOT NULL, + last_resolved_at TEXT NOT NULL, + notes TEXT +); + +------------------------------------------------------------------------------ +-- Search: FTS5 over packages +------------------------------------------------------------------------------ + +CREATE VIRTUAL TABLE packages_fts USING fts5( + name, + description, + keywords, + authors, + sections, + content='packages', + content_rowid='rowid', + tokenize='porter unicode61 remove_diacritics 2' +); + +CREATE TRIGGER packages_ai AFTER INSERT ON packages BEGIN + INSERT INTO packages_fts(rowid, name, description, keywords, authors, sections) + VALUES (new.rowid, new.name, new.description, new.keywords, new.authors, new.sections); +END; + +CREATE TRIGGER packages_au AFTER UPDATE ON packages BEGIN + INSERT INTO packages_fts(packages_fts, rowid, name, description, keywords, authors, sections) + VALUES ('delete', old.rowid, old.name, old.description, old.keywords, old.authors, old.sections); + INSERT INTO packages_fts(rowid, name, description, keywords, authors, sections) + VALUES (new.rowid, new.name, new.description, new.keywords, new.authors, new.sections); +END; + +CREATE TRIGGER packages_ad AFTER DELETE ON packages BEGIN + INSERT INTO packages_fts(packages_fts, rowid, name, description, keywords, authors, sections) + VALUES ('delete', old.rowid, old.name, old.description, old.keywords, old.authors, old.sections); +END; + +------------------------------------------------------------------------------ +-- Ingest cursor state +------------------------------------------------------------------------------ + +-- Cursor state for ingest sources (Jetstream microsecond timestamp, +-- subscribeLabels seq cursors per labeller, etc.). +CREATE TABLE ingest_state ( + source TEXT PRIMARY KEY, -- 'jetstream', 'labeller:did:web:labels.example.com', etc. + cursor TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Known publisher DIDs we've seen via Jetstream or Constellation. Reconciliation +-- iterates this table; cold-start backfill seeds it from Constellation. +-- +-- Doubles as the DID-document resolution cache: `pds`, `signing_key`, +-- `signing_key_id` are populated by the records consumer on first verification +-- and refreshed when `pds_resolved_at` is older than the consumer's TTL +-- (currently 24h, applied at query time as +-- `pds_resolved_at > datetime('now', '-1 day')`). Backfill may insert a row +-- with these fields null; the consumer's first event for that DID forces a +-- resolution and UPDATE. +CREATE TABLE known_publishers ( + did TEXT PRIMARY KEY, + pds TEXT, -- cached PDS endpoint from DID document + signing_key TEXT, -- cached #atproto signing key (multibase) + signing_key_id TEXT, -- e.g. 'did:plc:xxx#atproto' + pds_resolved_at TEXT, -- last successful DID-doc resolution + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL +); + +------------------------------------------------------------------------------ +-- Verification-failure forensics +------------------------------------------------------------------------------ + +-- Records that failed PDS-verified ingest (signature, MST proof, AT-URI, +-- lexicon, content-mismatch). Written instead of retrying, because these +-- failures indicate malicious or broken upstream — retrying would just burn +-- PDS round trips. Operators query this table to investigate suspected attacks +-- or upstream regressions; it is NOT used as a retry queue. +-- +-- Distinct from the configured Cloudflare DLQ (`emdash-aggregator-records-dlq`, +-- see wrangler.jsonc), which receives messages after `max_retries` exhausted — +-- that is for transient failures (PDS down, profile-not-yet-arrived). Two +-- distinct failure modes, two distinct destinations. +-- +-- `payload` holds the unverified record bytes from the Jetstream event so an +-- operator can inspect what was attempted without going back to the source PDS. +CREATE TABLE dead_letters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + did TEXT NOT NULL, + collection TEXT NOT NULL, + rkey TEXT NOT NULL, + -- Reason code; matches the `DeadLetterReason` union in records-consumer.ts. + -- Current values: 'RECORD_NOT_FOUND', 'RESPONSE_TOO_LARGE', 'INVALID_PROOF', + -- 'PDS_HTTP_ERROR', 'LEXICON_VALIDATION_FAILED', 'RKEY_MISMATCH', + -- 'CONTACT_VALIDATION_FAILED', 'INVALID_VERSION', 'UNKNOWN_COLLECTION', + -- 'UNEXPECTED_ERROR'. + reason TEXT NOT NULL, + -- Free-form context (which field, expected vs got, library error message, etc.). + detail TEXT, + -- UTF-8 encoded JSON bytes of `RecordsJob.jetstreamRecord` when present, or a + -- fallback envelope `{operation, cid}` for delete events that don't carry one. + -- Stored as BLOB so future formats (CBOR, raw record bytes) can land here + -- without a schema change; today operators must `CAST(payload AS TEXT)` to + -- read. + payload BLOB NOT NULL, + received_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_dead_letters_did ON dead_letters(did); +CREATE INDEX idx_dead_letters_received ON dead_letters(received_at); diff --git a/apps/aggregator/test/fixtures/main-migrations/0002_indexed_at.sql b/apps/aggregator/test/fixtures/main-migrations/0002_indexed_at.sql new file mode 100644 index 0000000000..385d7e35d9 --- /dev/null +++ b/apps/aggregator/test/fixtures/main-migrations/0002_indexed_at.sql @@ -0,0 +1,35 @@ +-- Add `indexed_at` to the four content tables that the read API exposes via +-- the `packageView` / `releaseView` lexicon shapes. +-- +-- Why a separate column from `verified_at`: `verified_at` tracks "when the +-- aggregator last verified this record", and is bumped on every upsert +-- (including no-op re-verifications). The read API needs "when the aggregator +-- first observed this record" so the lexicon's `indexedAt` field is stable +-- across re-ingest. They diverge whenever a record is re-fetched (e.g. +-- backfill catching up on a record we already had via Jetstream). +-- +-- Defaulting to `verified_at` for any rows that pre-date this migration: the +-- closest approximation we can make for already-indexed content. Going +-- forward, the consumer's INSERTs set `indexed_at` to `now()` on first write +-- and COALESCE the existing value on conflict (see records-consumer.ts). +-- +-- NOT NULL after backfill from `verified_at`. SQLite's `ALTER TABLE` doesn't +-- support DEFAULT-with-NOT-NULL in one step, so we add the column nullable, +-- backfill, then move the constraint via the new-table dance — except SQLite +-- also can't add NOT NULL via ALTER, so we keep the column nullable at the +-- schema level and have the writer (consumer) treat it as required. The +-- read-API code defends against NULL by falling back to verified_at if +-- indexed_at is somehow missing on a row, which won't happen for new writes +-- but covers the historical-data corner case without a table rebuild. + +ALTER TABLE packages ADD COLUMN indexed_at TEXT; +UPDATE packages SET indexed_at = verified_at WHERE indexed_at IS NULL; + +ALTER TABLE releases ADD COLUMN indexed_at TEXT; +UPDATE releases SET indexed_at = verified_at WHERE indexed_at IS NULL; + +ALTER TABLE publishers ADD COLUMN indexed_at TEXT; +UPDATE publishers SET indexed_at = verified_at WHERE indexed_at IS NULL; + +ALTER TABLE publisher_verifications ADD COLUMN indexed_at TEXT; +UPDATE publisher_verifications SET indexed_at = verified_at WHERE indexed_at IS NULL; diff --git a/apps/aggregator/test/migration-upgrade.test.ts b/apps/aggregator/test/migration-upgrade.test.ts new file mode 100644 index 0000000000..cc17ce2b67 --- /dev/null +++ b/apps/aggregator/test/migration-upgrade.test.ts @@ -0,0 +1,61 @@ +/** + * Upgrade-path regression for the `labellers` -> `labelers` rename. + * + * `0001_init.sql` shipped on `main` creating a `labellers` table, then a later + * revision renamed it in place. Editing an applied migration is invisible to + * databases that already recorded it, so instances deployed off `main` kept + * `labellers` while `src/` moved to querying `labelers` -> every such query + * failed with `no such table`. The rename now lands as a forward migration. + * + * This test provisions the schema the way a `main` deployment had it (via the + * `MAIN_MIGRATIONS` fixture, a frozen copy of `main`'s shipped migration set), + * then applies the live migration set on top. `applyD1Migrations` skips + * migrations already recorded by name, so neither frozen file is re-run — + * exactly what happens on a real upgrade. It lives in its own file because the workers pool isolates + * D1 storage per test file, not per test, and this scenario needs a database + * that starts from `main`'s schema rather than the live one. + */ + +import { applyD1Migrations, env } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; + MAIN_MIGRATIONS: Parameters[1]; +} + +const testEnv = env as unknown as TestEnv; + +describe("labellers -> labelers upgrade", () => { + it("converges an already-migrated main database on the labelers table", async () => { + await applyD1Migrations(testEnv.DB, testEnv.MAIN_MIGRATIONS); + expect((await testEnv.DB.prepare(`SELECT did FROM labellers`).all()).success).toBe(true); + + const did = "did:web:labels.example.test"; + await testEnv.DB.prepare( + `INSERT INTO labellers (did, endpoint, signing_key, signing_key_id, trusted, added_at, last_resolved_at) + VALUES (?, ?, ?, ?, 1, ?, ?)`, + ) + .bind( + did, + "https://labels.example.test/subscribe", + "zKey", + `${did}#atproto_label`, + "2026-01-01T00:00:00.000Z", + "2026-01-01T00:00:00.000Z", + ) + .run(); + + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); + + // The RENAME must carry the existing row across, not drop/recreate the table. + const result = await testEnv.DB.prepare( + `SELECT did FROM labelers WHERE trusted = 1 ORDER BY did ASC`, + ).all<{ did: string }>(); + expect(result.success).toBe(true); + expect(result.results?.map((row) => row.did)).toContain(did); + + await expect(testEnv.DB.prepare(`SELECT did FROM labellers`).all()).rejects.toThrow(); + }); +}); diff --git a/apps/aggregator/test/smoke.test.ts b/apps/aggregator/test/smoke.test.ts index c02ab33165..5ef081cea0 100644 --- a/apps/aggregator/test/smoke.test.ts +++ b/apps/aggregator/test/smoke.test.ts @@ -84,6 +84,14 @@ describe("aggregator scaffold smoke test", () => { expect(result?.slug).toBe("searchable"); }); + it("exposes a queryable labelers table under the name src queries", async () => { + // `0001_init.sql` creates the table as `labellers`; `0004` renames it to + // `labelers`, the spelling every query in `src/` uses. A fresh install + // must land on `labelers`. + const result = await testEnv.DB.prepare(`SELECT did FROM labelers`).all(); + expect(result.success).toBe(true); + }); + it("rejects a release whose did/package does not match an existing profile (FK)", async () => { // Releases reference packages via composite FK; SQLite enforces this when // FK checks are enabled. workers-pool's miniflare D1 has FK checks on by diff --git a/apps/aggregator/vitest.config.ts b/apps/aggregator/vitest.config.ts index c44bb02004..7eb2e844d3 100644 --- a/apps/aggregator/vitest.config.ts +++ b/apps/aggregator/vitest.config.ts @@ -28,6 +28,14 @@ import { defineConfig } from "vitest/config"; const migrationsPath = fileURLToPath(new URL("./migrations", import.meta.url)); const migrations = await readD1Migrations(migrationsPath); +// Frozen copy of the schema `main` shipped (pre `labellers` -> `labelers` +// rename), exposed so the upgrade test can provision a database the way an +// already-deployed instance was before applying the live migration set. +const mainMigrationsPath = fileURLToPath( + new URL("./test/fixtures/main-migrations", import.meta.url), +); +const mainMigrations = await readD1Migrations(mainMigrationsPath); + export default defineConfig({ plugins: [ cloudflareTest({ @@ -35,6 +43,7 @@ export default defineConfig({ miniflare: { bindings: { TEST_MIGRATIONS: migrations, + MAIN_MIGRATIONS: mainMigrations, // Stub admin auth token so tests can exercise the auth-gated // admin routes without needing a real secret in the test // environment. Production deploys pull from From 29bf914a69bfbd3a0fc7908f376a2f891a39c534 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 18 Jul 2026 19:53:05 +0100 Subject: [PATCH 129/137] fix: close four label-enforcement bypasses (review round 1) (#2112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(registry-moderation): enforce image-content automated-block labels hateful-imagery, explicit-imagery, and graphic-violence are declared as automated-block labels in the labeler policy fixture but were absent from AUTOMATED_BLOCKS, so the evaluator and RELEASE_BLOCK_VALUES ignored them -- a policy-blocked release stayed installable. Add the three, plus content-warning to WARNINGS (the same un-synced image-label batch), export both sets, and add a labeler-side parity test that fails whenever the fixture and package classification drift. * fix(registry-client): send an explicitly empty accept-labelers header DiscoveryClient treated acceptLabelers: "" as absent (`|| onResponseMeta` and `if (acceptLabelers)`), so a config meaning "accept no labelers" was dropped and the aggregator applied its trusted defaults. Switch to `!== undefined` checks so an empty string still goes on the wire while an omitted option still sends no header. * fix(aggregator): honor reviewer override pair in release enforcement SQL buildReleaseEnforcementSql excluded a release whenever any live automated block row existed, but the hydrated evaluator suppresses a source's automated blocks when that same source has an exact-CID assessment-passed + assessment-overridden pair (spec §10). A post-override re-assessment that re-issues a live automated block was therefore dropped from latest-selection even though the evaluator treats it as eligible. Split the release-URI block branch so automated blocks carry the same-source exact-CID override exception while the manual security-yanked / !takedown blocks and the package/publisher cascade keep firing unconditionally. * fix(aggregator): keep enforcement labels when capping view label arrays capLabels sliced a view's labels at LABELS_MAX_LENGTH in hydration order, so a subject carrying more than 64 labels could lose its only hard block; a client evaluating the truncated view would then treat the release as installable. Order labels by enforcement priority (hard blocks, then assessment states, then informational) before slicing so blocks survive, and log an error in the pathological case where blocks alone exceed the cap (the kept slice is still all blocks, so the release stays blocked). * fix(registry-moderation): add image labels to ModerationLabelValue union The exported nominal union drifted from the runtime sets when the image-content labels were added: hateful-imagery, explicit-imagery, graphic-violence, and content-warning are classified at runtime but were missing from ModerationLabelValue, so the type contract understated the values the labeler can issue. Add all four. * fix(aggregator): validate release/package SQL aliases buildReleaseEnforcementSql interpolates the caller-supplied aliases.release / aliases.package directly into raw SQL. Callers pass compile-time constants today, but the builder is exported, so validate both aliases against an identifier-plus-trailing-dot shape and throw a TypeError on mismatch before they reach the query text. * fix(aggregator): validate the package enforcement builder's SQL alias buildPackageEnforcementSql interpolates its caller-supplied alias into raw SQL the same way the release builder does, but the release-builder guard added earlier did not cover it. Apply the same assertAlias check right after the alias default so the exported package primitive rejects a malformed alias too. --- .changeset/discovery-empty-accept-labelers.md | 5 + .changeset/enforce-image-moderation-blocks.md | 5 + .../src/routes/xrpc/label-enforcement.ts | 104 ++++++++++++++++- apps/aggregator/src/routes/xrpc/views.ts | 43 +++++-- .../aggregator/test/label-enforcement.test.ts | 43 +++++++ apps/aggregator/test/read-api.test.ts | 109 ++++++++++++++++++ apps/aggregator/test/views.test.ts | 66 +++++++++++ .../test/moderation-policy-parity.test.ts | 78 +++++++++++++ .../registry-client/src/discovery/index.ts | 8 +- .../registry-client/tests/discovery.test.ts | 16 +++ packages/registry-moderation/src/index.ts | 19 ++- 11 files changed, 480 insertions(+), 16 deletions(-) create mode 100644 .changeset/discovery-empty-accept-labelers.md create mode 100644 .changeset/enforce-image-moderation-blocks.md create mode 100644 apps/aggregator/test/label-enforcement.test.ts create mode 100644 apps/aggregator/test/views.test.ts create mode 100644 apps/labeler/test/moderation-policy-parity.test.ts diff --git a/.changeset/discovery-empty-accept-labelers.md b/.changeset/discovery-empty-accept-labelers.md new file mode 100644 index 0000000000..ce9918563f --- /dev/null +++ b/.changeset/discovery-empty-accept-labelers.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-client": patch +--- + +Sends an explicitly empty `atproto-accept-labelers` header when `DiscoveryClient` is configured with `acceptLabelers: ""`, so "accept no labelers" is honored instead of being dropped and letting the aggregator apply its trusted defaults. Omitting the option entirely still sends no header. diff --git a/.changeset/enforce-image-moderation-blocks.md b/.changeset/enforce-image-moderation-blocks.md new file mode 100644 index 0000000000..8e0d0d8eba --- /dev/null +++ b/.changeset/enforce-image-moderation-blocks.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-moderation": minor +--- + +Blocks releases labeled `hateful-imagery`, `explicit-imagery`, or `graphic-violence` -- these automated-block labels were issuable by the labeler but silently ignored by release evaluation and enforcement, leaving a policy-blocked release installable. Also recognizes the `content-warning` label as a non-blocking warning, and exposes the `AUTOMATED_BLOCKS` and `WARNINGS` label-value sets for consumers that classify labels directly. diff --git a/apps/aggregator/src/routes/xrpc/label-enforcement.ts b/apps/aggregator/src/routes/xrpc/label-enforcement.ts index f3384440b2..be1b870dc1 100644 --- a/apps/aggregator/src/routes/xrpc/label-enforcement.ts +++ b/apps/aggregator/src/routes/xrpc/label-enforcement.ts @@ -19,6 +19,7 @@ import { NSID } from "@emdash-cms/registry-lexicons"; import { + AUTOMATED_BLOCKS, PACKAGE_SCOPE_BLOCK_VALUES, RELEASE_BLOCK_VALUES, type AcceptedLabelerPolicy, @@ -32,6 +33,41 @@ const HYDRATION_CHUNK_SIZE = 50; * hydration results — redaction decisions must see the full label set. */ export const LABELS_MAX_LENGTH = 64; +/** Release-scope automated-block values (`RELEASE_BLOCK_VALUES` minus the + * manual `security-yanked` / `!takedown`). The override exception below + * applies only to these — the manual values are never suppressed. */ +const AUTOMATED_BLOCK_VALUES: readonly string[] = [...AUTOMATED_BLOCKS]; +/** The manual release blocks the reviewer override pair never suppresses. */ +const MANUAL_RELEASE_BLOCK_VALUES: readonly string[] = RELEASE_BLOCK_VALUES.filter( + (value) => !AUTOMATED_BLOCKS.has(value), +); +/** Assessment-state label values: `assessment-pending` / `assessment-error` + * steer eligibility, and `assessment-passed` / `assessment-overridden` form + * the reviewer override pair. Enforcement-relevant for view truncation. */ +const ASSESSMENT_STATE_VALUES: readonly string[] = [ + "assessment-pending", + "assessment-passed", + "assessment-overridden", + "assessment-error", +]; + +const ENFORCEMENT_HARD_BLOCK_VALUES: ReadonlySet = new Set([ + ...RELEASE_BLOCK_VALUES, + ...PACKAGE_SCOPE_BLOCK_VALUES, +]); +const ENFORCEMENT_STATE_VALUES: ReadonlySet = new Set(ASSESSMENT_STATE_VALUES); + +/** Truncation priority for a view's `labels`: hard blocks (0) rank ahead of + * assessment states (1) ahead of informational labels (2). Blocks decide a + * client's install/serve refusal, so the lexicon `maxLength` cap must never + * drop one in favour of a display-only label; states change eligibility and + * complete the override pair. Used by `capLabels`. */ +export function labelTruncationPriority(val: string): 0 | 1 | 2 { + if (ENFORCEMENT_HARD_BLOCK_VALUES.has(val)) return 0; + if (ENFORCEMENT_STATE_VALUES.has(val)) return 1; + return 2; +} + export interface EnforcementSql { sql: string; bindings: unknown[]; @@ -88,6 +124,7 @@ export function buildPackageEnforcementSql( alias = "p.", ): EnforcementSql { if (accepted.length === 0) return { sql: "", bindings: [] }; + assertAlias(alias); const srcs = accepted.map((policy) => policy.did); const sql = ` AND NOT EXISTS ( @@ -111,12 +148,33 @@ export interface ReleaseEnforcementAliases { package?: string; } +/** A SQL row alias with its trailing dot (e.g. `"r."`). */ +const ALIAS_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*\.$/; + +/** Guards a caller-supplied alias before it is interpolated into raw SQL. + * Callers pass compile-time constants today, but the builders are exported. */ +function assertAlias(alias: string): void { + if (!ALIAS_PATTERN.test(alias)) + throw new TypeError("SQL alias must be an identifier followed by '.'"); +} + /** * `NOT EXISTS` clause excluding a release whose own URI carries a * `RELEASE_BLOCK_VALUES` label, or whose parent package URI / publisher DID * carries a `PACKAGE_SCOPE_BLOCK_VALUES` label (cascade), from an accepted * source. Requires the `packages` row joined under `aliases.package` — the * package-scope branch's CID comparison reads its `signature_metadata`. + * + * Mirrors the hydrated evaluator's §10 override rule (see + * `evaluateReleaseModerationCore` in `@emdash-cms/registry-moderation`): a + * source's exact-CID `assessment-passed` + `assessment-overridden` pair + * suppresses that same source's automated blocks for the release. Without + * this, a post-override re-assessment that re-issues a live automated block + * would keep the release out of latest-selection even though the evaluator + * treats it as eligible. The exception is scoped exactly as the evaluator + * scopes it: automated blocks on the release URI only — the manual + * `security-yanked` / `!takedown` release blocks and the package/publisher + * cascade are never suppressed by the pair. */ export function buildReleaseEnforcementSql( accepted: AcceptedLabelerPolicy[], @@ -126,7 +184,11 @@ export function buildReleaseEnforcementSql( if (accepted.length === 0) return { sql: "", bindings: [] }; const releaseAlias = aliases.release ?? "r."; const packageAlias = aliases.package ?? "p."; + assertAlias(releaseAlias); + assertAlias(packageAlias); const srcs = accepted.map((policy) => policy.did); + const releaseUriExpr = `'at://' || ${releaseAlias}did || '/${NSID.packageRelease}/' || ${releaseAlias}rkey`; + const releaseCidExpr = `json_extract(${releaseAlias}signature_metadata, '$.cid')`; const sql = ` AND NOT EXISTS ( SELECT 1 FROM label_state ls @@ -135,9 +197,35 @@ export function buildReleaseEnforcementSql( AND (ls.exp_epoch_ms IS NULL OR ls.exp_epoch_ms > ?) AND ( ( - ls.uri = 'at://' || ${releaseAlias}did || '/${NSID.packageRelease}/' || ${releaseAlias}rkey - AND ls.val IN (${inClause(RELEASE_BLOCK_VALUES)}) - AND (ls.cid IS NULL OR ls.cid = json_extract(${releaseAlias}signature_metadata, '$.cid')) + ls.uri = ${releaseUriExpr} + AND ls.val IN (${inClause(AUTOMATED_BLOCK_VALUES)}) + AND (ls.cid IS NULL OR ls.cid = ${releaseCidExpr}) + AND NOT ( + EXISTS ( + SELECT 1 FROM label_state pass + WHERE pass.src = ls.src + AND pass.uri = ${releaseUriExpr} + AND pass.val = 'assessment-passed' + AND pass.cid = ${releaseCidExpr} + AND pass.neg = 0 + AND (pass.exp_epoch_ms IS NULL OR pass.exp_epoch_ms > ?) + ) + AND EXISTS ( + SELECT 1 FROM label_state ovr + WHERE ovr.src = ls.src + AND ovr.uri = ${releaseUriExpr} + AND ovr.val = 'assessment-overridden' + AND ovr.cid = ${releaseCidExpr} + AND ovr.neg = 0 + AND (ovr.exp_epoch_ms IS NULL OR ovr.exp_epoch_ms > ?) + ) + ) + ) + OR + ( + ls.uri = ${releaseUriExpr} + AND ls.val IN (${inClause(MANUAL_RELEASE_BLOCK_VALUES)}) + AND (ls.cid IS NULL OR ls.cid = ${releaseCidExpr}) ) OR ( @@ -150,7 +238,15 @@ export function buildReleaseEnforcementSql( `; return { sql, - bindings: [...srcs, nowMs, ...RELEASE_BLOCK_VALUES, ...PACKAGE_SCOPE_BLOCK_VALUES], + bindings: [ + ...srcs, + nowMs, + ...AUTOMATED_BLOCK_VALUES, + nowMs, + nowMs, + ...MANUAL_RELEASE_BLOCK_VALUES, + ...PACKAGE_SCOPE_BLOCK_VALUES, + ], }; } diff --git a/apps/aggregator/src/routes/xrpc/views.ts b/apps/aggregator/src/routes/xrpc/views.ts index 5704a1fa1e..87be393bad 100644 --- a/apps/aggregator/src/routes/xrpc/views.ts +++ b/apps/aggregator/src/routes/xrpc/views.ts @@ -21,7 +21,7 @@ import { type AggregatorDefs, NSID } from "@emdash-cms/registry-lexicons"; import { isPlainObject, parseSignatureMetadataCid } from "../../utils.js"; -import { LABELS_MAX_LENGTH, type LabelView } from "./label-enforcement.js"; +import { LABELS_MAX_LENGTH, labelTruncationPriority, type LabelView } from "./label-enforcement.js"; /** Subset of columns from `packages` we read for `packageView`. Selecting * exactly these columns keeps the SQL query auditable and cheap. */ @@ -166,15 +166,42 @@ export function publisherUri(row: Pick) { * labels are the union of multiple hydrated subjects (e.g. a release's own * URI plus its parent package and publisher DID). Hydration returns * untruncated per-subject sets so redaction decisions see every label; - * this boundary cap trims only the final view array. */ + * this boundary cap trims only the final view array. + * + * The trim is enforcement-preserving: a plain slice can drop the only hard + * block when a subject carries more than `LABELS_MAX_LENGTH` labels, and a + * client evaluating the truncated view would then treat the release as + * installable. Labels are ordered by `labelTruncationPriority` (blocks, then + * assessment states, then informational) before slicing, so blocks survive. + * Hydrated labels never carry negations (`hydrateLabels` returns only + * `neg = 0` rows), so ordering can't split a label from its negation. In the + * pathological case of more than `LABELS_MAX_LENGTH` hard blocks the kept + * slice is still all blocks — the release stays blocked, only the exact set + * is lossy — and we log an error. */ function capLabels(labels: LabelView[], uri: string): LabelView[] { if (labels.length <= LABELS_MAX_LENGTH) return labels; - console.warn("[aggregator] view labels truncated to maxLength", { - uri, - count: labels.length, - cap: LABELS_MAX_LENGTH, - }); - return labels.slice(0, LABELS_MAX_LENGTH); + const ordered = labels.toSorted( + (left, right) => labelTruncationPriority(left.val) - labelTruncationPriority(right.val), + ); + const kept = ordered.slice(0, LABELS_MAX_LENGTH); + const droppedBlocks = ordered + .slice(LABELS_MAX_LENGTH) + .filter((label) => labelTruncationPriority(label.val) === 0).length; + if (droppedBlocks > 0) { + console.error("[aggregator] view label cap dropped hard-block labels", { + uri, + count: labels.length, + cap: LABELS_MAX_LENGTH, + droppedBlocks, + }); + } else { + console.warn("[aggregator] view labels truncated to maxLength", { + uri, + count: labels.length, + cap: LABELS_MAX_LENGTH, + }); + } + return kept; } /** `LabelView.src` is `string`; the lexicon's `Label.src` is a branded DID diff --git a/apps/aggregator/test/label-enforcement.test.ts b/apps/aggregator/test/label-enforcement.test.ts new file mode 100644 index 0000000000..1d92a748f4 --- /dev/null +++ b/apps/aggregator/test/label-enforcement.test.ts @@ -0,0 +1,43 @@ +/** + * Unit tests for the enforcement SQL builders' input guards. The builders are + * exported and interpolate the caller-supplied row alias into raw SQL, so a + * malformed alias must be rejected rather than reach the query text. + */ + +import { type AcceptedLabelerPolicy } from "@emdash-cms/registry-moderation"; +import { describe, expect, it } from "vitest"; + +import { + buildPackageEnforcementSql, + buildReleaseEnforcementSql, +} from "../src/routes/xrpc/label-enforcement.js"; + +const accepted: AcceptedLabelerPolicy[] = [{ did: "did:web:labels.example", redact: false }]; + +describe("buildReleaseEnforcementSql alias validation", () => { + it("rejects an alias that is not an identifier followed by a dot", () => { + expect(() => + buildReleaseEnforcementSql(accepted, 0, { release: "r; DROP TABLE releases;--" }), + ).toThrow(TypeError); + expect(() => buildReleaseEnforcementSql(accepted, 0, { package: "p" })).toThrow(TypeError); + }); + + it("accepts the default aliases", () => { + const { sql } = buildReleaseEnforcementSql(accepted, 0); + expect(sql).toContain("NOT EXISTS"); + }); +}); + +describe("buildPackageEnforcementSql alias validation", () => { + it("rejects an alias that is not an identifier followed by a dot", () => { + expect(() => buildPackageEnforcementSql(accepted, 0, "p; DROP TABLE packages;--")).toThrow( + TypeError, + ); + expect(() => buildPackageEnforcementSql(accepted, 0, "p")).toThrow(TypeError); + }); + + it("accepts the default alias", () => { + const { sql } = buildPackageEnforcementSql(accepted, 0); + expect(sql).toContain("NOT EXISTS"); + }); +}); diff --git a/apps/aggregator/test/read-api.test.ts b/apps/aggregator/test/read-api.test.ts index 22f8dccc6c..3d5fd93987 100644 --- a/apps/aggregator/test/read-api.test.ts +++ b/apps/aggregator/test/read-api.test.ts @@ -850,6 +850,115 @@ describe("getLatestRelease", () => { const body = (await res.json()) as Record; expect(body["version"]).toBe("1.0.0"); }); + + // §10 override rule: a source's exact-CID assessment-passed + + // assessment-overridden pair suppresses that source's automated blocks for + // the release, mirroring `evaluateReleaseModerationCore`. The enforcement + // SQL must not exclude a release whose only live block is so suppressed. + const OVERRIDE_CID = "bafoverridecid"; + + it("includes a release whose re-issued automated block is suppressed by a same-source exact-CID override pair", async () => { + await seedLabeler(LABELER_DID, true); + await seedPackage({ slug: "demo", latestVersion: "1.0.0" }); + await seedRelease({ version: "1.0.0", cid: OVERRIDE_CID }); + const uri = releaseUri("demo", "1.0.0"); + await seedLabelState({ uri, val: "assessment-passed", cid: OVERRIDE_CID }); + await seedLabelState({ uri, val: "assessment-overridden", cid: OVERRIDE_CID }); + await seedLabelState({ uri, val: "malware", cid: OVERRIDE_CID }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetLatestRelease}?did=${DID_A}&package=demo`, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body["version"]).toBe("1.0.0"); + }); + + it("includes a release carrying an override pair and no block", async () => { + await seedLabeler(LABELER_DID, true); + await seedPackage({ slug: "demo", latestVersion: "1.0.0" }); + await seedRelease({ version: "1.0.0", cid: OVERRIDE_CID }); + const uri = releaseUri("demo", "1.0.0"); + await seedLabelState({ uri, val: "assessment-passed", cid: OVERRIDE_CID }); + await seedLabelState({ uri, val: "assessment-overridden", cid: OVERRIDE_CID }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetLatestRelease}?did=${DID_A}&package=demo`, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body["version"]).toBe("1.0.0"); + }); + + it("excludes a release with an automated block and no override pair", async () => { + await seedLabeler(LABELER_DID, true); + await seedPackage({ slug: "demo", latestVersion: "1.0.0" }); + await seedRelease({ version: "1.0.0", cid: OVERRIDE_CID }); + await seedLabelState({ uri: releaseUri("demo", "1.0.0"), val: "malware", cid: OVERRIDE_CID }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetLatestRelease}?did=${DID_A}&package=demo`, + ); + expect(res.status).toBe(404); + }); + + it("still excludes when the override pair is from a different source than the block", async () => { + const OTHER_LABELER_DID = "did:web:other-labels.example"; + await seedLabeler(LABELER_DID, true); + await seedLabeler(OTHER_LABELER_DID, true); + await seedPackage({ slug: "demo", latestVersion: "1.0.0" }); + await seedRelease({ version: "1.0.0", cid: OVERRIDE_CID }); + const uri = releaseUri("demo", "1.0.0"); + await seedLabelState({ uri, val: "malware", cid: OVERRIDE_CID }); + await seedLabelState({ + uri, + val: "assessment-passed", + cid: OVERRIDE_CID, + src: OTHER_LABELER_DID, + }); + await seedLabelState({ + uri, + val: "assessment-overridden", + cid: OVERRIDE_CID, + src: OTHER_LABELER_DID, + }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetLatestRelease}?did=${DID_A}&package=demo`, + ); + expect(res.status).toBe(404); + }); + + it("still excludes when the override pair CID does not match the release", async () => { + await seedLabeler(LABELER_DID, true); + await seedPackage({ slug: "demo", latestVersion: "1.0.0" }); + await seedRelease({ version: "1.0.0", cid: OVERRIDE_CID }); + const uri = releaseUri("demo", "1.0.0"); + await seedLabelState({ uri, val: "malware", cid: OVERRIDE_CID }); + await seedLabelState({ uri, val: "assessment-passed", cid: "bafstalecid" }); + await seedLabelState({ uri, val: "assessment-overridden", cid: "bafstalecid" }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetLatestRelease}?did=${DID_A}&package=demo`, + ); + expect(res.status).toBe(404); + }); + + it("never suppresses a security-yanked release block, even with an override pair", async () => { + await seedLabeler(LABELER_DID, true); + await seedPackage({ slug: "demo", latestVersion: "1.0.0" }); + await seedRelease({ version: "1.0.0", cid: OVERRIDE_CID }); + const uri = releaseUri("demo", "1.0.0"); + await seedLabelState({ uri, val: "assessment-passed", cid: OVERRIDE_CID }); + await seedLabelState({ uri, val: "assessment-overridden", cid: OVERRIDE_CID }); + // `security-yanked` is issued CID-less (policy cidRule: forbidden). + await seedLabelState({ uri, val: "security-yanked" }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetLatestRelease}?did=${DID_A}&package=demo`, + ); + expect(res.status).toBe(404); + }); }); describe("searchPackages", () => { diff --git a/apps/aggregator/test/views.test.ts b/apps/aggregator/test/views.test.ts new file mode 100644 index 0000000000..dcebe992bd --- /dev/null +++ b/apps/aggregator/test/views.test.ts @@ -0,0 +1,66 @@ +/** + * View mapper unit tests. + * + * `capLabels` is exercised through `releaseView` (its only caller shape): + * the lexicon caps a view's `labels` at `LABELS_MAX_LENGTH`, and the trim + * must be enforcement-preserving — a plain slice in hydration order can drop + * the only hard block, and a client evaluating the truncated view would then + * treat the release as installable. + */ + +import { describe, expect, it } from "vitest"; + +import { LABELS_MAX_LENGTH, type LabelView } from "../src/routes/xrpc/label-enforcement.js"; +import { type ReleaseRow, releaseView } from "../src/routes/xrpc/views.js"; + +const NOW = "2026-05-10T12:00:00.000Z"; + +function makeReleaseRow(): ReleaseRow { + return { + did: "did:plc:abc", + package: "demo", + version: "1.0.0", + rkey: "demo:1.0.0", + artifacts: JSON.stringify({ package: { url: "https://x.test/d.tgz", checksum: "bsha256" } }), + requires: null, + suggests: null, + emdash_extension: JSON.stringify({ declaredAccess: {} }), + repo_url: null, + signature_metadata: JSON.stringify({ cid: "bafrel" }), + verified_at: NOW, + indexed_at: NOW, + }; +} + +function label(val: string, index: number): LabelView { + return { src: "did:web:labeler.example", uri: `at://did:plc:abc/x/${index}`, val, cts: NOW }; +} + +describe("releaseView label cap (enforcement-preserving)", () => { + it("returns labels unchanged when at or under the cap", () => { + const labels = [label("low-quality", 0), label("malware", 1), label("suspicious-code", 2)]; + const view = releaseView(makeReleaseRow(), labels); + expect(view.labels).toEqual(labels); + }); + + it("keeps the sole hard block even when it sorts last in hydration order", () => { + const labels: LabelView[] = []; + for (let i = 0; i < LABELS_MAX_LENGTH; i++) labels.push(label("low-quality", i)); + labels.push(label("malware", LABELS_MAX_LENGTH)); + expect(labels).toHaveLength(LABELS_MAX_LENGTH + 1); + + const view = releaseView(makeReleaseRow(), labels); + expect(view.labels).toHaveLength(LABELS_MAX_LENGTH); + expect(view.labels?.some((l) => l.val === "malware")).toBe(true); + }); + + it("keeps an assessment-state label above informational labels when over the cap", () => { + const labels: LabelView[] = []; + for (let i = 0; i < LABELS_MAX_LENGTH; i++) labels.push(label("low-quality", i)); + labels.push(label("assessment-pending", LABELS_MAX_LENGTH)); + + const view = releaseView(makeReleaseRow(), labels); + expect(view.labels).toHaveLength(LABELS_MAX_LENGTH); + expect(view.labels?.some((l) => l.val === "assessment-pending")).toBe(true); + }); +}); diff --git a/apps/labeler/test/moderation-policy-parity.test.ts b/apps/labeler/test/moderation-policy-parity.test.ts new file mode 100644 index 0000000000..c6486b9c9b --- /dev/null +++ b/apps/labeler/test/moderation-policy-parity.test.ts @@ -0,0 +1,78 @@ +import { + AUTOMATED_BLOCKS, + PACKAGE_SCOPE_BLOCK_VALUES, + RELEASE_BLOCK_VALUES, + WARNINGS, +} from "@emdash-cms/registry-moderation"; +import { describe, expect, it } from "vitest"; + +import { MODERATION_POLICY } from "../src/policy.js"; + +/** + * Dual-source parity: label classification lives on BOTH sides of the + * labeler/registry boundary -- the labeler issues labels per this policy + * fixture's `category`/`officialEffect`, and `@emdash-cms/registry-moderation` + * enforces them via its own hardcoded sets. A value the labeler can issue as a + * block but the package omits from `AUTOMATED_BLOCKS` is issued yet never + * enforced -- a policy-blocked release stays installable (the miss that let + * hateful-imagery/explicit-imagery/graphic-violence through, after the earlier + * block/warn reclassification miss). These assertions fail the moment the two + * sources drift so a third miss can't merge. + */ +describe("moderation policy <-> registry-moderation classification parity", () => { + function sorted(values: Iterable): string[] { + return [...values].toSorted(); + } + + const fixtureAutomatedBlocks = sorted( + MODERATION_POLICY.labels + .filter((label) => label.category === "automated-block") + .map((label) => label.value), + ); + const fixtureWarnEffect = sorted( + MODERATION_POLICY.labels + .filter((label) => label.officialEffect === "warn") + .map((label) => label.value), + ); + + it("enforces exactly the fixture's automated-block category as AUTOMATED_BLOCKS", () => { + expect(sorted(AUTOMATED_BLOCKS)).toEqual(fixtureAutomatedBlocks); + }); + + it("routes every automated-block value into the release hard-block set", () => { + for (const value of fixtureAutomatedBlocks) { + expect(RELEASE_BLOCK_VALUES).toContain(value); + } + }); + + // Automated blocks are not the only enforced blocks: the manual-system + // labels `security-yanked` and `publisher-compromised` also carry + // `officialEffect: "block"` but are enforced only via the hardcoded + // RELEASE_BLOCK_VALUES / PACKAGE_SCOPE_BLOCK_VALUES, which the assertions + // above never touch. Guard the whole block vocabulary so a future + // `officialEffect: "block"` fixture label can't be issued yet silently + // left unenforced. + it("enforces every fixture block-effect label at release or package/publisher scope", () => { + const enforcedBlocks = new Set([...RELEASE_BLOCK_VALUES, ...PACKAGE_SCOPE_BLOCK_VALUES]); + const fixtureBlockEffect = MODERATION_POLICY.labels + .filter((label) => label.officialEffect === "block") + .map((label) => label.value); + for (const value of fixtureBlockEffect) { + expect(enforcedBlocks.has(value)).toBe(true); + } + }); + + // The fixture's warn-effect set, not its `warning` *category*: the package's + // WARNINGS deliberately includes `package-disputed` (a `manual-system` label + // whose officialEffect is `warn`), so category-equality would never hold -- + // the officialEffect correspondence is the invariant. + it("classifies exactly the fixture's warn-effect labels as WARNINGS", () => { + expect(sorted(WARNINGS)).toEqual(fixtureWarnEffect); + }); + + it("never classifies a value as both a block and a warning", () => { + for (const value of AUTOMATED_BLOCKS) { + expect(WARNINGS.has(value)).toBe(false); + } + }); +}); diff --git a/packages/registry-client/src/discovery/index.ts b/packages/registry-client/src/discovery/index.ts index 9b7fecf37e..2b49d63f6d 100644 --- a/packages/registry-client/src/discovery/index.ts +++ b/packages/registry-client/src/discovery/index.ts @@ -188,10 +188,14 @@ export class DiscoveryClient { const acceptLabelers = this.acceptLabelers; const onResponseMeta = options.onResponseMeta; const handler: typeof baseHandler = - acceptLabelers || onResponseMeta + acceptLabelers !== undefined || onResponseMeta !== undefined ? async (pathname, init) => { const headers = new Headers(init.headers); - if (acceptLabelers) headers.set("atproto-accept-labelers", acceptLabelers); + // An explicit empty string means "accept no labelers" and must + // still go on the wire; only an omitted option (`undefined`) + // leaves the header off so the aggregator applies its defaults. + if (acceptLabelers !== undefined) + headers.set("atproto-accept-labelers", acceptLabelers); const response = await baseHandler(pathname, { ...init, headers }); onResponseMeta?.({ contentLabelers: response.headers.get("atproto-content-labelers") ?? undefined, diff --git a/packages/registry-client/tests/discovery.test.ts b/packages/registry-client/tests/discovery.test.ts index 0b9e8dbf3c..95097ef04a 100644 --- a/packages/registry-client/tests/discovery.test.ts +++ b/packages/registry-client/tests/discovery.test.ts @@ -92,6 +92,22 @@ describe("DiscoveryClient", () => { expect(headers.get("atproto-accept-labelers")).toBeNull(); }); + it("sends an explicitly empty atproto-accept-labelers header (accept no labelers)", async () => { + const { fetch, calls } = buildFetchStub({ + "/xrpc/com.emdashcms.experimental.aggregator.searchPackages": { + status: 200, + body: { packages: [] }, + }, + }); + + const client = new DiscoveryClient({ aggregatorUrl: aggregator, acceptLabelers: "", fetch }); + await client.searchPackages({ q: "x" }); + + const headers = new Headers(calls[0]!.init?.headers); + expect(headers.has("atproto-accept-labelers")).toBe(true); + expect(headers.get("atproto-accept-labelers")).toBe(""); + }); + it("reports the atproto-content-labelers response header via onResponseMeta", async () => { const { fetch } = buildFetchStub({ "/xrpc/com.emdashcms.experimental.aggregator.searchPackages": { diff --git a/packages/registry-moderation/src/index.ts b/packages/registry-moderation/src/index.ts index 69b74fd827..75448e0522 100644 --- a/packages/registry-moderation/src/index.ts +++ b/packages/registry-moderation/src/index.ts @@ -18,9 +18,13 @@ export type ModerationLabelValue = | "assessment-pending" | "artifact-integrity-failure" | "broken-release" + | "content-warning" | "credential-harvesting" | "critical-vulnerability" | "data-exfiltration" + | "explicit-imagery" + | "graphic-violence" + | "hateful-imagery" | "impersonation" | "invalid-bundle" | "low-quality" @@ -164,7 +168,11 @@ export interface ReleaseModeration { redacted: boolean; } -const AUTOMATED_BLOCKS = new Set([ +/** Label values the labeler policy classifies as `automated-block`; each + * hard-blocks a release. MUST stay in lock-step with the labeler policy + * fixture's `automated-block` category (`apps/labeler/fixtures/moderation-policy.json`) -- + * a value the labeler can issue but this set omits is not enforced. */ +export const AUTOMATED_BLOCKS: ReadonlySet = new Set([ "malware", "data-exfiltration", "credential-harvesting", @@ -173,15 +181,22 @@ const AUTOMATED_BLOCKS = new Set([ "artifact-integrity-failure", "invalid-bundle", "impersonation", + "hateful-imagery", + "explicit-imagery", + "graphic-violence", ]); -const WARNINGS = new Set([ +/** Label values the labeler policy classifies with `officialEffect: "warn"`; + * non-blocking. MUST stay in lock-step with the labeler policy fixture's + * warn-effect labels. */ +export const WARNINGS: ReadonlySet = new Set([ "suspicious-code", "obfuscated-code", "privacy-risk", "misleading-metadata", "low-quality", "broken-release", + "content-warning", "package-disputed", "undeclared-access", ]); From 3692bbf8ec1d093146138a578da98c00d514a37a Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 18 Jul 2026 20:49:03 +0100 Subject: [PATCH 130/137] fix(labeler): Access custom-group roles + PDS-fetch SSRF guard (review round 1) (#2113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(labeler): read Access group claims from the verified custom object Cloudflare Access places IdP group membership inside the verified application token's `custom` object (a `groups` claim configured on the SAML/OIDC integration), never as a top-level `groups` claim. The role mapper read `payload.groups`, which Access never populates, so the group-only reviewer/admin allowlist granted no roles to human operators whose access is conferred by group. Read the `groups` array from the verified `payload.custom` object, guarding the `custom` record and the array shape strictly. Restrict group extraction to human identities: a service token is authorized solely by common_name and carries no IdP identity, so a `custom.groups` on a service token must never confer a role (moving the group claim under `custom` would otherwise open a fail-open path). Email/common_name resolution is otherwise unchanged. * fix(labeler): route the PDS record fetch through an SSRF egress guard The PDS endpoint is resolved from a publisher-controlled DID document, and the CAR fetch followed redirects with the default fetch policy and no scheme, DNS, or reserved-address checks. A hostile DID/PDS document could point the endpoint (or a redirect it serves) at a private/reserved address or a non-HTTPS host, turning the verifier into a blind SSRF probe. Validate every hop before fetching, sharing the DoH resolver (`cloudflareDohResolver`) that artifact acquisition uses and routing each hop through core's `resolveAndValidateExternalUrl`: HTTPS-only, DoH resolution, and private/reserved-IP rejection. Redirects are now followed manually (`redirect: "manual"`) so each `Location` is re-validated per-hop. A blocked hop rejects with the new permanent `PDS_HOST_BLOCKED` reason (dead-lettered, never retried). This shares the resolver but NOT artifact acquisition's address predicate: `fetchVerifiedResource`'s stricter `isForbiddenAddress` is not exported from @emdash-cms/registry-verification, so this path uses core's `isPrivateIp`, which blocks loopback, RFC1918, link-local/metadata, and IPv4-mapped/NAT64 forms but not CGNAT (100.64.0.0/10), benchmarking (198.18.0.0/15), or multicast/reserved (224.0.0.0/4, ff00::/8) — an accepted residual for PDS egress; closing it would mean exporting a published-package predicate. Also bound the body read by a wall-clock budget: the header-phase abort timer is cleared once headers arrive, so each streamed chunk is now raced against the remaining fetch timeout, preventing a slow-drip PDS from holding the read open indefinitely. The resolver is threaded through the discovery consumer's closure deps; the existing 404/5xx/size status classification the delete and retry paths depend on is preserved. * fix(labeler): retry transient PDS host-resolution failures The PDS SSRF egress guard mapped every `SsrfError` from `resolveAndValidateExternalUrl` to the permanent `PDS_HOST_BLOCKED` reason. But that helper raises `SsrfError` for two distinct cases: a permanent private/reserved-address or scheme block, and a transient resolver failure (DoH network error, SERVFAIL, timeout). A transient DoH blip while resolving a publisher's PDS host therefore permanently dead-lettered a legitimate record instead of retrying it. Core exposes no structured discriminator (`SsrfError` carries a single `code`), so key on the resolver-failure message prefix ("Could not resolve hostname:") to map that case to the transient `PDS_NETWORK_ERROR` reason, which the consumer retries via `isTransient`. Genuine address/scheme blocks stay `PDS_HOST_BLOCKED` (permanent). A test pins the prefix so a core wording change fails loudly rather than silently mis-classifying. * fix(labeler): accept the empty sub Cloudflare Access sets for service tokens Cloudflare Access issues non-identity (service-token) application JWTs with `sub: ""` and identifies the token via `common_name` (the CF-Access-Client-Id). The verifier rejected any empty `sub` before it reached the common_name branch, so every real service-token request failed `invalid-token` and the service-identity path was unreachable in production — the prior service-token tests passed only because they minted a synthetic non-empty subject. Require `sub` to be a string, but only reject an empty `sub` on the human/email path; the service path (common_name present) now proceeds with the empty subject Access actually sends. Tests use the realistic service-token shape (empty sub + common_name), and a human token with an empty sub is still rejected. * fix(labeler): retry an empty DNS answer for a PDS host The PDS SSRF guard mapped every non-resolver-failure `SsrfError` to the permanent `PDS_HOST_BLOCKED` reason. But `resolveAndValidateExternalUrl` also raises `SsrfError` ("Hostname resolved to no addresses") when the resolver returns an empty answer (NXDOMAIN / NODATA), which a host mid-DNS-propagation produces transiently — so a legitimate record was permanently dead-lettered on a temporary negative answer. Classify the empty-answer message as the transient `PDS_NETWORK_ERROR` too (the consumer retries it via `isTransient`; a genuinely absent host dead-letters through retry exhaustion). Keyed on a pinned message constant like the resolver-failure prefix, so a core wording change fails a test. Genuine private/reserved-address and scheme blocks stay permanent. * fix(labeler): back off transient PDS retries so DNS propagation can complete The discovery consumer retried transient PDS failures with a bare `retry()`, and the queue consumer has no `retry_delay`, so Cloudflare Queues re-delivered immediately in the next batch. With only max_retries attempts, ordinary DNS propagation (now routed here via the empty-answer / resolver-failure transient classification) could exhaust every attempt before the host resolved — after which the record dead-lettered as UNEXPECTED_ERROR. Pass a capped exponential `delaySeconds` (15s, doubling per delivery attempt, capped at 300s) when retrying a transient PDS failure, giving propagation time without an unbounded backlog. The delay applies only to the transient PDS retry path; permanent failures still dead-letter immediately with no retry, and the other retry paths (label issuance, workflow dispatch, DID resolution) are unchanged. --- apps/labeler/src/access-auth.ts | 22 +- apps/labeler/src/discovery-consumer.ts | 36 ++- apps/labeler/src/pds-verify.ts | 248 ++++++++++++++++-- apps/labeler/src/record-verification.ts | 5 + apps/labeler/test/access-auth.test.ts | 135 ++++++++-- .../labeler/test/console-mutation-api.test.ts | 1 + apps/labeler/test/discovery-consumer.test.ts | 35 ++- apps/labeler/test/pds-verify.test.ts | 238 +++++++++++++++++ apps/labeler/test/record-verification.test.ts | 2 + 9 files changed, 667 insertions(+), 55 deletions(-) create mode 100644 apps/labeler/test/pds-verify.test.ts diff --git a/apps/labeler/src/access-auth.ts b/apps/labeler/src/access-auth.ts index c9f2aa22f2..4db9b83b92 100644 --- a/apps/labeler/src/access-auth.ts +++ b/apps/labeler/src/access-auth.ts @@ -110,7 +110,10 @@ export async function verifyAccessRequest( } const sub = payload.sub; - if (typeof sub !== "string" || sub.length === 0) + // Cloudflare Access sets sub: "" for non-identity (service-token) JWTs, so + // only reject a non-string sub here; the human path below additionally + // requires it to be non-empty. + if (typeof sub !== "string") throw new AccessAuthError("invalid-token", "Access assertion is missing sub"); const commonName = payload.common_name; @@ -120,6 +123,8 @@ export async function verifyAccessRequest( if (typeof commonName === "string" && commonName.length > 0) { identity = { kind: "service", commonName, sub, roles: [] }; } else if (typeof email === "string" && email.length > 0) { + if (sub.length === 0) + throw new AccessAuthError("invalid-token", "Access assertion is missing sub"); identity = { kind: "human", email, sub, roles: [] }; } else { throw new AccessAuthError( @@ -129,7 +134,13 @@ export async function verifyAccessRequest( } const principal = identity.kind === "service" ? identity.commonName : identity.email; - const principals = new Set([principal, ...groupPrincipals(payload.groups)]); + // Group membership authorizes humans only. A service token carries no IdP + // identity — it is authorized solely by common_name — so a `custom.groups` + // on a service token must never confer a role. + const principals = new Set([ + principal, + ...(identity.kind === "human" ? groupPrincipals(payload.custom) : []), + ]); const roles: OperatorRole[] = []; if (config.admins.some((admin) => principals.has(admin))) roles.push("admin"); if (config.reviewers.some((reviewer) => principals.has(reviewer))) roles.push("reviewer"); @@ -137,7 +148,12 @@ export async function verifyAccessRequest( return { ...identity, roles }; } -function groupPrincipals(groups: unknown): readonly string[] { +// Cloudflare Access surfaces IdP group membership inside the verified token's +// `custom` object (as a `groups` claim configured on the SAML/OIDC integration), +// never as a top-level claim. Read the group principals from there. +function groupPrincipals(custom: unknown): readonly string[] { + if (!isRecord(custom)) return []; + const groups = custom.groups; if (!Array.isArray(groups)) return []; return groups.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); } diff --git a/apps/labeler/src/discovery-consumer.ts b/apps/labeler/src/discovery-consumer.ts index ee42c02e59..18395e7b7b 100644 --- a/apps/labeler/src/discovery-consumer.ts +++ b/apps/labeler/src/discovery-consumer.ts @@ -32,6 +32,7 @@ import { PlcDidDocumentResolver, } from "@atcute/identity-resolver"; import type { LabelSigner } from "@emdash-cms/registry-moderation"; +import { cloudflareDohResolver, type DnsResolver } from "emdash/security/ssrf"; import { AssessmentDispatchError, @@ -94,6 +95,9 @@ export interface DiscoveryConsumerDeps { * (assessment-dispatch.ts). */ assessmentWorkflow: AssessmentWorkflowBinding; fetch?: typeof fetch; + /** Resolves each PDS hop's hostname for the SSRF egress guard; defaults to + * the DoH resolver used by artifact acquisition. */ + resolveHostname?: DnsResolver; now?: () => Date; /** * Optional override for the record-verification step. Used by tests to @@ -107,6 +111,7 @@ export interface DiscoveryConsumerDeps { cid: string; didDocumentResolver: DidDocumentResolverLike; fetch?: typeof fetch; + resolveHostname?: DnsResolver; }) => Promise; /** Override for the delete-path absence check; defaults to * `confirmRecordAbsent`. Returns `true` when the record is verifiably gone. */ @@ -114,14 +119,17 @@ export interface DiscoveryConsumerDeps { uri: string; didDocumentResolver: DidDocumentResolverLike; fetch?: typeof fetch; + resolveHostname?: DnsResolver; }) => Promise; } /** Subset of `cloudflare:workers` `Message` we use; defining inline so tests * don't need to import workerd types. */ export interface MessageController { + /** 1 on first delivery, incremented on each redelivery. */ + readonly attempts: number; ack(): void; - retry(): void; + retry(options?: { delaySeconds?: number }): void; } /** Subset of a `MessageBatch`. Workers' real batch object satisfies this. */ @@ -137,6 +145,7 @@ export type DiscoveryDeadLetterReason = | "RESPONSE_TOO_LARGE" | "INVALID_PROOF" | "PDS_HTTP_ERROR" + | "PDS_HOST_BLOCKED" | "DELETE_RECORD_PRESENT" | RecordVerificationFailureReason | "UNEXPECTED_ERROR"; @@ -215,6 +224,7 @@ export async function processDiscoveryMessage( uri, didDocumentResolver: deps.didDocumentResolver, ...(deps.fetch ? { fetch: deps.fetch } : {}), + ...(deps.resolveHostname ? { resolveHostname: deps.resolveHostname } : {}), }); if (!absent) { await writeDeadLetter( @@ -293,7 +303,10 @@ async function classifyDiscoveryError( } if (err instanceof PdsVerificationError) { if (isTransient(err.reason, err.status)) { - controller.retry(); + // Back off so a DNS-propagation or PDS blip has time to clear before + // max_retries is exhausted; an immediate re-delivery would burn every + // attempt in a couple of batches. + controller.retry({ delaySeconds: transientRetryDelaySeconds(controller.attempts) }); return; } let mapped: DiscoveryDeadLetterReason; @@ -349,6 +362,7 @@ async function verifyAndCreateRun( cid: job.cid, didDocumentResolver: deps.didDocumentResolver, ...(deps.fetch ? { fetch: deps.fetch } : {}), + ...(deps.resolveHostname ? { resolveHostname: deps.resolveHostname } : {}), }); await createSubject(deps.db, { @@ -508,6 +522,20 @@ function jobUri(job: DiscoveryJob): string { return `at://${job.did}/${job.collection}/${job.rkey}`; } +/** Capped exponential backoff for a transient PDS failure. The queue has no + * `retry_delay`, so a bare `retry()` re-delivers in the next batch — with only + * `max_retries` attempts, an immediate loop can exhaust every attempt before + * ordinary DNS propagation completes (the empty-answer/resolver-failure path + * routes propagation lag here). Delaying each retry gives the host time to + * resolve without an unbounded backlog. */ +const RETRY_BASE_DELAY_SECONDS = 15; +const RETRY_MAX_DELAY_SECONDS = 300; + +function transientRetryDelaySeconds(attempts: number): number { + const exponent = Math.max(0, attempts - 1); + return Math.min(RETRY_BASE_DELAY_SECONDS * 2 ** exponent, RETRY_MAX_DELAY_SECONDS); +} + /** * Translate a permanent `PdsVerificationError.reason` to its * `DiscoveryDeadLetterReason` counterpart. Transient reasons @@ -521,6 +549,7 @@ function mapPdsReason(reason: VerificationFailureReason): DiscoveryDeadLetterRea case "RESPONSE_TOO_LARGE": case "INVALID_PROOF": case "PDS_HTTP_ERROR": + case "PDS_HOST_BLOCKED": return reason; case "PDS_NETWORK_ERROR": throw new Error( @@ -554,6 +583,9 @@ async function createProductionDiscoveryDeps(env: Env): Promise { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new PdsVerificationError("PDS_HOST_BLOCKED", `PDS URL is not a valid URL: ${url}`); + } + if (parsed.protocol !== "https:") { + throw new PdsVerificationError( + "PDS_HOST_BLOCKED", + `PDS URL must use https, got ${parsed.protocol}`, + ); + } + try { + await resolveAndValidateExternalUrl(url, { resolver: resolveHostname }); + } catch (err) { + if (err instanceof SsrfError) { + // Two transient resolver outcomes: the resolver itself failed (DoH + // network error, SERVFAIL, timeout), or it returned an empty answer + // (NXDOMAIN / NODATA, which a host mid-DNS-propagation produces). + // Both retry rather than permanently dead-lettering a legitimate + // record. A true private/reserved-address or scheme block is permanent. + if ( + err.message.startsWith(SSRF_RESOLVER_FAILURE_PREFIX) || + err.message === SSRF_EMPTY_ANSWER_MESSAGE + ) { + throw new PdsVerificationError( + "PDS_NETWORK_ERROR", + `PDS host resolution failed: ${err.message}`, + undefined, + err, + ); + } + throw new PdsVerificationError( + "PDS_HOST_BLOCKED", + `PDS host rejected: ${err.message}`, + undefined, + err, + ); + } + throw err; + } +} + +/** + * Fetch `initialUrl`, following redirects manually so every hop — the + * publisher-controlled PDS endpoint and any `Location` it names — is + * re-validated against the SSRF egress rules before the request is made. A + * hop pointing at a forbidden scheme or address rejects with + * `PDS_HOST_BLOCKED`. + */ +async function fetchWithRedirectGuard( + fetchImpl: typeof fetch, + initialUrl: string, + signal: AbortSignal, + timeoutMs: number, + resolveHostname: DnsResolver, +): Promise { + let currentUrl = initialUrl; + for (let hop = 0; ; hop++) { + await assertAllowedPdsUrl(currentUrl, resolveHostname); + + let response: Response; + try { + response = await fetchImpl(currentUrl, { + signal, + redirect: "manual", + headers: { accept: "application/vnd.ipld.car" }, + }); + } catch (err) { + // Whether the fetch threw because we aborted (timeout) or because the + // network failed at the OS layer, the right caller behaviour is the + // same: retry. Lump them under PDS_NETWORK_ERROR. + throw new PdsVerificationError( + "PDS_NETWORK_ERROR", + err instanceof Error && err.name === "AbortError" + ? `PDS fetch aborted after ${timeoutMs}ms` + : `PDS fetch failed: ${err instanceof Error ? err.message : String(err)}`, + undefined, + err, + ); + } + + if (response.status < 300 || response.status >= 400) return response; + + if (hop >= MAX_PDS_REDIRECTS) { + throw new PdsVerificationError( + "PDS_HTTP_ERROR", + `PDS exceeded ${MAX_PDS_REDIRECTS} redirects`, + response.status, + ); + } + const location = response.headers.get("location"); + if (location === null) { + throw new PdsVerificationError( + "PDS_HTTP_ERROR", + `PDS redirect ${response.status} without a location header`, + response.status, + ); + } + try { + currentUrl = new URL(location, currentUrl).toString(); + } catch { + throw new PdsVerificationError( + "PDS_HOST_BLOCKED", + `PDS redirect location is not a valid URL: ${location}`, + response.status, + ); + } + } +} + async function fetchCar( fetchImpl: typeof fetch, url: string, timeoutMs: number, maxBytes: number, + resolveHostname: DnsResolver, ): Promise { + const deadline = Date.now() + timeoutMs; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); let response: Response; try { - response = await fetchImpl(url, { - signal: controller.signal, - headers: { accept: "application/vnd.ipld.car" }, - }); - } catch (err) { - // Whether the fetch threw because we aborted (timeout) or because the - // network failed at the OS layer, the right caller behaviour is the - // same: retry. Lump them under PDS_NETWORK_ERROR. - throw new PdsVerificationError( - "PDS_NETWORK_ERROR", - err instanceof Error && err.name === "AbortError" - ? `PDS fetch aborted after ${timeoutMs}ms` - : `PDS fetch failed: ${err instanceof Error ? err.message : String(err)}`, - undefined, - err, + response = await fetchWithRedirectGuard( + fetchImpl, + url, + controller.signal, + timeoutMs, + resolveHostname, ); } finally { clearTimeout(timer); @@ -172,12 +311,34 @@ async function fetchCar( if (!reader) { throw new PdsVerificationError("INVALID_PROOF", "PDS response body is null"); } + return readCarBody(reader, maxBytes, deadline, timeoutMs); +} + +/** + * Buffer the CAR body, bounding both its size (`maxBytes`) and its total wall + * time (`deadline`) — the header-phase abort timer is already cleared by the + * time we stream, so without this a slow-drip PDS could hold the read open + * indefinitely. Each read is raced against the remaining budget; exhausting it + * rejects as a transient `PDS_NETWORK_ERROR` so the consumer retries. + */ +async function readCarBody( + reader: ReadableStreamDefaultReader, + maxBytes: number, + deadline: number, + timeoutMs: number, +): Promise { const chunks: Uint8Array[] = []; let total = 0; try { - // biome-ignore lint/correctness/noConstantCondition: drains the stream - while (true) { - const { done, value } = await reader.read(); + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new PdsVerificationError( + "PDS_NETWORK_ERROR", + `PDS body read exceeded ${timeoutMs}ms`, + ); + } + const { done, value } = await readWithDeadline(reader, remaining, timeoutMs); if (done) break; total += value.byteLength; if (total > maxBytes) { @@ -193,10 +354,11 @@ async function fetchCar( await reader.cancel().catch(() => { /* swallow — we already have a primary error to surface */ }); - // A read error after headers (socket drop, stream abort) is transient — - // re-wrap it so the consumer retries rather than dead-lettering it as an - // unexpected failure. Our own PdsVerificationErrors (e.g. too-large) - // carry their own classification and pass through. + // A read error after headers (socket drop, stream abort, stall) is + // transient — re-wrap it so the consumer retries rather than + // dead-lettering it as an unexpected failure. Our own + // PdsVerificationErrors (too-large, budget exceeded) carry their own + // classification and pass through. if (err instanceof PdsVerificationError) throw err; throw new PdsVerificationError( "PDS_NETWORK_ERROR", @@ -217,6 +379,44 @@ async function fetchCar( return out; } +/** + * Race a single `reader.read()` against a per-read timeout. Handlers are + * attached to the read promise so a read that loses the race cannot later + * surface as an unhandled rejection. + */ +function readWithDeadline( + reader: ReadableStreamDefaultReader, + remainingMs: number, + timeoutMs: number, +): Promise> { + return new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + reject( + new PdsVerificationError("PDS_NETWORK_ERROR", `PDS body read exceeded ${timeoutMs}ms`), + ); + }, remainingMs); + void reader.read().then( + (result) => { + if (settled) return undefined; + settled = true; + clearTimeout(timer); + resolve(result); + return undefined; + }, + (err: unknown) => { + if (settled) return undefined; + settled = true; + clearTimeout(timer); + reject(err); + return undefined; + }, + ); + }); +} + /** * Map a `PdsVerificationError.reason` to "should the consumer retry?". Network * blips and 5xx are transient; everything else is permanent (forensics + ack). diff --git a/apps/labeler/src/record-verification.ts b/apps/labeler/src/record-verification.ts index cd71a5e470..3a103863c4 100644 --- a/apps/labeler/src/record-verification.ts +++ b/apps/labeler/src/record-verification.ts @@ -22,6 +22,7 @@ import { } from "@atcute/crypto"; import { type DidDocument, getAtprotoVerificationMaterial, getPdsEndpoint } from "@atcute/identity"; import { type Did, isDid } from "@atcute/lexicons/syntax"; +import type { DnsResolver } from "emdash/security/ssrf"; import { fetchAndVerifyRecord, @@ -77,6 +78,9 @@ export interface FetchAndVerifyExactRecordOptions { didDocumentResolver: DidDocumentResolverLike; /** Inject for tests; defaults to `globalThis.fetch`. */ fetch?: typeof fetch; + /** Inject for tests; defaults to the DoH resolver used by artifact + * acquisition. Threaded into the SSRF egress guard around the PDS fetch. */ + resolveHostname?: DnsResolver; timeoutMs?: number; maxResponseBytes?: number; } @@ -178,6 +182,7 @@ async function fetchAndVerifyLatestRecord( rkey, publicKey, ...(opts.fetch ? { fetch: opts.fetch } : {}), + ...(opts.resolveHostname ? { resolveHostname: opts.resolveHostname } : {}), ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}), ...(opts.maxResponseBytes !== undefined ? { maxResponseBytes: opts.maxResponseBytes } : {}), }); diff --git a/apps/labeler/test/access-auth.test.ts b/apps/labeler/test/access-auth.test.ts index 8618d3ae31..bb2e86a90e 100644 --- a/apps/labeler/test/access-auth.test.ts +++ b/apps/labeler/test/access-auth.test.ts @@ -28,6 +28,7 @@ interface MintOverrides { email?: string; common_name?: string; groups?: unknown; + custom?: unknown; issuer?: string; audience?: string; expiresInSeconds?: number; @@ -87,8 +88,10 @@ describe("verifyAccessRequest", () => { }); }); - it("accepts a valid service token identified by common_name", async () => { - const token = await mintToken({ common_name: "ci-automation" }, signKey); + it("accepts a valid service token identified by common_name (empty sub)", async () => { + // Cloudflare Access sets sub: "" for non-identity (service-token) JWTs and + // identifies the token via common_name (the CF-Access-Client-Id). + const token = await mintToken({ common_name: "ci-automation", sub: "" }, signKey); const identity = await verifyAccessRequest( requestWith({ "Cf-Access-Jwt-Assertion": token }), baseConfig({ admins: ["ci-automation"] }), @@ -97,14 +100,27 @@ describe("verifyAccessRequest", () => { expect(identity).toEqual({ kind: "service", commonName: "ci-automation", - sub: "user-sub-1", + sub: "", roles: ["admin"], }); }); - it("maps roles from a groups claim", async () => { + it("rejects a human token with an empty sub", async () => { + // An empty sub is only legitimate for a service token (common_name path); + // a human/email identity must carry a non-empty subject. + const token = await mintToken({ email: "admin@example.com", sub: "" }, signKey); + await expect( + verifyAccessRequest( + requestWith({ "Cf-Access-Jwt-Assertion": token }), + baseConfig(), + resolver, + ), + ).rejects.toMatchObject({ reason: "invalid-token" }); + }); + + it("maps roles from a groups claim nested under the verified custom object", async () => { const token = await mintToken( - { email: "someone@example.com", groups: ["emdash-labeler-reviewers"] }, + { email: "someone@example.com", custom: { groups: ["emdash-labeler-reviewers"] } }, signKey, ); const identity = await verifyAccessRequest( @@ -115,6 +131,35 @@ describe("verifyAccessRequest", () => { expect(identity.roles).toEqual(["reviewer"]); }); + it("ignores a top-level groups claim (Access places IdP groups under custom)", async () => { + const token = await mintToken( + { email: "someone@example.com", groups: ["emdash-labeler-admins"] }, + signKey, + ); + const identity = await verifyAccessRequest( + requestWith({ "Cf-Access-Jwt-Assertion": token }), + baseConfig(), + resolver, + ); + expect(identity.roles).toEqual([]); + }); + + it("maps both admin and reviewer when custom.groups lists both", async () => { + const token = await mintToken( + { + email: "someone@example.com", + custom: { groups: ["emdash-labeler-admins", "emdash-labeler-reviewers"] }, + }, + signKey, + ); + const identity = await verifyAccessRequest( + requestWith({ "Cf-Access-Jwt-Assertion": token }), + baseConfig(), + resolver, + ); + expect(identity.roles).toEqual(["admin", "reviewer"]); + }); + it("grants only reviewer role for a reviewer-only principal", async () => { const token = await mintToken({ email: "reviewer@example.com" }, signKey); const identity = await verifyAccessRequest( @@ -330,33 +375,39 @@ describe("verifyAccessRequest", () => { ).rejects.toMatchObject({ reason: "invalid-token" }); }); - it("ignores a groups claim of the wrong shape without crashing", async () => { - const stringShape = await mintToken( - { email: "reviewer@example.com", groups: "emdash-labeler-reviewers" }, - signKey, - ); - const numberArrayShape = await mintToken( - { email: "reviewer@example.com", groups: [1, 2, 3] }, - signKey, - ); + it("ignores malformed custom / groups shapes without crashing", async () => { + // custom itself is not an object; custom.groups is a string not an array; + // custom.groups is a non-string array; custom is absent. None should throw + // and none should leak a group principal — the reviewer role here comes + // only from the email allowlist. + const shapes: unknown[] = [ + "custom-is-a-string", + { groups: "emdash-labeler-reviewers" }, + { groups: [1, 2, 3] }, + { groups: { nested: true } }, + ]; + for (const custom of shapes) { + const token = await mintToken({ email: "reviewer@example.com", custom }, signKey); + const identity = await verifyAccessRequest( + requestWith({ "Cf-Access-Jwt-Assertion": token }), + baseConfig(), + resolver, + ); + expect(identity.roles).toEqual(["reviewer"]); + } - const identityA = await verifyAccessRequest( - requestWith({ "Cf-Access-Jwt-Assertion": stringShape }), - baseConfig(), - resolver, - ); - const identityB = await verifyAccessRequest( - requestWith({ "Cf-Access-Jwt-Assertion": numberArrayShape }), + const noCustom = await mintToken({ email: "reviewer@example.com" }, signKey); + const identity = await verifyAccessRequest( + requestWith({ "Cf-Access-Jwt-Assertion": noCustom }), baseConfig(), resolver, ); - expect(identityA.roles).toEqual(["reviewer"]); - expect(identityB.roles).toEqual(["reviewer"]); + expect(identity.roles).toEqual(["reviewer"]); }); - it("does not treat an empty-string group entry as a principal", async () => { + it("does not treat an empty-string custom group entry as a principal", async () => { const token = await mintToken( - { email: "nobody@example.com", groups: ["", "not-a-configured-group"] }, + { email: "nobody@example.com", custom: { groups: ["", "not-a-configured-group"] } }, signKey, ); // An empty-string allowlist entry can only match if an empty group leaks in as a principal. @@ -368,6 +419,40 @@ describe("verifyAccessRequest", () => { expect(identity.roles).toEqual([]); }); + it("never lets a service token gain a role from custom.groups (service = common_name only)", async () => { + // A service token carries no IdP identity, so a `custom.groups` that + // happens to match an allowlist entry must NOT confer a role — otherwise + // moving the group claim under `custom` opens a fail-open path. + const unauthorized = await mintToken( + { common_name: "unknown-service", sub: "", custom: { groups: ["emdash-labeler-admins"] } }, + signKey, + ); + const identityA = await verifyAccessRequest( + requestWith({ "Cf-Access-Jwt-Assertion": unauthorized }), + baseConfig(), + resolver, + ); + expect(identityA.roles).toEqual([]); + + // A service token authorized by common_name keeps exactly that role; a + // reviewer group in its custom object adds nothing. + const authorized = await mintToken( + { common_name: "ci-automation", sub: "", custom: { groups: ["emdash-labeler-reviewers"] } }, + signKey, + ); + const identityB = await verifyAccessRequest( + requestWith({ "Cf-Access-Jwt-Assertion": authorized }), + baseConfig({ admins: ["ci-automation"] }), + resolver, + ); + expect(identityB).toEqual({ + kind: "service", + commonName: "ci-automation", + sub: "", + roles: ["admin"], + }); + }); + it("never includes the raw token in a thrown error message", async () => { const token = await mintToken({ email: "admin@example.com" }, otherKey); try { diff --git a/apps/labeler/test/console-mutation-api.test.ts b/apps/labeler/test/console-mutation-api.test.ts index d194a55d49..e98706172d 100644 --- a/apps/labeler/test/console-mutation-api.test.ts +++ b/apps/labeler/test/console-mutation-api.test.ts @@ -1743,6 +1743,7 @@ describe("console mutation: automation replay and idempotency", () => { class KillSwitchMessage implements MessageController { acked = 0; retried = 0; + readonly attempts = 1; ack() { this.acked += 1; } diff --git a/apps/labeler/test/discovery-consumer.test.ts b/apps/labeler/test/discovery-consumer.test.ts index e76529aa8f..5325c4d221 100644 --- a/apps/labeler/test/discovery-consumer.test.ts +++ b/apps/labeler/test/discovery-consumer.test.ts @@ -115,11 +115,14 @@ class StubResolver implements DidDocumentResolverLike { class FakeMessage implements MessageController { acked = 0; retried = 0; + retryDelaySeconds: number | undefined; + constructor(readonly attempts = 1) {} ack() { this.acked += 1; } - retry() { + retry(options?: { delaySeconds?: number }) { this.retried += 1; + this.retryDelaySeconds = options?.delaySeconds; } } @@ -394,6 +397,8 @@ describe("processDiscoveryMessage: verification failures", () => { expect(msg.acked).toBe(1); expect(msg.retried).toBe(0); + // A permanent failure dead-letters immediately — no retry, so no delay. + expect(msg.retryDelaySeconds).toBeUndefined(); const dl = await testEnv.DB.prepare(`SELECT reason FROM dead_letters WHERE rkey = ?`) .bind(job.rkey) @@ -477,12 +482,40 @@ describe("processDiscoveryMessage: verification failures", () => { expect(msg.retried).toBe(1); expect(msg.acked).toBe(0); + // The transient retry must carry a backoff delay so DNS propagation / PDS + // blips have time to clear before max_retries is exhausted. + expect(msg.retryDelaySeconds).toBeGreaterThan(0); const dl = await testEnv.DB.prepare(`SELECT COUNT(*) AS n FROM dead_letters WHERE rkey = ?`) .bind(job.rkey) .first<{ n: number }>(); expect(dl?.n).toBe(0); }); + it("scales the transient retry backoff with delivery attempts", async () => { + const job = await jobFor({ rkey: rkey() }); + const deps = await buildDeps(); + const transient = { + ...deps, + verify: () => + Promise.reject( + new PdsVerificationError( + "PDS_NETWORK_ERROR", + "PDS host resolution failed: Hostname resolved to no addresses", + ), + ), + }; + + const first = new FakeMessage(1); + const later = new FakeMessage(4); + await processDiscoveryMessage(job, first, transient); + await processDiscoveryMessage(job, later, transient); + + expect(first.retryDelaySeconds).toBeGreaterThan(0); + // A later delivery attempt backs off longer (capped), giving slow + // propagation more time before the DLQ. + expect(later.retryDelaySeconds).toBeGreaterThan(first.retryDelaySeconds!); + }); + it("transient PDS 5xx retries, no dead letter", async () => { const job = await jobFor({ rkey: rkey() }); const deps = await buildDeps(); diff --git a/apps/labeler/test/pds-verify.test.ts b/apps/labeler/test/pds-verify.test.ts new file mode 100644 index 0000000000..bf6919e3a9 --- /dev/null +++ b/apps/labeler/test/pds-verify.test.ts @@ -0,0 +1,238 @@ +/** + * pds-verify SSRF-egress + HTTP-shaping unit tests. + * + * The PDS endpoint (and any redirect it serves) is publisher-controlled, so the + * CAR fetch is routed through the same SSRF egress guard artifact acquisition + * uses: HTTPS-only, DoH resolution with private/reserved-IP rejection, and + * per-hop redirect re-resolution. These tests inject a fake `fetch` and a fake + * `resolveHostname` so no real network or DoH round-trip happens. + * + * The crypto handoff to `@atcute/repo`'s `verifyRecord` is not exercised + * end-to-end (a valid signed CAR would re-implement what the library already + * tests, and its fixture can't load inside `@cloudflare/vitest-pool-workers`); + * reaching `verifyRecord` at all is proof the request passed the egress guard. + */ + +import { P256PrivateKeyExportable, P256PublicKey } from "@atcute/crypto"; +import type { DnsResolver } from "emdash/security/ssrf"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { fetchAndVerifyRecord, isTransient, PdsVerificationError } from "../src/pds-verify.js"; + +const TEST_DID = "did:plc:test00000000000000000000"; +const TEST_PDS = "https://pds.test.example"; +const PUBLIC_IP = "93.184.216.34"; + +let publicKey: P256PublicKey; + +beforeAll(async () => { + const kp = await P256PrivateKeyExportable.createKeypair(); + publicKey = await P256PublicKey.importRaw(await kp.exportPublicKey("raw")); +}); + +const resolvePublic: DnsResolver = () => Promise.resolve([PUBLIC_IP]); + +async function captureRejection(promise: Promise): Promise { + try { + await promise; + } catch (err) { + if (err instanceof PdsVerificationError) return err; + throw err; + } + throw new Error("expected promise to reject with PdsVerificationError"); +} + +function buildOpts(overrides: { + fetch: typeof fetch; + pds?: string; + resolveHostname?: DnsResolver; + timeoutMs?: number; + maxResponseBytes?: number; +}) { + return { + pds: overrides.pds ?? TEST_PDS, + did: TEST_DID, + collection: "com.emdashcms.experimental.package.profile", + rkey: "demo", + publicKey, + resolveHostname: overrides.resolveHostname ?? resolvePublic, + fetch: overrides.fetch, + ...(overrides.timeoutMs !== undefined ? { timeoutMs: overrides.timeoutMs } : {}), + ...(overrides.maxResponseBytes !== undefined + ? { maxResponseBytes: overrides.maxResponseBytes } + : {}), + }; +} + +function redirectTo(location: string, status = 302): Response { + return new Response(null, { status, headers: { location } }); +} + +describe("fetchAndVerifyRecord — SSRF egress guard", () => { + it("rejects a non-HTTPS PDS with PDS_HOST_BLOCKED before fetching", async () => { + let called = false; + const fetchImpl: typeof fetch = () => { + called = true; + return Promise.resolve(new Response(new Uint8Array([1]), { status: 200 })); + }; + const err = await captureRejection( + fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl, pds: "http://pds.test.example" })), + ); + expect(err.reason).toBe("PDS_HOST_BLOCKED"); + expect(called).toBe(false); + }); + + it("rejects a PDS whose host resolves to a private address", async () => { + let called = false; + const fetchImpl: typeof fetch = () => { + called = true; + return Promise.resolve(new Response(new Uint8Array([1]), { status: 200 })); + }; + const err = await captureRejection( + fetchAndVerifyRecord( + buildOpts({ fetch: fetchImpl, resolveHostname: () => Promise.resolve(["10.0.0.1"]) }), + ), + ); + expect(err.reason).toBe("PDS_HOST_BLOCKED"); + // A true address block is permanent — the consumer dead-letters it. + expect(isTransient(err.reason, err.status)).toBe(false); + expect(called).toBe(false); + }); + + it("classifies a resolver failure (DoH blip) as transient PDS_NETWORK_ERROR, not a permanent block", async () => { + let called = false; + const fetchImpl: typeof fetch = () => { + called = true; + return Promise.resolve(new Response(new Uint8Array([1]), { status: 200 })); + }; + // A throwing resolver stands in for a DoH network error / SERVFAIL / + // timeout. `resolveAndValidateExternalUrl` wraps it in an SsrfError whose + // message prefix we key on; if core changes that wording this test fails + // (the mapping would silently fall through to a permanent block instead). + const err = await captureRejection( + fetchAndVerifyRecord( + buildOpts({ + fetch: fetchImpl, + resolveHostname: () => Promise.reject(new Error("DoH request timed out")), + }), + ), + ); + expect(err.reason).toBe("PDS_NETWORK_ERROR"); + // The consumer retries transient reasons rather than dead-lettering them. + expect(isTransient(err.reason, err.status)).toBe(true); + expect(called).toBe(false); + }); + + it("classifies an empty DNS answer (NXDOMAIN/NODATA) as transient, not a permanent block", async () => { + let called = false; + const fetchImpl: typeof fetch = () => { + called = true; + return Promise.resolve(new Response(new Uint8Array([1]), { status: 200 })); + }; + // An empty resolver answer is what a host mid-DNS-propagation yields. + // `resolveAndValidateExternalUrl` raises the pinned "Hostname resolved to + // no addresses" SsrfError; it must retry (so the record survives a + // temporary negative answer) rather than dead-letter. A core wording + // change would fail this test instead of silently mis-classifying. + const err = await captureRejection( + fetchAndVerifyRecord( + buildOpts({ fetch: fetchImpl, resolveHostname: () => Promise.resolve([]) }), + ), + ); + expect(err.reason).toBe("PDS_NETWORK_ERROR"); + expect(isTransient(err.reason, err.status)).toBe(true); + expect(called).toBe(false); + }); + + it("rejects a redirect that points at a private address (per-hop re-resolution)", async () => { + let calls = 0; + const fetchImpl: typeof fetch = () => { + calls += 1; + return Promise.resolve(redirectTo("https://internal.example/xrpc")); + }; + const resolveByHost: DnsResolver = (hostname) => + Promise.resolve(hostname === "internal.example" ? ["10.0.0.1"] : [PUBLIC_IP]); + const err = await captureRejection( + fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl, resolveHostname: resolveByHost })), + ); + expect(err.reason).toBe("PDS_HOST_BLOCKED"); + // Only the first hop was fetched; the private redirect target is blocked + // before its request is made. + expect(calls).toBe(1); + }); + + it("rejects a redirect that downgrades to a non-HTTPS scheme", async () => { + const fetchImpl: typeof fetch = () => + Promise.resolve(redirectTo("http://pds.test.example/xrpc")); + const err = await captureRejection(fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl }))); + expect(err.reason).toBe("PDS_HOST_BLOCKED"); + }); + + it("stops after the redirect limit with PDS_HTTP_ERROR", async () => { + const fetchImpl: typeof fetch = () => + Promise.resolve(redirectTo("https://loop.test.example/next")); + const err = await captureRejection(fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl }))); + expect(err.reason).toBe("PDS_HTTP_ERROR"); + }); +}); + +describe("fetchAndVerifyRecord — the guard permits public hosts", () => { + it("follows an allowed HTTPS redirect and re-resolves each hop", async () => { + let calls = 0; + const fetchImpl: typeof fetch = () => { + calls += 1; + if (calls === 1) return Promise.resolve(redirectTo("https://mirror.test.example/xrpc")); + return Promise.resolve(new Response("", { status: 404 })); + }; + const err = await captureRejection(fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl }))); + // Reaching the 404 proves the redirect was followed and both hops passed + // the egress guard. + expect(err.reason).toBe("RECORD_NOT_FOUND"); + expect(err.status).toBe(404); + expect(calls).toBe(2); + }); + + it("maps a 404 to RECORD_NOT_FOUND with status", async () => { + const fetchImpl: typeof fetch = () => Promise.resolve(new Response("", { status: 404 })); + const err = await captureRejection(fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl }))); + expect(err.reason).toBe("RECORD_NOT_FOUND"); + expect(err.status).toBe(404); + }); + + it("maps a 5xx to PDS_HTTP_ERROR with status", async () => { + const fetchImpl: typeof fetch = () => Promise.resolve(new Response("", { status: 503 })); + const err = await captureRejection(fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl }))); + expect(err.reason).toBe("PDS_HTTP_ERROR"); + expect(err.status).toBe(503); + }); + + it("surfaces a network error from an allowed host as PDS_NETWORK_ERROR", async () => { + const fetchImpl: typeof fetch = () => Promise.reject(new TypeError("connection refused")); + const err = await captureRejection(fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl }))); + expect(err.reason).toBe("PDS_NETWORK_ERROR"); + }); + + it("bounds a slow-drip body read by the timeout budget", async () => { + // The stream yields one chunk then stalls forever: the next read never + // resolves, so only the per-read time budget can end it. + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + }, + }); + const fetchImpl: typeof fetch = () => Promise.resolve(new Response(stream, { status: 200 })); + const err = await captureRejection( + fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl, timeoutMs: 40 })), + ); + expect(err.reason).toBe("PDS_NETWORK_ERROR"); + expect(err.message).toMatch(/exceeded 40ms/); + }); + + it("hands successful body bytes to verifyRecord (INVALID_PROOF on garbage)", async () => { + const garbage = new Uint8Array([1, 2, 3, 4, 5]); + const fetchImpl: typeof fetch = () => Promise.resolve(new Response(garbage, { status: 200 })); + const err = await captureRejection(fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl }))); + expect(err.reason).toBe("INVALID_PROOF"); + expect(err.cause).toBeDefined(); + }); +}); diff --git a/apps/labeler/test/record-verification.test.ts b/apps/labeler/test/record-verification.test.ts index aee123cd1b..d67725b6a6 100644 --- a/apps/labeler/test/record-verification.test.ts +++ b/apps/labeler/test/record-verification.test.ts @@ -160,6 +160,7 @@ describe("fetchAndVerifyExactRecord: PDS fetch propagation", () => { cid: "bafkreiplaceholder00000000000000000000000000000000000000000", didDocumentResolver: resolver, fetch: () => Promise.resolve(new Response("", { status: 404 })), + resolveHostname: () => Promise.resolve(["93.184.216.34"]), }), ).rejects.toBeInstanceOf(PdsVerificationError); try { @@ -168,6 +169,7 @@ describe("fetchAndVerifyExactRecord: PDS fetch propagation", () => { cid: "bafkreiplaceholder00000000000000000000000000000000000000000", didDocumentResolver: resolver, fetch: () => Promise.resolve(new Response("", { status: 404 })), + resolveHostname: () => Promise.resolve(["93.184.216.34"]), }); throw new Error("expected rejection"); } catch (err) { From 08237b876fd00f4f2e275222d87c470f08e7f46e Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 18 Jul 2026 20:49:06 +0100 Subject: [PATCH 131/137] fix(labeler): notification contact-resolution, unsubscribe, and verification-trust fixes (review round 1) (#2116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(labeler): honor RFC 8058 one-click unsubscribe query param A one-click unsubscribe POST carries the recipient hash (c) in the List-Unsubscribe header URL's query string and only List-Unsubscribe=One-Click in the body. The POST parser read c from the form body only, so a one-click request returned success without suppressing the recipient. Read c (and the confirm token) from the body first, falling back to the query. * fix(labeler): resolve package contacts for release-subject notices A release subject's rkey is slug:version, but contactTargetFromUri passed the whole rkey as the package slug, so getPackage missed the package and its security[]/authors[] contacts were skipped in favor of the publisher-profile fallback. Parse the parent package slug (before the version delimiter) so a release notice reaches the package's contacts; a package rkey and a malformed rkey both degrade safely. * fix(labeler): alert operators when a takedown notice has no contact An emergency takedown issuance that resolves no publisher contact recorded only a generic undeliverable notification row, so the signal that manual outreach is needed was easy to miss. Emit a dedicated takedown-no-contact operational event (severity high) plus its operator-alert outbox row on that path. A takedown retract and a takedown that does resolve a contact emit nothing, and a deduped replay does not re-emit. * fix(labeler): gate double-opt-in bypass on trusted, current verification A publisher's verification claim upgraded a contact past double opt-in on the strength of any in-force claim. Verification claims are self-assertable — the aggregator indexes any issuer, including self-issued ones — so a publisher could name a victim address in its profile, self-issue a claim, and have an unconfirmed notice sent there. Upgrade only on a claim whose issuer is in the configured trust set, that is unexpired, and whose bound displayName still matches the publisher's current identity. No issuer is trusted by default, so every address falls back to double opt-in. The publisher view carries a current displayName but no handle (the aggregator's identity-event ingestion is unbuilt), so the binding checks displayName only; handle-binding is deferred until the view carries a handle. * fix(labeler): make takedown no-contact alert recoverable The takedown-no-contact operator alert was emitted only on the first pass's send outcome, after the undeliverable notifications row had committed. A transient failure of that fire-and-forget event write lost the alert permanently: a deferred replay dedups on the existing notifications row and never reached the emit again. Key the alert on its own operational_events row instead, and run the check on every takedown issuance (including the dedup replay path), so a replay after a failed emit recovers the signal while a normal replay does not duplicate it. * fix(labeler): scope unsubscribe query fallback, keep confirm body-only The one-click query fallback was applied to the confirm and not-me POST parsers too, so a scanner POSTing the original confirmation link URL (with c and t in the query) could confirm an address without the user submitting the form. Restrict the query fallback to the RFC 8058 one-click unsubscribe path; confirm and not-me require their capability from the POST body. * fix(labeler): validate release rkey shape; make no-contact alert dedup atomic Contact resolution stripped a subject rkey at the first colon without checking the collection or the canonical release rkey shape, so a malformed colon-bearing subject could resolve a DIFFERENT package's contacts. Only strip a canonical release record (release collection + well-formed slug:version); anything else keeps its rkey and degrades to the publisher tier. The takedown-no-contact alert used an existence-check-then-insert with no uniqueness guard, so two concurrent replays could double-emit. Add a UNIQUE(action_id, event_type) index (migration 0012) and make the insert idempotent (ON CONFLICT DO NOTHING), with the outbox gated on the event being written; concurrency and replay now converge to exactly one event. * fix(labeler): scope dedup index to takedown-no-contact; require semver Migration 0012 created a GLOBAL unique index on (action_id, event_type), which would fail at migration time on any existing DB holding historical duplicate rows of other event types. Make it PARTIAL (WHERE event_type = 'takedown-no-contact') and point the insert's ON CONFLICT target at that predicate, so it stays safe on historical data while still deduping the alert. The release-rkey parse validated only the slug and a non-empty suffix, so 'gallery:not-semver' still resolved the gallery package. Validate the complete canonical shape (slug + percent-decoded semver version, mirroring the aggregator's parseReleaseRkey) before stripping; anything else degrades to the publisher tier. --- ..._operational_events_action_type_unique.sql | 14 + apps/labeler/src/notification-endpoints.ts | 29 +- apps/labeler/src/notification-triggers.ts | 222 +++++++- apps/labeler/src/operational-events.ts | 33 +- .../test/notification-endpoints.test.ts | 51 ++ .../test/notification-triggers.test.ts | 511 +++++++++++++++++- apps/labeler/test/operational-events.test.ts | 39 +- 7 files changed, 845 insertions(+), 54 deletions(-) create mode 100644 apps/labeler/migrations/0012_operational_events_action_type_unique.sql diff --git a/apps/labeler/migrations/0012_operational_events_action_type_unique.sql b/apps/labeler/migrations/0012_operational_events_action_type_unique.sql new file mode 100644 index 0000000000..7abf31a41e --- /dev/null +++ b/apps/labeler/migrations/0012_operational_events_action_type_unique.sql @@ -0,0 +1,14 @@ +-- At-most-one `takedown-no-contact` operational event per action. That deferred +-- alert is raised from a fire-and-forget tail with a check-then-insert, so a +-- concurrent replay could double-emit; this unique index is the hard guarantee +-- that collapses them to one row (paired with ON CONFLICT DO NOTHING on the +-- insert). +-- +-- PARTIAL, scoped to `takedown-no-contact` only: the prior schema allowed +-- duplicate (action_id, event_type) rows and historical data may hold them for +-- OTHER event types, which a global unique index would reject at migration time. +-- Restricting the predicate to the one event type that needs the guarantee keeps +-- the migration safe on existing data while still enforcing the dedup we require. +CREATE UNIQUE INDEX idx_operational_events_takedown_no_contact + ON operational_events(action_id, event_type) + WHERE event_type = 'takedown-no-contact'; diff --git a/apps/labeler/src/notification-endpoints.ts b/apps/labeler/src/notification-endpoints.ts index b3abed96d7..198bb6efc5 100644 --- a/apps/labeler/src/notification-endpoints.ts +++ b/apps/labeler/src/notification-endpoints.ts @@ -14,8 +14,10 @@ * Safety properties: * - GET renders a confirmation page with a POST form; only POST mutates. An * email scanner's or link-prefetcher's automated GET therefore never - * confirms, unsubscribes, or suppresses. The token/hash live in hidden form - * fields, so the mutating POST carries them in the body, not the URL. + * confirms, unsubscribes, or suppresses. The browser POST carries the + * token/hash in hidden form fields; only the RFC 8058 one-click UNSUBSCRIBE + * POST carries `c` in the header URL's query, so the query fallback is scoped + * to that path — confirm and not-me require their capability from the body. * - CSRF: a cross-site POST cannot supply a valid recipient hash (or confirm * token) for a victim, so possession of the capability is the CSRF defense; * a custom header (unsendable from a plain email-client form) is not used. @@ -114,18 +116,25 @@ async function readPostParams( request: Request, action: NotificationAction, ): Promise { - let form: FormData; + let form: FormData | null = null; try { form = await request.formData(); } catch { - return { recipientHash: "", token: "" }; + form = null; } - const recipientHash = form.get("c"); - const token = form.get("t"); - return { - recipientHash: typeof recipientHash === "string" ? recipientHash : "", - token: action === "confirm" && typeof token === "string" ? token : "", - }; + const bodyHash = form?.get("c"); + let recipientHash = typeof bodyHash === "string" ? bodyHash : ""; + // RFC 8058 one-click unsubscribe POSTs to the List-Unsubscribe header URL with + // `c` only in its query string and `List-Unsubscribe=One-Click` in the body. + // That query fallback is scoped to unsubscribe ONLY: confirm and not-me require + // their capability from the POST body, so a scanner cannot confirm (or suppress + // via not-me) an address by POSTing a link URL without the rendered form. + if (action === "unsubscribe" && recipientHash.length === 0) { + recipientHash = new URL(request.url).searchParams.get("c") ?? ""; + } + const bodyToken = form?.get("t"); + const token = action === "confirm" && typeof bodyToken === "string" ? bodyToken : ""; + return { recipientHash, token }; } /** diff --git a/apps/labeler/src/notification-triggers.ts b/apps/labeler/src/notification-triggers.ts index 6726f89789..0978d08dc5 100644 --- a/apps/labeler/src/notification-triggers.ts +++ b/apps/labeler/src/notification-triggers.ts @@ -16,10 +16,15 @@ * `notifications` row is skipped, so a Workflow-step retry or a mutation * replay does not re-notify. `sendNotification`'s notice claim closes the * residual concurrent race atomically. - * - Verified-publisher skip: a publisher with an in-force verification claim - * bypasses double opt-in (the notice goes out directly). The verification read - * fails CLOSED — any error leaves the publisher on the normal confirmation - * path, never looser. + * - Verified-publisher skip: a publisher whose CURRENT identity is vouched for + * by an in-force verification claim from a TRUSTED issuer bypasses double + * opt-in (the notice goes out directly). A self-issued or otherwise untrusted + * claim carries no authority — verification claims are self-assertable and the + * aggregator indexes any issuer — so only a claim whose issuer is in the + * configured trust set AND whose bound displayName still matches the + * publisher's current identity upgrades a contact. The read fails CLOSED, and + * with no trusted issuer configured (the default) NOTHING upgrades: every + * address stays on the normal confirmation path. * * All notice copy is public-safe: subject line, label effect, the assessment's * public summary, and the public assessment + reconsideration URLs. No findings, @@ -41,6 +46,11 @@ import type { SendContext, } from "./notification-send.js"; import { sendNotification } from "./notification-send.js"; +import { + buildOperationalEventInsert, + buildOutboxInsert, + newOperationalEventId, +} from "./operational-events.js"; import { getOperatorActionById } from "./operator-actions.js"; import { getLabelDefinition } from "./policy.js"; import type { ContactTarget } from "./publisher-contact.js"; @@ -60,9 +70,18 @@ export interface NotifyDeps { serviceUrl: string; /** The monitored reconsideration URL from the moderation policy. */ reconsiderationUrl: string; + /** DIDs whose verification claims may upgrade a contact past double opt-in. + * Verification claims are self-assertable (the aggregator indexes any issuer), + * so a claim upgrades only when its issuer is in this set. Empty (the default) + * trusts no issuer, so every address stays on the confirmation path. */ + trustedVerificationIssuers?: ReadonlySet; now?: () => Date; } +/** No verification issuer is trusted — the conservative default for + * {@link NotifyDeps.trustedVerificationIssuers}. */ +const NO_TRUSTED_ISSUERS: ReadonlySet = new Set(); + /** * Build the production {@link NotifyDeps} from the Worker env: the real Cloudflare * Email Sending adapter over the `EMAIL` binding, the aggregator client over the @@ -82,6 +101,10 @@ export async function createNotifyDeps(env: Env): Promise { pepper, serviceUrl: env.LABELER_SERVICE_URL, reconsiderationUrl: moderationPolicy.contact.reconsiderationUrl, + // No verification issuer is trusted to bypass double opt-in: the labeler + // issues no first-party verification and the aggregator indexes any + // self-asserted claim, so every address goes through confirmation. + trustedVerificationIssuers: NO_TRUSTED_ISSUERS, }; } @@ -327,6 +350,97 @@ export async function notifyEmergencyTakedown( target, emergencyNoticeContent(deps, input), ); + // A takedown ISSUANCE (not a retract) that resolves no contact is the most + // consequential delivery failure — the undeliverable audit row alone is easy to + // miss. Raise a dedicated operator alert, keyed on ITS OWN operational_events + // row rather than the notifications-row dedup: this runs on every issuance, + // including a replay that `runTrigger` short-circuited on the existing + // notifications row, so a first pass whose event write failed transiently + // recovers on the next replay instead of losing the signal forever. + if (!input.neg) await ensureTakedownNoContactAlert(deps, input.actionId, input.uri); +} + +/** The operator-alert channel for the emergency no-contact signal — mirrors the + * emergency-action alert channel in `console-mutation-api`. */ +const OPERATOR_ALERT_CHANNEL = "deployment-alert"; + +/** + * Raise the `takedown-no-contact` operational event + its outbox row when a + * takedown issuance resolved no contact and the alert is not already recorded. + * Idempotent and recoverable: the operational_events row (not the notifications + * row) is the dedup key, so a first pass whose event batch failed re-emits on a + * later replay, while a normal replay does not duplicate. The insert is guarded + * by the `(action_id, event_type)` unique index + ON CONFLICT DO NOTHING, so two + * concurrent replays that both pass the existence check still converge to one + * event (the outbox is gated on the event being written, so a conflict leaves no + * orphan). Fire-and-forget — an error is swallowed and logged, never propagated + * into the deferred tail (a later replay retries anyway, the event still absent). + */ +async function ensureTakedownNoContactAlert( + deps: NotifyDeps, + actionId: string, + uri: string, +): Promise { + try { + if (!(await sourceResolvedNoContact(deps.db, actionId))) return; + if (await takedownNoContactAlertExists(deps.db, actionId)) return; + const now = (deps.now ?? (() => new Date()))(); + const eventId = newOperationalEventId(); + await deps.db.batch([ + buildOperationalEventInsert(deps.db, { + id: eventId, + eventType: "takedown-no-contact", + severity: "high", + actionId, + subjectUri: uri, + labelValue: "!takedown", + payload: { + reason: + "Emergency takedown has no resolvable publisher contact; manual outreach required.", + }, + now, + idempotentTakedownNoContact: true, + }), + buildOutboxInsert(deps.db, { + eventId, + channel: OPERATOR_ALERT_CHANNEL, + now, + gateOnEventPresent: true, + }), + ]); + } catch (error) { + console.error("[notifications] takedown no-contact alert failed", { + actionId, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** Whether this operator action's notification resolved to NO contact: the send + * path records that (and only that) undeliverable case with a NULL + * `recipient_hash` (suppressed/declined carry a hash). */ +async function sourceResolvedNoContact(db: D1Database, actionId: string): Promise { + const row = await db + .prepare( + `SELECT 1 FROM notifications + WHERE source_type = 'operator' AND source_id = ? AND recipient_hash IS NULL LIMIT 1`, + ) + .bind(actionId) + .first<{ 1: number }>(); + return row !== null; +} + +/** Whether a `takedown-no-contact` alert already exists for this action — the + * event's dedup key, decoupled from the notifications-row dedup. */ +async function takedownNoContactAlertExists(db: D1Database, actionId: string): Promise { + const row = await db + .prepare( + `SELECT 1 FROM operational_events + WHERE action_id = ? AND event_type = 'takedown-no-contact' LIMIT 1`, + ) + .bind(actionId) + .first<{ 1: number }>(); + return row !== null; } /** Reconsideration outcome notice (console `reconsiderations/:id/resolve`). Fired @@ -491,7 +605,12 @@ async function runTrigger( logTrigger(source, target.did, "deduped"); return true; } - const verifiedPublisher = await isVerifiedPublisher(deps.aggregator, target.did, now()); + const verifiedPublisher = await isVerifiedPublisher( + deps.aggregator, + deps.trustedVerificationIssuers ?? NO_TRUSTED_ISSUERS, + target.did, + now(), + ); const ctx: SendContext = { db: deps.db, aggregator: deps.aggregator, @@ -531,21 +650,45 @@ async function sourceAlreadyProcessed( } /** - * Whether the publisher holds an IN-FORCE verification claim — reuses the - * history-context notion of "in force" (a claim whose `expiresAt` is absent or in - * the future). FAILS CLOSED: a `null` view (redacted / unverified), an empty - * claim set, or ANY read error returns `false`, so the address stays on the - * stricter double-opt-in path. + * Whether the publisher may bypass double opt-in on the strength of a TRUSTED + * verification claim. A claim upgrades a contact only when it is + * (a) issued by a DID in `trustedIssuers` — verification claims are + * self-assertable and the aggregator indexes any issuer, so an untrusted or + * self-issued claim carries no authority; + * (b) in force — no expiry, or an expiry still in the future; and + * (c) bound to the publisher's CURRENT identity — its `displayName` still + * matches `getPublisher`'s live value, so a displayName drift since the + * verification does not carry trust. The claim also binds a `handle`, but + * the publisher view carries no current handle to compare it against (the + * aggregator's identity-event ingestion is unbuilt), so handle-binding is + * deferred until it does. + * Note this vouches for the publisher's identity, not that they own the contact + * address — the trust set is the operator's explicit decision to accept that. + * + * FAILS CLOSED: an empty trust set (the default), a `null` verification/publisher + * view, an empty claim set, unknown current handle/displayName, or ANY read error + * returns `false`, so the address stays on the stricter double-opt-in path. */ export async function isVerifiedPublisher( - aggregator: Pick, + aggregator: Pick, + trustedIssuers: ReadonlySet, did: string, now: Date, ): Promise { + if (trustedIssuers.size === 0) return false; try { const state = await aggregator.getPublisherVerification(did); if (!state || state.verifications.length === 0) return false; - return state.verifications.some((claim) => isInForce(claim.expiresAt, now)); + const trustedInForce = state.verifications.filter( + (claim) => trustedIssuers.has(claim.issuer) && isInForce(claim.expiresAt, now), + ); + if (trustedInForce.length === 0) return false; + + const publisher = await aggregator.getPublisher(did); + if (!publisher) return false; + const displayName = readDisplayName(publisher.profile); + if (displayName === undefined) return false; + return trustedInForce.some((claim) => claim.displayName === displayName); } catch (error) { console.error("[notifications] verification read failed, using double opt-in", { did, @@ -555,6 +698,14 @@ export async function isVerifiedPublisher( } } +/** The `displayName` a publisher-profile record carries, or undefined when the + * profile is not a `{ displayName: string }`-shaped object. */ +function readDisplayName(profile: unknown): string | undefined { + if (typeof profile !== "object" || profile === null) return undefined; + const value = (profile as { displayName?: unknown }).displayName; + return typeof value === "string" ? value : undefined; +} + /** A claim is in force when it has no expiry or its expiry is still in the future * (mirrors `history-context`'s `isExpired`). */ function isInForce(expiresAt: string | undefined, now: Date): boolean { @@ -577,17 +728,19 @@ function assessmentUrl(serviceUrl: string, uri: string, cid?: string): string { /** * The `{ did, slug }` a notice's contact resolution needs, parsed from the * subject URI. A record URI (`at://did/collection/rkey`) yields the DID and the - * rkey as the slug — for a package record the rkey IS the package slug (tier-1/2 - * resolve); for a release the rkey misses the package tiers and resolution falls - * through to the DID-keyed publisher-profile contact (tier 3), which is the - * reliable channel. A bare DID subject (publisher-level action) resolves by DID - * alone. Anything unparseable returns null and the notice is skipped. + * parent package slug: a package rkey IS the slug, and a canonical release rkey + * is `slug:version`, so the slug is the part before the first `:`. That lets + * `getPackage` reach the package's `security[]`/`authors[]` contacts (tier-1/2) + * for a release subject instead of falling straight through to the DID-keyed + * publisher profile (tier 3). A bare DID subject (publisher-level action) + * resolves by DID alone. Anything unparseable returns null and the notice is + * skipped. */ export function contactTargetFromUri(uri: string): ContactTarget | null { if (uri.startsWith("at://")) { - const [did, , rkey] = uri.slice("at://".length).split("/"); + const [did, collection, rkey] = uri.slice("at://".length).split("/"); if (did === undefined || did.length === 0) return null; - return { did, slug: rkey ?? "" }; + return { did, slug: packageSlugFromRecord(collection, rkey ?? "") }; } if (uri.startsWith("did:")) { return { did: uri, slug: uri.split(":").at(-1) ?? uri }; @@ -595,6 +748,37 @@ export function contactTargetFromUri(uri: string): ContactTarget | null { return null; } +// Canonical release-rkey shape, mirroring the aggregator's ingest validation +// (`records-consumer`'s `parseReleaseRkey`): `:` with the version +// percent-decoded before the semver check. Kept in sync by the pinned tests. +const PACKAGE_SLUG_RE = /^[a-zA-Z][a-zA-Z0-9_-]*$/; +const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/; + +/** + * The parent package slug a subject resolves against. Only a CANONICAL release + * record — the release collection AND a `slug:version` rkey whose slug is + * well-formed AND whose version is valid semver — is stripped to its package slug + * (`gallery:1.2.0` → `gallery`). Every other subject keeps its rkey verbatim: a + * package rkey IS the slug, and a non-release collection or a malformed + * colon-bearing rkey (`gallery:not-semver`) stays whole so it can never strip to + * a DIFFERENT package's slug — it misses at `getPackage` and resolution degrades + * to the publisher tier. + */ +function packageSlugFromRecord(collection: string | undefined, rkey: string): string { + if (collection !== NSID.packageRelease) return rkey; + const delimiter = rkey.indexOf(":"); + if (delimiter <= 0 || delimiter === rkey.length - 1) return rkey; + const slug = rkey.slice(0, delimiter); + if (!PACKAGE_SLUG_RE.test(slug)) return rkey; + let version: string; + try { + version = decodeURIComponent(rkey.slice(delimiter + 1)); + } catch { + return rkey; + } + return SEMVER_RE.test(version) ? slug : rkey; +} + function logTrigger(source: NotificationSource, did: string, outcome: string): void { console.log("[notifications]", { action: "trigger", diff --git a/apps/labeler/src/operational-events.ts b/apps/labeler/src/operational-events.ts index 557170cb76..9aa36fad96 100644 --- a/apps/labeler/src/operational-events.ts +++ b/apps/labeler/src/operational-events.ts @@ -14,7 +14,8 @@ export type OperationalEventType = | "dead-letter-quarantined" | "reconsideration-opened" | "reconsideration-resolved" - | "assessment-prolonged-error"; + | "assessment-prolonged-error" + | "takedown-no-contact"; export type OperationalEventSeverity = "critical" | "high" | "info"; @@ -79,6 +80,15 @@ export interface OperationalEventInsert { * ticks are not serialized against each other). */ gateOnUnalertedEscalation?: { assessmentId: string }; + /** + * Appends `ON CONFLICT ... DO NOTHING` targeting the partial unique index on + * `(action_id, event_type) WHERE event_type = 'takedown-no-contact'` (migration + * 0012), so a concurrent replay of the `takedown-no-contact` alert converges to + * one row. Only valid for that event type with a non-null `actionId` and no + * gate. Pair the outbox with {@link OutboxInsert.gateOnEventPresent} so a + * conflicted (no-op) event does not orphan an outbox row. + */ + idempotentTakedownNoContact?: boolean; } export interface OutboxInsert { @@ -89,6 +99,10 @@ export interface OutboxInsert { gateOnIssuedLabelActionId?: number; /** Same in-batch label gating as {@link OperationalEventInsert.gateOnIssuedLabelActionKey}. */ gateOnIssuedLabelActionKey?: string; + /** Insert only if the event row was actually written — `WHERE EXISTS (event + * with this id)`. Pairs with {@link OperationalEventInsert.idempotentTakedownNoContact} + * so an ON CONFLICT no-op event leaves no orphan outbox row in the same batch. */ + gateOnEventPresent?: boolean; } export interface StoredOperationalEvent { @@ -225,10 +239,15 @@ export function buildOperationalEventInsert( .bind(...values, input.gateOnUnalertedEscalation.assessmentId); } + // The conflict target repeats the partial index's predicate so SQLite resolves + // it to `idx_operational_events_takedown_no_contact` (migration 0012). + const onConflict = input.idempotentTakedownNoContact + ? ` ON CONFLICT (action_id, event_type) WHERE event_type = 'takedown-no-contact' DO NOTHING` + : ""; return db .prepare( `INSERT INTO operational_events (${columns}) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)${onConflict}`, ) .bind(...values); } @@ -271,6 +290,16 @@ export function buildOutboxInsert(db: D1Database, input: OutboxInsert): D1Prepar .bind(...values, input.gateOnIssuedLabelActionId); } + if (input.gateOnEventPresent) { + return db + .prepare( + `INSERT INTO notification_outbox (id, event_id, channel, created_at, created_at_epoch_ms) + SELECT ?, ?, ?, ?, ? + WHERE EXISTS (SELECT 1 FROM operational_events WHERE id = ?)`, + ) + .bind(...values, input.eventId); + } + return db .prepare( `INSERT INTO notification_outbox (id, event_id, channel, created_at, created_at_epoch_ms) diff --git a/apps/labeler/test/notification-endpoints.test.ts b/apps/labeler/test/notification-endpoints.test.ts index 588cafd8ff..f3b68f78de 100644 --- a/apps/labeler/test/notification-endpoints.test.ts +++ b/apps/labeler/test/notification-endpoints.test.ts @@ -145,6 +145,24 @@ describe("confirm", () => { expect((await getContactState(db(), hash))?.confirmState).toBe("confirmed"); }); + it("does NOT confirm when the credentials are only in the query (no form body)", async () => { + const token = "confirm-token-query-only"; + const hash = await seedPending(token); + + // A scanner POSTing the confirmation link URL with c/t in the query and no + // form body must not confirm — confirm has no query fallback. + const response = await SELF.fetch( + `https://labeler.test/notifications/confirm?c=${hash}&t=${encodeURIComponent(token)}`, + { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "", + }, + ); + expect(response.status).toBe(200); + expect((await getContactState(db(), hash))?.confirmState).toBe("unconfirmed"); + }); + it("never confirms a suppressed contact even with a matching token", async () => { const token = "confirm-token-suppressed"; const hash = await seedPending(token); @@ -204,6 +222,39 @@ describe("unsubscribe", () => { .first<{ n: number }>(); expect(count?.n).toBe(0); }); + + it("suppresses on an RFC 8058 one-click POST carrying c in the query and the marker in the body", async () => { + const hash = await freshHash(); + await ensureContact(db(), hash, "2026-07-16T00:00:00.000Z"); + + const response = await SELF.fetch(`https://labeler.test/notifications/unsubscribe?c=${hash}`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "List-Unsubscribe=One-Click", + }); + expect(response.status).toBe(200); + expect(await isSuppressed(db(), hash)).toBe(true); + expect(await reasonFor(hash)).toBe("unsubscribe"); + }); + + it("prefers the body hash over the query hash when both are present", async () => { + const bodyHash = await freshHash(); + const queryHash = await freshHash(); + await ensureContact(db(), bodyHash, "2026-07-16T00:00:00.000Z"); + await ensureContact(db(), queryHash, "2026-07-16T00:00:00.000Z"); + + const response = await SELF.fetch( + `https://labeler.test/notifications/unsubscribe?c=${queryHash}`, + { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ c: bodyHash }).toString(), + }, + ); + expect(response.status).toBe(200); + expect(await isSuppressed(db(), bodyHash)).toBe(true); + expect(await isSuppressed(db(), queryHash)).toBe(false); + }); }); describe("not-me", () => { diff --git a/apps/labeler/test/notification-triggers.test.ts b/apps/labeler/test/notification-triggers.test.ts index 265652f459..230079599b 100644 --- a/apps/labeler/test/notification-triggers.test.ts +++ b/apps/labeler/test/notification-triggers.test.ts @@ -13,6 +13,7 @@ import { } from "../src/notification-contacts.js"; import type { ConfirmationPayload, NoticePayload, SendResult } from "../src/notification-send.js"; import { + contactTargetFromUri, notifyAssessmentOutcome, notifyEmergencyTakedown, notifyOperatorLabel, @@ -20,6 +21,11 @@ import { notifyOverrideRetract, type NotifyDeps, } from "../src/notification-triggers.js"; +import { + buildOperationalEventInsert, + buildOutboxInsert, + newOperationalEventId, +} from "../src/operational-events.js"; interface TestEnv { DB: D1Database; @@ -45,6 +51,12 @@ interface AggregatorOpts { email?: string; verifications?: unknown[]; verifyStatus?: number; + /** The publisher's current displayName, served by getPublisher — the live + * identity the verification binding is checked against. The publisher view + * carries no handle, so none is served. */ + displayName?: string; + /** A package `security[]` contact served by getPackage for a specific slug. */ + packageSecurity?: { slug: string; email: string }; } function aggregatorFor(opts: AggregatorOpts): AggregatorClient { @@ -55,10 +67,21 @@ function aggregatorFor(opts: AggregatorOpts): AggregatorClient { return new Response("err", { status: opts.verifyStatus }); return Response.json({ did: DID, verifications: opts.verifications ?? [], labels: [] }); } + if (url.includes("getPackage") && opts.packageSecurity !== undefined) { + const slug = new URL(url).searchParams.get("slug"); + if (slug === opts.packageSecurity.slug) { + return Response.json({ + profile: { security: [{ email: opts.packageSecurity.email }] }, + }); + } + } if (url.includes("getPublisher") && opts.email !== undefined) { return Response.json({ did: DID, - profile: { contact: [{ kind: "security", email: opts.email }] }, + profile: { + contact: [{ kind: "security", email: opts.email }], + ...(opts.displayName !== undefined ? { displayName: opts.displayName } : {}), + }, }); } return new Response(JSON.stringify({ error: "NotFound" }), { @@ -94,6 +117,27 @@ function recordingSender(result: SendResult = { ok: true, providerId: "p" }): Re }; } +/** Wraps a D1 database so its FIRST `batch()` throws (a transient failure), + * delegating every other call — and later batches — to the real db. */ +function dbThrowingFirstBatch(real: D1Database): D1Database { + let thrown = false; + return new Proxy(real, { + get(target, prop, receiver) { + if (prop === "batch") { + return async (statements: D1PreparedStatement[]) => { + if (!thrown) { + thrown = true; + throw new Error("transient batch failure"); + } + return target.batch(statements); + }; + } + const value = Reflect.get(target, prop, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + function throwingSender(): RecordingSender { return { confirmations: [], @@ -107,7 +151,11 @@ function throwingSender(): RecordingSender { }; } -function deps(aggregator: AggregatorClient, sender: RecordingSender): NotifyDeps { +function deps( + aggregator: AggregatorClient, + sender: RecordingSender, + trustedVerificationIssuers?: ReadonlySet, +): NotifyDeps { return { db: db(), aggregator, @@ -115,6 +163,7 @@ function deps(aggregator: AggregatorClient, sender: RecordingSender): NotifyDeps pepper: PEPPER, serviceUrl: SERVICE, reconsiderationUrl: RECON, + ...(trustedVerificationIssuers ? { trustedVerificationIssuers } : {}), }; } @@ -146,17 +195,49 @@ async function notificationRows(sourceId: string): Promise<{ kind: string; state return r.results ?? []; } -const IN_FORCE = [ - { issuer: "did:plc:issuer", handle: "x.test", createdAt: "2026-01-01T00:00:00.000Z" }, +const TRUSTED_ISSUER = "did:plc:trusted"; +const TRUSTED = new Set([TRUSTED_ISSUER]); +const HANDLE = "acme.test"; +const DISPLAY_NAME = "Acme"; + +/** A trusted, in-force claim whose bound displayName matches the publisher's + * current displayName. */ +const TRUSTED_CLAIM = [ + { + issuer: TRUSTED_ISSUER, + handle: HANDLE, + displayName: DISPLAY_NAME, + createdAt: "2026-01-01T00:00:00.000Z", + }, ]; -const EXPIRED = [ +/** An in-force claim from an issuer NOT in the trust set (self-assertable). */ +const UNTRUSTED_CLAIM = [ { - issuer: "did:plc:issuer", - handle: "x.test", + issuer: "did:plc:attacker", + handle: HANDLE, + displayName: DISPLAY_NAME, + createdAt: "2026-01-01T00:00:00.000Z", + }, +]; +const EXPIRED_TRUSTED_CLAIM = [ + { + issuer: TRUSTED_ISSUER, + handle: HANDLE, + displayName: DISPLAY_NAME, createdAt: "2020-01-01T00:00:00.000Z", expiresAt: "2021-01-01T00:00:00.000Z", }, ]; +/** Trusted + in-force but bound to a stale displayName (the publisher's + * displayName has drifted since verification). */ +const STALE_BINDING_CLAIM = [ + { + issuer: TRUSTED_ISSUER, + handle: HANDLE, + displayName: "Acme (old name)", + createdAt: "2026-01-01T00:00:00.000Z", + }, +]; describe("the five events each notify a confirmed publisher", () => { it("automated block → notice from source 'issuance'", async () => { @@ -281,35 +362,123 @@ describe("dedup", () => { }); }); -describe("verified-publisher skip", () => { - it("an in-force verification claim delivers the notice without a confirmation mail", async () => { +describe("verified-publisher skip (trusted issuer only)", () => { + async function confirmStateOf(email: string): Promise { + const hash = await recipientHash(PEPPER, email); + const contact = await db() + .prepare(`SELECT confirm_state FROM notification_contacts WHERE recipient_hash = ?`) + .bind(hash) + .first<{ confirm_state: string }>(); + return contact?.confirm_state; + } + + it("a trusted issuer claim with current bindings delivers the notice and upgrades the contact", async () => { const email = uniq("vok") + "@x.test"; const sender = recordingSender(); const a = assessmentRow({ state: "blocked" }); - // The contact is UNCONFIRMED; verification upgrades it in place. + // The contact is UNCONFIRMED; the trusted, current-binding claim upgrades it. await notifyAssessmentOutcome( - deps(aggregatorFor({ email, verifications: IN_FORCE }), sender), + deps( + aggregatorFor({ + email, + verifications: TRUSTED_CLAIM, + displayName: DISPLAY_NAME, + }), + sender, + TRUSTED, + ), a, ); expect(sender.notices).toHaveLength(1); expect(sender.confirmations).toHaveLength(0); - const hash = await recipientHash(PEPPER, email); - const contact = await db() - .prepare(`SELECT confirm_state FROM notification_contacts WHERE recipient_hash = ?`) - .bind(hash) - .first<{ confirm_state: string }>(); - expect(contact?.confirm_state).toBe("confirmed"); + expect(await confirmStateOf(email)).toBe("confirmed"); + }); + + it("a self-issued / untrusted-issuer claim does NOT bypass double opt-in", async () => { + const email = uniq("vself") + "@x.test"; + const sender = recordingSender(); + const a = assessmentRow({ state: "blocked" }); + + // A claim naming an arbitrary address, issued by an untrusted DID, even with + // a trust set configured for a DIFFERENT issuer. + await notifyAssessmentOutcome( + deps( + aggregatorFor({ + email, + verifications: UNTRUSTED_CLAIM, + displayName: DISPLAY_NAME, + }), + sender, + TRUSTED, + ), + a, + ); + + expect(sender.notices).toHaveLength(0); + expect(sender.confirmations).toHaveLength(1); + }); + + it("with no trusted issuer configured (the default), an in-force claim does NOT bypass", async () => { + const email = uniq("vnodef") + "@x.test"; + const sender = recordingSender(); + const a = assessmentRow({ state: "blocked" }); + + await notifyAssessmentOutcome( + deps( + aggregatorFor({ + email, + verifications: TRUSTED_CLAIM, + displayName: DISPLAY_NAME, + }), + sender, + ), + a, + ); + + expect(sender.notices).toHaveLength(0); + expect(sender.confirmations).toHaveLength(1); }); - it("an EXPIRED claim falls back to double opt-in", async () => { + it("an EXPIRED trusted claim falls back to double opt-in", async () => { const email = uniq("vexp") + "@x.test"; const sender = recordingSender(); const a = assessmentRow({ state: "blocked" }); await notifyAssessmentOutcome( - deps(aggregatorFor({ email, verifications: EXPIRED }), sender), + deps( + aggregatorFor({ + email, + verifications: EXPIRED_TRUSTED_CLAIM, + displayName: DISPLAY_NAME, + }), + sender, + TRUSTED, + ), + a, + ); + + expect(sender.notices).toHaveLength(0); + expect(sender.confirmations).toHaveLength(1); + }); + + it("a trusted claim with a STALE identity binding does NOT bypass", async () => { + const email = uniq("vstale") + "@x.test"; + const sender = recordingSender(); + const a = assessmentRow({ state: "blocked" }); + + // Claim's bound displayName has drifted from the publisher's current one. + await notifyAssessmentOutcome( + deps( + aggregatorFor({ + email, + verifications: STALE_BINDING_CLAIM, + displayName: DISPLAY_NAME, + }), + sender, + TRUSTED, + ), a, ); @@ -322,13 +491,16 @@ describe("verified-publisher skip", () => { const sender = recordingSender(); const a = assessmentRow({ state: "blocked" }); - await notifyAssessmentOutcome(deps(aggregatorFor({ email, verifyStatus: 500 }), sender), a); + await notifyAssessmentOutcome( + deps(aggregatorFor({ email, verifyStatus: 500 }), sender, TRUSTED), + a, + ); expect(sender.notices).toHaveLength(0); expect(sender.confirmations).toHaveLength(1); }); - it("a suppressed address gets NOTHING even when the publisher is verified", async () => { + it("a suppressed address gets NOTHING even when the publisher is trusted-verified", async () => { const email = uniq("vsupp") + "@x.test"; const hash = await recipientHash(PEPPER, email); await suppress(db(), hash, "not_me", "2026-07-16T00:00:00.000Z", 1_000); @@ -336,7 +508,15 @@ describe("verified-publisher skip", () => { const a = assessmentRow({ state: "blocked" }); await notifyAssessmentOutcome( - deps(aggregatorFor({ email, verifications: IN_FORCE }), sender), + deps( + aggregatorFor({ + email, + verifications: TRUSTED_CLAIM, + displayName: DISPLAY_NAME, + }), + sender, + TRUSTED, + ), a, ); @@ -368,6 +548,293 @@ describe("provider hard-bounce", () => { }); }); +describe("package slug parse", () => { + it("parses the parent package slug from a release rkey (slug:version)", () => { + const target = contactTargetFromUri( + `at://${DID}/com.emdashcms.experimental.package.release/gallery:1.2.0`, + ); + expect(target).toEqual({ did: DID, slug: "gallery" }); + }); + + it("keeps a package rkey (no version) as the slug", () => { + const target = contactTargetFromUri(`at://${DID}/com.emdashcms.experimental.package/gallery`); + expect(target).toEqual({ did: DID, slug: "gallery" }); + }); + + it("degrades safely on an rkey with no slug before the version delimiter", () => { + const target = contactTargetFromUri( + `at://${DID}/com.emdashcms.experimental.package.release/:1.2.0`, + ); + expect(target).toEqual({ did: DID, slug: ":1.2.0" }); + }); + + it("resolves the package's security contact for a release URI", async () => { + const email = uniq("pkgsec") + "@x.test"; + await seedConfirmed(email); + const sender = recordingSender(); + const releaseUri = `at://${DID}/com.emdashcms.experimental.package.release/gallery:1.2.0`; + const a = assessmentRow({ state: "blocked", uri: releaseUri }); + + await notifyAssessmentOutcome( + deps(aggregatorFor({ packageSecurity: { slug: "gallery", email } }), sender), + a, + ); + + expect(sender.notices).toHaveLength(1); + expect(sender.notices[0]?.to).toBe(email); + }); + + it("does not strip a colon-bearing rkey under a non-release collection", () => { + const target = contactTargetFromUri( + `at://${DID}/com.emdashcms.experimental.package.profile/gallery:evil`, + ); + expect(target).toEqual({ did: DID, slug: "gallery:evil" }); + }); + + it("does not strip a release rkey whose slug is malformed", () => { + const target = contactTargetFromUri( + `at://${DID}/com.emdashcms.experimental.package.release/1bad:2.0.0`, + ); + expect(target).toEqual({ did: DID, slug: "1bad:2.0.0" }); + }); + + it("does not strip a release rkey whose version is not valid semver", () => { + const target = contactTargetFromUri( + `at://${DID}/com.emdashcms.experimental.package.release/gallery:not-semver`, + ); + expect(target).toEqual({ did: DID, slug: "gallery:not-semver" }); + }); + + it("strips a release rkey with a valid prerelease semver version", () => { + const target = contactTargetFromUri( + `at://${DID}/com.emdashcms.experimental.package.release/gallery:1.2.0-beta.1`, + ); + expect(target).toEqual({ did: DID, slug: "gallery" }); + }); + + it("a release rkey with a non-semver version degrades to the publisher contact", async () => { + const publisherEmail = uniq("pubsem") + "@x.test"; + const galleryEmail = uniq("galsem") + "@x.test"; + await seedConfirmed(publisherEmail); + const sender = recordingSender(); + const uri = `at://${DID}/com.emdashcms.experimental.package.release/gallery:not-semver`; + const a = assessmentRow({ state: "blocked", uri }); + + await notifyAssessmentOutcome( + deps( + aggregatorFor({ + email: publisherEmail, + packageSecurity: { slug: "gallery", email: galleryEmail }, + }), + sender, + ), + a, + ); + + expect(sender.notices).toHaveLength(1); + expect(sender.notices[0]?.to).toBe(publisherEmail); + }); + + it("a malformed colon-bearing subject degrades to the publisher contact, not a wrong package", async () => { + const publisherEmail = uniq("pub") + "@x.test"; + const galleryEmail = uniq("gal") + "@x.test"; + await seedConfirmed(publisherEmail); + const sender = recordingSender(); + // Colon-bearing rkey under a non-release collection: must NOT resolve the + // gallery package's security contact by stripping to "gallery". + const uri = `at://${DID}/com.emdashcms.experimental.package.profile/gallery:evil`; + const a = assessmentRow({ state: "blocked", uri }); + + await notifyAssessmentOutcome( + deps( + aggregatorFor({ + email: publisherEmail, + packageSecurity: { slug: "gallery", email: galleryEmail }, + }), + sender, + ), + a, + ); + + expect(sender.notices).toHaveLength(1); + expect(sender.notices[0]?.to).toBe(publisherEmail); + }); +}); + +describe("emergency takedown with no resolvable contact", () => { + /** The committed operator action the takedown notify references — the + * operational_events.action_id FK requires it (satisfied in production because + * the emergency action commits before the deferred notify). */ + async function seedTakedownAction(actionId: string): Promise { + await db() + .prepare( + `INSERT INTO operator_actions + (id, actor_type, actor_id, role, action, subject_uri, label_value, reason, + idempotency_key, request_fingerprint, created_at, created_at_epoch_ms) + VALUES (?, 'human', 'op', 'admin', 'takedown', ?, '!takedown', 'r', ?, 'fp', ?, ?)`, + ) + .bind(actionId, RELEASE_URI, actionId, "2026-07-16T00:00:00.000Z", 1_000) + .run(); + } + + async function takedownEvents( + actionId: string, + ): Promise<{ eventType: string; severity: string; subjectUri: string | null }[]> { + const r = await db() + .prepare( + `SELECT event_type, severity, subject_uri FROM operational_events WHERE action_id = ?`, + ) + .bind(actionId) + .all<{ event_type: string; severity: string; subject_uri: string | null }>(); + return (r.results ?? []).map((row) => ({ + eventType: row.event_type, + severity: row.severity, + subjectUri: row.subject_uri, + })); + } + + it("emits a takedown-no-contact operational event and outbox row", async () => { + const sender = recordingSender(); + const actionId = uniq("oact"); + await seedTakedownAction(actionId); + + // aggregatorFor with no email/package resolves no contact. + await notifyEmergencyTakedown(deps(aggregatorFor({}), sender), { + actionId, + uri: RELEASE_URI, + neg: false, + }); + + expect(sender.notices).toHaveLength(0); + expect(await takedownEvents(actionId)).toEqual([ + { eventType: "takedown-no-contact", severity: "high", subjectUri: RELEASE_URI }, + ]); + const event = await db() + .prepare(`SELECT id FROM operational_events WHERE action_id = ?`) + .bind(actionId) + .first<{ id: string }>(); + const outbox = await db() + .prepare(`SELECT channel FROM notification_outbox WHERE event_id = ?`) + .bind(event?.id) + .first<{ channel: string }>(); + expect(outbox?.channel).toBe("deployment-alert"); + }); + + it("does not emit the event on a takedown RETRACT with no contact", async () => { + const sender = recordingSender(); + const actionId = uniq("oact"); + + await notifyEmergencyTakedown(deps(aggregatorFor({}), sender), { + actionId, + uri: RELEASE_URI, + neg: true, + }); + + expect(await takedownEvents(actionId)).toEqual([]); + }); + + it("does not emit the event when a contact resolves", async () => { + const email = uniq("tdok") + "@x.test"; + await seedConfirmed(email); + const sender = recordingSender(); + const actionId = uniq("oact"); + + await notifyEmergencyTakedown(deps(aggregatorFor({ email }), sender), { + actionId, + uri: RELEASE_URI, + neg: false, + }); + + expect(sender.notices).toHaveLength(1); + expect(await takedownEvents(actionId)).toEqual([]); + }); + + it("does not re-emit on a deduped replay", async () => { + const sender = recordingSender(); + const actionId = uniq("oact"); + await seedTakedownAction(actionId); + const d = deps(aggregatorFor({}), sender); + + await notifyEmergencyTakedown(d, { actionId, uri: RELEASE_URI, neg: false }); + await notifyEmergencyTakedown(d, { actionId, uri: RELEASE_URI, neg: false }); + + expect(await takedownEvents(actionId)).toHaveLength(1); + }); + + it("converges to one event when two replays both emit (unique index + ON CONFLICT)", async () => { + const actionId = uniq("oact"); + await seedTakedownAction(actionId); + const database = db(); + const now = new Date("2026-07-18T00:00:00.000Z"); + + // Two replays that both passed the existence check each build their own + // event+outbox batch for the same (action_id, event_type). The unique index + // + ON CONFLICT DO NOTHING must collapse them to a single event with no throw. + const build = () => { + const eventId = newOperationalEventId(); + return [ + buildOperationalEventInsert(database, { + id: eventId, + eventType: "takedown-no-contact", + severity: "high", + actionId, + subjectUri: RELEASE_URI, + labelValue: "!takedown", + payload: { reason: "manual outreach required" }, + now, + idempotentTakedownNoContact: true, + }), + buildOutboxInsert(database, { + eventId, + channel: "deployment-alert", + now, + gateOnEventPresent: true, + }), + ]; + }; + await database.batch(build()); + await database.batch(build()); + + expect(await takedownEvents(actionId)).toHaveLength(1); + const outbox = await database + .prepare( + `SELECT COUNT(*) n FROM notification_outbox nob + JOIN operational_events oe ON oe.id = nob.event_id WHERE oe.action_id = ?`, + ) + .bind(actionId) + .first<{ n: number }>(); + expect(outbox?.n).toBe(1); + }); + + it("recovers the alert on a replay after a transient first-pass emit failure", async () => { + const sender = recordingSender(); + const actionId = uniq("oact"); + await seedTakedownAction(actionId); + + // First pass: the undeliverable notifications row commits, but the alert + // batch fails transiently, so no event is recorded. + const firstPass: NotifyDeps = { + db: dbThrowingFirstBatch(db()), + aggregator: aggregatorFor({}), + sender, + pepper: PEPPER, + serviceUrl: SERVICE, + reconsiderationUrl: RECON, + }; + await notifyEmergencyTakedown(firstPass, { actionId, uri: RELEASE_URI, neg: false }); + expect(await takedownEvents(actionId)).toEqual([]); + + // Replay on a healthy db: the notifications row already exists (runTrigger + // dedups), but the alert is keyed on its own event row, so it recovers. + await notifyEmergencyTakedown(deps(aggregatorFor({}), sender), { + actionId, + uri: RELEASE_URI, + neg: false, + }); + expect(await takedownEvents(actionId)).toHaveLength(1); + }); +}); + describe("failure isolation", () => { it("a sender that THROWS never propagates out of the trigger (the label is safe)", async () => { const email = uniq("throw") + "@x.test"; diff --git a/apps/labeler/test/operational-events.test.ts b/apps/labeler/test/operational-events.test.ts index 560dc080a1..b3fe18bb4a 100644 --- a/apps/labeler/test/operational-events.test.ts +++ b/apps/labeler/test/operational-events.test.ts @@ -113,8 +113,14 @@ describe("operational_events store", () => { const otherId = `oact_other${counter}`; await insertOperatorAction(actionId); await insertOperatorAction(otherId); + // One action can raise events of DIFFERENT types (a takedown emits both + // `emergency-takedown` and the deferred `takedown-no-contact`); the + // (action_id, event_type) unique index forbids the SAME type twice. await buildOperationalEventInsert(testEnv.DB, eventInput({ actionId })).run(); - await buildOperationalEventInsert(testEnv.DB, eventInput({ actionId })).run(); + await buildOperationalEventInsert( + testEnv.DB, + eventInput({ actionId, eventType: "takedown-no-contact" }), + ).run(); await buildOperationalEventInsert(testEnv.DB, eventInput({ actionId: otherId })).run(); const rows = await getOperationalEventsByActionId(testEnv.DB, actionId); @@ -122,6 +128,37 @@ describe("operational_events store", () => { expect(rows.every((r) => r.actionId === actionId)).toBe(true); }); + it("allows duplicate (action_id, event_type) for a non-takedown event type", async () => { + // The 0012 unique index is PARTIAL to `takedown-no-contact`, so historical + // duplicates of other types are unconstrained — this is what keeps the + // migration safe on existing data. + const actionId = `oact_dup${counter}`; + await insertOperatorAction(actionId); + await buildOperationalEventInsert( + testEnv.DB, + eventInput({ actionId, eventType: "emergency-takedown" }), + ).run(); + await buildOperationalEventInsert( + testEnv.DB, + eventInput({ actionId, eventType: "emergency-takedown" }), + ).run(); + + const rows = await getOperationalEventsByActionId(testEnv.DB, actionId); + expect(rows).toHaveLength(2); + }); + + it("collapses a second takedown-no-contact for the same action to a no-op", async () => { + const actionId = `oact_tnc${counter}`; + await insertOperatorAction(actionId); + const input = () => + eventInput({ actionId, eventType: "takedown-no-contact", idempotentTakedownNoContact: true }); + await buildOperationalEventInsert(testEnv.DB, input()).run(); + await buildOperationalEventInsert(testEnv.DB, input()).run(); + + const rows = await getOperationalEventsByActionId(testEnv.DB, actionId); + expect(rows).toHaveLength(1); + }); + it("rejects UPDATE on a recorded event (immutable log)", async () => { const input = eventInput(); await buildOperationalEventInsert(testEnv.DB, input).run(); From 1f6925fe46e79e63440b77bc09812fdf702b7b50 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 18 Jul 2026 20:49:08 +0100 Subject: [PATCH 132/137] fix(aggregator): wrap unexpected XRPC errors + harden PDS egress against SSRF (review round 1) (#2117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(aggregator): wrap unexpected XRPC errors and harden PDS egress against SSRF Finding 17 (Sol): a non-XRPC failure resolving the labeler policy (e.g. a D1 error) escaped `handleXrpc` unwrapped, hitting workerd's bare 500 without the CORS or `private, no-store` headers every aggregator response carries. Convert unexpected errors to a generic 500 through the same wrapper, logging the internal detail but never leaking it to the client. XRPCError handling is unchanged. SSRF follow-up: `pds-verify.ts` fetched the publisher-controlled PDS endpoint (from a DID document) following redirects with no scheme/DNS/reserved-address validation and no body-read deadline. Route it through the shared hardened egress (`resolveAndValidateExternalUrl` + injected `cloudflareDohResolver`): HTTPS-only, per-hop re-resolution under `redirect: "manual"` with a bounded redirect count, a wall-clock deadline spanning redirects and the body read, and fail-closed when no resolver is injected. Blocks are reported as a new permanent `PDS_ADDRESS_BLOCKED` reason; existing 404/5xx/too-large/invalid-proof and the transient network classification are preserved. * fix(aggregator): retry PDS verification on resolver-infra failures, don't dead-letter `assertFetchableUrl` collapsed every `SsrfError` from `resolveAndValidateExternalUrl` into the permanent `PDS_ADDRESS_BLOCKED`, but that helper also throws `SsrfError` when the injected DNS resolver itself fails (DoH network error / SERVFAIL / timeout) — transient infrastructure, not a disallowed publisher address. A DoH blip therefore permanently dead-lettered legitimate records and risked mass dead-lettering during a resolver outage. `SsrfError.code` is the same constant for both cases, so key on the message prefix the helper uses only for the resolver-threw path (`Could not resolve hostname:`): map that to the transient `PDS_NETWORK_ERROR` (retried), and keep genuine address blocks — private/reserved IP, blocked host, scheme — as the permanent `PDS_ADDRESS_BLOCKED`. A test asserts the transient mapping so a future wording change in the shared core helper breaks loudly rather than silently mis-classifying. Also adds a redirect-hop-limit boundary test. * fix(aggregator): retry PDS verification on empty DNS answer, don't dead-letter An empty resolver answer (NXDOMAIN / NOERROR-NODATA / CNAME-only) makes `resolveAndValidateExternalUrl` throw `SsrfError("Hostname resolved to no addresses")`, which the classifier mapped to the permanent PDS_ADDRESS_BLOCKED — so a host mid-DNS-propagation lost the record to a dead-letter. Treat the empty-answer message as a transient resolution failure (PDS_NETWORK_ERROR, retried) alongside the existing resolver-threw case; a genuinely-gone host still dead-letters via retry exhaustion. Genuine private/reserved/blocked-host addresses stay permanent. Keyed on the shared helper's message as a module constant with a test, matching the resolver-failure-prefix approach. * fix(aggregator): delay re-delivery on transient DNS-resolution failure The records queue re-delivers immediately (no `retry_delay`), so classifying an empty DNS answer / resolver outage as transient only helped if the retry waited for propagation — otherwise all `max_retries` (5) burn in seconds and the record DLQs as UNEXPECTED_ERROR before DNS settles. `PdsVerificationError` now carries an optional `retryAfterSeconds`, set (to 60s) only on the transient DNS-resolution path; the consumer passes it as `retry({ delaySeconds })`. Other transient retries (network, 5xx, timeout) stay immediate. Delay strategy: fixed 60s. With max_retries: 5 that spreads retries across a ~5-minute propagation window. Chosen for reconciliation with labeler #2113. --- apps/aggregator/package.json | 3 +- apps/aggregator/src/pds-verify.ts | 263 +++++++++++++++--- apps/aggregator/src/records-consumer.ts | 23 +- apps/aggregator/src/routes/xrpc/router.ts | 28 +- apps/aggregator/test/pds-verify.test.ts | 187 +++++++++++++ apps/aggregator/test/read-api.test.ts | 40 ++- apps/aggregator/test/records-consumer.test.ts | 60 +++- pnpm-lock.yaml | 3 + 8 files changed, 553 insertions(+), 54 deletions(-) diff --git a/apps/aggregator/package.json b/apps/aggregator/package.json index 5e7f8ac93f..7137b0efbf 100644 --- a/apps/aggregator/package.json +++ b/apps/aggregator/package.json @@ -32,7 +32,8 @@ "@atcute/xrpc-server": "catalog:", "@atcute/xrpc-server-cloudflare": "catalog:", "@emdash-cms/registry-lexicons": "workspace:*", - "@emdash-cms/registry-moderation": "workspace:*" + "@emdash-cms/registry-moderation": "workspace:*", + "emdash": "workspace:*" }, "devDependencies": { "@cloudflare/vite-plugin": "catalog:", diff --git a/apps/aggregator/src/pds-verify.ts b/apps/aggregator/src/pds-verify.ts index 4b31bb0b0a..1c8965bd19 100644 --- a/apps/aggregator/src/pds-verify.ts +++ b/apps/aggregator/src/pds-verify.ts @@ -20,18 +20,40 @@ import type { PublicKey } from "@atcute/crypto"; import { type AtprotoDid, isDid } from "@atcute/lexicons/syntax"; import { verifyRecord } from "@atcute/repo"; +import { type DnsResolver, resolveAndValidateExternalUrl, SsrfError } from "emdash/security/ssrf"; const DEFAULT_TIMEOUT_MS = 15_000; /** 5 MB ceiling. Records and their proofs are tiny (sub-KB typical); this is * a defence against a hostile or broken PDS streaming an unbounded body. */ const DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024; +/** Redirect hops we'll follow before giving up. The PDS endpoint is + * publisher-controlled, so redirects are an SSRF pivot: every hop is + * re-validated, and this caps the chain. */ +const MAX_PDS_REDIRECTS = 3; +/** Message prefix `resolveAndValidateExternalUrl` uses when the injected DNS + * resolver itself throws (DoH network error / SERVFAIL / timeout) — a transient + * infrastructure failure, distinct from a disallowed address. Keyed on here to + * classify those as retryable; see `assertFetchableUrl`. */ +const RESOLVER_FAILURE_PREFIX = "Could not resolve hostname:"; +/** Message `resolveAndValidateExternalUrl` uses for an empty DNS answer + * (NXDOMAIN / NOERROR-NODATA / CNAME-only). Transient: a host mid-propagation + * would otherwise be permanently dead-lettered; a genuinely-gone host instead + * dead-letters via retry exhaustion. Not a disallowed address. */ +const EMPTY_ANSWER_MESSAGE = "Hostname resolved to no addresses"; +/** Delay, in seconds, before the consumer re-delivers a message that failed on a + * transient DNS resolution (empty answer / resolver outage). The records queue + * re-delivers immediately by default, so without this a host mid-propagation + * burns all `max_retries` in seconds and lands in the DLQ before DNS settles. + * A fixed pause spreads the retries across a propagation window. */ +const RESOLUTION_RETRY_DELAY_SECONDS = 60; export type VerificationFailureReason = | "PDS_NETWORK_ERROR" | "PDS_HTTP_ERROR" | "RECORD_NOT_FOUND" | "RESPONSE_TOO_LARGE" - | "INVALID_PROOF"; + | "INVALID_PROOF" + | "PDS_ADDRESS_BLOCKED"; export class PdsVerificationError extends Error { override readonly name = "PdsVerificationError"; @@ -40,6 +62,10 @@ export class PdsVerificationError extends Error { message: string, readonly status?: number, override readonly cause?: unknown, + /** When set, the consumer re-delivers the message after this many seconds + * instead of immediately. Set only for transient DNS-resolution failures + * that need time to propagate; other transient retries stay immediate. */ + readonly retryAfterSeconds?: number, ) { super(message); } @@ -51,12 +77,20 @@ export interface FetchAndVerifyOptions { collection: string; rkey: string; publicKey: PublicKey; - /** Default 15s. Aborts the fetch if the PDS is slow. */ + /** Default 15s wall-clock budget covering every redirect hop AND the body + * read — a slow-drip body is bounded by this, not just the initial fetch. */ timeoutMs?: number; /** Default 5 MB. Rejects with `RESPONSE_TOO_LARGE` if exceeded. */ maxResponseBytes?: number; /** Inject for tests; defaults to `globalThis.fetch`. */ fetch?: typeof fetch; + /** + * SSRF-safe hostname resolver applied to the PDS endpoint and every redirect + * target before we connect. Production injects `cloudflareDohResolver`; tests + * stub it. Absent means fail closed — the publisher-controlled fetch is + * refused rather than issued unprotected. + */ + resolveHostname?: DnsResolver; } export interface VerifiedPdsRecord { @@ -74,6 +108,16 @@ export async function fetchAndVerifyRecord( const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; const maxResponseBytes = opts.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES; + // Fail closed: without a resolver we can't validate the publisher-controlled + // endpoint against the SSRF blocklist, so we refuse rather than fetch blind. + const resolveHostname = opts.resolveHostname; + if (!resolveHostname) { + throw new PdsVerificationError( + "PDS_ADDRESS_BLOCKED", + "refusing PDS fetch: no SSRF-safe hostname resolver was provided", + ); + } + if (!isAtprotoDid(opts.did)) { // Caller is expected to have validated this upstream (the resolver // rejects non-DID strings before reaching here), but the verifier's @@ -84,7 +128,7 @@ export async function fetchAndVerifyRecord( ); } const url = buildGetRecordUrl(opts.pds, opts.did, opts.collection, opts.rkey); - const carBytes = await fetchCar(fetchImpl, url, timeoutMs, maxResponseBytes); + const carBytes = await fetchCar(fetchImpl, url, timeoutMs, maxResponseBytes, resolveHostname); try { const result = await verifyRecord({ @@ -117,54 +161,179 @@ function buildGetRecordUrl(pds: string, did: string, collection: string, rkey: s return url.toString(); } +function isRedirectStatus(status: number): boolean { + return status === 301 || status === 302 || status === 303 || status === 307 || status === 308; +} + +/** + * True when an `SsrfError` message denotes a transient resolution failure (the + * resolver threw, or the DNS answer was empty) rather than a disallowed address. + * Keyed on the shared helper's messages; guarded by tests so a wording change + * there fails loudly instead of silently re-classifying. + */ +function isTransientResolutionFailure(message: string): boolean { + return message.startsWith(RESOLVER_FAILURE_PREFIX) || message === EMPTY_ANSWER_MESSAGE; +} + +/** + * Reject the URL unless it's HTTPS and resolves to a public address. The PDS + * endpoint (and every redirect target) is publisher-controlled, so this is the + * SSRF boundary: HTTP is refused (no plaintext downgrade to an internal + * listener the resolver can't see), and `resolveAndValidateExternalUrl` resolves + * the hostname and rejects any private/reserved address. IP/address logic lives + * in the shared helper — never duplicated here. + */ +async function assertFetchableUrl(url: string, resolver: DnsResolver): Promise { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new PdsVerificationError("PDS_ADDRESS_BLOCKED", `invalid PDS URL: ${url}`); + } + if (parsed.protocol !== "https:") { + throw new PdsVerificationError("PDS_ADDRESS_BLOCKED", `PDS URL must be https: ${url}`); + } + try { + await resolveAndValidateExternalUrl(url, { resolver }); + } catch (err) { + if (err instanceof SsrfError) { + // `resolveAndValidateExternalUrl` throws `SsrfError` for two very + // different classes of situation: a disallowed address (permanent — + // dead-letter) and a resolution failure (transient — retry). The + // transient class is a DoH network error / SERVFAIL / timeout (the + // resolver threw, prefixed `RESOLVER_FAILURE_PREFIX`) or an empty DNS + // answer (`EMPTY_ANSWER_MESSAGE` — a host mid-propagation). `SsrfError.code` + // is the same constant for all, so the only discriminator is the message. + // Tests assert both transient mappings so a future wording change in the + // shared helper breaks loudly here rather than silently dead-lettering + // legitimate records during a DoH blip or DNS propagation. + if (isTransientResolutionFailure(err.message)) { + throw new PdsVerificationError( + "PDS_NETWORK_ERROR", + `PDS host resolution failed: ${err.message}`, + undefined, + err, + RESOLUTION_RETRY_DELAY_SECONDS, + ); + } + throw new PdsVerificationError( + "PDS_ADDRESS_BLOCKED", + `PDS address rejected: ${err.message}`, + undefined, + err, + ); + } + throw err; + } +} + async function fetchCar( fetchImpl: typeof fetch, - url: string, + initialUrl: string, timeoutMs: number, maxBytes: number, + resolver: DnsResolver, ): Promise { + // One wall-clock budget spanning every redirect hop and the body read, so a + // PDS that dribbles headers-then-body can't hold the request open past it. const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); - let response: Response; try { - response = await fetchImpl(url, { - signal: controller.signal, - headers: { accept: "application/vnd.ipld.car" }, - }); - } catch (err) { - // Whether the fetch threw because we aborted (timeout) or because the - // network failed at the OS layer, the right caller behaviour is the - // same: retry. Lump them under PDS_NETWORK_ERROR. - throw new PdsVerificationError( - "PDS_NETWORK_ERROR", - err instanceof Error && err.name === "AbortError" - ? `PDS fetch aborted after ${timeoutMs}ms` - : `PDS fetch failed: ${err instanceof Error ? err.message : String(err)}`, - undefined, - err, - ); + let currentUrl = initialUrl; + for (let hop = 0; ; hop++) { + // Re-resolve and re-validate every hop: an allowed public host can + // 30x to a private one, so validating only the initial URL is a hole. + await assertFetchableUrl(currentUrl, resolver); + + let response: Response; + try { + response = await fetchImpl(currentUrl, { + signal: controller.signal, + // Intercept redirects so each target is re-validated before we + // follow it, rather than letting fetch chase them unchecked. + redirect: "manual", + headers: { accept: "application/vnd.ipld.car" }, + }); + } catch (err) { + // Whether the fetch threw because we aborted (timeout) or because + // the network failed at the OS layer, the right caller behaviour + // is the same: retry. Lump them under PDS_NETWORK_ERROR. + throw new PdsVerificationError( + "PDS_NETWORK_ERROR", + err instanceof Error && err.name === "AbortError" + ? `PDS fetch aborted after ${timeoutMs}ms` + : `PDS fetch failed: ${err instanceof Error ? err.message : String(err)}`, + undefined, + err, + ); + } + + if (isRedirectStatus(response.status)) { + // Free the redirect response's socket before following. + await response.body?.cancel().catch(() => {}); + if (hop >= MAX_PDS_REDIRECTS) { + throw new PdsVerificationError( + "PDS_ADDRESS_BLOCKED", + `PDS exceeded ${MAX_PDS_REDIRECTS} redirects`, + ); + } + const location = response.headers.get("location"); + if (!location) { + throw new PdsVerificationError( + "PDS_HTTP_ERROR", + `PDS redirect without a Location header`, + response.status, + ); + } + // The publisher fully controls this header; a malformed value + // (e.g. `http://` with an empty authority) makes `new URL` throw a + // raw TypeError. Classify it as a blocked redirect rather than + // letting it escape as an UNEXPECTED_ERROR the publisher can force. + try { + currentUrl = new URL(location, currentUrl).toString(); + } catch { + throw new PdsVerificationError( + "PDS_ADDRESS_BLOCKED", + `PDS redirect Location is not a valid URL: ${location}`, + ); + } + continue; + } + + if (response.status === 404) { + // Distinct from a generic 4xx. The publisher may have deleted the + // record between Jetstream emitting and us fetching, which is the + // common cause; other 4xx (auth, bad request) suggest programming + // errors. Both end up dead-lettered by the consumer (the audit + // trail is useful even for legitimate races so operators can spot + // systematic Jetstream-vs-PDS skew); the distinct reason code keeps + // them queryable separately. + throw new PdsVerificationError( + "RECORD_NOT_FOUND", + `PDS returned 404 for ${currentUrl}`, + 404, + ); + } + if (!response.ok) { + throw new PdsVerificationError( + "PDS_HTTP_ERROR", + `PDS returned ${response.status} for ${currentUrl}`, + response.status, + ); + } + + return await readCarBody(response, maxBytes, timeoutMs); + } } finally { clearTimeout(timer); } +} - if (response.status === 404) { - // Distinct from a generic 4xx. The publisher may have deleted the - // record between Jetstream emitting and us fetching, which is the - // common cause; other 4xx (auth, bad request) suggest programming - // errors. Both end up dead-lettered by the consumer (the audit trail - // is useful even for legitimate races so operators can spot - // systematic Jetstream-vs-PDS skew); the distinct reason code keeps - // them queryable separately. - throw new PdsVerificationError("RECORD_NOT_FOUND", `PDS returned 404 for ${url}`, 404); - } - if (!response.ok) { - throw new PdsVerificationError( - "PDS_HTTP_ERROR", - `PDS returned ${response.status} for ${url}`, - response.status, - ); - } - +async function readCarBody( + response: Response, + maxBytes: number, + timeoutMs: number, +): Promise { // Buffer the body up to the size limit. Don't trust Content-Length; a // hostile or buggy PDS could under-report and stream more bytes than // advertised. @@ -175,8 +344,7 @@ async function fetchCar( const chunks: Uint8Array[] = []; let total = 0; try { - // biome-ignore lint/correctness/noConstantCondition: drains the stream - while (true) { + for (;;) { const { done, value } = await reader.read(); if (done) break; total += value.byteLength; @@ -193,7 +361,18 @@ async function fetchCar( await reader.cancel().catch(() => { /* swallow — we already have a primary error to surface */ }); - throw err; + // Our own errors (too-large) carry their classification; pass them + // through. Anything else — a dropped socket, or the wall-clock deadline + // aborting a slow-drip body mid-download — is transient: retry. + if (err instanceof PdsVerificationError) throw err; + throw new PdsVerificationError( + "PDS_NETWORK_ERROR", + err instanceof Error && err.name === "AbortError" + ? `PDS body read aborted after ${timeoutMs}ms` + : `PDS stream failed mid-download: ${err instanceof Error ? err.message : String(err)}`, + undefined, + err, + ); } finally { reader.releaseLock(); } diff --git a/apps/aggregator/src/records-consumer.ts b/apps/aggregator/src/records-consumer.ts index eb2c0af03e..e44fe5e23d 100644 --- a/apps/aggregator/src/records-consumer.ts +++ b/apps/aggregator/src/records-consumer.ts @@ -43,6 +43,7 @@ import { PublisherProfile, PublisherVerification, } from "@emdash-cms/registry-lexicons"; +import { cloudflareDohResolver, type DnsResolver } from "emdash/security/ssrf"; import { createD1DidDocCache, DidResolver } from "./did-resolver.js"; import type { RecordsJob } from "./env.js"; @@ -63,6 +64,12 @@ export interface ConsumerDeps { db: D1Database; resolver: DidResolver; fetch?: typeof fetch; + /** + * SSRF-safe hostname resolver handed to `fetchAndVerifyRecord` so it can + * validate the publisher-controlled PDS endpoint (and any redirect) before + * connecting. Production wires `cloudflareDohResolver`. + */ + resolveHostname?: DnsResolver; now?: () => Date; /** * Optional override for the PDS-verification step. Used by tests to inject @@ -78,6 +85,7 @@ export interface ConsumerDeps { rkey: string; publicKey: import("@atcute/crypto").PublicKey; fetch?: typeof fetch; + resolveHostname?: DnsResolver; }) => Promise; } @@ -85,7 +93,7 @@ export interface ConsumerDeps { * don't need to import workerd types. */ export interface MessageController { ack(): void; - retry(): void; + retry(options?: { delaySeconds?: number }): void; } /** Subset of a `MessageBatch`. Workers' real batch object satisfies this. */ @@ -102,6 +110,7 @@ export type DeadLetterReason = | "RESPONSE_TOO_LARGE" | "INVALID_PROOF" | "PDS_HTTP_ERROR" + | "PDS_ADDRESS_BLOCKED" // structural checks (consumer-enforced) | "LEXICON_VALIDATION_FAILED" | "RKEY_MISMATCH" @@ -241,7 +250,12 @@ export async function processMessage( } catch (err) { if (err instanceof PdsVerificationError) { if (isTransient(err.reason, err.status)) { - controller.retry(); + // A transient DNS-resolution failure carries a re-delivery delay so + // retries span the DNS-propagation window instead of firing back to + // back; other transient failures (network, 5xx, timeout) retry now. + controller.retry( + err.retryAfterSeconds !== undefined ? { delaySeconds: err.retryAfterSeconds } : undefined, + ); return; } // Compute the mapped reason in its own try/catch — `mapPdsReason` @@ -311,6 +325,7 @@ async function verifyAndIngest(job: RecordsJob, deps: ConsumerDeps): Promise>; try { policy = await resolveRequestLabelerPolicy(env, request); } catch (err) { - if (!(err instanceof XRPCError)) throw err; - const errorResponse = err.toResponse(); + // Both branches take the same wrapper as a normal response so the + // CORS + `no-store` cache invariant holds on the error path too — an + // unwrapped throw would escape to workerd's bare 500, dropping both + // and leaving a takedown-relevant response cacheable. + const errorResponse = err instanceof XRPCError ? err.toResponse() : internalErrorResponse(err); const headers = new Headers(errorResponse.headers); headers.set("cache-control", NO_STORE); applyCorsHeaders(headers); diff --git a/apps/aggregator/test/pds-verify.test.ts b/apps/aggregator/test/pds-verify.test.ts index 68c2055d10..f3d62b2330 100644 --- a/apps/aggregator/test/pds-verify.test.ts +++ b/apps/aggregator/test/pds-verify.test.ts @@ -15,12 +15,21 @@ */ import { P256PublicKey, P256PrivateKeyExportable } from "@atcute/crypto"; +import type { DnsResolver } from "emdash/security/ssrf"; import { beforeAll, describe, expect, it } from "vitest"; import { fetchAndVerifyRecord, isTransient, PdsVerificationError } from "../src/pds-verify.js"; const TEST_DID = "did:plc:test00000000000000000000"; const TEST_PDS = "https://pds.test.example"; +const TEST_PDS_HOST = "pds.test.example"; +/** A public, non-reserved address so the default resolver stub passes the + * SSRF blocklist. Real production uses `cloudflareDohResolver`. */ +const PUBLIC_IP = "93.184.216.34"; + +/** Default resolver stub: every hostname maps to one public address. Tests that + * exercise the SSRF boundary pass their own. */ +const publicResolver: DnsResolver = async () => [PUBLIC_IP]; let publicKey: P256PublicKey; @@ -44,6 +53,8 @@ function buildOpts(overrides: { fetch: typeof fetch; timeoutMs?: number; maxResponseBytes?: number; + pds?: string; + resolveHostname?: DnsResolver; }) { return { pds: TEST_PDS, @@ -51,6 +62,7 @@ function buildOpts(overrides: { collection: "com.emdashcms.experimental.package.profile", rkey: "demo", publicKey, + resolveHostname: publicResolver, ...overrides, }; } @@ -149,6 +161,181 @@ describe("fetchAndVerifyRecord — HTTP path", () => { }); }); +describe("fetchAndVerifyRecord — SSRF egress hardening", () => { + const rejectFetch: typeof fetch = () => { + throw new Error("fetch must not be reached when the URL is blocked"); + }; + + it("fails closed with PDS_ADDRESS_BLOCKED when no resolver is provided", async () => { + const err = await captureRejection( + fetchAndVerifyRecord(buildOpts({ fetch: rejectFetch, resolveHostname: undefined })), + ); + expect(err.reason).toBe("PDS_ADDRESS_BLOCKED"); + }); + + it("rejects a non-HTTPS PDS endpoint with PDS_ADDRESS_BLOCKED", async () => { + const err = await captureRejection( + fetchAndVerifyRecord(buildOpts({ fetch: rejectFetch, pds: "http://pds.test.example" })), + ); + expect(err.reason).toBe("PDS_ADDRESS_BLOCKED"); + }); + + it("maps an empty DNS answer to the transient PDS_NETWORK_ERROR", async () => { + // An empty resolver answer (NXDOMAIN / NOERROR-NODATA / CNAME-only) is a + // host mid-propagation, not a disallowed address — it must retry, not + // dead-letter. A genuinely-gone host dead-letters via retry exhaustion. + // Keyed on the shared helper's `Hostname resolved to no addresses` wording; + // this breaks if that wording changes rather than silently mis-classifying. + const err = await captureRejection( + fetchAndVerifyRecord(buildOpts({ fetch: rejectFetch, resolveHostname: async () => [] })), + ); + expect(err.reason).toBe("PDS_NETWORK_ERROR"); + expect(isTransient(err.reason, err.status)).toBe(true); + // Carries a re-delivery delay so retries span the DNS-propagation window. + expect(err.retryAfterSeconds).toBeGreaterThan(0); + }); + + it("maps a resolver infrastructure failure to the transient PDS_NETWORK_ERROR", async () => { + // A throwing resolver models a DoH network error / SERVFAIL / timeout — + // transient infrastructure, not a disallowed address. It must NOT dead-letter. + // If the shared helper's `Could not resolve hostname:` wording ever changes, + // this assertion breaks instead of silently mis-classifying as permanent. + const throwingResolver: DnsResolver = () => Promise.reject(new Error("DoH 503")); + const err = await captureRejection( + fetchAndVerifyRecord(buildOpts({ fetch: rejectFetch, resolveHostname: throwingResolver })), + ); + expect(err.reason).toBe("PDS_NETWORK_ERROR"); + expect(isTransient(err.reason, err.status)).toBe(true); + expect(err.retryAfterSeconds).toBeGreaterThan(0); + }); + + it("rejects when the endpoint resolves to a private address", async () => { + const err = await captureRejection( + fetchAndVerifyRecord( + buildOpts({ fetch: rejectFetch, resolveHostname: async () => ["10.0.0.5"] }), + ), + ); + expect(err.reason).toBe("PDS_ADDRESS_BLOCKED"); + }); + + it("re-validates a redirect target and blocks one resolving to a reserved address", async () => { + // Initial host resolves public; the redirect target resolves to the + // cloud-metadata address. The private target must never be fetched. + const resolver: DnsResolver = async (host) => + host === TEST_PDS_HOST ? [PUBLIC_IP] : ["169.254.169.254"]; + let evilFetched = false; + const redirectModes: RequestInit["redirect"][] = []; + const fetchImpl: typeof fetch = (input, init) => { + redirectModes.push(init?.redirect); + const href = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (href.includes(TEST_PDS_HOST)) { + return Promise.resolve( + new Response(null, { + status: 302, + headers: { location: "https://metadata.evil.example/x" }, + }), + ); + } + evilFetched = true; + return Promise.resolve(new Response(new Uint8Array([0]), { status: 200 })); + }; + const err = await captureRejection( + fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl, resolveHostname: resolver })), + ); + expect(err.reason).toBe("PDS_ADDRESS_BLOCKED"); + expect(evilFetched).toBe(false); + // `redirect: "manual"` is load-bearing: it's what forces our own per-hop + // re-validation instead of fetch auto-following unchecked. + expect(redirectModes).toEqual(["manual"]); + }); + + it("follows a redirect to a public target under redirect:manual on every hop", async () => { + // Both hosts resolve public; the redirect is followed to the second host, + // which returns garbage bytes (verifyRecord then rejects). The point is + // that each hop was issued with `redirect: "manual"`. + const redirectModes: RequestInit["redirect"][] = []; + const fetchImpl: typeof fetch = (input, init) => { + redirectModes.push(init?.redirect); + const href = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (href.includes(TEST_PDS_HOST)) { + return Promise.resolve( + new Response(null, { + status: 302, + headers: { location: "https://mirror.test.example/x" }, + }), + ); + } + return Promise.resolve(new Response(new Uint8Array([1, 2, 3]), { status: 200 })); + }; + await captureRejection( + fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl, resolveHostname: publicResolver })), + ); + expect(redirectModes).toEqual(["manual", "manual"]); + }); + + it("rejects a malformed redirect Location with PDS_ADDRESS_BLOCKED", async () => { + // `http://` has an empty authority — `new URL` throws. A hostile publisher + // controls this header, so the parse failure must be a blocked redirect, + // not an escaping UNEXPECTED_ERROR, and the next hop must not be fetched. + let hops = 0; + const fetchImpl: typeof fetch = () => { + hops += 1; + return Promise.resolve(new Response(null, { status: 302, headers: { location: "http://" } })); + }; + const err = await captureRejection(fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl }))); + expect(err.reason).toBe("PDS_ADDRESS_BLOCKED"); + expect(hops).toBe(1); + }); + + it("rejects with PDS_ADDRESS_BLOCKED when redirects exceed the hop limit", async () => { + // Every hop redirects to a fresh public host. After MAX_PDS_REDIRECTS + // followed hops the next redirect is refused (permanent — dead-letter). + let hops = 0; + const fetchImpl: typeof fetch = () => { + hops += 1; + return Promise.resolve( + new Response(null, { + status: 302, + headers: { location: `https://hop-${hops}.test.example/x` }, + }), + ); + }; + const err = await captureRejection( + fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl, resolveHostname: publicResolver })), + ); + expect(err.reason).toBe("PDS_ADDRESS_BLOCKED"); + expect(err.message).toMatch(/exceeded 3 redirects/); + // Initial hop + 3 followed redirects are fetched; the 4th is refused + // before a fetch is issued. + expect(hops).toBe(4); + }); + + it("bounds a slow-drip body by the wall-clock deadline", async () => { + // One byte then silence; the read only unblocks when the deadline aborts + // the fetch signal, which errors the stream. + const fetchImpl: typeof fetch = (_input, init) => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1])); + init?.signal?.addEventListener("abort", () => { + controller.error(new DOMException("aborted", "AbortError")); + }); + }, + }); + return Promise.resolve(new Response(stream, { status: 200 })); + }; + const err = await captureRejection( + fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl, timeoutMs: 30 })), + ); + expect(err.reason).toBe("PDS_NETWORK_ERROR"); + expect(err.message).toMatch(/aborted after 30ms/); + // A non-resolution transient retries immediately — no propagation delay. + expect(err.retryAfterSeconds).toBeUndefined(); + }); +}); + describe("isTransient policy", () => { it("network errors retry", () => { expect(isTransient("PDS_NETWORK_ERROR", undefined)).toBe(true); diff --git a/apps/aggregator/test/read-api.test.ts b/apps/aggregator/test/read-api.test.ts index 3d5fd93987..b62a716d71 100644 --- a/apps/aggregator/test/read-api.test.ts +++ b/apps/aggregator/test/read-api.test.ts @@ -14,7 +14,7 @@ import { NSID } from "@emdash-cms/registry-lexicons"; import { applyD1Migrations, env, SELF } from "cloudflare:test"; -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; interface TestEnv { DB: D1Database; @@ -1255,6 +1255,44 @@ describe("XRPC dispatcher", () => { }); }); +describe("XRPC dispatcher — unexpected policy-resolution failure", () => { + // A non-XRPC throw before dispatch (here: a D1 error because `labelers` + // is gone) must still take the CORS + `no-store` wrapper, not escape to + // workerd's bare 500. Capture the table's schema so the shared beforeEach + // (`DELETE FROM labelers`) keeps working after a test drops it. + let labelersSchema: string; + beforeAll(async () => { + const row = await testEnv.DB.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='labelers'", + ).first<{ sql: string }>(); + labelersSchema = row!.sql; + }); + afterEach(async () => { + const exists = await testEnv.DB.prepare( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='labelers'", + ).first(); + if (!exists) await testEnv.DB.prepare(labelersSchema).run(); + }); + + it("wraps a non-XRPC failure in a 500 carrying CORS + no-store and no leaked internals", async () => { + await seedPackage({ slug: "demo" }); + // Resolving the default policy runs `SELECT did FROM labelers`; dropping + // the table makes that throw a non-XRPC D1 error before dispatch. + await testEnv.DB.prepare("DROP TABLE labelers").run(); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetPackage}?did=${DID_A}&slug=demo`, + ); + expect(res.status).toBe(500); + expect(res.headers.get("cache-control")).toBe("private, no-store"); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + const body = (await res.json()) as { error: string; message?: string }; + expect(body.error).toBe("InternalServerError"); + // No internal detail (SQL text, table name, stack) leaks to the client. + expect(JSON.stringify(body)).not.toMatch(/labelers|no such table|SQL|SELECT/i); + }); +}); + describe("getPublisherVerification", () => { it("returns the verification claims naming a DID as subject, newest first", async () => { await seedVerification({ diff --git a/apps/aggregator/test/records-consumer.test.ts b/apps/aggregator/test/records-consumer.test.ts index e5609426c4..f5980d3b31 100644 --- a/apps/aggregator/test/records-consumer.test.ts +++ b/apps/aggregator/test/records-consumer.test.ts @@ -543,15 +543,20 @@ class MapDidDocCache implements DidDocCache { class FakeMessage implements MessageController { acked = 0; retried = 0; + retryDelaySeconds: number | undefined = undefined; ack() { this.acked += 1; } - retry() { + retry(options?: { delaySeconds?: number }) { this.retried += 1; + this.retryDelaySeconds = options?.delaySeconds; } } -function buildDeps(opts: { fetch: typeof fetch }): { +function buildDeps(opts: { + fetch: typeof fetch; + resolveHostname?: ConsumerDeps["resolveHostname"]; +}): { deps: ConsumerDeps; cache: MapDidDocCache; } { @@ -564,7 +569,15 @@ function buildDeps(opts: { fetch: typeof fetch }): { now: () => NOW, }); return { - deps: { db: testEnv.DB, resolver, fetch: opts.fetch, now: () => NOW }, + deps: { + db: testEnv.DB, + resolver, + fetch: opts.fetch, + // pds.test.example resolves public so the SSRF egress guard in + // `fetchAndVerifyRecord` passes and these tests reach the fetch stub. + resolveHostname: opts.resolveHostname ?? (async () => ["93.184.216.34"]), + now: () => NOW, + }, cache, }; } @@ -608,6 +621,8 @@ describe("processMessage dispatcher", () => { expect(msg.retried).toBe(1); expect(msg.acked).toBe(0); expect(await deadLetterCount()).toBe(0); + // A 5xx retries immediately — the propagation delay is DNS-only. + expect(msg.retryDelaySeconds).toBeUndefined(); }); it("retries on a network error", async () => { @@ -621,6 +636,45 @@ describe("processMessage dispatcher", () => { expect(msg.retried).toBe(1); expect(await deadLetterCount()).toBe(0); + expect(msg.retryDelaySeconds).toBeUndefined(); + }); + + it("retries when the SSRF host resolver fails (transient DoH outage, not dead-lettered)", async () => { + const { deps, cache } = buildDeps({ + // Reached only if the resolver somehow passed — it must not. + fetch: () => Promise.reject(new Error("fetch must not run when resolution fails")), + resolveHostname: () => Promise.reject(new Error("DoH 503")), + }); + cache.seed(DID_A); + const msg = new FakeMessage(); + + await processMessage(jobFor(DID_A, NSID.packageProfile, "demo"), msg, deps); + + expect(msg.retried).toBe(1); + expect(msg.acked).toBe(0); + expect(await deadLetterCount()).toBe(0); + // Re-delivery is delayed so retries span the DNS-propagation window + // instead of burning max_retries before the record's host resolves. + expect(msg.retryDelaySeconds).toBeGreaterThan(0); + }); + + it("acks and dead-letters PDS_ADDRESS_BLOCKED when the endpoint resolves to a private address", async () => { + const { deps, cache } = buildDeps({ + fetch: () => Promise.reject(new Error("fetch must not run for a blocked address")), + resolveHostname: async () => ["10.0.0.5"], + }); + cache.seed(DID_A); + const msg = new FakeMessage(); + + await processMessage(jobFor(DID_A, NSID.packageProfile, "demo"), msg, deps); + + expect(msg.acked).toBe(1); + expect(msg.retried).toBe(0); + expect(await deadLetterCount()).toBe(1); + const row = await testEnv.DB.prepare(`SELECT reason FROM dead_letters`).first<{ + reason: string; + }>(); + expect(row?.reason).toBe("PDS_ADDRESS_BLOCKED"); }); it("forensics + acks on garbage CAR bytes (verifyRecord rejects → INVALID_PROOF)", async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47bd5d6a4c..5a1c40082c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -392,6 +392,9 @@ importers: '@emdash-cms/registry-moderation': specifier: workspace:* version: link:../../packages/registry-moderation + emdash: + specifier: workspace:* + version: link:../../packages/core devDependencies: '@cloudflare/vite-plugin': specifier: 'catalog:' From 3128aa9919a4285212c813e4ba93ac6eb4824085 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 19 Jul 2026 08:39:01 +0100 Subject: [PATCH 133/137] fix(labeler): durable label publication + rotation-safe replay (review round 1) (#2115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(labeler): redrive the subscription-DO notify on console mutation replay A transient afterCommit failure after a label commits leaves the row publication_pending=1 and drops the live broadcast; the guard replay branch did not re-drive the notify, so a client retry never recovered it. The label-issue and emergency replay branches now re-fire the post-commit notify (keyed on the committed action), giving retries a cheap path back to a live broadcast. The reconciliation publication-pending sweep remains the durable backstop. Refs Sol finding 7. * fix(labeler): re-sign retired-key labels on WebSocket subscription replay After a routine key rotation the DID document publishes only the new key, but retained rows keep their old-key signatures; queryLabels re-signs lazily on read while the subscribeLabels replay path sent rows unchanged, so a fresh aggregator rejected the first old-key frame and could not advance. The replay reader now reuses queryLabels' lazy re-sign helper (exported from query-labels), bringing stale-key rows onto the active key before framing and persisting the result so the work is not repeated per connection. Sequence and ordering are preserved; a signing pause throws rather than serving an unverifiable frame. Refs Sol finding 5. * fix(labeler): publish automated assessment labels live, with a reconciliation backstop Automated pending/outcome/block/negation labels committed without ever notifying the subscription DO, so a connected aggregator received nothing until it reconnected or an unrelated notification arrived. The orchestrator now issues finalization labels publication_pending=1 and broadcasts each to the same subscription-DO publisher the console path uses, off the commit. A new publication-pending sweep in the reconciliation cron re-drives any notify that was dropped (assessment or console path alike) — the durable guarantee that a stranded row reaches subscribers and stops blocking the next key rotation. Refs Sol findings 4 and 7. * fix(labeler): guard the finalization CAS on signing state so a rotation pause can't strand a run If signing paused between finalization prep and the batch commit, the guarded label INSERTs no-oped while the unguarded assessment CAS still committed terminal state — leaving the run terminal with its pending-negation, outcome, and block labels missing, and the Workflow retry seeing terminal and never repairing them. The CAS now shares the same signing-state predicate as the issuance statements, so the whole db.batch is all-or-nothing: a mid-batch pause no-ops the CAS too, the run stays running, and the retry re-runs finalization after signing resumes. Refs Sol finding 6. * perf(labeler): index issued_labels for the publication-pending sweep The reconciliation sweep filtered issued_labels by publication_pending ordered by sequence with no covering index, full-scanning a monotonically growing table every cron tick — a D1 query-timeout there would strand pending rows and block the very rotation drain the sweep exists to unblock. Adds migration 0011 with a partial covering index on (sequence, cts) WHERE publication_pending = 1, and a query-plan guard so the sweep can't silently regress to a full scan. Follow-up to emdashbot review on #2115. * docs(labeler): drop the stale mid-batch signing-flip gap from finalize The F6 fix guards the finalization CAS on the same signing-state predicate as the label issuances, so the race-analysis comment's first bullet (claiming the CAS is unguarded and a flip could commit terminal state with labels suppressed) is no longer true. Replaces it with the now-closed description and keeps the two genuinely-remaining narrower gaps. Follow-up to emdashbot review on #2115. * fix(labeler): publish discovery-consumer labels live, with the sweep backstop The #4 fix gave the orchestrator path live publication but missed the discovery-consumer path: its initial assessment-pending issuance and its deletion negations committed publication_pending=0 with no subscription-DO broadcast, so connected subscribers never received them and the new reconciliation sweep (pending=1 only) could not recover them. Both issuances now take the same subscription-DO publisher the orchestrator uses — publication_pending=1 on commit plus a best-effort post-commit notify (a dropped broadcast never fails the discovery message; the sweep re-drives it). The rotation-drain contract is unchanged: these pending rows drain exactly like the orchestrator's. Follow-up to Sol review on #2115. * fix(labeler): negate a deleted run's pending label before cancelling it The delete path cancelled pending/running runs to terminal 'cancelled' before issuing their assessment-pending negations. If issuance was paused (signing rotation) the negation threw and the message retried, but on redelivery listNonTerminalAssessmentsForUri excluded the already-cancelled runs — so there was nothing left to negate, the message acked, and the active assessment-pending label survived a deleted release forever. Each run's negation is now issued before its terminal cancellation, so a paused/failed negation leaves the run non-terminal and re-discoverable on redelivery; a run is retired only after its negation commits, and the message cannot ack while any pending/running run is un-negated. No active assessment-pending survives an acked delete. Follow-up to Sol review on #2115. * fix(labeler): guard the finalization CAS on the subject not being tombstoned Finalization's isSubjectCurrent re-check is a separate read from its commit CAS, a TOCTOU: a delete that tombstoned the subject in between still let the CAS commit 'running -> outcome' plus positive outcome/block labels, then the delete's own cancel CAS conflicted and was swallowed — leaving live block labels on a deleted release the evaluator would still honor. The finalization CAS now carries a not-deleted predicate on the run's subject (same all-or-nothing batch idiom as the F6 signing guard), so a tombstone landing before the batch no-ops the CAS and every label gated on the outcome state; finalization retries and stales the run out, never labelling a deleted subject. Follow-up to Sol round-3 review on #2115. * fix(labeler): retry (never dead-letter+ack) a delete-path mutation failure The 'a negation throw prevents ack' invariant only held for recognized issuance-unavailable errors; an unexpected signing/D1 failure during the delete's tombstone/negate/cancel fell through to dead-letter+ack (the create-path policy), leaving the run non-terminal with its assessment-pending label live on a deleted subject forever. The delete handler now splits the absence-verification phase (classified like create: transient retries, forged/permanent dead-letters) from the mutation phase, which ALWAYS retries on failure so the label can never be left live on an acked delete. A genuinely permanent fault exhausts to the DLQ via max_retries — acceptable versus acking a stranded label. Follow-up to Sol round-3 review on #2115. * docs(labeler): correct the delete-mutation retry reference post-merge After merging the integration branch (which added #2113's transient-DNS retry backoff), the applyDiscoveryDelete docstring still said a failed negation propagates to classifyDiscoveryError. Since the Blocker-2 fix, the delete mutation phase has its own always-retry catch — update the comment to match. No behavior change. * fix(labeler): guard the initial pending-label issuance against a concurrent delete The create path committed a recreated run as pending and THEN issued its positive assessment-pending with no commit-time guard. A concurrent delete could, in the gap, tombstone the subject, negate + cancel the (still-positive-less) run, and ack; the create then committed its positive at a higher sequence, winning label reduction — a live pending label on a tombstoned, cancelled subject. (Queue invocations overlap; max_concurrency is unset.) The initial issuance now goes through buildIssuanceStatements gated atomically on BOTH the run still being pending AND the exact (uri,cid) subject still undeleted (the same guard idiom as Blocker 1's finalization CAS). On a guard miss the label no-ops: the issuance is obsolete, so the create neither publishes nor dispatches a Workflow for a label that never committed; a non-persist not explained by the guard (a signing flip) still retries. With this and Blocker 1, every automated- positive issuance site in the discovery consumer is now delete-guarded (the delete-path negation is negative and exempt). Follow-up to Sol round-4 review on #2115. * fix(labeler): guard the operator-rerun pending label against a concurrent delete The operator rerun issued its positive assessment-pending with no commit-time guard and while its run was still 'observed' (advance to 'pending' is deferred), opening two integrity races with a concurrent discovery-delete (queue + HTTP overlap; max_concurrency unset): (a) ordering: the rerun commits its run+pending after a delete tombstoned and snapshotted the non-terminal runs, so the delete never negates it and the positive survives on the tombstoned subject; (b) observed gap: the delete-path negation only fired for pending/running runs, so a delete that saw the rerun's 'observed' run cancelled it WITHOUT negating its already-live positive. Fix (a): gate the rerun's assessment-pending issuance on requireSubjectNotDeleted + requireAssessmentState (threaded through prepareAutomatedLabelIssuance); on a miss the label no-ops, assertIssuancePersisted aborts before the deferred tail, so nothing is published, dispatched, or advanced. Fix (b): the delete-path negation now fires for any non-terminal run that committed a positive pending (keyed on the committed positive, not lifecycle state) — no dangling negations, no interleaving where a cancelled/deleted run keeps a live positive. With Blocker 1 and the round-4 discovery guard, every positive assessment-pending issuance across the discovery and console paths is now delete-safe. Follow-up to adversary review on #2115. * fix(labeler): structural delete-generation guard closing the create/verify/rerun-vs-delete race class Adds a monotonic delete_generation to subjects (migration 0013). Every delete increments it; create/verify/rerun capture it before reading state/verifying and CAS-guard their subject-undelete, run creation, and label issuance on the generation not having advanced. A decision made before a delete is rejected obsolete; one that captured the post-delete generation still works (republish). Closes three race seams: (1) createSubject un-delete is generation-gated so a stale verify can't resurrect a deleted subject; (2) the delete cleanup now scans pending-bearing runs INCLUDING terminal stale and negates any committed positive; (3) the console rerun's run creation is generation-gated so no orphan observed run. Implementation checkpoint; barrier tests for each seam + a republish test follow. Refs Sol round-5 (systemic close). * test(labeler): barrier tests for the delete-generation close (three seams + republish) Seam 1: a stale verify (barrier during verify, full delete completes) cannot resurrect the tombstoned subject — createSubject's generation-guarded undelete no-ops, no run, no positive, no dispatch. Seam 2: the delete negates a terminal stale run's stranded positive (widened scan). Seam 3: a rerun after a concurrent tombstone leaves no orphan observed operator run. Plus a delete-then-republish test proving a new revision after a delete assesses cleanly (the generation does not over-block). Each verified failing pre-fix. Refs Sol round-5 (systemic close). --- .../0011_publication_pending_index.sql | 11 + .../0013_subject_delete_generation.sql | 9 + apps/labeler/src/assessment-orchestrator.ts | 82 ++- apps/labeler/src/assessment-store.ts | 255 +++++++-- apps/labeler/src/assessment-workflow.ts | 4 + apps/labeler/src/console-mutation-api.ts | 32 ++ apps/labeler/src/discovery-consumer.ts | 339 ++++++++--- apps/labeler/src/index.ts | 20 +- apps/labeler/src/query-labels.ts | 16 +- apps/labeler/src/reconciliation.ts | 67 +++ apps/labeler/src/service.ts | 27 +- apps/labeler/src/subscribe-labels.ts | 48 +- .../test/assessment-orchestrator.test.ts | 272 ++++++++- .../test/console-assessment-mutations.test.ts | 43 ++ .../labeler/test/console-mutation-api.test.ts | 30 + apps/labeler/test/discovery-consumer.test.ts | 534 +++++++++++++++++- apps/labeler/test/reconciliation.test.ts | 108 +++- apps/labeler/test/subscribe-resign.test.ts | 172 ++++++ 18 files changed, 1920 insertions(+), 149 deletions(-) create mode 100644 apps/labeler/migrations/0011_publication_pending_index.sql create mode 100644 apps/labeler/migrations/0013_subject_delete_generation.sql create mode 100644 apps/labeler/test/subscribe-resign.test.ts diff --git a/apps/labeler/migrations/0011_publication_pending_index.sql b/apps/labeler/migrations/0011_publication_pending_index.sql new file mode 100644 index 0000000000..03d97c880d --- /dev/null +++ b/apps/labeler/migrations/0011_publication_pending_index.sql @@ -0,0 +1,11 @@ +-- Partial covering index for the reconciliation publication-pending sweep +-- (`sweepPendingPublications`): it scans `publication_pending = 1` rows ordered +-- by `sequence` and filtered by `cts`. Without this the sweep full-scans the +-- monotonically growing `issued_labels` table every cron tick, risking a D1 +-- query-timeout that would strand pending rows and block the rotation drain the +-- sweep exists to unblock. The partial predicate keeps the index to the tiny +-- set of un-broadcast rows; `(sequence, cts)` covers the SELECT, the cts filter, +-- and the sequence ordering. +CREATE INDEX idx_issued_labels_publication_pending +ON issued_labels(sequence, cts) +WHERE publication_pending = 1; diff --git a/apps/labeler/migrations/0013_subject_delete_generation.sql b/apps/labeler/migrations/0013_subject_delete_generation.sql new file mode 100644 index 0000000000..507f11bbc4 --- /dev/null +++ b/apps/labeler/migrations/0013_subject_delete_generation.sql @@ -0,0 +1,9 @@ +-- Monotonic tombstone counter on subjects, the structural close of the +-- delete-vs-{create,verify,rerun} race class. Every delete of a `(uri, cid)` +-- increments `delete_generation`; a create/verify/rerun captures the generation +-- when it reads state / verifies and CAS-guards its subject-undelete, run +-- creation, and label issuance on the generation not having advanced. A stale +-- operation (older generation) is rejected as obsolete; an operation that began +-- AFTER the delete reads the new generation and proceeds (delete-then-republish +-- still works). Backfilled to 0 for existing rows (never-deleted baseline). +ALTER TABLE subjects ADD COLUMN delete_generation INTEGER NOT NULL DEFAULT 0; diff --git a/apps/labeler/src/assessment-orchestrator.ts b/apps/labeler/src/assessment-orchestrator.ts index 72f5fff6fa..f577d1f146 100644 --- a/apps/labeler/src/assessment-orchestrator.ts +++ b/apps/labeler/src/assessment-orchestrator.ts @@ -31,9 +31,13 @@ import { resolvePolicyOutcome, type OutcomeLabel, type PolicyOutcome } from "./p import type { ModerationPolicy } from "./policy.js"; import { buildIssuanceStatements, + markPublicationAccepted, type AutomatedIssuanceAction, type AutomatedLabelProposal, + type IssuedLabel, } from "./service.js"; +import { getSigningStatusIfInitialized } from "./signing-rotation.js"; +import type { LabelPublisher } from "./subscribe-labels.js"; /** * A stage's finding is the canonical normalized contract (`findings.ts`). @@ -106,6 +110,13 @@ export interface AssessmentOrchestratorOptions { * AI stages accumulate (`assessment-stages.ts`); `undefined` (or an undefined * return) leaves the stored value unchanged. */ resolveCoverageJson?: () => string | undefined; + /** Broadcasts each finalized label to the subscription DO after the batch + * commits (the same publisher the console path uses via `createLabelPublisher`). + * When present, finalization labels are issued `publication_pending = 1` and + * this drives the live notify; the reconciliation `publication_pending` sweep is + * the durable backstop for a notify that fails here. Omitted in tests that don't + * exercise publication. */ + publisher?: LabelPublisher; } export class AssessmentOrchestrator { @@ -119,6 +130,7 @@ export class AssessmentOrchestrator { private readonly sleep: (ms: number) => Promise; private readonly retryDelayMs: number; private readonly resolveCoverageJson: (() => string | undefined) | undefined; + private readonly publisher: LabelPublisher | undefined; constructor(opts: AssessmentOrchestratorOptions) { this.db = opts.db; @@ -131,6 +143,7 @@ export class AssessmentOrchestrator { this.sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); this.retryDelayMs = opts.retryDelayMs ?? 0; this.resolveCoverageJson = opts.resolveCoverageJson; + this.publisher = opts.publisher; } async runAssessment(runId: string): Promise { @@ -258,6 +271,12 @@ export class AssessmentOrchestrator { const positiveLabels: readonly OutcomeLabel[] = outcome?.labels ?? []; const coverageJson = this.resolveCoverageJson?.(); + // Read the signing status once so the CAS transition carries the same + // signing-state predicate as every label issuance below: a signing pause + // landing between prep and the batch then no-ops the CAS too, keeping the run + // `running` for the Workflow retry rather than committing terminal state with + // its labels suppressed. + const signingStatus = await getSigningStatusIfInitialized(this.db); const finalization = buildFinalizationStatements(this.db, { assessmentId: assessment.id, fromState: "running", @@ -267,10 +286,18 @@ export class AssessmentOrchestrator { cid: assessment.cid, now, ...(coverageJson !== undefined ? { coverageJson } : {}), + signingGuard: { + isPrebootstrap: signingStatus === null, + activeKeyVersion: this.config.signingKeyVersion, + }, + // Close the delete-vs-finalization TOCTOU: a delete tombstoning the + // subject after the currency re-check below no-ops this CAS at commit + // time, so no outcome/block label commits for a deleted subject. + guardSubjectNotDeleted: true, }); const statements = [...finalization.statements]; - const postCommits: Array<() => Promise> = []; + const postCommits: Array<() => Promise> = []; const issue = async ( val: string, @@ -298,7 +325,11 @@ export class AssessmentOrchestrator { ...proposal, }, now, - false, + // Mark for publication only when a publisher is wired: the post-commit + // notify (below) drains it, the reconciliation sweep backstops a failed + // notify, and rotation waits for the drain. With no publisher (tests), + // the label commits already-published so nothing strands. + this.publisher !== undefined, // Gate every finalization label on the run reaching `toState`, so a // concurrent cancel/delete that no-ops the CAS also no-ops the labels. { requireAssessmentState: toState }, @@ -347,11 +378,20 @@ export class AssessmentOrchestrator { // of `running` in this gap therefore no-ops the CAS AND every label — nothing // leaks — and the lost race raises AssessmentFinalizationConflictError. // + // A signing-state flip mid-batch is closed the same way: the CAS carries the + // same signing-state guard as every label insert (buildFinalizationStatements' + // signingGuard), so a flip no-ops the CAS too and the batch commits nothing — + // the run stays `running` for the Workflow retry. + // + // A delete tombstoning the subject in this gap is closed likewise: the CAS + // carries a not-deleted predicate on this run's subject + // (buildFinalizationStatements' guardSubjectNotDeleted). A pure tombstone does + // not move the run out of `running` (that only happens via the delete's + // separate cancel CAS, which can lose the race), so without this predicate the + // CAS would commit outcome/block labels for a subject the delete just removed; + // with it, the tombstone no-ops the CAS and the retry stales the run out. + // // Narrower gaps remain, tracked with the real-stage wiring: - // - a signing-state flip mid-batch: the label inserts are guarded on active - // signing state and no-op if it flips, but the CAS is not, so a flip could - // commit the terminal state with its labels suppressed (the CAS still - // changed a row, so the postCommit below surfaces it as a signing error); // - a CID supersession landing in this gap does not move the run out of // `running`, so the CAS succeeds and this run finalizes labels for its own // CID (the pointer upsert is guarded on created-at ordering); @@ -382,12 +422,40 @@ export class AssessmentOrchestrator { const raced = await getAssessment(this.db, assessment.id); throw new AssessmentFinalizationConflictError(assessment.id, toState, raced?.state ?? null); } - for (const postCommit of postCommits) await postCommit(); + const issued: IssuedLabel[] = []; + for (const postCommit of postCommits) issued.push(await postCommit()); + await this.publishLabels(issued); const finalised = await getAssessment(this.db, assessment.id); if (!finalised) throw new Error(`assessment ${assessment.id} disappeared after finalization`); return finalised; } + + /** + * Live broadcast of the finalized labels to the subscription DO, best-effort: + * the batch has already committed, so a notify failure must never fail the run. + * A dropped notify leaves the row `publication_pending = 1` for the + * reconciliation sweep to re-drive (which also unblocks a rotation waiting on + * the drain). Mirrors `service.ts` `issueLabel`: when the publisher manages + * publication state (the DO clears the flag on `/notify`) the caller does not. + */ + private async publishLabels(issued: readonly IssuedLabel[]): Promise { + const publisher = this.publisher; + if (!publisher) return; + for (const label of issued) { + try { + await publisher.publish(label); + if (!publisher.managesPublicationState) await markPublicationAccepted(this.db, label); + } catch (error) { + console.error("[assessment-orchestrator] label publication failed", { + assessmentId: + label.action.type === "automated-assessment" ? label.action.assessmentId : undefined, + sequence: label.sequence, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } } /** diff --git a/apps/labeler/src/assessment-store.ts b/apps/labeler/src/assessment-store.ts index e821b32972..6b55b6d553 100644 --- a/apps/labeler/src/assessment-store.ts +++ b/apps/labeler/src/assessment-store.ts @@ -68,6 +68,14 @@ export interface CreateSubjectInput { collection: string; rkey: string; now?: Date; + /** + * The delete-generation the caller captured before verifying. When set, a + * re-observation reactivates the tombstoned row ONLY if the generation still + * matches — a delete that advanced it in the meantime leaves the row tombstoned, + * so a stale verify cannot resurrect a subject deleted after it read state. A + * brand-new row is inserted at generation 0 regardless (no prior delete to race). + */ + expectedGeneration?: number; } /** @@ -75,10 +83,25 @@ export interface CreateSubjectInput { * A verified re-observation reactivates a tombstoned row (the create path * only reaches here after the PDS confirms the record exists, so clearing * `deleted_at` is correct — it closes the delete-then-recreate race and - * handles a genuine republish of the same rkey+cid). + * handles a genuine republish of the same rkey+cid). When `expectedGeneration` + * is supplied the reactivation is gated on the tombstone counter not having + * advanced past the captured value, so a delete concurrent with a slow verify + * cannot be undone by the verify's re-observation. */ export async function createSubject(db: D1Database, input: CreateSubjectInput): Promise { const now = input.now ?? new Date(); + const generationGuard = + input.expectedGeneration === undefined ? "" : `\n\t\t\t WHERE subjects.delete_generation = ?`; + const binds: unknown[] = [ + input.uri, + input.cid, + input.did, + input.collection, + input.rkey, + now.toISOString(), + now.getTime(), + ]; + if (input.expectedGeneration !== undefined) binds.push(input.expectedGeneration); await db .prepare( `INSERT INTO subjects (uri, cid, did, collection, rkey, observed_at, observed_at_epoch_ms) @@ -90,17 +113,9 @@ export async function createSubject(db: D1Database, input: CreateSubjectInput): observed_at = excluded.observed_at, observed_at_epoch_ms = excluded.observed_at_epoch_ms, deleted_at = NULL, - deleted_at_epoch_ms = NULL`, - ) - .bind( - input.uri, - input.cid, - input.did, - input.collection, - input.rkey, - now.toISOString(), - now.getTime(), + deleted_at_epoch_ms = NULL${generationGuard}`, ) + .bind(...binds) .run(); } @@ -111,7 +126,8 @@ export async function deleteSubject( const now = input.now ?? new Date(); await db .prepare( - `UPDATE subjects SET deleted_at = ?, deleted_at_epoch_ms = ? + `UPDATE subjects SET deleted_at = ?, deleted_at_epoch_ms = ?, + delete_generation = delete_generation + 1 WHERE uri = ? AND cid = ? AND deleted_at IS NULL`, ) .bind(now.toISOString(), now.getTime(), input.uri, input.cid) @@ -131,13 +147,71 @@ export async function deleteSubjectsByUri( const now = input.now ?? new Date(); await db .prepare( - `UPDATE subjects SET deleted_at = ?, deleted_at_epoch_ms = ? + `UPDATE subjects SET deleted_at = ?, deleted_at_epoch_ms = ?, + delete_generation = delete_generation + 1 WHERE uri = ? AND deleted_at IS NULL`, ) .bind(now.toISOString(), now.getTime(), input.uri) .run(); } +/** + * The subject's monotonic tombstone counter for `(uri, cid)`, or 0 when the + * subject has never been observed. A create/verify/rerun captures this at the + * point it reads state / verifies, then CAS-guards its subject-undelete, run + * creation, and label issuance on the generation not having advanced — so a + * delete landing after the capture rejects the operation as obsolete, while an + * operation that captured the post-delete generation still proceeds. + */ +export async function readDeleteGeneration( + db: D1Database, + input: { uri: string; cid: string }, +): Promise { + const row = await db + .prepare(`SELECT delete_generation FROM subjects WHERE uri = ? AND cid = ?`) + .bind(input.uri, input.cid) + .first<{ delete_generation: number }>(); + return row?.delete_generation ?? 0; +} + +/** + * True when a non-tombstoned subject row exists for `(uri, cid)` at exactly the + * captured `generation` — the same predicate the generation-guarded issuance and + * run-creation use. Classifies a guarded-issuance miss precisely: a subject the + * caller's generation no longer matches (a newer delete) reads as obsolete. + */ +export async function subjectMatchesGeneration( + db: D1Database, + input: { uri: string; cid: string; generation: number }, +): Promise { + const row = await db + .prepare( + `SELECT 1 FROM subjects + WHERE uri = ? AND cid = ? AND deleted_at IS NULL AND delete_generation = ?`, + ) + .bind(input.uri, input.cid, input.generation) + .first(); + return row !== null; +} + +/** + * True when a non-tombstoned subject row exists for this exact `(uri, cid)` — the + * same condition the `requireSubjectNotDeleted` issuance guard checks. Distinct + * from `isSubjectCurrent`: this ignores CID-supersession, so it classifies a + * guarded-issuance miss precisely (a superseded-but-undeleted subject would not + * miss the guard, so it must not read as deleted here). + */ +export async function subjectIsUndeleted( + db: D1Database, + input: { uri: string; cid: string }, +): Promise { + const row = await db + .prepare(`SELECT 1 FROM subjects WHERE uri = ? AND cid = ? AND deleted_at IS NULL`) + .bind(input.uri, input.cid) + .first(); + return row !== null; +} + /** * A subject is current when its row isn't tombstoned and no later-observed, * non-deleted subject at the same URI carries a different CID. Used @@ -182,6 +256,13 @@ export interface CreateAssessmentRunInput { promptHash?: string; coverageJson: string; now?: Date; + /** + * When set, the run row is created only if the subject `(uri, cid)` is + * undeleted at exactly this captured generation. A delete that advanced the + * generation after the caller verified makes the INSERT match no row (no orphan + * run), the same all-or-nothing guard the issuance uses. + */ + requireSubjectGeneration?: number; } export interface CreateAssessmentRunResult { @@ -201,6 +282,30 @@ export function buildAssessmentRunStatement( input: CreateAssessmentRunInput & { id: string }, ): D1PreparedStatement { const now = input.now ?? new Date(); + const generationGuard = + input.requireSubjectGeneration === undefined + ? "" + : `\n\t\t\t AND EXISTS (SELECT 1 FROM subjects + WHERE uri = ? AND cid = ? AND deleted_at IS NULL AND delete_generation = ?)`; + const binds: unknown[] = [ + input.id, + input.runKey, + input.uri, + input.cid, + input.artifactId ?? null, + input.artifactChecksum ?? null, + input.trigger, + input.triggerId, + input.policyVersion, + input.modelId ?? null, + input.promptHash ?? null, + input.coverageJson, + now.toISOString(), + now.getTime(), + input.runKey, + ]; + if (input.requireSubjectGeneration !== undefined) + binds.push(input.uri, input.cid, input.requireSubjectGeneration); return db .prepare( `INSERT INTO assessments @@ -208,25 +313,9 @@ export function buildAssessmentRunStatement( policy_version, model_id, prompt_hash, coverage_json, created_at, created_at_epoch_ms) SELECT ?, ?, ?, ?, ?, ?, 'observed', ?, ?, ?, ?, ?, ?, ?, ? - WHERE NOT EXISTS (SELECT 1 FROM assessments WHERE run_key = ?)`, + WHERE NOT EXISTS (SELECT 1 FROM assessments WHERE run_key = ?)${generationGuard}`, ) - .bind( - input.id, - input.runKey, - input.uri, - input.cid, - input.artifactId ?? null, - input.artifactChecksum ?? null, - input.trigger, - input.triggerId, - input.policyVersion, - input.modelId ?? null, - input.promptHash ?? null, - input.coverageJson, - now.toISOString(), - now.getTime(), - input.runKey, - ); + .bind(...binds); } /** @@ -298,6 +387,43 @@ export async function listNonTerminalAssessmentsForUri( return (rows.results ?? []).map(rowToAssessment); } +/** States a run can be in and still carry a live, un-negated positive + * `assessment-pending`: every non-terminal state, plus terminal `stale` (which, + * unlike the other terminals, does not negate its own pending on transition). + * `cancelled` is excluded — the delete negates before cancelling; the decision + * outcomes negate their own pending at finalization. */ +const PENDING_BEARING_STATES: readonly AssessmentState[] = [ + "observed", + "verifying", + "pending", + "running", + "stale", +]; + +/** + * Runs for a URI that could still carry a live positive `assessment-pending` + * label — the delete cleanup's scan set (spec §9.1). Widens + * `listNonTerminalAssessmentsForUri` to include terminal `stale` runs: a run that + * self-transitioned to `stale` on detecting a deleted/superseded subject keeps + * its committed positive, so the delete must still reach it to negate. + */ +export async function listPendingBearingAssessmentsForUri( + db: D1Database, + uri: string, +): Promise { + const rows = await db + .prepare( + `SELECT id, run_key, uri, cid, artifact_id, artifact_checksum, state, trigger, trigger_id, + policy_version, model_id, prompt_hash, public_summary, coverage_json, + supersedes_assessment_id, started_at, completed_at, created_at + FROM assessments + WHERE uri = ? AND state IN (${PENDING_BEARING_STATES.map(() => "?").join(", ")})`, + ) + .bind(uri, ...PENDING_BEARING_STATES) + .all(); + return (rows.results ?? []).map(rowToAssessment); +} + export interface TransitionAssessmentInput { id: string; from: AssessmentState; @@ -474,6 +600,27 @@ export interface FinalizationInput { publicSummary?: string; coverageJson?: string; supersedesAssessmentId?: string; + /** + * Gate the CAS transition on the same signing-state predicate the finalization + * label issuances use (`buildIssuanceStatements`). A signing pause landing + * between statement preparation and the batch commit otherwise no-ops every + * guarded label INSERT while the unguarded CAS still commits terminal state, + * stranding the run terminal with its labels missing. Sharing the predicate + * makes the batch all-or-nothing: a mid-batch pause no-ops the CAS too, the run + * stays `running`, and the Workflow retry re-runs finalization after resume. + */ + signingGuard?: { isPrebootstrap: boolean; activeKeyVersion: string }; + /** + * Gate the CAS on the subject `(uri, cid)` still being non-tombstoned at commit + * time, closing the delete-vs-finalization TOCTOU: finalization's + * `isSubjectCurrent` re-check is a separate read from this commit, so a delete + * that tombstones the subject in between would otherwise let the CAS commit + * terminal state with live outcome/block labels for a deleted release. Folding + * the not-deleted predicate into the CAS makes a tombstone landing before the + * batch no-op the CAS (and, transitively, every label gated on `toState`), so + * finalization retries and stales out instead of labelling a deleted subject. + */ + guardSubjectNotDeleted?: boolean; } export interface FinalizationStatements { @@ -502,6 +649,39 @@ export function buildFinalizationStatements( `buildFinalizationStatements is for decision outcomes; use transitionAssessmentState for ${input.toState}`, ); const now = (input.now ?? new Date()).toISOString(); + // Mirror of `buildIssuanceStatements`' signing guard so the CAS shares the + // batch's all-or-nothing behaviour under a mid-batch signing pause. + const signingGuardSql = + input.signingGuard === undefined + ? "" + : `\n\t\t\t\t AND ( + (? = 1 AND NOT EXISTS (SELECT 1 FROM signing_state)) + OR (? = 0 AND EXISTS ( + SELECT 1 FROM signing_state + WHERE id = 1 AND phase = 'active' AND active_key_version = ? + )) + )`; + const casBinds: unknown[] = [ + input.toState, + now, + Date.parse(now), + input.publicSummary ?? null, + input.coverageJson ?? null, + input.supersedesAssessmentId ?? null, + input.assessmentId, + input.fromState, + ]; + if (input.signingGuard !== undefined) { + const prebootstrap = input.signingGuard.isPrebootstrap ? 1 : 0; + casBinds.push(prebootstrap, prebootstrap, input.signingGuard.activeKeyVersion); + } + // Not-tombstoned predicate on this run's subject, evaluated at commit time in + // the same batch — a delete landing after finalization's currency re-check + // no-ops the CAS here rather than committing labels for a deleted subject. + const subjectGuardSql = input.guardSubjectNotDeleted + ? `\n\t\t\t\t AND EXISTS (SELECT 1 FROM subjects WHERE uri = ? AND cid = ? AND deleted_at IS NULL)` + : ""; + if (input.guardSubjectNotDeleted) casBinds.push(input.uri, input.cid); const statements: D1PreparedStatement[] = [ db .prepare( @@ -510,18 +690,9 @@ export function buildFinalizationStatements( public_summary = COALESCE(?, public_summary), coverage_json = COALESCE(?, coverage_json), supersedes_assessment_id = COALESCE(?, supersedes_assessment_id) - WHERE id = ? AND state = ?`, + WHERE id = ? AND state = ?${signingGuardSql}${subjectGuardSql}`, ) - .bind( - input.toState, - now, - Date.parse(now), - input.publicSummary ?? null, - input.coverageJson ?? null, - input.supersedesAssessmentId ?? null, - input.assessmentId, - input.fromState, - ), + .bind(...casBinds), ]; let pointerUpdateIndex: number | null = null; if (CURRENT_POINTER_STATES.has(input.toState)) { diff --git a/apps/labeler/src/assessment-workflow.ts b/apps/labeler/src/assessment-workflow.ts index db0dbeb68a..f0d91fef1c 100644 --- a/apps/labeler/src/assessment-workflow.ts +++ b/apps/labeler/src/assessment-workflow.ts @@ -52,6 +52,7 @@ import { createNotifyDeps, notifyAssessmentOutcome } from "./notification-trigge import { MODERATION_POLICY, type ModerationPolicy } from "./policy.js"; import { createReleaseResolver, type ReleaseReader } from "./release-resolution.js"; import { createRuntimeSigner, getRuntimeSigningSecret } from "./signing-runtime.js"; +import { createLabelPublisher } from "./subscribe-labels.js"; const RUN_STEP_CONFIG = { retries: { limit: 3, delay: "10 seconds" as const, backoff: "exponential" as const }, @@ -118,6 +119,9 @@ export async function executeAssessmentInstance( ai: env.AI, }), resolveCoverageJson: () => serializeCoverage(coverage), + // Same subscription-DO publisher the console path uses: finalized labels + // broadcast live post-commit, with the reconciliation sweep as the backstop. + publisher: createLabelPublisher(env), }); const finalized = await orchestrator.runAssessment(assessmentId); await notifyOutcome(env, finalized); diff --git a/apps/labeler/src/console-mutation-api.ts b/apps/labeler/src/console-mutation-api.ts index 4e869e9f94..b0f55613a4 100644 --- a/apps/labeler/src/console-mutation-api.ts +++ b/apps/labeler/src/console-mutation-api.ts @@ -35,6 +35,7 @@ import { buildAssessmentRunStatement, getActiveLabelState, getAssessment, + readDeleteGeneration, type Assessment, } from "./assessment-store.js"; import { buildAutomationPauseUpdate } from "./automation-state.js"; @@ -291,6 +292,11 @@ async function runLabelMutation( const outcome = await guardMutation(request, spec, guardDeps); if (outcome.outcome === "replay") { await assertIssuancePersisted(deps.db, [outcome.actionId]); + // Redrive the subscription-DO notify: the original request's afterCommit may + // have failed, leaving the committed label `publication_pending = 1`. Replay + // is the cheap latency path back to a live broadcast; the reconciliation + // sweep is the durable backstop. + deps.defer(deps.afterCommit(outcome.actionId)); deferLabelNotify(deps, storedDescriptor(outcome.result)); return jsonData(outcome.result); } @@ -693,6 +699,15 @@ async function runRerun( const assessment = await loadAssessment(deps.db, id); assertConfirmationCid(ctx.body.confirmation, assessment.cid); + // Capture the subject's tombstone generation before minting the run, so the run + // creation AND the pending issuance below are both gated on no delete having + // advanced it — a concurrent discovery-delete makes both no-op (no orphan run, + // no resurrected positive) rather than leaving a stranded `observed` run. + const generation = await readDeleteGeneration(deps.db, { + uri: assessment.uri, + cid: assessment.cid, + }); + const triggerId = operatorTriggerId(ctx.actionId); const runKey = await rerunRunKey(assessment.uri, assessment.cid, triggerId); const runId = `asmt_${ulid()}`; @@ -710,6 +725,9 @@ async function runRerun( promptHash: RERUN_PROMPT_HASH, coverageJson: "{}", now: ctx.now, + // No orphan `observed` run when a delete removed the subject: the run row + // commits only if the subject is undeleted at the captured generation. + requireSubjectGeneration: generation, }); const signer = await deps.createSigner(); const pending = await prepareAutomatedLabelIssuance( @@ -725,6 +743,17 @@ async function runRerun( }, { uri: assessment.uri, cid: assessment.cid, val: "assessment-pending" }, ctx.now, + // Gate the rerun's positive assessment-pending on the run (created `observed` + // in this batch) and the subject still being undeleted at the captured + // generation, so a concurrent discovery-delete that tombstoned the subject + // makes the positive no-op instead of resurrecting a live label on a deleted + // release. On a miss the label does not persist -> `assertIssuancePersisted` + // below aborts before `deferRerunTail`, so nothing is published, dispatched, + // or advanced, and the guarded run row above never committed. + { + requireAssessmentState: "observed", + requireSubjectNotDeleted: { uri: assessment.uri, cid: assessment.cid, generation }, + }, ); const descriptor: RerunDescriptor = { @@ -1040,6 +1069,9 @@ async function runEmergencyAction( if (outcome.outcome === "replay") { const stored = storedDescriptor(outcome.result); await assertIssuancePersisted(deps.db, [stored.actionId]); + // Redrive the subscription-DO notify in case the original afterCommit dropped + // it (see runLabelMutation's replay branch). + deps.defer(deps.afterCommit(stored.actionId)); if (action === "takedown") deferTakedownNotify(deps, stored); return jsonData(stored); } diff --git a/apps/labeler/src/discovery-consumer.ts b/apps/labeler/src/discovery-consumer.ts index 18395e7b7b..f481e26663 100644 --- a/apps/labeler/src/discovery-consumer.ts +++ b/apps/labeler/src/discovery-consumer.ts @@ -33,6 +33,7 @@ import { } from "@atcute/identity-resolver"; import type { LabelSigner } from "@emdash-cms/registry-moderation"; import { cloudflareDohResolver, type DnsResolver } from "emdash/security/ssrf"; +import { ulid } from "ulidx"; import { AssessmentDispatchError, @@ -46,11 +47,14 @@ import { initialTriggerId, } from "./assessment-lifecycle.js"; import { - createAssessmentRun, + buildAssessmentRunStatement, createSubject, deleteSubjectsByUri, getAssessment, - listNonTerminalAssessmentsForUri, + getAssessmentByRunKey, + listPendingBearingAssessmentsForUri, + readDeleteGeneration, + subjectMatchesGeneration, transitionAssessmentState, type Assessment, } from "./assessment-store.js"; @@ -71,8 +75,16 @@ import { RecordVerificationError, type RecordVerificationFailureReason, } from "./record-verification.js"; -import { issueAutomatedAssessmentLabel, LabelIssuanceUnavailableError } from "./service.js"; +import { + buildIssuanceStatements, + issueAutomatedAssessmentLabel, + LabelIssuanceUnavailableError, + readIssuedLabelByActionKey, + type AutomatedIssuanceAction, + type AutomatedLabelProposal, +} from "./service.js"; import { createRuntimeSigner, getRuntimeSigningSecret } from "./signing-runtime.js"; +import { createLabelPublisher, type LabelPublisher } from "./subscribe-labels.js"; /** * Stub identifiers for the model/prompt/scanner-set components of the run @@ -94,6 +106,13 @@ export interface DiscoveryConsumerDeps { * runKey, so a redelivered event dedups onto the same instance * (assessment-dispatch.ts). */ assessmentWorkflow: AssessmentWorkflowBinding; + /** Live broadcast for the pending-label and deletion-negation issuances. When + * present they commit `publication_pending = 1` and notify the subscription DO + * post-commit (the same publisher the orchestrator path uses); the + * reconciliation sweep is the durable backstop for a dropped notify. Omitted in + * tests that don't exercise publication (labels then commit already-published, + * matching the orchestrator's no-publisher behaviour). */ + publisher?: LabelPublisher; fetch?: typeof fetch; /** Resolves each PDS hop's hostname for the SSRF egress guard; defaults to * the DoH resolver used by artifact acquisition. */ @@ -214,34 +233,49 @@ export async function processDiscoveryMessage( const uri = jobUri(job); if (job.operation === "delete") { + // A delete suppresses assessment work (tombstone + cancel runs), so it gets + // the same distrust as a create: confirm the record is genuinely gone at the + // PDS before acting. A still-present record means a forged or premature + // delete — dead-letter it, suppress nothing. A verification failure here + // classifies like the create path (transient retries, permanent dead-letters). + let absent: boolean; try { - // A delete suppresses assessment work (tombstone + cancel runs), so it - // gets the same distrust as a create: confirm the record is genuinely - // gone at the PDS before acting. A still-present record means a forged - // or premature delete — dead-letter it, suppress nothing. const confirmAbsent = deps.confirmDeleted ?? confirmRecordAbsent; - const absent = await confirmAbsent({ + absent = await confirmAbsent({ uri, didDocumentResolver: deps.didDocumentResolver, ...(deps.fetch ? { fetch: deps.fetch } : {}), ...(deps.resolveHostname ? { resolveHostname: deps.resolveHostname } : {}), }); - if (!absent) { - await writeDeadLetter( - deps.db, - job, - "DELETE_RECORD_PRESENT", - "record still resolves", - now(), - ); - controller.ack(); - return; - } - const cancelled = await applyDiscoveryDelete(deps.db, uri, now()); - await negatePendingForDeletedRuns(deps, cancelled, now()); - controller.ack(); } catch (err) { await classifyDiscoveryError(err, job, deps, controller, now()); + return; + } + if (!absent) { + await writeDeadLetter(deps.db, job, "DELETE_RECORD_PRESENT", "record still resolves", now()); + controller.ack(); + return; + } + try { + await applyDiscoveryDelete(deps, uri, now()); + controller.ack(); + } catch (err) { + // The mutation phase (tombstone → pending-negation → cancellation) can + // leave a run's `assessment-pending` label live on a now-deleted subject + // if it fails partway. Acking here — as the create path's unexpected-error + // policy would — strands that label forever, so ALWAYS retry. Redelivery + // re-attempts idempotently; a genuinely permanent failure exhausts to the + // DLQ, which is acceptable versus acking a live label on a deleted subject. + console.error( + "[labeler] discovery delete mutation failed; retrying to avoid a stranded label", + { + did: job.did, + collection: job.collection, + rkey: job.rkey, + error: err instanceof Error ? err.message : String(err), + }, + ); + controller.retry(); } return; } @@ -354,6 +388,13 @@ async function verifyAndCreateRun( deps: DiscoveryConsumerDeps, now: Date, ): Promise { + // Capture the subject's tombstone generation BEFORE verifying. Every commit + // below (subject-undelete, run creation, label issuance) is gated on the + // generation not having advanced past this — so a delete that lands during the + // (slow) verify is seen as a newer generation and the whole create is rejected + // obsolete, while a create that captured the post-delete generation proceeds. + const generation = await readDeleteGeneration(deps.db, { uri, cid: job.cid }); + const verifyFn = deps.verify ?? fetchAndVerifyExactRecord; // Propagates PdsVerificationError / RecordVerificationError untouched — // the caller classifies retry vs dead-letter. @@ -365,6 +406,9 @@ async function verifyAndCreateRun( ...(deps.resolveHostname ? { resolveHostname: deps.resolveHostname } : {}), }); + // Generation-gated: a re-observation only clears `deleted_at` if no delete has + // advanced the generation since the capture. A stale verify cannot resurrect a + // subject deleted after it read state. await createSubject(deps.db, { uri, cid: job.cid, @@ -372,6 +416,7 @@ async function verifyAndCreateRun( collection: job.collection, rkey: job.rkey, now, + expectedGeneration: generation, }); const triggerId = initialTriggerId(job.cid); @@ -385,7 +430,10 @@ async function verifyAndCreateRun( triggerId, }); - const { assessment } = await createAssessmentRun(deps.db, { + // Generation-gated run creation: no orphan run for a subject the delete removed + // between the capture and here. + await buildAssessmentRunStatement(deps.db, { + id: `asmt_${ulid()}`, runKey, uri, cid: job.cid, @@ -396,24 +444,34 @@ async function verifyAndCreateRun( promptHash: DISCOVERY_PROMPT_HASH, coverageJson: "{}", now, - }); + requireSubjectGeneration: generation, + }).run(); + const assessment = await getAssessmentByRunKey(deps.db, runKey); + if (!assessment) { + // The run insert matched no row: a delete advanced the generation before it + // committed. Obsolete — nothing to advance, issue, or dispatch. + return; + } await advanceToPending(deps.db, assessment, now); - await issueAutomatedAssessmentLabel( - deps.db, - deps.config, - deps.signer, - { - actor: deps.config.labelerDid, - type: "automated-assessment", - assessmentId: assessment.id, - reason: "initial discovery", - idempotencyKey: automatedIdempotencyKey(runKey, "assessment-pending", false), - }, - { uri, cid: job.cid, val: "assessment-pending" }, + const outcome = await issueInitialPendingLabel( + deps, + assessment.id, + runKey, + uri, + job.cid, + generation, now, ); + if (outcome === "obsolete") { + // A concurrent delete tombstoned the subject (advancing the generation) or + // cancelled the run before the positive assessment-pending could commit — the + // run is moot. The issuance no-op'd, so there is nothing to publish and + // nothing to assess; the delete owns tombstone + cancel + negation. Do not + // dispatch a Workflow for a label that never committed. + return; + } // Hand the run to its Workflow instance. The instance id is the run's runKey, // so a redelivered event (same runKey) converges on the same instance rather @@ -426,6 +484,82 @@ async function verifyAndCreateRun( }); } +/** + * Issues the initial positive `assessment-pending` label, atomically gated at + * commit on BOTH the run still being `pending` AND the subject `(uri, cid)` still + * undeleted (`buildIssuanceStatements`' `requireAssessmentState` + + * `requireSubjectNotDeleted`). A concurrent delete that tombstones the subject or + * cancels the run in the gap after `advanceToPending` makes the guarded insert + * match no row: the issuance is obsolete — no label commits, so this returns + * without publishing or signalling a dispatch, and the delete's negation owns the + * stream. A non-persist NOT explained by the guard (a signing flip mid-batch) + * throws `LabelIssuanceUnavailableError` so the message retries. Reuses the same + * guarded-issuance machinery as finalization and the console path (no second SQL + * path); `readIssuedLabelByActionKey` tolerates a legitimate no-op without the + * signing-diagnosis throw `issueLabel`'s post-commit applies. + */ +async function issueInitialPendingLabel( + deps: DiscoveryConsumerDeps, + assessmentId: string, + runKey: string, + uri: string, + cid: string, + generation: number, + now: Date, +): Promise<"issued" | "obsolete"> { + const idempotencyKey = automatedIdempotencyKey(runKey, "assessment-pending", false); + const action: AutomatedIssuanceAction = { + actor: deps.config.labelerDid, + type: "automated-assessment", + assessmentId, + reason: "initial discovery", + idempotencyKey, + }; + const proposal: AutomatedLabelProposal = { uri, cid, val: "assessment-pending" }; + + // A redelivery whose first attempt already committed the label converges here: + // re-drive the live notify (best-effort) and treat it as issued. + const existing = await readIssuedLabelByActionKey(deps.db, idempotencyKey); + if (existing) { + if (deps.publisher) await deps.publisher.publish(existing); + return "issued"; + } + + const { statements } = await buildIssuanceStatements( + deps.db, + deps.config, + deps.signer, + action, + proposal, + now, + deps.publisher !== undefined, + { + requireAssessmentState: "pending", + requireSubjectNotDeleted: { uri, cid, generation }, + }, + ); + await deps.db.batch(statements); + + const issued = await readIssuedLabelByActionKey(deps.db, idempotencyKey); + if (issued) { + if (deps.publisher) await deps.publisher.publish(issued); + return "issued"; + } + + // The guarded insert matched no row. If the run is no longer `pending` or the + // subject was tombstoned / re-deleted (generation advanced), a concurrent delete + // won — a benign no-op. Anything else (the signing guard no-op'ing on a mid-batch + // pause/rotation) is retryable. + const run = await getAssessment(deps.db, assessmentId); + if ( + !run || + run.state !== "pending" || + !(await subjectMatchesGeneration(deps.db, { uri, cid, generation })) + ) + return "obsolete"; + throw new LabelIssuanceUnavailableError("initial pending label did not persist"); +} + /** * Advances a freshly-created (or redelivered) run from `observed` to * `pending`, tolerating a concurrent invocation that already did some or all @@ -460,24 +594,54 @@ async function transitionOrObserve( } } -/** Tombstones the subject, cancels non-terminal runs, and returns the runs - * that had already reached `pending` (so they carry an active - * `assessment-pending` label the caller must negate). */ -async function applyDiscoveryDelete(db: D1Database, uri: string, now: Date): Promise { - await deleteSubjectsByUri(db, { uri, now }); - const runs = await listNonTerminalAssessmentsForUri(db, uri); - const hadPending: Assessment[] = []; +/** + * Tombstones the subject (advancing its `delete_generation`) and retires every + * run that could still carry a live positive `assessment-pending` — including + * terminal `stale` runs, which self-transition on detecting a deleted subject and + * do NOT negate their own pending. For each such run the negation is issued + * BEFORE any cancellation, so a failed or paused negation (signing mid-rotation) + * leaves a non-terminal run non-terminal and re-discoverable on redelivery; + * cancelling first would drop it from the scan set, stranding the pending live + * once the message acks. + * + * The negation is keyed on the run having committed a live positive — NOT on its + * lifecycle state — because an operator rerun issues its positive while the run is + * still `observed`, and a stale run keeps its positive after going terminal. + * Cancellation applies only to non-terminal runs (a stale run is already + * terminal). The invariant: a run is cancelled only after any positive it + * committed has been negated, and the message cannot ack while a negation is still + * owed (a throw propagates to the delete handler's mutation-phase catch, which + * always retries), so no active `assessment-pending` survives an acked delete. + */ +async function applyDiscoveryDelete( + deps: DiscoveryConsumerDeps, + uri: string, + now: Date, +): Promise { + await deleteSubjectsByUri(deps.db, { uri, now }); + const runs = await listPendingBearingAssessmentsForUri(deps.db, uri); for (const run of runs) { - if ( - run.state !== "observed" && - run.state !== "verifying" && - run.state !== "pending" && - run.state !== "running" - ) - continue; - if (run.state === "pending" || run.state === "running") hadPending.push(run); + // Negate (before any cancel) any run — non-terminal OR terminal `stale` — + // that committed a positive pending and has not already been negated. + const positive = await readIssuedLabelByActionKey( + deps.db, + automatedIdempotencyKey(run.runKey, "assessment-pending", false), + ); + if (positive) { + const negated = await readIssuedLabelByActionKey( + deps.db, + automatedIdempotencyKey(run.runKey, "assessment-pending", true), + ); + if (!negated) await negateRunPendingLabel(deps, run, now); + } + if (run.state === "stale") continue; // already terminal — negated, nothing to cancel try { - await transitionAssessmentState(db, { id: run.id, from: run.state, to: "cancelled", now }); + await transitionAssessmentState(deps.db, { + id: run.id, + from: run.state, + to: "cancelled", + now, + }); } catch (err) { // A concurrent invocation already moved this run past `from` — // harmless, the delete's intent (no non-terminal run survives) is @@ -485,37 +649,36 @@ async function applyDiscoveryDelete(db: D1Database, uri: string, now: Date): Pro if (!(err instanceof AssessmentTransitionConflictError)) throw err; } } - return hadPending; } /** - * Negates the `assessment-pending` label each cancelled run issued, so a - * deleted release stops advertising an in-progress assessment. Uses the - * run's own assessment id and a deterministic idempotency key, so a - * redelivered delete converges. Idempotent and best-effort per run: a run - * whose pending is already negated (finalized) no-ops. + * Negates one run's `assessment-pending` label so a deleted release stops + * advertising an in-progress assessment. Deterministic idempotency key (the + * run's runKey), so a redelivered delete converges and a run whose pending is + * already negated (finalized) no-ops. Publishes best-effort with the sweep as + * backstop. Throws `LabelIssuanceUnavailableError` when signing is paused — the + * caller must let that propagate so the delete retries. */ -async function negatePendingForDeletedRuns( +async function negateRunPendingLabel( deps: DiscoveryConsumerDeps, - runs: Assessment[], + run: Assessment, now: Date, ): Promise { - for (const run of runs) { - await issueAutomatedAssessmentLabel( - deps.db, - deps.config, - deps.signer, - { - actor: deps.config.labelerDid, - type: "automated-assessment", - assessmentId: run.id, - reason: "subject deleted", - idempotencyKey: automatedIdempotencyKey(run.runKey, "assessment-pending", true), - }, - { uri: run.uri, cid: run.cid, val: "assessment-pending", neg: true }, - now, - ); - } + await issueAutomatedAssessmentLabel( + deps.db, + deps.config, + deps.signer, + { + actor: deps.config.labelerDid, + type: "automated-assessment", + assessmentId: run.id, + reason: "subject deleted", + idempotencyKey: automatedIdempotencyKey(run.runKey, "assessment-pending", true), + }, + { uri: run.uri, cid: run.cid, val: "assessment-pending", neg: true }, + now, + deps.publisher, + ); } function jobUri(job: DiscoveryJob): string { @@ -572,6 +735,7 @@ async function createProductionDiscoveryDeps(env: Env): Promise notifyLabelSubscription(env, sequence), + now: new Date(), + }).catch((err: unknown) => { + console.error("[labeler] publication sweep failed", { + error: err instanceof Error ? err.message : String(err), + }); + }), + ); + // Publisher-notification retry sweep (plan W10.5): re-drive failed / // crash-stuck sends, abandon the exhausted, prune terminal rows. Isolated in // its own branch so a sweep failure never disturbs the passes above. diff --git a/apps/labeler/src/query-labels.ts b/apps/labeler/src/query-labels.ts index ebc127a4d5..d490a5800e 100644 --- a/apps/labeler/src/query-labels.ts +++ b/apps/labeler/src/query-labels.ts @@ -14,7 +14,7 @@ const POSITIVE_INTEGER = /^[1-9]\d*$/; const DEFAULT_LIMIT = 50; const MAX_LIMIT = 250; -interface LabelRow { +export interface LabelRow { id: number; sequence: number; ver: number; @@ -100,7 +100,19 @@ export async function queryLabels( }); } -async function resignStaleLabels( +/** + * Lazily brings a page of retained label rows onto the active signing key: any + * row whose `signing_key_version` differs from the active key is re-signed with + * the current key and persisted (its prior signature archived in + * `label_signature_history`), mutating the passed rows in place. `sequence`, + * `cts`, and every label field except the signature are untouched, so ordering + * and identity are preserved. Shared by the public `queryLabels` reader and the + * WebSocket subscription replay so both serve verifiable frames after a routine + * key rotation. Throws when the current signing key is unavailable (paused mid + * rotation, or a configuration/state mismatch) — the caller must not serve the + * still-stale rows. + */ +export async function resignStaleLabels( db: D1Database, labels: LabelRow[], signingInput?: VersionedLabelSigner | (() => Promise), diff --git a/apps/labeler/src/reconciliation.ts b/apps/labeler/src/reconciliation.ts index 7380ff5636..3663d3980a 100644 --- a/apps/labeler/src/reconciliation.ts +++ b/apps/labeler/src/reconciliation.ts @@ -19,6 +19,15 @@ import type { AssessmentState } from "./assessment-lifecycle.js"; const STUCK_STATES: readonly AssessmentState[] = ["verifying", "pending", "running"]; const DEFAULT_STALE_THRESHOLD_MS = 60 * 60 * 1000; +/** How long a committed label may sit `publication_pending` before the sweep + * re-drives its subscription-DO notify. Short relative to the stuck-run + * threshold: a stranded pending row blocks the next key rotation. */ +const DEFAULT_PUBLICATION_STALE_THRESHOLD_MS = 5 * 60 * 1000; + +/** Per-pass cap on re-driven publications. The 5-minute cron reconvenes to drain + * a larger backlog across passes rather than fan out an unbounded batch. */ +const PUBLICATION_SWEEP_LIMIT = 200; + export interface StuckAssessmentRun { id: string; uri: string; @@ -76,3 +85,61 @@ export async function reconcileAssessments( return { stuckRuns, subjectsWithoutRuns }; } + +export interface PendingPublicationSweepDeps { + db: D1Database; + /** Drives the subscription-DO notify for one committed sequence. The DO + * broadcasts it and clears `publication_pending`; a throw leaves the flag set + * for the next pass. */ + notify: (sequence: number) => Promise; + now: Date; + thresholdMs?: number; + limit?: number; +} + +export interface PendingPublicationSweepReport { + redriven: number; + failed: number; +} + +/** + * Durable backstop for the live post-commit notify (assessment finalization and + * the console mutation path both issue labels `publication_pending = 1` and + * broadcast off the response path). A transient notify failure otherwise strands + * the row pending forever — an aggregator never receives it and, worse, the next + * key rotation refuses to activate while a row signed with the outgoing key stays + * pending. This re-drives the DO notify for pending rows older than the + * threshold; the DO clears the flag on success. + */ +export async function sweepPendingPublications( + deps: PendingPublicationSweepDeps, +): Promise { + const thresholdMs = deps.thresholdMs ?? DEFAULT_PUBLICATION_STALE_THRESHOLD_MS; + const limit = deps.limit ?? PUBLICATION_SWEEP_LIMIT; + const staleBefore = new Date(deps.now.getTime() - thresholdMs).toISOString(); + + const rows = await deps.db + .prepare( + `SELECT sequence FROM issued_labels + WHERE publication_pending = 1 AND sequence IS NOT NULL AND cts <= ? + ORDER BY sequence ASC LIMIT ?`, + ) + .bind(staleBefore, limit) + .all<{ sequence: number }>(); + + let redriven = 0; + let failed = 0; + for (const row of rows.results ?? []) { + try { + await deps.notify(row.sequence); + redriven++; + } catch (error) { + failed++; + console.error("[labeler] reconciliation: publication redrive failed", { + sequence: row.sequence, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return { redriven, failed }; +} diff --git a/apps/labeler/src/service.ts b/apps/labeler/src/service.ts index 1256fcd6f0..7c3fb62dde 100644 --- a/apps/labeler/src/service.ts +++ b/apps/labeler/src/service.ts @@ -120,6 +120,18 @@ export interface BuildIssuanceOptions { * automated-assessment action (it carries the assessmentId to check). */ requireAssessmentState?: AssessmentState; + /** + * Gate the action insert (and thus its label) additionally on the subject + * `(uri, cid)` still being non-tombstoned at commit time, at exactly the + * captured `generation`. The initial discovery issuance and the operator rerun + * pair this with `requireAssessmentState` so a concurrent delete that tombstones + * the subject (advancing its `delete_generation`) or cancels the run in the gap + * before this commit makes the positive `assessment-pending` no-op — no live + * label is resurrected for a deleted release, even if the create path itself + * cleared `deleted_at` (a generation bump the create cannot undo). Gating the + * action (not the label) leaves no orphan label, same as the state guard. + */ + requireSubjectNotDeleted?: { uri: string; cid: string; generation: number }; } /** @@ -214,6 +226,12 @@ export async function buildIssuanceStatements( requireState === undefined ? "" : `\n\t\t\t\t AND EXISTS (SELECT 1 FROM assessments WHERE id = ? AND state = ?)`; + const requireSubject = options.requireSubjectNotDeleted; + const subjectGuardSql = + requireSubject === undefined + ? "" + : `\n\t\t\t\t AND EXISTS (SELECT 1 FROM subjects + WHERE uri = ? AND cid = ? AND deleted_at IS NULL AND delete_generation = ?)`; const actionBinds: unknown[] = [ action.actor, action.type, @@ -233,6 +251,8 @@ export async function buildIssuanceStatements( proposal.val, ]; if (requireState !== undefined) actionBinds.push(assessmentId, requireState); + if (requireSubject !== undefined) + actionBinds.push(requireSubject.uri, requireSubject.cid, requireSubject.generation); const statements: D1PreparedStatement[] = [ db @@ -257,7 +277,7 @@ export async function buildIssuanceStatements( ) AND l2.neg = 0 AND a2.type <> 'automated-assessment' ) - )${stateGuardSql} + )${stateGuardSql}${subjectGuardSql} ON CONFLICT(idempotency_key) DO NOTHING`, ) .bind(...actionBinds), @@ -447,13 +467,14 @@ export async function prepareAutomatedLabelIssuance( action: AutomatedIssuanceAction, proposal: AutomatedLabelProposal, now: Date, + options: BuildIssuanceOptions = {}, ): Promise { if (signer.issuerDid !== config.labelerDid) throw new TypeError("signer issuer does not match the configured labeler DID"); validateKeyVersion(config.signingKeyVersion); validateAutomatedAction(action); validateAutomatedProposal(proposal); - return buildIssuanceStatements(db, config, signer, action, proposal, now, true); + return buildIssuanceStatements(db, config, signer, action, proposal, now, true, options); } export interface OverrideIssuanceSpec { @@ -599,7 +620,7 @@ export async function issueAutomatedAssessmentLabel( return issueLabel(db, config, signer, action, proposal, now, publisher); } -async function markPublicationAccepted(db: D1Database, issued: IssuedLabel): Promise { +export async function markPublicationAccepted(db: D1Database, issued: IssuedLabel): Promise { await db .prepare( `UPDATE issued_labels SET publication_pending = 0 diff --git a/apps/labeler/src/subscribe-labels.ts b/apps/labeler/src/subscribe-labels.ts index 0b71af5c8c..9e71aecf29 100644 --- a/apps/labeler/src/subscribe-labels.ts +++ b/apps/labeler/src/subscribe-labels.ts @@ -1,7 +1,10 @@ import { encode, toBytes } from "@atcute/cbor"; import { DurableObject } from "cloudflare:workers"; +import { getLabelerIdentityConfig } from "./config.js"; +import { resignStaleLabels, type LabelRow } from "./query-labels.js"; import type { IssuedLabel } from "./service.js"; +import { createRuntimeSigner, getRuntimeSigningSecret } from "./signing-runtime.js"; export const LABEL_SUBSCRIPTION_DO_NAME = "main"; @@ -43,18 +46,26 @@ export interface LabelPublisher { publish(issued: IssuedLabel): Promise; } -export function createLabelPublisher(env: Env): LabelPublisher { +/** + * Notifies the subscription DO of a committed label sequence. The DO broadcasts + * it and clears the row's `publication_pending` flag. Shared by the live + * publisher and the reconciliation `publication_pending` sweep so both drive the + * exact same DO endpoint. + */ +export async function notifyLabelSubscription(env: Env, sequence: number): Promise { const subscription = env.LABEL_SUBSCRIPTION.getByName(LABEL_SUBSCRIPTION_DO_NAME); + const response = await subscription.fetch("https://labeler.internal/notify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sequence }), + }); + if (!response.ok) throw new Error(`label notification failed with ${response.status}`); +} + +export function createLabelPublisher(env: Env): LabelPublisher { return { managesPublicationState: true, - async publish(issued) { - const response = await subscription.fetch("https://labeler.internal/notify", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ sequence: issued.sequence }), - }); - if (!response.ok) throw new Error(`label notification failed with ${response.status}`); - }, + publish: (issued) => notifyLabelSubscription(env, issued.sequence), }; } @@ -208,15 +219,28 @@ export class LabelSubscriptionDO extends DurableObject { private async labelsAfter(cursor: number, through: number): Promise { const rows = await this.env.DB.prepare( - `SELECT sequence, ver, src, uri, cid, val, neg, cts, exp, sig + `SELECT id, sequence, ver, src, uri, cid, val, neg, cts, exp, sig, + signing_key_id, signing_key_version FROM issued_labels WHERE sequence > ? AND sequence <= ? ORDER BY sequence ASC LIMIT ?`, ) .bind(cursor, through, REPLAY_PAGE_SIZE) - .all(); - return (rows.results ?? []).map((row) => ({ + .all(); + // Bring any row signed with a retired key onto the active key before framing + // it, the same lazy re-sign the public query reader does — a fresh aggregator + // verifies replayed frames against the current published key, so an + // un-re-signed retained row would fail verification and stall the stream. The + // re-sign preserves sequence and ordering; a signing pause throws (mirroring + // queryLabels' 503) rather than serving a still-stale frame. + const resigned = await resignStaleLabels(this.env.DB, rows.results ?? [], async () => + createRuntimeSigner( + await getLabelerIdentityConfig(this.env), + getRuntimeSigningSecret(this.env), + ), + ); + return resigned.map((row) => ({ sequence: row.sequence, label: rowToLabel(row), })); diff --git a/apps/labeler/test/assessment-orchestrator.test.ts b/apps/labeler/test/assessment-orchestrator.test.ts index ae9d1b7245..f4c2634590 100644 --- a/apps/labeler/test/assessment-orchestrator.test.ts +++ b/apps/labeler/test/assessment-orchestrator.test.ts @@ -33,8 +33,13 @@ import { import { FindingValidationError, HISTORY_FINDING_CATEGORIES } from "../src/findings.js"; import { analyzeHistory } from "../src/history-context.js"; import { MODERATION_POLICY } from "../src/policy.js"; -import { issueManualLabel } from "../src/service.js"; -import { initializeSigningState } from "../src/signing-rotation.js"; +import { issueManualLabel, type IssuedLabel } from "../src/service.js"; +import { + abortRoutineKeyRotation, + beginRoutineKeyRotation, + initializeSigningState, +} from "../src/signing-rotation.js"; +import type { LabelPublisher } from "../src/subscribe-labels.js"; import { canonicalBundle, checksumOf, file } from "./bundle-fixture.js"; interface TestEnv { @@ -47,8 +52,48 @@ const LABELER_DID = "did:web:labels.emdashcms.com"; const PUBLISHER_DID = "did:plc:publisher000000000000000000"; const PRIVATE_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE"; const MULTIKEY = "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ"; +const ROTATED_MULTIKEY = "zDnaer52RTwabaBeMkKYYwZmEFqPabLW78cRK62iovMUQhFif"; const config = { labelerDid: LABELER_DID, signingKeyVersion: "v1" }; +/** Records every label handed to `publish`, mimicking the console/DO publisher. */ +function recordingPublisher(managesPublicationState: boolean): { + publisher: LabelPublisher; + published: IssuedLabel[]; +} { + const published: IssuedLabel[] = []; + return { + published, + publisher: { + managesPublicationState, + async publish(issued) { + published.push(issued); + }, + }, + }; +} + +/** A D1 wrapper that runs `onFirstBatch` immediately before the first + * `db.batch` call — the seam between finalization prep and commit. Everything + * else delegates to the real database. */ +function pauseBeforeFirstBatch(db: D1Database, onFirstBatch: () => Promise): D1Database { + let triggered = false; + return new Proxy(db, { + get(target, prop, receiver) { + if (prop === "batch") { + return async (statements: D1PreparedStatement[]) => { + if (!triggered) { + triggered = true; + await onFirstBatch(); + } + return target.batch(statements); + }; + } + const value = Reflect.get(target, prop, receiver) as unknown; + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + beforeAll(async () => { await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); await initializeSigningState(testEnv.DB, { @@ -1119,3 +1164,226 @@ describe("AssessmentOrchestrator: real acquire stage (W7.2)", () => { expect(error?.neg).toBe(0); }); }); + +describe("AssessmentOrchestrator: live publication (Finding 4)", () => { + it("issues finalization labels publication_pending and broadcasts each to the publisher", async () => { + const run = await pendingRun({ name: "publish-live", cidValue: await cid("publish-live") }); + const { publisher, published } = recordingPublisher(true); + const orchestrator = new AssessmentOrchestrator({ + db: testEnv.DB, + config, + signer: await signer(), + policy: MODERATION_POLICY, + stages: stubStages, + sleep: () => Promise.resolve(), + publisher, + }); + + const result = await orchestrator.runAssessment(run.id); + expect(result.state).toBe("passed"); + + // Every label this run committed was broadcast: the assessment-pending + // negation and the assessment-passed positive. + const publishedVals = published.map((p) => p.label.val).toSorted(); + expect(publishedVals).toEqual(["assessment-passed", "assessment-pending"]); + + // A publisher that manages publication state (the real DO clears the flag on + // /notify) leaves the rows pending here — the fake never cleared them. + const pending = await testEnv.DB.prepare( + `SELECT COUNT(*) AS n FROM issued_labels WHERE uri = ? AND cid = ? AND publication_pending = 1`, + ) + .bind(run.uri, run.cid) + .first<{ n: number }>(); + expect(pending?.n).toBe(2); + }); + + it("clears publication_pending via markPublicationAccepted for a non-managing publisher", async () => { + const run = await pendingRun({ name: "publish-accept", cidValue: await cid("publish-accept") }); + const { publisher, published } = recordingPublisher(false); + const orchestrator = new AssessmentOrchestrator({ + db: testEnv.DB, + config, + signer: await signer(), + policy: MODERATION_POLICY, + stages: stubStages, + sleep: () => Promise.resolve(), + publisher, + }); + + await orchestrator.runAssessment(run.id); + expect(published.length).toBe(2); + + const pending = await testEnv.DB.prepare( + `SELECT COUNT(*) AS n FROM issued_labels WHERE uri = ? AND cid = ? AND publication_pending = 1`, + ) + .bind(run.uri, run.cid) + .first<{ n: number }>(); + expect(pending?.n).toBe(0); + }); + + it("finalizes and leaves rows publication_pending when the notify fails — the sweep backstops", async () => { + const run = await pendingRun({ name: "publish-fail", cidValue: await cid("publish-fail") }); + const failing: LabelPublisher = { + managesPublicationState: true, + publish: () => Promise.reject(new Error("subscription DO unreachable")), + }; + const orchestrator = new AssessmentOrchestrator({ + db: testEnv.DB, + config, + signer: await signer(), + policy: MODERATION_POLICY, + stages: stubStages, + sleep: () => Promise.resolve(), + publisher: failing, + }); + + // A dropped notify never fails the run: the batch already committed. + const result = await orchestrator.runAssessment(run.id); + expect(result.state).toBe("passed"); + + const pending = await testEnv.DB.prepare( + `SELECT COUNT(*) AS n FROM issued_labels WHERE uri = ? AND cid = ? AND publication_pending = 1`, + ) + .bind(run.uri, run.cid) + .first<{ n: number }>(); + expect(pending?.n).toBe(2); + }); + + it("issues finalization labels already-published (publication_pending = 0) when no publisher is wired", async () => { + const run = await pendingRun({ name: "publish-none", cidValue: await cid("publish-none") }); + const orchestrator = await buildOrchestrator(); + + await orchestrator.runAssessment(run.id); + + const pending = await testEnv.DB.prepare( + `SELECT COUNT(*) AS n FROM issued_labels WHERE uri = ? AND cid = ? AND publication_pending = 1`, + ) + .bind(run.uri, run.cid) + .first<{ n: number }>(); + expect(pending?.n).toBe(0); + }); +}); + +describe("AssessmentOrchestrator: signing pause between prep and commit (Finding 6)", () => { + it("leaves the run running and issues no labels when signing pauses before the batch commits", async () => { + const run = await pendingRun({ name: "pause-race", cidValue: await cid("pause-race") }); + const rotationId = "finding6-pause"; + // Pause signing in the seam between finalization prep (statements built while + // active) and the batch commit. + const db = pauseBeforeFirstBatch(testEnv.DB, async () => { + await beginRoutineKeyRotation(testEnv.DB, { + rotationId, + expectedActiveKeyVersion: "v1", + nextKeyVersion: "v2-finding6", + nextPublicKeyMultibase: ROTATED_MULTIKEY, + }); + }); + const orchestrator = new AssessmentOrchestrator({ + db, + config, + signer: await signer(), + policy: MODERATION_POLICY, + stages: stubStages, + sleep: () => Promise.resolve(), + }); + + // The CAS shares the issuance signing guard, so the whole batch no-ops: the + // finalization conflict is raised rather than a suppressed-label signing error. + await expect(orchestrator.runAssessment(run.id)).rejects.toBeInstanceOf( + AssessmentFinalizationConflictError, + ); + + // The run is still running (not stranded terminal) and NO label leaked. + expect((await getAssessment(testEnv.DB, run.id))?.state).toBe("running"); + const labels = await testEnv.DB.prepare( + `SELECT COUNT(*) AS n FROM issued_labels l JOIN issuance_actions a ON a.id = l.action_id + WHERE a.assessment_id = ?`, + ) + .bind(run.id) + .first<{ n: number }>(); + expect(labels?.n).toBe(0); + + // Resume signing and re-run (as the Workflow retry does): finalization now + // completes and issues the labels the paused attempt withheld. + await abortRoutineKeyRotation(testEnv.DB, { + rotationId, + expectedPendingKeyVersion: "v2-finding6", + }); + const resumed = new AssessmentOrchestrator({ + db: testEnv.DB, + config, + signer: await signer(), + policy: MODERATION_POLICY, + stages: stubStages, + sleep: () => Promise.resolve(), + }); + const finalized = await resumed.runAssessment(run.id); + expect(finalized.state).toBe("passed"); + const pendingNeg = await testEnv.DB.prepare( + `SELECT neg FROM issued_labels WHERE uri = ? AND cid = ? AND val = 'assessment-pending'`, + ) + .bind(run.uri, run.cid) + .first<{ neg: number }>(); + expect(pendingNeg?.neg).toBe(1); + }); +}); + +describe("AssessmentOrchestrator: delete racing finalization (Blocker 1)", () => { + it("commits no outcome/block labels when the subject is tombstoned between the currency re-check and the batch", async () => { + const run = await pendingRun({ name: "delete-toctou", cidValue: await cid("delete-toctou") }); + const blockingStages: OrchestratorStages = { + ...stubStages, + codeAi: () => Promise.resolve([finding({ category: "malware", severity: "critical" })]), + }; + // Tombstone the subject in the seam between finalize's currency re-check and + // the batch commit — the exact TOCTOU window the CAS subject-guard closes. + const db = pauseBeforeFirstBatch(testEnv.DB, async () => { + await deleteSubject(testEnv.DB, { uri: run.uri, cid: run.cid }); + }); + const orchestrator = new AssessmentOrchestrator({ + db, + config, + signer: await signer(), + policy: MODERATION_POLICY, + stages: blockingStages, + sleep: () => Promise.resolve(), + }); + + // The guarded CAS no-ops against the tombstone, so the whole batch commits + // nothing and the finalization conflict is raised. + await expect(orchestrator.runAssessment(run.id)).rejects.toBeInstanceOf( + AssessmentFinalizationConflictError, + ); + + // The run is still running and NO label — not the block, not even the pending + // negation — was committed for the now-deleted subject. + expect((await getAssessment(testEnv.DB, run.id))?.state).toBe("running"); + const labels = await testEnv.DB.prepare( + `SELECT COUNT(*) AS n FROM issued_labels l JOIN issuance_actions a ON a.id = l.action_id + WHERE a.assessment_id = ?`, + ) + .bind(run.id) + .first<{ n: number }>(); + expect(labels?.n).toBe(0); + + // The Workflow retry re-runs finalization, sees the deleted subject, and + // stales the run out — still never labelling a deleted release. + const resumed = new AssessmentOrchestrator({ + db: testEnv.DB, + config, + signer: await signer(), + policy: MODERATION_POLICY, + stages: blockingStages, + sleep: () => Promise.resolve(), + }); + const finalized = await resumed.runAssessment(run.id); + expect(finalized.state).toBe("stale"); + const labelsAfter = await testEnv.DB.prepare( + `SELECT COUNT(*) AS n FROM issued_labels l JOIN issuance_actions a ON a.id = l.action_id + WHERE a.assessment_id = ?`, + ) + .bind(run.id) + .first<{ n: number }>(); + expect(labelsAfter?.n).toBe(0); + }); +}); diff --git a/apps/labeler/test/console-assessment-mutations.test.ts b/apps/labeler/test/console-assessment-mutations.test.ts index 6521f0a3ce..262f39df87 100644 --- a/apps/labeler/test/console-assessment-mutations.test.ts +++ b/apps/labeler/test/console-assessment-mutations.test.ts @@ -17,6 +17,7 @@ import { computeRunKey, initialTriggerId } from "../src/assessment-lifecycle.js" import { createAssessmentRun, createSubject, + deleteSubjectsByUri, getActiveLabelState, getAssessment, getCurrentAssessment, @@ -375,6 +376,48 @@ describe("rerun", () => { expect(await getCurrentAssessment(testEnv.DB, { src: LABELER_DID, uri, cid: CID })).toBeNull(); }); + it("commits no live pending and does not dispatch when a delete tombstoned the subject first (rerun-path ordering race)", async () => { + const { id, uri } = await seedRun("rerun-delete-ordering"); + // The concurrent discovery-delete already tombstoned the subject (its + // non-terminal-run snapshot predates the rerun's run, so it never sees it). + await deleteSubjectsByUri(testEnv.DB, { uri }); + + const { deps, workflow, settle } = captureDeferred(); + const response = await handleConsoleMutation( + post(`/admin/api/assessments/${id}/rerun`, { + confirmation: CID, + reason: "rerun races a delete", + idempotencyKey: nextKey(), + }), + deps, + ); + + // The subject-guarded positive no-op'd, so the phantom-success check fails + // (503) and the deferred tail — advance, dispatch, publish — never runs. + expect(response.status).toBe(503); + await settle(); + expect(workflow.created.length).toBe(0); + + // No active positive assessment-pending survives on the tombstoned subject, + // and no positive row committed at all. + const winners = await getActiveLabelState(testEnv.DB, { src: LABELER_DID, uri, cid: CID }); + expect(winners.get("assessment-pending")?.active ?? false).toBe(false); + expect( + await countRows( + `SELECT COUNT(*) n FROM issued_labels WHERE uri = ? AND val = 'assessment-pending' AND neg = 0`, + uri, + ), + ).toBe(0); + // Seam 3: the generation-guarded run creation left NO orphan `observed` + // operator run (reconciliation would ignore an `observed` run forever). + expect( + await countRows( + `SELECT COUNT(*) n FROM assessments WHERE uri = ? AND trigger = 'operator'`, + uri, + ), + ).toBe(0); + }); + it("a second rerun creates another distinct run", async () => { const { id } = await seedRun("rerun-twice"); const first = await bodyData<{ runId: string }>( diff --git a/apps/labeler/test/console-mutation-api.test.ts b/apps/labeler/test/console-mutation-api.test.ts index e98706172d..4f8f0ce98c 100644 --- a/apps/labeler/test/console-mutation-api.test.ts +++ b/apps/labeler/test/console-mutation-api.test.ts @@ -595,6 +595,36 @@ describe("console mutation: replay and conflict", () => { ).toBe(1); }); + it("redrives the subscription-DO notify on replay (Finding 7)", async () => { + const uri = await seedReleaseSubject("replay-redrive"); + const key = nextKey(); + const request = () => + post("/admin/api/labels/issue", { + uri, + val: "security-yanked", + confirmation: "replay-redrive", + reason: "redrive on replay", + idempotencyKey: key, + }); + const notified: string[] = []; + const deps = mutationDeps({ + afterCommit: async (actionId) => { + notified.push(actionId); + }, + }); + + const first = await handleConsoleMutation(request(), deps); + expect(first.status).toBe(200); + const second = await handleConsoleMutation(request(), deps); + expect(second.status).toBe(200); + + // afterCommit (the live subscription-DO notify) fired on the proceed path AND + // again on the replay, keyed on the same committed action — so a client retry + // re-drives a broadcast the original request may have dropped. + expect(notified).toHaveLength(2); + expect(new Set(notified).size).toBe(1); + }); + it("returns 409 for the same key with a different fingerprint", async () => { const uri = await seedReleaseSubject("conflict"); const key = nextKey(); diff --git a/apps/labeler/test/discovery-consumer.test.ts b/apps/labeler/test/discovery-consumer.test.ts index 5325c4d221..0aacd97439 100644 --- a/apps/labeler/test/discovery-consumer.test.ts +++ b/apps/labeler/test/discovery-consumer.test.ts @@ -16,10 +16,18 @@ import { automatedIdempotencyKey, computeRunKey, initialTriggerId, + operatorTriggerId, } from "../src/assessment-lifecycle.js"; -import { getAssessmentByRunKey, getCurrentAssessment } from "../src/assessment-store.js"; +import { + createAssessmentRun, + createSubject, + getAssessmentByRunKey, + getCurrentAssessment, + transitionAssessmentState, +} from "../src/assessment-store.js"; import { buildAutomationPauseUpdate } from "../src/automation-state.js"; import { + bestEffortPublisher, type DiscoveryConsumerDeps, type MessageController, processDiscoveryMessage, @@ -27,12 +35,14 @@ import { import type { DiscoveryJob } from "../src/env.js"; import { PdsVerificationError, type VerifiedPdsRecord } from "../src/pds-verify.js"; import { MODERATION_POLICY } from "../src/policy.js"; +import { sweepPendingPublications } from "../src/reconciliation.js"; import { RecordVerificationError, type DidDocumentResolverLike, } from "../src/record-verification.js"; -import { issueAutomatedAssessmentLabel } from "../src/service.js"; +import { issueAutomatedAssessmentLabel, type IssuedLabel } from "../src/service.js"; import { initializeSigningState } from "../src/signing-rotation.js"; +import type { LabelPublisher } from "../src/subscribe-labels.js"; interface TestEnv { DB: D1Database; @@ -558,6 +568,155 @@ describe("processDiscoveryMessage: verification failures", () => { }); }); +describe("processDiscoveryMessage: create racing delete (Blocker 1 create-path)", () => { + it("commits no live pending label and does not dispatch when a delete completes before the create's positive commits", async () => { + const job = await jobFor({ rkey: rkey() }); + const workflow = new FakeAssessmentWorkflow(); + const published: IssuedLabel[] = []; + const publisher: LabelPublisher = { + managesPublicationState: true, + async publish(issued) { + published.push(issued); + }, + }; + const baseDeps = { ...(await buildDeps()), assessmentWorkflow: workflow, publisher }; + const runKey = await runKeyFor(job); + + // Barrier: the create's positive issuance signs its label right before the + // commit batch. Hook that seam to let a full delete complete first — + // tombstone + negate + cancel + ack — exactly the concurrent-delete window. + const realSigner = await signer(); + let deleteDone = false; + const barrierSigner: LabelSigner = { + issuerDid: realSigner.issuerDid, + async sign(label) { + if (!deleteDone) { + deleteDone = true; + const deleteJob: DiscoveryJob = { ...job, operation: "delete", cid: "" }; + await processDiscoveryMessage(deleteJob, new FakeMessage(), { + ...baseDeps, + confirmDeleted: () => Promise.resolve(true), + }); + } + return realSigner.sign(label); + }, + }; + + const create = new FakeMessage(); + await processDiscoveryMessage(job, create, { + ...baseDeps, + signer: barrierSigner, + verify: verifiedFor(job), + }); + + // The create acked (the run is obsolete), never dispatched a Workflow, and + // committed no positive assessment-pending — the guarded issuance no-op'd. + expect(create.acked).toBe(1); + expect(create.retried).toBe(0); + expect(workflow.created.length).toBe(0); + + const assessment = await getAssessmentByRunKey(testEnv.DB, runKey); + expect(assessment?.state).toBe("cancelled"); + const positive = await testEnv.DB.prepare( + `SELECT COUNT(*) AS n FROM issued_labels l JOIN issuance_actions a ON a.id = l.action_id + WHERE a.assessment_id = ? AND l.val = 'assessment-pending' AND l.neg = 0`, + ) + .bind(assessment!.id) + .first<{ n: number }>(); + expect(positive?.n).toBe(0); + + // No positive assessment-pending was ever broadcast. + expect( + published.some( + (entry) => entry.label.val === "assessment-pending" && entry.label.neg !== true, + ), + ).toBe(false); + + // No active positive assessment-pending survives. Here the positive never + // committed (guarded no-op) and the delete issued no negation — it correctly + // negates only runs that committed a positive — so the stream winner is either + // absent or a negation, never a live positive. + const winner = await testEnv.DB.prepare( + `SELECT neg FROM issued_labels WHERE uri = ? AND val = 'assessment-pending' + ORDER BY sequence DESC LIMIT 1`, + ) + .bind(uriFor(job)) + .first<{ neg: number }>(); + expect(winner === null || winner.neg === 1).toBe(true); + }); + + it("negates an operator rerun's live positive pending even while its run is still observed (rerun observed-state gap)", async () => { + // Reconstruct the rerun's committed state: an `observed` run already carrying + // a live positive assessment-pending (the rerun issues its positive before the + // deferred advance to `pending`). + const job = await jobFor({ rkey: rkey() }); + const uri = uriFor(job); + await createSubject(testEnv.DB, { + uri, + cid: job.cid, + did: PUBLISHER_DID, + collection: RELEASE_COLLECTION, + rkey: job.rkey, + }); + const triggerId = operatorTriggerId("op-observed-gap"); + const runKey = await computeRunKey({ + uri, + cid: job.cid, + policyVersion: MODERATION_POLICY.policyVersion, + modelId: "unassigned", + promptHash: "unassigned", + scannerSetVersion: "unassigned", + triggerId, + }); + const { assessment } = await createAssessmentRun(testEnv.DB, { + runKey, + uri, + cid: job.cid, + trigger: "operator", + triggerId, + policyVersion: MODERATION_POLICY.policyVersion, + coverageJson: "{}", + }); + expect(assessment.state).toBe("observed"); + await issueAutomatedAssessmentLabel( + testEnv.DB, + config, + await signer(), + { + actor: LABELER_DID, + type: "automated-assessment", + assessmentId: assessment.id, + reason: "operator rerun", + idempotencyKey: automatedIdempotencyKey(runKey, "assessment-pending", false), + }, + { uri, cid: job.cid, val: "assessment-pending" }, + ); + + const latestPending = () => + testEnv.DB.prepare( + `SELECT neg FROM issued_labels WHERE uri = ? AND val = 'assessment-pending' + ORDER BY sequence DESC LIMIT 1`, + ) + .bind(uri) + .first<{ neg: number }>(); + expect((await latestPending())?.neg).toBe(0); + + // A discovery delete arrives while the run is still `observed`. + const deleteJob: DiscoveryJob = { ...job, operation: "delete", cid: "" }; + const msg = new FakeMessage(); + await processDiscoveryMessage(deleteJob, msg, { + ...(await buildDeps()), + confirmDeleted: () => Promise.resolve(true), + }); + + expect(msg.acked).toBe(1); + // The observed run is cancelled AND its live positive is negated — no stale + // positive survives (pre-fix the state-based negation skipped observed runs). + expect((await getAssessmentByRunKey(testEnv.DB, runKey))?.state).toBe("cancelled"); + expect((await latestPending())?.neg).toBe(1); + }); +}); + describe("processDiscoveryMessage: delete", () => { it("tombstones the subject and cancels non-terminal runs", async () => { const job = await jobFor({ rkey: rkey() }); @@ -631,6 +790,116 @@ describe("processDiscoveryMessage: delete", () => { expect(dl?.n).toBe(0); }); + it("negates the pending label on redelivery after a paused first delivery — no stale pending survives", async () => { + const job = await jobFor({ rkey: rkey() }); + const deps = await buildDeps(); + await processDiscoveryMessage(job, new FakeMessage(), { ...deps, verify: verifiedFor(job) }); + const runKey = await runKeyFor(job); + expect((await getAssessmentByRunKey(testEnv.DB, runKey))?.state).toBe("pending"); + + const deleteJob: DiscoveryJob = { ...job, operation: "delete", cid: "" }; + const latestPending = () => + testEnv.DB.prepare( + `SELECT neg FROM issued_labels WHERE uri = ? AND val = 'assessment-pending' + ORDER BY sequence DESC LIMIT 1`, + ) + .bind(uriFor(job)) + .first<{ neg: number }>(); + + // First delivery: signing paused mid-rotation, so the pending-negation throws + // and the message retries. + const first = new FakeMessage(); + await testEnv.DB.prepare( + `UPDATE signing_state SET phase = 'paused', pending_key_version = 'v2', + pending_public_multikey = ?, rotation_id = 'rot-redeliver' WHERE id = 1`, + ) + .bind(MULTIKEY) + .run(); + try { + await processDiscoveryMessage(deleteJob, first, { + ...deps, + confirmDeleted: () => Promise.resolve(true), + }); + } finally { + await testEnv.DB.prepare( + `UPDATE signing_state SET phase = 'active', pending_key_version = NULL, + pending_public_multikey = NULL, rotation_id = NULL WHERE id = 1`, + ).run(); + } + expect(first.retried).toBe(1); + expect(first.acked).toBe(0); + // The crux of the fix: negating BEFORE cancelling means the throw leaves the + // run non-terminal (still `pending`), so the redelivery can re-discover it. + // Pre-fix the run was cancelled here and the redelivery found nothing to negate. + expect((await getAssessmentByRunKey(testEnv.DB, runKey))?.state).toBe("pending"); + expect((await latestPending())?.neg).toBe(0); + + // Redelivery with signing resumed: the negation now commits and the run is + // retired, so no active assessment-pending survives the delete. + const second = new FakeMessage(); + await processDiscoveryMessage(deleteJob, second, { + ...deps, + confirmDeleted: () => Promise.resolve(true), + }); + expect(second.acked).toBe(1); + expect(second.retried).toBe(0); + expect((await getAssessmentByRunKey(testEnv.DB, runKey))?.state).toBe("cancelled"); + expect((await latestPending())?.neg).toBe(1); + }); + + it("retries (never dead-letter+acks) an unexpected issuance error during delete negation, leaving no acked live label", async () => { + const job = await jobFor({ rkey: rkey() }); + const deps = await buildDeps(); + await processDiscoveryMessage(job, new FakeMessage(), { ...deps, verify: verifiedFor(job) }); + const runKey = await runKeyFor(job); + + const deleteJob: DiscoveryJob = { ...job, operation: "delete", cid: "" }; + const latestPending = () => + testEnv.DB.prepare( + `SELECT neg FROM issued_labels WHERE uri = ? AND val = 'assessment-pending' + ORDER BY sequence DESC LIMIT 1`, + ) + .bind(uriFor(job)) + .first<{ neg: number }>(); + + // An UNEXPECTED (not issuance-unavailable) failure during the negation — a + // signer that throws a plain error, standing in for an HSM/D1 fault. + const brokenSigner: LabelSigner = { + issuerDid: LABELER_DID, + sign: () => Promise.reject(new Error("HSM offline")), + }; + const first = new FakeMessage(); + await processDiscoveryMessage(deleteJob, first, { + ...deps, + signer: brokenSigner, + confirmDeleted: () => Promise.resolve(true), + }); + + // The delete path RETRIES rather than dead-letter+acking (the create path's + // unexpected-error policy) — acking would strand the live pending label on a + // deleted subject. + expect(first.retried).toBe(1); + expect(first.acked).toBe(0); + const dl = await testEnv.DB.prepare(`SELECT COUNT(*) AS n FROM dead_letters WHERE rkey = ?`) + .bind(job.rkey) + .first<{ n: number }>(); + expect(dl?.n).toBe(0); + // The pending label is still live and the run non-terminal — recoverable. + expect((await latestPending())?.neg).toBe(0); + expect((await getAssessmentByRunKey(testEnv.DB, runKey))?.state).toBe("pending"); + + // Redelivery with a working signer completes the delete and negates the label. + const second = new FakeMessage(); + await processDiscoveryMessage(deleteJob, second, { + ...deps, + confirmDeleted: () => Promise.resolve(true), + }); + expect(second.acked).toBe(1); + expect(second.retried).toBe(0); + expect((await getAssessmentByRunKey(testEnv.DB, runKey))?.state).toBe("cancelled"); + expect((await latestPending())?.neg).toBe(1); + }); + it("dead-letters a forged/premature delete whose record still resolves, suppressing nothing", async () => { const job = await jobFor({ rkey: rkey() }); const deps = await buildDeps(); @@ -838,3 +1107,264 @@ describe("processDiscoveryMessage: automation kill-switch", () => { expect(negated?.neg).toBe(1); }); }); + +describe("processDiscoveryMessage: live publication (Sol follow-up)", () => { + function recordingPublisher(): { publisher: LabelPublisher; published: number[] } { + const published: number[] = []; + return { + published, + publisher: { + managesPublicationState: true, + async publish(issued) { + published.push(issued.sequence); + }, + }, + }; + } + + async function pendingLabelRow( + assessmentId: string, + neg: number, + ): Promise<{ sequence: number; publication_pending: number }> { + const row = await testEnv.DB.prepare( + `SELECT l.sequence, l.publication_pending FROM issued_labels l + JOIN issuance_actions a ON a.id = l.action_id + WHERE a.assessment_id = ? AND l.val = 'assessment-pending' AND l.neg = ?`, + ) + .bind(assessmentId, neg) + .first<{ sequence: number; publication_pending: number }>(); + if (!row) throw new Error("assessment-pending label not found"); + return row; + } + + it("issues the discovery pending label publication_pending=1 and broadcasts it live", async () => { + const job = await jobFor({ rkey: rkey() }); + const { publisher, published } = recordingPublisher(); + const deps = { ...(await buildDeps()), publisher, verify: verifiedFor(job) }; + + await processDiscoveryMessage(job, new FakeMessage(), deps); + + const assessment = await getAssessmentByRunKey(testEnv.DB, await runKeyFor(job)); + const row = await pendingLabelRow(assessment!.id, 0); + // Committed publication_pending=1 (pre-fix: 0, so the sweep could never + // recover it) and broadcast to the subscription DO. + expect(row.publication_pending).toBe(1); + expect(published).toContain(row.sequence); + }); + + it("survives a dropped broadcast and leaves the pending label for the sweep", async () => { + const job = await jobFor({ rkey: rkey() }); + const failing: LabelPublisher = { + managesPublicationState: true, + publish: () => Promise.reject(new Error("subscription DO unreachable")), + }; + const deps = { + ...(await buildDeps()), + publisher: bestEffortPublisher(failing), + verify: verifiedFor(job), + }; + const msg = new FakeMessage(); + + await processDiscoveryMessage(job, msg, deps); + + // Best-effort: a failed live broadcast never fails/retries the message. + expect(msg.acked).toBe(1); + expect(msg.retried).toBe(0); + + const assessment = await getAssessmentByRunKey(testEnv.DB, await runKeyFor(job)); + const row = await pendingLabelRow(assessment!.id, 0); + expect(row.publication_pending).toBe(1); + + // The reconciliation sweep re-drives the stranded row. + const swept: number[] = []; + await sweepPendingPublications({ + db: testEnv.DB, + notify: async (sequence) => { + swept.push(sequence); + }, + now: new Date(Date.now() + 60_000), + thresholdMs: 0, + }); + expect(swept).toContain(row.sequence); + }); + + it("issues the deletion negation publication_pending=1 and broadcasts it", async () => { + const job = await jobFor({ rkey: rkey() }); + const { publisher, published } = recordingPublisher(); + const deps = { ...(await buildDeps()), publisher }; + await processDiscoveryMessage(job, new FakeMessage(), { ...deps, verify: verifiedFor(job) }); + const assessment = await getAssessmentByRunKey(testEnv.DB, await runKeyFor(job)); + published.length = 0; + + const deleteJob: DiscoveryJob = { ...job, operation: "delete", cid: "" }; + await processDiscoveryMessage(deleteJob, new FakeMessage(), { + ...deps, + confirmDeleted: () => Promise.resolve(true), + }); + + const row = await pendingLabelRow(assessment!.id, 1); + expect(row.publication_pending).toBe(1); + expect(published).toContain(row.sequence); + }); +}); + +describe("processDiscoveryMessage: systemic delete-generation (round-5 close)", () => { + const latestPending = (uri: string) => + testEnv.DB.prepare( + `SELECT neg FROM issued_labels WHERE uri = ? AND val = 'assessment-pending' + ORDER BY sequence DESC LIMIT 1`, + ) + .bind(uri) + .first<{ neg: number }>(); + + it("seam 1: a stale verify cannot resurrect a subject a concurrent delete tombstoned", async () => { + const job = await jobFor({ rkey: rkey() }); + const uri = uriFor(job); + // The subject already exists, undeleted, at generation 0 (a prior observation). + await createSubject(testEnv.DB, { + uri, + cid: job.cid, + did: PUBLISHER_DID, + collection: RELEASE_COLLECTION, + rkey: job.rkey, + }); + + const workflow = new FakeAssessmentWorkflow(); + const baseDeps = { ...(await buildDeps()), assessmentWorkflow: workflow }; + + // Barrier: during THIS create's verify (after it captured generation 0), a + // full concurrent delete completes — tombstone + generation bump + ack. + let deleteRan = false; + const barrierVerify = async (): Promise => { + if (!deleteRan) { + deleteRan = true; + await processDiscoveryMessage({ ...job, operation: "delete", cid: "" }, new FakeMessage(), { + ...baseDeps, + confirmDeleted: () => Promise.resolve(true), + }); + } + return verifiedFor(job)(); + }; + + const msg = new FakeMessage(); + await processDiscoveryMessage(job, msg, { ...baseDeps, verify: barrierVerify }); + + expect(msg.acked).toBe(1); + // createSubject's generation-guarded undelete no-op'd (captured gen 0, subject + // now gen 1): the subject stays tombstoned, no run was created, no positive + // issued, nothing dispatched. No resurrection. + const subject = await testEnv.DB.prepare( + `SELECT deleted_at, delete_generation FROM subjects WHERE uri = ? AND cid = ?`, + ) + .bind(uri, job.cid) + .first<{ deleted_at: string | null; delete_generation: number }>(); + expect(subject?.deleted_at).not.toBeNull(); + expect(subject?.delete_generation).toBe(1); + expect(await getAssessmentByRunKey(testEnv.DB, await runKeyFor(job))).toBeNull(); + expect(workflow.created.length).toBe(0); + expect( + await testEnv.DB.prepare( + `SELECT COUNT(*) AS n FROM issued_labels WHERE uri = ? AND val = 'assessment-pending' AND neg = 0`, + ) + .bind(uri) + .first<{ n: number }>(), + ).toEqual({ n: 0 }); + }); + + it("seam 2: the delete negates a stale run's stranded positive", async () => { + const job = await jobFor({ rkey: rkey() }); + const uri = uriFor(job); + await createSubject(testEnv.DB, { + uri, + cid: job.cid, + did: PUBLISHER_DID, + collection: RELEASE_COLLECTION, + rkey: job.rkey, + }); + const runKey = await runKeyFor(job); + const { assessment } = await createAssessmentRun(testEnv.DB, { + runKey, + uri, + cid: job.cid, + trigger: "initial", + triggerId: initialTriggerId(job.cid), + policyVersion: MODERATION_POLICY.policyVersion, + coverageJson: "{}", + }); + await issueAutomatedAssessmentLabel( + testEnv.DB, + config, + await signer(), + { + actor: LABELER_DID, + type: "automated-assessment", + assessmentId: assessment.id, + reason: "initial discovery", + idempotencyKey: automatedIdempotencyKey(runKey, "assessment-pending", false), + }, + { uri, cid: job.cid, val: "assessment-pending" }, + ); + // Drive the run to terminal `stale`, as the orchestrator would on detecting a + // non-current subject — WITHOUT negating its own pending. + for (const [from, to] of [ + ["observed", "verifying"], + ["verifying", "pending"], + ["pending", "running"], + ["running", "stale"], + ] as const) { + await transitionAssessmentState(testEnv.DB, { id: assessment.id, from, to }); + } + expect((await latestPending(uri))?.neg).toBe(0); + + // A delete arrives. The stale run is terminal, so the old non-terminal-only + // scan would miss it; the widened scan reaches it and negates its positive. + await processDiscoveryMessage({ ...job, operation: "delete", cid: "" }, new FakeMessage(), { + ...(await buildDeps()), + confirmDeleted: () => Promise.resolve(true), + }); + + expect((await latestPending(uri))?.neg).toBe(1); + expect((await getAssessmentByRunKey(testEnv.DB, runKey))?.state).toBe("stale"); + }); + + it("delete-then-republish: a new revision after a delete assesses cleanly (generation does not over-block)", async () => { + const job1 = await jobFor({ rkey: rkey() }); + const uri = uriFor(job1); + await processDiscoveryMessage(job1, new FakeMessage(), { + ...(await buildDeps()), + verify: verifiedFor(job1), + }); + await processDiscoveryMessage({ ...job1, operation: "delete", cid: "" }, new FakeMessage(), { + ...(await buildDeps()), + confirmDeleted: () => Promise.resolve(true), + }); + expect((await latestPending(uri))?.neg).toBe(1); + + // A genuine republish: a new revision (same rkey, new cid) discovered AFTER the + // delete. Its subject row is fresh (generation 0), so the capture-and-guard + // admits it — the delete of the old revision must not block the new one. + const job2 = { ...job1, cid: await cid(`${job1.rkey}-v2`) }; + const workflow = new FakeAssessmentWorkflow(); + const msg = new FakeMessage(); + await processDiscoveryMessage(job2, msg, { + ...(await buildDeps()), + assessmentWorkflow: workflow, + verify: verifiedFor(job2), + }); + + expect(msg.acked).toBe(1); + const run = await getAssessmentByRunKey(testEnv.DB, await runKeyFor(job2)); + expect(run?.state).toBe("pending"); + expect(workflow.created.length).toBe(1); + const positive = await testEnv.DB.prepare( + `SELECT l.neg FROM issued_labels l + JOIN issuance_actions a ON a.id = l.action_id + JOIN assessments r ON r.id = a.assessment_id + WHERE r.run_key = ? AND l.val = 'assessment-pending' + ORDER BY l.sequence DESC LIMIT 1`, + ) + .bind(await runKeyFor(job2)) + .first<{ neg: number }>(); + expect(positive?.neg).toBe(0); + }); +}); diff --git a/apps/labeler/test/reconciliation.test.ts b/apps/labeler/test/reconciliation.test.ts index 37bef9b4e1..ab6ba52599 100644 --- a/apps/labeler/test/reconciliation.test.ts +++ b/apps/labeler/test/reconciliation.test.ts @@ -7,7 +7,7 @@ import { createSubject, transitionAssessmentState, } from "../src/assessment-store.js"; -import { reconcileAssessments } from "../src/reconciliation.js"; +import { reconcileAssessments, sweepPendingPublications } from "../src/reconciliation.js"; interface TestEnv { DB: D1Database; @@ -138,3 +138,109 @@ describe("reconcileAssessments", () => { expect(withTightThreshold.stuckRuns.some((run) => run.id === id)).toBe(true); }); }); + +let seedCounter = 0; + +/** Inserts a signed-label row directly with a chosen `cts` and pending flag, + * returning its trigger-assigned sequence. */ +async function seedPendingLabel(opts: { + cts: string; + publicationPending: boolean; +}): Promise { + seedCounter++; + const idempotencyKey = `sweep-seed-${seedCounter}`; + await testEnv.DB.prepare( + `INSERT INTO issuance_actions (actor, type, reason, idempotency_key, created_at) + VALUES (?, 'manual-label', 'sweep seed', ?, ?)`, + ) + .bind("did:example:seed", idempotencyKey, opts.cts) + .run(); + await testEnv.DB.prepare( + `INSERT INTO issued_labels + (action_id, ver, src, uri, cid, val, neg, cts, exp, sig, signing_key_id, + signing_key_version, publication_pending) + SELECT id, 1, 'did:example:seed', ?, NULL, 'security-yanked', 0, ?, NULL, ?, + 'did:example:seed#atproto_label', 'v1', ? + FROM issuance_actions WHERE idempotency_key = ?`, + ) + .bind( + `at://did:example:seed/com.emdashcms.experimental.package.release/sweep-${seedCounter}:1.0.0`, + opts.cts, + new Uint8Array([1, 2, 3]), + opts.publicationPending ? 1 : 0, + idempotencyKey, + ) + .run(); + const row = await testEnv.DB.prepare( + `SELECT sequence FROM issued_labels l JOIN issuance_actions a ON a.id = l.action_id + WHERE a.idempotency_key = ?`, + ) + .bind(idempotencyKey) + .first<{ sequence: number }>(); + return row!.sequence; +} + +describe("sweepPendingPublications", () => { + const now = new Date("2026-07-18T12:00:00.000Z"); + const oldCts = new Date(now.getTime() - 30 * 60 * 1000).toISOString(); + const recentCts = new Date(now.getTime() - 60 * 1000).toISOString(); + + it("re-drives only pending rows older than the threshold", async () => { + const stale = await seedPendingLabel({ cts: oldCts, publicationPending: true }); + const recent = await seedPendingLabel({ cts: recentCts, publicationPending: true }); + const settled = await seedPendingLabel({ cts: oldCts, publicationPending: false }); + + const notified: number[] = []; + const report = await sweepPendingPublications({ + db: testEnv.DB, + notify: async (sequence) => { + notified.push(sequence); + }, + now, + }); + + expect(notified).toContain(stale); + expect(notified).not.toContain(recent); + expect(notified).not.toContain(settled); + expect(report.redriven).toBeGreaterThanOrEqual(1); + expect(report.failed).toBe(0); + }); + + it("counts a failed notify without throwing, leaving the row for a later pass", async () => { + const stuck = await seedPendingLabel({ cts: oldCts, publicationPending: true }); + + const report = await sweepPendingPublications({ + db: testEnv.DB, + notify: (sequence) => + sequence === stuck ? Promise.reject(new Error("DO unreachable")) : Promise.resolve(), + now, + }); + + expect(report.failed).toBeGreaterThanOrEqual(1); + // The row is untouched (still pending) — the sweep never clears the flag + // itself; only a successful DO notify does. + const stillPending = await testEnv.DB.prepare( + `SELECT publication_pending FROM issued_labels WHERE sequence = ?`, + ) + .bind(stuck) + .first<{ publication_pending: number }>(); + expect(stillPending?.publication_pending).toBe(1); + }); + + it("plans the sweep query through the partial index, not a full table scan", async () => { + // Guards against the sweep degrading to a full scan of the monotonically + // growing issued_labels table (a D1 query-timeout would strand pending rows + // and block the rotation drain). The query and the 0011 partial index must + // stay in sync. + const plan = await testEnv.DB.prepare( + `EXPLAIN QUERY PLAN SELECT sequence FROM issued_labels + WHERE publication_pending = 1 AND sequence IS NOT NULL AND cts <= ? + ORDER BY sequence ASC LIMIT ?`, + ) + .bind(new Date().toISOString(), 200) + .all<{ detail: string }>(); + const detail = (plan.results ?? []).map((row) => row.detail).join(" | "); + expect(detail).toContain("idx_issued_labels_publication_pending"); + expect(detail).not.toContain("SCAN issued_labels"); + }); +}); diff --git a/apps/labeler/test/subscribe-resign.test.ts b/apps/labeler/test/subscribe-resign.test.ts new file mode 100644 index 0000000000..b4316a3230 --- /dev/null +++ b/apps/labeler/test/subscribe-resign.test.ts @@ -0,0 +1,172 @@ +import { decodeFirst, fromBytes } from "@atcute/cbor"; +import { + createLabelSigner, + verifyLabel, + type LabelDidDocument, + type SignedLabel, +} from "@emdash-cms/registry-moderation"; +import { applyD1Migrations, env, SELF } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { initializeSigningState } from "../src/signing-rotation.js"; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; +} + +const testEnv = env as unknown as TestEnv; +const LABELER_DID = "did:web:labels.emdashcms.com"; +const PUBLISHER_DID = "did:plc:publisher000000000000000000"; +const ACTIVE_MULTIKEY = "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ"; +const RETIRED_PRIVATE_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAI"; +const RETIRED_MULTIKEY = "zDnaer52RTwabaBeMkKYYwZmEFqPabLW78cRK62iovMUQhFif"; + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); + // The worker env signs v1 with ACTIVE_MULTIKEY, so the active signing key here + // matches the deployment config the subscription DO re-signs with. + await initializeSigningState(testEnv.DB, { + issuerDid: LABELER_DID, + keyVersion: "v1", + publicKeyMultibase: ACTIVE_MULTIKEY, + }); +}); + +function retiredDocument(): LabelDidDocument { + return { + id: LABELER_DID, + verificationMethod: [ + { + id: "#atproto_label", + type: "Multikey", + controller: LABELER_DID, + publicKeyMultibase: RETIRED_MULTIKEY, + }, + ], + }; +} + +function activeDocument(): LabelDidDocument { + return { + id: LABELER_DID, + verificationMethod: [ + { + id: "#atproto_label", + type: "Multikey", + controller: LABELER_DID, + publicKeyMultibase: ACTIVE_MULTIKEY, + }, + ], + }; +} + +/** Persists a label signed with the retired key at a stale key version, exactly + * the shape a routine rotation leaves behind in retained history. */ +async function seedRetiredKeyLabel(uri: string): Promise { + const signer = await createLabelSigner({ + issuerDid: LABELER_DID, + privateKey: RETIRED_PRIVATE_KEY, + resolveDid: async () => retiredDocument(), + }); + const cts = new Date().toISOString(); + const unsigned = { ver: 1, uri, val: "security-yanked", cts } as const; + const returned = await signer.sign(unsigned); + const idempotencyKey = `resign-seed-${uri}`; + await testEnv.DB.prepare( + `INSERT INTO issuance_actions (actor, type, reason, idempotency_key, created_at) + VALUES (?, 'manual-label', 'retired-key seed', ?, ?)`, + ) + .bind(LABELER_DID, idempotencyKey, cts) + .run(); + await testEnv.DB.prepare( + `INSERT INTO issued_labels + (action_id, ver, src, uri, cid, val, neg, cts, exp, sig, signing_key_id, + signing_key_version, publication_pending) + SELECT id, 1, ?, ?, NULL, 'security-yanked', 0, ?, NULL, ?, ?, 'v0', 0 + FROM issuance_actions WHERE idempotency_key = ?`, + ) + .bind(LABELER_DID, uri, cts, returned.sig, `${LABELER_DID}#atproto_label`, idempotencyKey) + .run(); + const row = await testEnv.DB.prepare( + `SELECT sequence FROM issued_labels l JOIN issuance_actions a ON a.id = l.action_id + WHERE a.idempotency_key = ?`, + ) + .bind(idempotencyKey) + .first<{ sequence: number }>(); + return row!.sequence; +} + +async function subscribe(cursor: number): Promise { + const response = await SELF.fetch( + `https://test/xrpc/com.atproto.label.subscribeLabels?cursor=${cursor}`, + { headers: { upgrade: "websocket" } }, + ); + expect(response.status).toBe(101); + if (!response.webSocket) throw new Error("subscription did not upgrade to a WebSocket"); + response.webSocket.accept(); + return response.webSocket; +} + +function decodeLabel(message: ArrayBuffer): { + seq: number; + label: Record; +} { + const [header, payload] = decodeFirst(new Uint8Array(message)) as [ + { op: number; t: string }, + Uint8Array, + ]; + expect(header).toEqual({ op: 1, t: "#labels" }); + const event = decodeFirst(payload)[0] as { seq: number; labels: Record[] }; + const label = event.labels[0]; + if (!label) throw new Error("labels event did not contain a label"); + return { seq: event.seq, label }; +} + +async function nextLabelWithSeq(ws: WebSocket, seq: number): Promise> { + return new Promise((resolve) => { + const listener = (event: MessageEvent) => { + const decoded = decodeLabel(event.data as ArrayBuffer); + if (decoded.seq !== seq) return; + ws.removeEventListener("message", listener); + resolve(decoded.label); + }; + ws.addEventListener("message", listener); + }); +} + +describe("subscription replay re-signs retired-key labels (Finding 5)", () => { + it("delivers a replayed frame that verifies under the active key and persists the re-sign", async () => { + const uri = `at://${PUBLISHER_DID}/com.emdashcms.experimental.package.release/resign-replay:1.0.0`; + const sequence = await seedRetiredKeyLabel(uri); + + const ws = await subscribe(sequence - 1); + const frame = await nextLabelWithSeq(ws, sequence); + ws.close(); + + const label: SignedLabel = { + ver: 1, + src: String(frame.src), + uri: String(frame.uri), + val: String(frame.val), + cts: String(frame.cts), + sig: fromBytes(frame.sig as { $bytes: string }), + }; + // The replayed frame no longer verifies under the retired key... + await expect( + verifyLabel({ label, resolveDid: async () => retiredDocument() }), + ).rejects.toThrow(); + // ...it verifies under the active (published) key a fresh aggregator would use. + await expect( + verifyLabel({ label, resolveDid: async () => activeDocument() }), + ).resolves.toMatchObject({ uri, val: "security-yanked" }); + + // The re-sign is persisted, so the work is not repeated per connection. + const persisted = await testEnv.DB.prepare( + `SELECT signing_key_version FROM issued_labels WHERE sequence = ?`, + ) + .bind(sequence) + .first<{ signing_key_version: string }>(); + expect(persisted?.signing_key_version).toBe("v1"); + }); +}); From c7fb4c92494f8fc2999bef761d9f2be10a345faf Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 19 Jul 2026 12:21:42 +0100 Subject: [PATCH 134/137] =?UTF-8?q?chore:=20consolidate=20changesets=20?= =?UTF-8?q?=E2=80=94=20one=20initial-release=20for=20the=20new=20registry-?= =?UTF-8?q?moderation/verification=20packages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new (unpublished) registry-moderation and registry-verification packages accumulated a pile of per-change changesets; since they're initial releases, collapse them into a single initial-release changeset (net bump unchanged: both minor -> 0.1.0). Also drops two empty changesets. Changes to already- published packages (registry-lexicons, registry-client, emdash, admin, auth, plugin-types, plugin-cli) keep their individual changesets. --- .changeset/bright-labels-verify.md | 5 ----- .changeset/cozy-worlds-smile.md | 2 -- .changeset/enforce-image-moderation-blocks.md | 5 ----- .changeset/graduate-registry-verification.md | 5 ----- .changeset/labeler-headers-negotiation.md | 5 ----- .changeset/loud-jobs-trade.md | 2 -- .changeset/lucky-labelers-hard-blocks.md | 5 ----- .changeset/registry-moderation-stack-initial.md | 9 +++++++++ .changeset/registry-verification-ipv6-ssrf.md | 5 ----- .changeset/shiny-ants-deny.md | 5 ----- .changeset/tame-labels-hydrated.md | 5 ----- .changeset/undeclared-access-warning.md | 5 ----- .changeset/verified-artifact-inventory.md | 5 ----- 13 files changed, 9 insertions(+), 54 deletions(-) delete mode 100644 .changeset/bright-labels-verify.md delete mode 100644 .changeset/cozy-worlds-smile.md delete mode 100644 .changeset/enforce-image-moderation-blocks.md delete mode 100644 .changeset/graduate-registry-verification.md delete mode 100644 .changeset/labeler-headers-negotiation.md delete mode 100644 .changeset/loud-jobs-trade.md delete mode 100644 .changeset/lucky-labelers-hard-blocks.md create mode 100644 .changeset/registry-moderation-stack-initial.md delete mode 100644 .changeset/registry-verification-ipv6-ssrf.md delete mode 100644 .changeset/shiny-ants-deny.md delete mode 100644 .changeset/tame-labels-hydrated.md delete mode 100644 .changeset/undeclared-access-warning.md delete mode 100644 .changeset/verified-artifact-inventory.md diff --git a/.changeset/bright-labels-verify.md b/.changeset/bright-labels-verify.md deleted file mode 100644 index bf8f491d65..0000000000 --- a/.changeset/bright-labels-verify.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@emdash-cms/registry-moderation": patch ---- - -Adds signed-label parsing, encoding, and public-key verification APIs with distinguishable invalid-signature errors that remain `TypeError`-compatible. diff --git a/.changeset/cozy-worlds-smile.md b/.changeset/cozy-worlds-smile.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/cozy-worlds-smile.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/enforce-image-moderation-blocks.md b/.changeset/enforce-image-moderation-blocks.md deleted file mode 100644 index 8e0d0d8eba..0000000000 --- a/.changeset/enforce-image-moderation-blocks.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@emdash-cms/registry-moderation": minor ---- - -Blocks releases labeled `hateful-imagery`, `explicit-imagery`, or `graphic-violence` -- these automated-block labels were issuable by the labeler but silently ignored by release evaluation and enforcement, leaving a policy-blocked release installable. Also recognizes the `content-warning` label as a non-blocking warning, and exposes the `AUTOMATED_BLOCKS` and `WARNINGS` label-value sets for consumers that classify labels directly. diff --git a/.changeset/graduate-registry-verification.md b/.changeset/graduate-registry-verification.md deleted file mode 100644 index 5dc6fa3b8d..0000000000 --- a/.changeset/graduate-registry-verification.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@emdash-cms/registry-verification": minor ---- - -Adds `@emdash-cms/registry-verification`: runtime-neutral primitives for verifying plugin registry release artifacts. Validates multihash checksums, fetches artifacts with size and redirect guards, checks canonical tarball and bundle structure, and verifies Sigstore build provenance. Runs on both Node and workerd. diff --git a/.changeset/labeler-headers-negotiation.md b/.changeset/labeler-headers-negotiation.md deleted file mode 100644 index b5194a9529..0000000000 --- a/.changeset/labeler-headers-negotiation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@emdash-cms/registry-moderation": patch ---- - -Adds `parseAcceptLabelersHeader` and `serializeContentLabelersHeader` for the `atproto-accept-labelers` / `atproto-content-labelers` negotiation headers, with DID deduplication and redact-flag merging. diff --git a/.changeset/loud-jobs-trade.md b/.changeset/loud-jobs-trade.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/loud-jobs-trade.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/lucky-labelers-hard-blocks.md b/.changeset/lucky-labelers-hard-blocks.md deleted file mode 100644 index da4a7b6672..0000000000 --- a/.changeset/lucky-labelers-hard-blocks.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@emdash-cms/registry-moderation": patch ---- - -Exports the package-scope and release-scope hard-block label value sets so consumers share one vocabulary. diff --git a/.changeset/registry-moderation-stack-initial.md b/.changeset/registry-moderation-stack-initial.md new file mode 100644 index 0000000000..bf7847fdb1 --- /dev/null +++ b/.changeset/registry-moderation-stack-initial.md @@ -0,0 +1,9 @@ +--- +"@emdash-cms/registry-moderation": minor +"@emdash-cms/registry-verification": minor +--- + +Initial release of the plugin-registry moderation stack. + +- `@emdash-cms/registry-moderation`: ATProto signed-label parsing, encoding, and public-key verification; release moderation evaluation over both signed and aggregator-hydrated labels (package and publisher cascades, CID-bound labels, negation and expiry); the shared hard-block and warning label vocabularies; the `atproto-accept-labelers` / `atproto-content-labelers` negotiation headers; and `isModerationBlocking`, the canonical blocking predicate for enforcement consumers. +- `@emdash-cms/registry-verification`: runtime-neutral primitives (Node and workerd) for verifying plugin release artifacts — multihash checksums, SSRF-guarded artifact fetch, canonical tarball and bundle structure validation with a validated file inventory, and Sigstore build-provenance verification. diff --git a/.changeset/registry-verification-ipv6-ssrf.md b/.changeset/registry-verification-ipv6-ssrf.md deleted file mode 100644 index a4dd018e66..0000000000 --- a/.changeset/registry-verification-ipv6-ssrf.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@emdash-cms/registry-verification": patch ---- - -Rejects additional IPv6-encoded private/metadata addresses in the verified-fetch SSRF guard: deprecated IPv4-compatible (`::a.b.c.d`) and NAT64 (`64:ff9b::a.b.c.d`) forms that embed a private or link-local IPv4 address are now resolved to their embedded address and blocked. diff --git a/.changeset/shiny-ants-deny.md b/.changeset/shiny-ants-deny.md deleted file mode 100644 index 1a12206d70..0000000000 --- a/.changeset/shiny-ants-deny.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@emdash-cms/registry-moderation": patch ---- - -Adds shared ATProto label verification and release moderation evaluation. diff --git a/.changeset/tame-labels-hydrated.md b/.changeset/tame-labels-hydrated.md deleted file mode 100644 index fc80d93afa..0000000000 --- a/.changeset/tame-labels-hydrated.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@emdash-cms/registry-moderation": patch ---- - -Adds `evaluateHydratedReleaseModeration` for evaluating aggregator-hydrated labels that carry no signature, and `isModerationBlocking`, the canonical blocking predicate for enforcement consumers. diff --git a/.changeset/undeclared-access-warning.md b/.changeset/undeclared-access-warning.md deleted file mode 100644 index 37b02c3943..0000000000 --- a/.changeset/undeclared-access-warning.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@emdash-cms/registry-moderation": minor ---- - -Reclassifies the `undeclared-access` moderation label from a hard block to a warning — releases carrying it stay eligible and surface it as a warning rather than being blocked from install. diff --git a/.changeset/verified-artifact-inventory.md b/.changeset/verified-artifact-inventory.md deleted file mode 100644 index 425cd01020..0000000000 --- a/.changeset/verified-artifact-inventory.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@emdash-cms/registry-verification": minor ---- - -Adds a validated file inventory to `validatePluginBundle`: the result now carries `files`, every regular archive file (path and bytes) in tar order, so consumers can extract analysis inputs without re-parsing the bundle. From 9596a0437b38fef731874e71f19ae2b0bc2f70d2 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 21 Jul 2026 13:05:56 +0100 Subject: [PATCH 135/137] fix(aggregator): wrap syncGetRecord errors in the CORS + no-store envelope (#2167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(aggregator): wrap syncGetRecord errors in the CORS + no-store envelope An unexpected error (e.g. a D1 failure in the record-blob fetch) escaped handleXrpc unwrapped, hitting workerd's bare 500 with no CORS or cache headers — the parallel gap #2117 left out of scope on the cacheable record-blob path. Extract the shared no-store+CORS wrapper (wrapDispatchError) so the sync and policy error paths stay in lockstep, add a context string for correct log attribution, and route syncGetRecord throws through it. The success 200 keeps its cacheable header; only errors get private, no-store. * docs(aggregator): drop redundant catch comment (wrapDispatchError docstring covers it) --- apps/aggregator/src/routes/xrpc/router.ts | 45 +++++++++++++++-------- apps/aggregator/test/read-api.test.ts | 38 +++++++++++++++++++ 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/apps/aggregator/src/routes/xrpc/router.ts b/apps/aggregator/src/routes/xrpc/router.ts index b5cf5bb28e..4c3f219fe7 100644 --- a/apps/aggregator/src/routes/xrpc/router.ts +++ b/apps/aggregator/src/routes/xrpc/router.ts @@ -76,13 +76,13 @@ function applyCorsHeaders(headers: Headers): void { } /** - * Generic 500 for an unexpected pre-dispatch failure (e.g. a D1 error resolving - * the labeler policy). Logs the internal error for operators but returns the - * router's own opaque envelope, so no internal detail (SQL text, stack) reaches - * the client. Matches the body the router itself produces for a handler throw. + * Generic 500 for an unexpected failure (e.g. a D1 error). Logs the internal + * error under `context` for operators but returns the router's own opaque + * envelope, so no internal detail (SQL text, stack) reaches the client. + * Matches the body the router itself produces for a handler throw. */ -function internalErrorResponse(err: unknown): Response { - console.error("[aggregator] xrpc policy resolution failed", { +function internalErrorResponse(err: unknown, context: string): Response { + console.error(`[aggregator] xrpc ${context} failed`, { error: err instanceof Error ? (err.stack ?? err.message) : String(err), }); return new InternalServerError({ @@ -90,6 +90,22 @@ function internalErrorResponse(err: unknown): Response { }).toResponse(); } +/** + * Wrap an unexpected dispatch failure as an error Response carrying CORS + + * `no-store`. An unwrapped throw escapes to workerd's bare 500, dropping both + * — and on the cacheable `sync.getRecord` path it would leave a takedown- + * relevant error under a public cache header. `XRPCError` keeps its typed + * envelope (e.g. a malformed accept-labelers header); anything else is opaque. + */ +function wrapDispatchError(err: unknown, context: string): Response { + const errorResponse = + err instanceof XRPCError ? err.toResponse() : internalErrorResponse(err, context); + const headers = new Headers(errorResponse.headers); + headers.set("cache-control", NO_STORE); + applyCorsHeaders(headers); + return new Response(errorResponse.body, { status: errorResponse.status, headers }); +} + /** * Dispatch any `/xrpc/*` request. Returns null when the path isn't an * XRPC route (caller falls through to other route matching). @@ -108,7 +124,12 @@ export async function handleXrpc(env: Env, request: Request): Promise { }); }); +describe("sync.getRecord — unexpected blob-fetch failure", () => { + // A D1 error inside the CAR passthrough (here: `publishers` is gone) must + // take the CORS + `no-store` wrapper rather than escape to workerd's bare + // 500 — and must NOT inherit the cacheable success header. Capture the + // table's schema so the shared beforeEach (`DELETE FROM publishers`) keeps + // working after a test drops it. + let publishersSchema: string; + beforeAll(async () => { + const row = await testEnv.DB.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='publishers'", + ).first<{ sql: string }>(); + publishersSchema = row!.sql; + }); + afterEach(async () => { + const exists = await testEnv.DB.prepare( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='publishers'", + ).first(); + if (!exists) await testEnv.DB.prepare(publishersSchema).run(); + }); + + it("wraps a D1 failure in a 500 carrying CORS + no-store and no leaked internals", async () => { + // The publisher-profile blob read runs `SELECT record_blob FROM publishers`; + // dropping the table makes that throw a non-XRPC D1 error. + await testEnv.DB.prepare("DROP TABLE publishers").run(); + + const res = await SELF.fetch( + `https://test/xrpc/com.atproto.sync.getRecord?did=${DID_A}&collection=${NSID.publisherProfile}&rkey=self`, + ); + expect(res.status).toBe(500); + expect(res.headers.get("cache-control")).toBe("private, no-store"); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + const body = (await res.json()) as { error: string; message?: string }; + expect(body.error).toBe("InternalServerError"); + // No internal detail (SQL text, table name, stack) leaks to the client. + expect(JSON.stringify(body)).not.toMatch(/publishers|no such table|SQL|SELECT/i); + }); +}); + describe("getPublisherVerification", () => { it("returns the verification claims naming a DID as subject, newest first", async () => { await seedVerification({ From fa4d25bf099b035233e20a43936644333f8c82a7 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 21 Jul 2026 13:06:12 +0100 Subject: [PATCH 136/137] fix(labeler): kill-switch on in-flight runs, durable dead-letter retry, pointer tie-break (held review findings) (#2166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(labeler): tie-break current-pointer update on assessment id The current-pointer upsert guard accepted any run at an equal created_at_epoch_ms, but list/current ordering is (created_at_epoch_ms, id). A later lower-id run could displace a higher-id current sharing a millisecond. Match the pointer guard to the ordering so a lower-id run cannot overwrite a higher-id current at an equal epoch. * fix(labeler): enforce automation kill-switch on in-flight runs An admin pausing automation only halted discovery; already-dispatched Workflow runs never re-read automation_state and finalization had no pause predicate, so in-flight runs kept issuing automated labels after a successful pause. Re-read the kill-switch on Workflow entry (halting before AI/network work) and fold a fail-closed automation-not-paused predicate into the finalization CAS. A pause landing mid-flight no-ops the whole batch, leaving the run running for the Workflow retry to resume after unpause -- matching the signing-pause precedent. * fix(labeler): make dead-letter retry durable before marking retried The dead-letter retry committed status='retried' before decoding the payload and delivering the re-drive to the queue. A failed or undecodable delivery stranded the row 'retried': health checks (which count only 'new') stopped seeing it and a later operator retry returned 409, with nothing ever re-driven. Enqueue the discovery job before committing 'retried', so the status flips only once the queue accepts the job. An undecodable payload (422) or a queue failure (503) leaves the row 'new' -- still counted and retryable. A duplicate enqueue under concurrent double-retry is absorbed by the consumer's runKey dedup. * docs(labeler): correct sendDiscoveryJob comment — awaited before commit, not deferred --- apps/labeler/src/assessment-orchestrator.ts | 4 + apps/labeler/src/assessment-store.ts | 29 ++++- apps/labeler/src/assessment-workflow.ts | 9 ++ apps/labeler/src/automation-state.ts | 9 ++ apps/labeler/src/console-mutation-api.ts | 106 +++++++++++------- .../test/assessment-orchestrator.test.ts | 57 ++++++++++ apps/labeler/test/assessment-store.test.ts | 62 ++++++++++ apps/labeler/test/assessment-workflow.test.ts | 17 +++ .../labeler/test/console-mutation-api.test.ts | 46 ++++++++ 9 files changed, 297 insertions(+), 42 deletions(-) diff --git a/apps/labeler/src/assessment-orchestrator.ts b/apps/labeler/src/assessment-orchestrator.ts index f577d1f146..1384622274 100644 --- a/apps/labeler/src/assessment-orchestrator.ts +++ b/apps/labeler/src/assessment-orchestrator.ts @@ -294,6 +294,10 @@ export class AssessmentOrchestrator { // subject after the currency re-check below no-ops this CAS at commit // time, so no outcome/block label commits for a deleted subject. guardSubjectNotDeleted: true, + // Halt an in-flight run whose automation kill-switch was paused after + // dispatch: the CAS (and every label gated on `toState`) no-ops, leaving + // the run `running` for the Workflow retry to re-run after resume. + guardAutomationNotPaused: true, }); const statements = [...finalization.statements]; diff --git a/apps/labeler/src/assessment-store.ts b/apps/labeler/src/assessment-store.ts index 6b55b6d553..6b28b705bb 100644 --- a/apps/labeler/src/assessment-store.ts +++ b/apps/labeler/src/assessment-store.ts @@ -621,6 +621,16 @@ export interface FinalizationInput { * finalization retries and stales out instead of labelling a deleted subject. */ guardSubjectNotDeleted?: boolean; + /** + * Gate the CAS on the automation kill-switch reading `paused = 0` at commit + * time. An admin pausing automation after a run was dispatched otherwise lets + * the in-flight run commit its automated labels; folding the predicate into the + * CAS (and, transitively, every label gated on `toState`) no-ops finalization + * under a pause, leaving the run `running` for the Workflow retry to re-run + * after resume. Fails closed like `isAutomationPaused`: a missing singleton row + * fails the `paused = 0` read and halts finalization. + */ + guardAutomationNotPaused?: boolean; } export interface FinalizationStatements { @@ -682,6 +692,12 @@ export function buildFinalizationStatements( ? `\n\t\t\t\t AND EXISTS (SELECT 1 FROM subjects WHERE uri = ? AND cid = ? AND deleted_at IS NULL)` : ""; if (input.guardSubjectNotDeleted) casBinds.push(input.uri, input.cid); + // Fail-closed automation kill-switch: `EXISTS (… paused = 0)` no-ops the CAS + // (and every label gated on `toState`) when automation is paused or the + // singleton row is missing. + const automationGuardSql = input.guardAutomationNotPaused + ? `\n\t\t\t\t AND EXISTS (SELECT 1 FROM automation_state WHERE id = 1 AND paused = 0)` + : ""; const statements: D1PreparedStatement[] = [ db .prepare( @@ -690,7 +706,7 @@ export function buildFinalizationStatements( public_summary = COALESCE(?, public_summary), coverage_json = COALESCE(?, coverage_json), supersedes_assessment_id = COALESCE(?, supersedes_assessment_id) - WHERE id = ? AND state = ?${signingGuardSql}${subjectGuardSql}`, + WHERE id = ? AND state = ?${signingGuardSql}${subjectGuardSql}${automationGuardSql}`, ) .bind(...casBinds), ]; @@ -706,8 +722,15 @@ export function buildFinalizationStatements( ON CONFLICT(src, uri, cid) DO UPDATE SET assessment_id = excluded.assessment_id, updated_at = excluded.updated_at WHERE EXISTS (SELECT 1 FROM assessments WHERE id = ? AND state = ?) - AND (SELECT created_at_epoch_ms FROM assessments WHERE id = excluded.assessment_id) - >= (SELECT created_at_epoch_ms FROM assessments WHERE id = current_assessments.assessment_id)`, + AND ( + (SELECT created_at_epoch_ms FROM assessments WHERE id = excluded.assessment_id) + > (SELECT created_at_epoch_ms FROM assessments WHERE id = current_assessments.assessment_id) + OR ( + (SELECT created_at_epoch_ms FROM assessments WHERE id = excluded.assessment_id) + = (SELECT created_at_epoch_ms FROM assessments WHERE id = current_assessments.assessment_id) + AND excluded.assessment_id >= current_assessments.assessment_id + ) + )`, ) .bind( input.src, diff --git a/apps/labeler/src/assessment-workflow.ts b/apps/labeler/src/assessment-workflow.ts index f0d91fef1c..dda09d10ff 100644 --- a/apps/labeler/src/assessment-workflow.ts +++ b/apps/labeler/src/assessment-workflow.ts @@ -44,6 +44,7 @@ import { type CoverageAccumulator, } from "./assessment-stages.js"; import { getAssessment, type Assessment } from "./assessment-store.js"; +import { AutomationPausedError, isAutomationPaused } from "./automation-state.js"; import type { AiBinding } from "./code-ai-adapter.js"; import { getLabelerIdentityConfig, type LabelerConfig } from "./config.js"; import type { PublisherVerificationReader } from "./history-context.js"; @@ -94,6 +95,14 @@ export async function executeAssessmentInstance( return existing.state; } + // Re-read the kill-switch on entry: an admin pausing automation after this run + // was dispatched halts it here, before it spends AI/network work. Throwing + // leaves the row untouched for the Workflow-step retry to resume once + // automation is unpaused; finalization carries the same guard as the backstop. + // `isAutomationPaused` fails closed — an unreadable switch throws and retries. + if (await isAutomationPaused(env.DB)) + throw new AutomationPausedError(`assessment ${assessmentId} halted: automation is paused`); + assertRequiredBindings(env); const config = await getLabelerIdentityConfig(env); const versioned = await createRuntimeSigner(config, getRuntimeSigningSecret(env)); diff --git a/apps/labeler/src/automation-state.ts b/apps/labeler/src/automation-state.ts index c57438e2b3..beb49b752e 100644 --- a/apps/labeler/src/automation-state.ts +++ b/apps/labeler/src/automation-state.ts @@ -14,6 +14,15 @@ export class AutomationStateUnavailableError extends Error { override readonly name = "AutomationStateUnavailableError"; } +/** + * Raised when an in-flight assessment run re-reads the kill-switch on Workflow + * entry and finds automation paused. Halts the run before it spends AI/network + * work; the Workflow step retries, resuming once automation is unpaused. + */ +export class AutomationPausedError extends Error { + override readonly name = "AutomationPausedError"; +} + export interface AutomationPauseUpdate { paused: boolean; reason: string | null; diff --git a/apps/labeler/src/console-mutation-api.ts b/apps/labeler/src/console-mutation-api.ts index b0f55613a4..077bd870d2 100644 --- a/apps/labeler/src/console-mutation-api.ts +++ b/apps/labeler/src/console-mutation-api.ts @@ -163,9 +163,10 @@ export interface ConsoleMutationDeps { afterCommit: (actionId: string) => Promise; /** Schedules `afterCommit` without blocking the response (workerd `waitUntil`). */ defer: (work: Promise) => void; - /** Re-enqueues a dead letter's discovery job on retry (design §6). A queue op - * cannot join the D1 batch, so it runs in the deferred tail; the discovery - * consumer's `runKey` dedup absorbs a duplicate re-drive. */ + /** Re-enqueues a dead letter's discovery job on retry. A queue op cannot join + * the D1 batch, so it is awaited before `retried` is committed — `retried` is a + * durable claim only once the job is accepted; the discovery consumer's `runKey` + * dedup absorbs a duplicate re-drive. */ sendDiscoveryJob: (job: DiscoveryJob) => Promise; /** Dispatches a rerun's fresh assessment run to its Workflow instance (the same * binding discovery uses). The instance id is the run's `runKey`, so a @@ -247,6 +248,8 @@ export async function handleConsoleMutation( error instanceof ReadGuardError || error instanceof LabelMutationError || error instanceof DeadLetterResolvedError || + error instanceof DeadLetterUndeliverableError || + error instanceof DeadLetterReenqueueError || error instanceof NoActiveLabelError || error instanceof ReconsiderationStateError ) @@ -1307,6 +1310,47 @@ class DeadLetterResolvedError extends Error { } } +/** A dead letter whose stored payload no longer decodes to a discovery job, so a + * retry could never re-drive it. A 422 that leaves the row `new` (the operator + * quarantines it) rather than stranding it `retried` with nothing delivered. */ +class DeadLetterUndeliverableError extends Error { + override readonly name = "DeadLetterUndeliverableError"; + readonly code = "DEAD_LETTER_UNDELIVERABLE"; + readonly status = 422; + + constructor() { + super( + "Dead letter payload cannot be reconstructed into a discovery job; quarantine it instead", + ); + } + + toResponse(): Response { + return Response.json( + { error: { code: this.code, message: this.message } }, + { status: this.status }, + ); + } +} + +/** The re-drive enqueue failed, so `retried` was not committed. A retryable 503: + * the row stays `new` and a later retry re-attempts the enqueue. */ +class DeadLetterReenqueueError extends Error { + override readonly name = "DeadLetterReenqueueError"; + readonly code = "DEAD_LETTER_REENQUEUE_FAILED"; + readonly status = 503; + + constructor(options?: { cause?: unknown }) { + super("Dead letter re-drive could not be enqueued; retry.", options); + } + + toResponse(): Response { + return Response.json( + { error: { code: this.code, message: this.message } }, + { status: this.status }, + ); + } +} + /** The id from the path, folded into the parsed body so it joins the request * fingerprint — a replayed key must target the same dead letter. */ interface DeadLetterActionBody { @@ -1326,9 +1370,10 @@ interface DeadLetterActionDescriptor { * operational controls (design §6). Neither issues a label: the effect is a * `status` UPDATE guarded by `status = 'new'` plus an ungated `info` operational * event, both committed in one atomic `commitMutation` batch with the audit row. - * Retry additionally re-enqueues the dead letter's discovery job in the deferred - * tail. A row that is absent (404) or already resolved (409) is rejected before - * any commit, so a late request commits nothing and enqueues nothing (T6). + * Retry first durably enqueues the discovery job, then commits `retried`, so the + * status flips only once the re-drive is accepted. A row that is absent (404), + * already resolved (409), undeliverable (422), or whose enqueue fails (503) is + * rejected before the commit, leaving the row `new` and actionable (T6). */ async function runDeadLetterAction( request: Request, @@ -1353,6 +1398,22 @@ async function runDeadLetterAction( if (!letter) throw new ReadGuardError("NOT_FOUND"); if (letter.status !== "new") throw new DeadLetterResolvedError(); + // Durably enqueue the re-drive BEFORE committing `retried`, so the status can + // only flip once the discovery consumer has accepted the job. An undecodable + // payload or a queue failure leaves the row `new` — still counted by health and + // retryable — rather than stranding it `retried` with nothing delivered. A + // duplicate enqueue under a concurrent double-retry is absorbed by the + // consumer's `runKey` dedup. + if (status === "retried") { + const job = await readDeadLetterJob(deps.db, id); + if (!job) throw new DeadLetterUndeliverableError(); + try { + await deps.sendDiscoveryJob(job); + } catch (error) { + throw new DeadLetterReenqueueError({ cause: error }); + } + } + const subjectUri = `at://${letter.did}/${letter.collection}/${letter.rkey}`; const update = buildDeadLetterResolveUpdate(deps.db, { id, @@ -1388,42 +1449,9 @@ async function runDeadLetterAction( descriptor, ); - if (status === "retried") deferDeadLetterReenqueue(deps, id, ctx.actionId); return jsonData(returned); } -/** - * Re-enqueues a retried dead letter's discovery job off the response path, but - * only when this action won the `status = 'new'` race: a concurrent retry that - * committed its audit row yet matched zero rows in the UPDATE reads back a - * `resolved_by_action_id` that is not ours and skips, so the job enqueues once - * even under a double retry. The consumer's `runKey` dedup is the backstop; a - * lost enqueue is recoverable by re-driving `status = 'retried'` rows. - */ -function deferDeadLetterReenqueue(deps: ConsoleMutationDeps, id: number, actionId: string): void { - deps.defer( - (async () => { - try { - const letter = await getDeadLetter(deps.db, id); - if (!letter || letter.resolvedByActionId !== actionId) return; - const job = await readDeadLetterJob(deps.db, id); - if (!job) { - // The row flipped to 'retried' but its payload didn't decode to a - // valid job (e.g. a row written before the full-job payload shape), - // so nothing re-drives it and a fresh retry is barred by status. - console.error("[console-mutation] dead-letter payload undecodable, re-enqueue skipped", { - id, - }); - return; - } - await deps.sendDiscoveryJob(job); - } catch (error) { - console.error("[console-mutation] dead-letter re-enqueue failed", error); - } - })(), - ); -} - /** A non-numeric or non-positive path segment names no dead letter — a 404, * matching the absent-row case. Runs after the guard's role gate, so a non-admin * is rejected before this. */ diff --git a/apps/labeler/test/assessment-orchestrator.test.ts b/apps/labeler/test/assessment-orchestrator.test.ts index f4c2634590..8b8cdff7c5 100644 --- a/apps/labeler/test/assessment-orchestrator.test.ts +++ b/apps/labeler/test/assessment-orchestrator.test.ts @@ -1387,3 +1387,60 @@ describe("AssessmentOrchestrator: delete racing finalization (Blocker 1)", () => expect(labelsAfter?.n).toBe(0); }); }); + +describe("AssessmentOrchestrator: automation pause between prep and commit", () => { + it("leaves the run running and issues no labels when automation pauses before the batch commits", async () => { + const run = await pendingRun({ + name: "automation-pause", + cidValue: await cid("automation-pause"), + }); + // Pause automation in the seam between finalization prep (statements built + // while unpaused) and the batch commit. + const db = pauseBeforeFirstBatch(testEnv.DB, async () => { + await testEnv.DB.prepare(`UPDATE automation_state SET paused = 1 WHERE id = 1`).run(); + }); + const orchestrator = new AssessmentOrchestrator({ + db, + config, + signer: await signer(), + policy: MODERATION_POLICY, + stages: stubStages, + sleep: () => Promise.resolve(), + }); + + // The CAS carries the automation guard, so the whole batch no-ops and the + // finalization conflict is raised rather than terminal state with labels. + await expect(orchestrator.runAssessment(run.id)).rejects.toBeInstanceOf( + AssessmentFinalizationConflictError, + ); + + expect((await getAssessment(testEnv.DB, run.id))?.state).toBe("running"); + const labels = await testEnv.DB.prepare( + `SELECT COUNT(*) AS n FROM issued_labels l JOIN issuance_actions a ON a.id = l.action_id + WHERE a.assessment_id = ?`, + ) + .bind(run.id) + .first<{ n: number }>(); + expect(labels?.n).toBe(0); + + // Resume automation and re-run (as the Workflow retry does): finalization now + // completes and issues the labels the paused attempt withheld. + await testEnv.DB.prepare(`UPDATE automation_state SET paused = 0 WHERE id = 1`).run(); + const resumed = new AssessmentOrchestrator({ + db: testEnv.DB, + config, + signer: await signer(), + policy: MODERATION_POLICY, + stages: stubStages, + sleep: () => Promise.resolve(), + }); + const finalized = await resumed.runAssessment(run.id); + expect(finalized.state).toBe("passed"); + const pendingNeg = await testEnv.DB.prepare( + `SELECT neg FROM issued_labels WHERE uri = ? AND cid = ? AND val = 'assessment-pending'`, + ) + .bind(run.uri, run.cid) + .first<{ neg: number }>(); + expect(pendingNeg?.neg).toBe(1); + }); +}); diff --git a/apps/labeler/test/assessment-store.test.ts b/apps/labeler/test/assessment-store.test.ts index 11d452d407..561f27c999 100644 --- a/apps/labeler/test/assessment-store.test.ts +++ b/apps/labeler/test/assessment-store.test.ts @@ -9,6 +9,7 @@ import { operatorTriggerId, } from "../src/assessment-lifecycle.js"; import { + buildAssessmentRunStatement, buildFinalizationStatements, createAssessmentRun, createSubject, @@ -575,6 +576,67 @@ describe("supersession", () => { expect(pointer?.assessmentId).toBe(newerId); }); + it("a lower-ID run at an equal epoch never displaces a higher-ID current", async () => { + const subject = await observedSubject(); + const createdAt = new Date("2026-07-12T00:00:00.000Z"); + + async function runningRun(id: string, triggerSuffix: string) { + const triggerId = operatorTriggerId(triggerSuffix); + const runKey = await computeRunKey({ + uri: subject.uri, + cid: subject.cid, + policyVersion: "v1", + modelId: "m", + promptHash: "p", + scannerSetVersion: "v1", + triggerId, + }); + await buildAssessmentRunStatement(testEnv.DB, { + id, + runKey, + uri: subject.uri, + cid: subject.cid, + trigger: "operator", + triggerId, + policyVersion: "v1", + coverageJson: "{}", + now: createdAt, + }).run(); + for (const [from, to] of [ + ["observed", "verifying"], + ["verifying", "pending"], + ["pending", "running"], + ] as const) { + await transitionAssessmentState(testEnv.DB, { id, from, to }); + } + } + + const higherId = "asmt_ZZZZZZZZZZZZZZZZZZZZZZZZZZ"; + const lowerId = "asmt_00000000000000000000000000"; + await runningRun(higherId, "higher"); + await runningRun(lowerId, "lower"); + + const finalize = (id: string) => + buildFinalizationStatements(testEnv.DB, { + assessmentId: id, + fromState: "running", + toState: "passed", + src: LABELER_DID, + uri: subject.uri, + cid: subject.cid, + }).statements; + + await testEnv.DB.batch(finalize(higherId)); + await testEnv.DB.batch(finalize(lowerId)); + + const pointer = await getCurrentAssessment(testEnv.DB, { + src: LABELER_DID, + uri: subject.uri, + cid: subject.cid, + }); + expect(pointer?.assessmentId).toBe(higherId); + }); + it("a pending newer run never moves the current pointer", async () => { const subject = await observedSubject(); const first = await runningAssessment(subject); diff --git a/apps/labeler/test/assessment-workflow.test.ts b/apps/labeler/test/assessment-workflow.test.ts index f89162cfd4..e92b2209b0 100644 --- a/apps/labeler/test/assessment-workflow.test.ts +++ b/apps/labeler/test/assessment-workflow.test.ts @@ -31,9 +31,11 @@ import { serializeCoverage, type CoverageAccumulator } from "../src/assessment-s import { createAssessmentRun, createSubject, + getAssessment, transitionAssessmentState, } from "../src/assessment-store.js"; import { buildStages, executeAssessmentInstance } from "../src/assessment-workflow.js"; +import { AutomationPausedError } from "../src/automation-state.js"; import type { AiBinding } from "../src/code-ai-adapter.js"; import type { PublisherVerificationReader } from "../src/history-context.js"; import type { ImageAiBinding } from "../src/image-ai-adapter.js"; @@ -352,4 +354,19 @@ describe("executeAssessmentInstance: shell", () => { /missing required bindings/, ); }); + + it("halts on entry, before any stage work, when automation is paused", async () => { + const run = await pendingRun("wf-paused-entry"); + await testEnv.DB.prepare(`UPDATE automation_state SET paused = 1 WHERE id = 1`).run(); + + // The paused switch throws before `assertRequiredBindings`, so the error is + // the kill-switch halt — not the missing-binding failure minimalEnv would + // otherwise raise once it reached stage assembly. + await expect(executeAssessmentInstance(minimalEnv, run.id)).rejects.toBeInstanceOf( + AutomationPausedError, + ); + + const assessment = await getAssessment(testEnv.DB, run.id); + expect(assessment?.state).toBe("pending"); + }); }); diff --git a/apps/labeler/test/console-mutation-api.test.ts b/apps/labeler/test/console-mutation-api.test.ts index 4f8f0ce98c..2883e198b0 100644 --- a/apps/labeler/test/console-mutation-api.test.ts +++ b/apps/labeler/test/console-mutation-api.test.ts @@ -1965,6 +1965,52 @@ describe("console mutation: dead-letter retry (admin)", () => { }); }); +describe("console mutation: dead-letter retry durability", () => { + it("leaves the row new (not retried) when the queue enqueue fails", async () => { + const { id } = await seedDeadLetter(); + const deps = mutationDeps({ + sendDiscoveryJob: async () => { + throw new Error("queue unavailable"); + }, + }); + const response = await handleConsoleMutation( + deadLetterReq(`/admin/api/dead-letters/${id}/retry`), + deps, + ); + // A failed re-drive is retryable, not a phantom success: the row is untouched + // (still new, unresolved) so a later retry works and health still counts it. + expect(response.status).toBe(503); + const row = await deadLetterRow(id); + expect(row?.status).toBe("new"); + expect(row?.resolved_at).toBeNull(); + expect(row?.resolved_by_action_id).toBeNull(); + }); + + it("leaves the row new (not retried) when the stored payload cannot be decoded", async () => { + const garbage = new TextEncoder().encode(JSON.stringify({ not: "a discovery job" })); + const result = await testEnv.DB.prepare( + `INSERT INTO dead_letters (did, collection, rkey, reason, detail, payload, received_at, status) + VALUES (?, 'com.emdashcms.experimental.package.release', 'dl-garbage', 'verify-failed', 'detail', ?, datetime('now'), 'new')`, + ) + .bind(PUBLISHER_DID, garbage) + .run(); + const id = Number(result.meta.last_row_id); + const { deps, sent, settle } = captureReenqueue(); + + const response = await handleConsoleMutation( + deadLetterReq(`/admin/api/dead-letters/${id}/retry`), + deps, + ); + + // An undecodable payload can never be delivered, so the retry is refused and + // the row stays new (the operator quarantines it) rather than stranding retried. + expect(response.status).toBe(422); + expect((await deadLetterRow(id))?.status).toBe("new"); + await settle(); + expect(sent).toHaveLength(0); + }); +}); + describe("console mutation: dead-letter quarantine (admin)", () => { it("flips new→quarantined, emits one info event, enqueues nothing", async () => { const { id } = await seedDeadLetter(); From abe4d729b4eb040a166b2207fa0ed4b876f1f56e Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 22 Jul 2026 10:12:50 +0100 Subject: [PATCH 137/137] fix(labeler): keep assessment-pending live until the last concurrent run resolves (#2168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concurrent assessment runs for the same (uri,cid) all target the single assessment-pending label, so one run's finalization negation cleared the shared gate while a sibling run was still active — briefly exposing a stale pass. Gate the pending negation, in-batch, on no other non-terminal run for the subject (requireNoOtherActiveRun), so only the last run to resolve clears it. Make the delete cleanup's pending scan label-driven (any run holding a committed un-negated positive, at any state) so a decision run that now finalizes while still holding a positive is still negated on delete. --- apps/labeler/src/assessment-orchestrator.ts | 43 ++++++++- apps/labeler/src/assessment-store.ts | 40 ++++---- apps/labeler/src/discovery-consumer.ts | 34 +++---- apps/labeler/src/service.ts | 32 ++++++- .../test/assessment-orchestrator.test.ts | 93 ++++++++++++++++++- apps/labeler/test/discovery-consumer.test.ts | 39 ++++++++ 6 files changed, 238 insertions(+), 43 deletions(-) diff --git a/apps/labeler/src/assessment-orchestrator.ts b/apps/labeler/src/assessment-orchestrator.ts index 1384622274..bb27f7e694 100644 --- a/apps/labeler/src/assessment-orchestrator.ts +++ b/apps/labeler/src/assessment-orchestrator.ts @@ -32,6 +32,7 @@ import type { ModerationPolicy } from "./policy.js"; import { buildIssuanceStatements, markPublicationAccepted, + readIssuedLabelByActionKey, type AutomatedIssuanceAction, type AutomatedLabelProposal, type IssuedLabel, @@ -351,8 +352,40 @@ export class AssessmentOrchestrator { if (toState === "error") { await issue("assessment-error", false); } - // Spec §9.9 point 9: always negate this run's own assessment-pending. - await issue("assessment-pending", true); + // Spec §9.9 point 9: negate this run's own assessment-pending — but only when + // this is the last run in flight for the subject. `requireNoOtherActiveRun` + // no-ops the negation while a sibling run for the same (uri, cid) is still + // non-terminal, so the positive assessment-pending stays the stream head and + // the release stays gated; the last run to finalize clears it. + const pendingNegationKey = automatedIdempotencyKey( + assessment.runKey, + "assessment-pending", + true, + ); + const pendingNegation = await buildIssuanceStatements( + this.db, + this.config, + this.signer, + { + actor: this.config.labelerDid, + type: "automated-assessment", + assessmentId: assessment.id, + reason: `assessment ${toState}`, + idempotencyKey: pendingNegationKey, + }, + { uri: assessment.uri, cid: assessment.cid, val: "assessment-pending", neg: true }, + now, + this.publisher !== undefined, + { + requireAssessmentState: toState, + requireNoOtherActiveRun: { + uri: assessment.uri, + cid: assessment.cid, + assessmentId: assessment.id, + }, + }, + ); + statements.push(...pendingNegation.statements); // Spec §9.9 point 8 / §10: negate prior active automated labels this // outcome no longer supports. @@ -428,6 +461,12 @@ export class AssessmentOrchestrator { } const issued: IssuedLabel[] = []; for (const postCommit of postCommits) issued.push(await postCommit()); + // The pending negation is suppressed (writes no row) while a sibling run is + // still in flight; broadcast it only when it committed. It is the only label + // here that can legitimately be absent after the CAS succeeded — the CAS shares + // its signing guard — so its absence needs no signing diagnosis. + const pendingNegated = await readIssuedLabelByActionKey(this.db, pendingNegationKey); + if (pendingNegated) issued.push(pendingNegated); await this.publishLabels(issued); const finalised = await getAssessment(this.db, assessment.id); diff --git a/apps/labeler/src/assessment-store.ts b/apps/labeler/src/assessment-store.ts index 6b28b705bb..da2d7e20a2 100644 --- a/apps/labeler/src/assessment-store.ts +++ b/apps/labeler/src/assessment-store.ts @@ -387,25 +387,14 @@ export async function listNonTerminalAssessmentsForUri( return (rows.results ?? []).map(rowToAssessment); } -/** States a run can be in and still carry a live, un-negated positive - * `assessment-pending`: every non-terminal state, plus terminal `stale` (which, - * unlike the other terminals, does not negate its own pending on transition). - * `cancelled` is excluded — the delete negates before cancelling; the decision - * outcomes negate their own pending at finalization. */ -const PENDING_BEARING_STATES: readonly AssessmentState[] = [ - "observed", - "verifying", - "pending", - "running", - "stale", -]; - /** - * Runs for a URI that could still carry a live positive `assessment-pending` - * label — the delete cleanup's scan set (spec §9.1). Widens - * `listNonTerminalAssessmentsForUri` to include terminal `stale` runs: a run that - * self-transitioned to `stale` on detecting a deleted/superseded subject keeps - * its committed positive, so the delete must still reach it to negate. + * Runs for a URI the delete cleanup must reach (spec §9.1): every non-terminal + * run (to cancel it and negate any pending it issued) plus any run — of ANY state, + * terminal included — still holding a committed, un-negated positive + * `assessment-pending`. A run that self-stales on a deleted/superseded subject, or + * a decision run that suppressed its own pending-negation while a sibling was still + * in flight (`requireNoOtherActiveRun`), stays terminal yet keeps its positive, so + * its state alone does not identify it — the label stream does. */ export async function listPendingBearingAssessmentsForUri( db: D1Database, @@ -416,10 +405,19 @@ export async function listPendingBearingAssessmentsForUri( `SELECT id, run_key, uri, cid, artifact_id, artifact_checksum, state, trigger, trigger_id, policy_version, model_id, prompt_hash, public_summary, coverage_json, supersedes_assessment_id, started_at, completed_at, created_at - FROM assessments - WHERE uri = ? AND state IN (${PENDING_BEARING_STATES.map(() => "?").join(", ")})`, + FROM assessments a + WHERE a.uri = ? + AND ( + a.state NOT IN (${Array.from(TERMINAL_STATES, () => "?").join(", ")}) + OR ( + EXISTS (SELECT 1 FROM issued_labels l JOIN issuance_actions act ON act.id = l.action_id + WHERE act.assessment_id = a.id AND l.val = 'assessment-pending' AND l.neg = 0) + AND NOT EXISTS (SELECT 1 FROM issued_labels l JOIN issuance_actions act ON act.id = l.action_id + WHERE act.assessment_id = a.id AND l.val = 'assessment-pending' AND l.neg = 1) + ) + )`, ) - .bind(uri, ...PENDING_BEARING_STATES) + .bind(uri, ...TERMINAL_STATES) .all(); return (rows.results ?? []).map(rowToAssessment); } diff --git a/apps/labeler/src/discovery-consumer.ts b/apps/labeler/src/discovery-consumer.ts index f481e26663..e8e4f563b3 100644 --- a/apps/labeler/src/discovery-consumer.ts +++ b/apps/labeler/src/discovery-consumer.ts @@ -45,6 +45,7 @@ import { automatedIdempotencyKey, computeRunKey, initialTriggerId, + TERMINAL_STATES, } from "./assessment-lifecycle.js"; import { buildAssessmentRunStatement, @@ -596,22 +597,23 @@ async function transitionOrObserve( /** * Tombstones the subject (advancing its `delete_generation`) and retires every - * run that could still carry a live positive `assessment-pending` — including - * terminal `stale` runs, which self-transition on detecting a deleted subject and - * do NOT negate their own pending. For each such run the negation is issued - * BEFORE any cancellation, so a failed or paused negation (signing mid-rotation) - * leaves a non-terminal run non-terminal and re-discoverable on redelivery; - * cancelling first would drop it from the scan set, stranding the pending live - * once the message acks. + * run that could still carry a live positive `assessment-pending` — including any + * terminal run still holding a committed, un-negated positive (a `stale` run that + * self-transitioned on a deleted/superseded subject, or a decision run that + * suppressed its own pending-negation while a sibling was in flight). For each such + * run the negation is issued BEFORE any cancellation, so a failed or paused + * negation (signing mid-rotation) leaves a non-terminal run non-terminal and + * re-discoverable on redelivery; cancelling first would drop it from the scan set, + * stranding the pending live once the message acks. * * The negation is keyed on the run having committed a live positive — NOT on its * lifecycle state — because an operator rerun issues its positive while the run is - * still `observed`, and a stale run keeps its positive after going terminal. - * Cancellation applies only to non-terminal runs (a stale run is already - * terminal). The invariant: a run is cancelled only after any positive it - * committed has been negated, and the message cannot ack while a negation is still - * owed (a throw propagates to the delete handler's mutation-phase catch, which - * always retries), so no active `assessment-pending` survives an acked delete. + * still `observed`, and a terminal run keeps its positive after finalizing. + * Cancellation applies only to non-terminal runs (a terminal run needs none). The + * invariant: a run is cancelled only after any positive it committed has been + * negated, and the message cannot ack while a negation is still owed (a throw + * propagates to the delete handler's mutation-phase catch, which always retries), + * so no active `assessment-pending` survives an acked delete. */ async function applyDiscoveryDelete( deps: DiscoveryConsumerDeps, @@ -621,8 +623,8 @@ async function applyDiscoveryDelete( await deleteSubjectsByUri(deps.db, { uri, now }); const runs = await listPendingBearingAssessmentsForUri(deps.db, uri); for (const run of runs) { - // Negate (before any cancel) any run — non-terminal OR terminal `stale` — - // that committed a positive pending and has not already been negated. + // Negate (before any cancel) any run — non-terminal or terminal — that + // committed a positive pending and has not already been negated. const positive = await readIssuedLabelByActionKey( deps.db, automatedIdempotencyKey(run.runKey, "assessment-pending", false), @@ -634,7 +636,7 @@ async function applyDiscoveryDelete( ); if (!negated) await negateRunPendingLabel(deps, run, now); } - if (run.state === "stale") continue; // already terminal — negated, nothing to cancel + if (TERMINAL_STATES.has(run.state)) continue; // already terminal — negated, nothing to cancel try { await transitionAssessmentState(deps.db, { id: run.id, diff --git a/apps/labeler/src/service.ts b/apps/labeler/src/service.ts index 7c3fb62dde..58c4e4c8e2 100644 --- a/apps/labeler/src/service.ts +++ b/apps/labeler/src/service.ts @@ -1,6 +1,6 @@ import type { LabelSigner, SignedLabel } from "@emdash-cms/registry-moderation"; -import { ASSESSMENT_ID, type AssessmentState } from "./assessment-lifecycle.js"; +import { ASSESSMENT_ID, TERMINAL_STATES, type AssessmentState } from "./assessment-lifecycle.js"; import { getNegatableAutomatedLabels } from "./assessment-store.js"; import type { LabelerConfig } from "./config.js"; import type { FindingSeverity } from "./evidence.js"; @@ -132,6 +132,18 @@ export interface BuildIssuanceOptions { * action (not the label) leaves no orphan label, same as the state guard. */ requireSubjectNotDeleted?: { uri: string; cid: string; generation: number }; + /** + * Gate the action insert (and thus its label) on NO OTHER assessment run for the + * same subject `(uri, cid)` being non-terminal at commit time. Finalization pairs + * this with `requireAssessmentState` on the `assessment-pending` negation so the + * shared pending gate clears only when the LAST run resolves: while any sibling + * run is still in flight, this run's negation no-ops and the positive + * `assessment-pending` stays the stream head, holding the release gated. + * `assessmentId` excludes this run itself from the sibling scan (by id), so + * self-exclusion holds regardless of batch ordering; siblings are read as their + * committed terminal state. Requires an automated-assessment action. + */ + requireNoOtherActiveRun?: { uri: string; cid: string; assessmentId: string }; } /** @@ -232,6 +244,15 @@ export async function buildIssuanceStatements( ? "" : `\n\t\t\t\t AND EXISTS (SELECT 1 FROM subjects WHERE uri = ? AND cid = ? AND deleted_at IS NULL AND delete_generation = ?)`; + const requireNoOtherActiveRun = options.requireNoOtherActiveRun; + if (requireNoOtherActiveRun !== undefined && action.type !== "automated-assessment") + throw new TypeError("requireNoOtherActiveRun requires an automated-assessment action"); + const activeRunGuardSql = + requireNoOtherActiveRun === undefined + ? "" + : `\n\t\t\t\t AND NOT EXISTS (SELECT 1 FROM assessments + WHERE uri = ? AND cid = ? AND id != ? + AND state NOT IN (${Array.from(TERMINAL_STATES, () => "?").join(", ")}))`; const actionBinds: unknown[] = [ action.actor, action.type, @@ -253,6 +274,13 @@ export async function buildIssuanceStatements( if (requireState !== undefined) actionBinds.push(assessmentId, requireState); if (requireSubject !== undefined) actionBinds.push(requireSubject.uri, requireSubject.cid, requireSubject.generation); + if (requireNoOtherActiveRun !== undefined) + actionBinds.push( + requireNoOtherActiveRun.uri, + requireNoOtherActiveRun.cid, + requireNoOtherActiveRun.assessmentId, + ...TERMINAL_STATES, + ); const statements: D1PreparedStatement[] = [ db @@ -277,7 +305,7 @@ export async function buildIssuanceStatements( ) AND l2.neg = 0 AND a2.type <> 'automated-assessment' ) - )${stateGuardSql}${subjectGuardSql} + )${stateGuardSql}${subjectGuardSql}${activeRunGuardSql} ON CONFLICT(idempotency_key) DO NOTHING`, ) .bind(...actionBinds), diff --git a/apps/labeler/test/assessment-orchestrator.test.ts b/apps/labeler/test/assessment-orchestrator.test.ts index 8b8cdff7c5..31d7d1a51d 100644 --- a/apps/labeler/test/assessment-orchestrator.test.ts +++ b/apps/labeler/test/assessment-orchestrator.test.ts @@ -12,7 +12,12 @@ import { type AcquisitionHolder, type AcquisitionTarget, } from "../src/artifact-acquisition.js"; -import { computeRunKey, initialTriggerId, operatorTriggerId } from "../src/assessment-lifecycle.js"; +import { + automatedIdempotencyKey, + computeRunKey, + initialTriggerId, + operatorTriggerId, +} from "../src/assessment-lifecycle.js"; import { AssessmentFinalizationConflictError, AssessmentOrchestrator, @@ -26,6 +31,7 @@ import { createAssessmentRun, createSubject, deleteSubject, + getActiveLabelState, getAssessment, getCurrentAssessment, transitionAssessmentState, @@ -33,7 +39,12 @@ import { import { FindingValidationError, HISTORY_FINDING_CATEGORIES } from "../src/findings.js"; import { analyzeHistory } from "../src/history-context.js"; import { MODERATION_POLICY } from "../src/policy.js"; -import { issueManualLabel, type IssuedLabel } from "../src/service.js"; +import { + issueAutomatedAssessmentLabel, + issueManualLabel, + readIssuedLabelByActionKey, + type IssuedLabel, +} from "../src/service.js"; import { abortRoutineKeyRotation, beginRoutineKeyRotation, @@ -1388,6 +1399,84 @@ describe("AssessmentOrchestrator: delete racing finalization (Blocker 1)", () => }); }); +/** Issues a run's initial positive `assessment-pending`, the way the discovery + * consumer does, so the subject carries a live pending gate before a run finalizes. */ +async function issuePendingPositive(run: { + id: string; + uri: string; + cid: string; + runKey: string; +}): Promise { + await issueAutomatedAssessmentLabel( + testEnv.DB, + config, + await signer(), + { + actor: LABELER_DID, + type: "automated-assessment", + assessmentId: run.id, + reason: "initial discovery", + idempotencyKey: automatedIdempotencyKey(run.runKey, "assessment-pending", false), + }, + { uri: run.uri, cid: run.cid, val: "assessment-pending" }, + ); +} + +describe("AssessmentOrchestrator: concurrent runs share one pending gate", () => { + it("keeps assessment-pending live while a sibling run for the same subject is in flight, clearing it only when the last run finalizes", async () => { + const cidValue = await cid("shared-pending"); + const runA = await pendingRun({ + name: "shared-pending", + cidValue, + triggerId: initialTriggerId(cidValue), + }); + const runB = await pendingRun({ + name: "shared-pending", + cidValue, + triggerId: operatorTriggerId("op-shared-pending"), + }); + await issuePendingPositive(runA); + await issuePendingPositive(runB); + + const orchestrator = await buildOrchestrator(); + + // A finalizes while B is still `pending`. Its own pending-negation is + // suppressed, so the gate B also depends on stays live. + const a = await orchestrator.runAssessment(runA.id); + expect(a.state).toBe("passed"); + + const aNegation = await readIssuedLabelByActionKey( + testEnv.DB, + automatedIdempotencyKey(runA.runKey, "assessment-pending", true), + ); + expect(aNegation).toBeNull(); + + const gateWhileBActive = await getActiveLabelState(testEnv.DB, { + src: LABELER_DID, + uri: runA.uri, + cid: cidValue, + }); + expect(gateWhileBActive.get("assessment-pending")?.active).toBe(true); + + // B is the last run in flight; finalizing it clears the shared gate. + const b = await orchestrator.runAssessment(runB.id); + expect(b.state).toBe("passed"); + + const bNegation = await readIssuedLabelByActionKey( + testEnv.DB, + automatedIdempotencyKey(runB.runKey, "assessment-pending", true), + ); + expect(bNegation).not.toBeNull(); + + const gateAfterLast = await getActiveLabelState(testEnv.DB, { + src: LABELER_DID, + uri: runB.uri, + cid: cidValue, + }); + expect(gateAfterLast.get("assessment-pending")?.active).toBe(false); + }); +}); + describe("AssessmentOrchestrator: automation pause between prep and commit", () => { it("leaves the run running and issues no labels when automation pauses before the batch commits", async () => { const run = await pendingRun({ diff --git a/apps/labeler/test/discovery-consumer.test.ts b/apps/labeler/test/discovery-consumer.test.ts index 0aacd97439..e0c28ca27d 100644 --- a/apps/labeler/test/discovery-consumer.test.ts +++ b/apps/labeler/test/discovery-consumer.test.ts @@ -900,6 +900,45 @@ describe("processDiscoveryMessage: delete", () => { expect((await latestPending())?.neg).toBe(1); }); + it("negates a live pending positive still held by a terminal decision run on delete", async () => { + const job = await jobFor({ rkey: rkey() }); + const deps = await buildDeps(); + await processDiscoveryMessage(job, new FakeMessage(), { ...deps, verify: verifiedFor(job) }); + const runKey = await runKeyFor(job); + const run = await getAssessmentByRunKey(testEnv.DB, runKey); + + // A finalization that suppressed its own pending-negation (a sibling run was + // still in flight) leaves a terminal `passed` run that still holds a live + // positive assessment-pending — the orchestrator produces exactly this state. + await testEnv.DB.prepare( + `UPDATE assessments SET state = 'passed', completed_at = ?, completed_at_epoch_ms = ? WHERE id = ?`, + ) + .bind(new Date().toISOString(), Date.now(), run!.id) + .run(); + + const latestPending = () => + testEnv.DB.prepare( + `SELECT neg FROM issued_labels WHERE uri = ? AND val = 'assessment-pending' + ORDER BY sequence DESC LIMIT 1`, + ) + .bind(uriFor(job)) + .first<{ neg: number }>(); + expect((await latestPending())?.neg).toBe(0); + + const deleteJob: DiscoveryJob = { ...job, operation: "delete", cid: "" }; + const msg = new FakeMessage(); + await processDiscoveryMessage(deleteJob, msg, { + ...deps, + confirmDeleted: () => Promise.resolve(true), + }); + + expect(msg.acked).toBe(1); + expect(msg.retried).toBe(0); + // The delete cleanup reaches the terminal run's un-negated positive and clears + // it, so no active assessment-pending survives the delete. + expect((await latestPending())?.neg).toBe(1); + }); + it("dead-letters a forged/premature delete whose record still resolves, suppressing nothing", async () => { const job = await jobFor({ rkey: rkey() }); const deps = await buildDeps();