diff --git a/docs/README.md b/docs/README.md index ebf50ee5..16fff78e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,6 +27,7 @@ contributors. This page routes deeper work without duplicating it. ## System guides - [Farcaster integration](farcaster-integration.md) +- [Reliable Farcaster Mini App notifications](farcaster-notification-playbook.md) - [Auth bridge](../services/auth-bridge/README.md) - [SpacetimeDB module](../spacetimedb/README.md) - [Community Marks policy](gameplay/marks-policy-v1.md) diff --git a/docs/farcaster-integration.md b/docs/farcaster-integration.md index 75f4a22d..7315ac01 100644 --- a/docs/farcaster-integration.md +++ b/docs/farcaster-integration.md @@ -1,5 +1,9 @@ # Farcaster authentication +For a shorter, reusable account of the notification lifecycle, failure modes, +and production canary process, see +[`farcaster-notification-playbook.md`](farcaster-notification-playbook.md). + Warpkeep supports two presentations of the same server-owned identity boundary: ordinary browsers use Sign In with Farcaster (SIWF), while a verified Farcaster Mini App host may use Quick Auth. Neither path is a wallet connection, @@ -313,9 +317,10 @@ The control is staged behind the exact public presentation gate `VITE_WARPKEEP_ADMISSION_NOTIFICATIONS_ENABLED`. Only the literal value `true` enables it; the checked-in example and Pages fallback are `false`. This gate does not enable delivery, alter Quick Auth, submit another access request, or -change admission. The repository currently contains no privacy-safe record of -the required genuine production signed add/enable and disable/remove canary, so -the gate must remain false until the owner completes and records that review. +change admission. An owner-controlled production canary completed on 4 August +2026 and visibly received the Farcaster alert. That observation establishes the +canary outcome, not universal device delivery; presentation rollout remains a +separate deliberate configuration choice. The host adapter projects add/remove, enable/disable, rejection, invalid manifest, timeout, and setup-pending outcomes into a closed presentation state. @@ -333,20 +338,46 @@ with bounded retries. When both RPCs answer they must agree; after one exhausts its transport retries, the healthy independent view may complete verification and emits only a static degraded-mode event. Conflicting answers, two failed transports, or inactive on-chain state still fail closed. Delivery is accepted -only for the exact configured client FID and URL. A valid disable/remove -remains usable while outbound delivery is paused so opt-out cannot be trapped -behind a feature gate. Raw notification tokens stay inside one private +only for the exact configured client FID and URL. Valid add/enable and +disable/remove events remain usable while outbound delivery is paused: consent +is not lost during a rollout pause, and opt-out cannot be trapped behind a +feature gate. The pause still clears or rejects every outbound send. Raw notification tokens stay inside one private Cloudflare Durable Object per FID, never in React, browser storage, logs, URLs, public state, or SpacetimeDB. -Before Hermes requests administrator authority or mutates admission, it calls a -separate-secret operator endpoint for the exact pending access-request -timestamp. The Durable Object proves that request is still pending and that -admission is not enabled immediately before sending. For an opted-in player, -Hermes proceeds only after Farcaster reports the matching token in -`successfulTokens`; without notification consent, it records the explicit -`not-subscribed` result and may proceed. Provider acceptance proves handoff to -Farcaster, not device display or that the player opened the alert. +Hermes first inspects the exact pending access-request tuple, then disconnects +its short-lived administrator session before calling the separate-secret +notification endpoint. For an opted-in player, Farcaster provider acceptance +creates an `awaiting-client` intent; it does not admit the player. The alert +opens an unguessable fragment capability, which is scrubbed from the URL before +rendering. Acknowledgement requires fresh same-FID Quick Auth plus the exact +Farcaster launch context whose notification ID matches the stored intent. The +Durable Object compares both the fragment ticket and that launch ID. If a +same-document WebView exposes a new fragment alongside stale host context, the +client retains the one-use ticket in memory and asks the player to close the +view and reopen the exact alert. Hermes waits for `client-acknowledged`, mints +a new five-minute administrator session, and re-reads the unchanged request +tuple before invoking a request-CAS reducer. +`queued`, `not-subscribed`, `delivery-exhausted`, a legacy receipt, expiry, +identity mismatch, or a changed request all fail closed without admission. +Provider acceptance alone proves only handoff to Farcaster, not display or an +authenticated player open. + +For the bounded provider-accepted-but-unopened exception, an operator may use +`reissue-admission-notification --confirm`. This is a distinct serialized +transition, never an ordinary queue poll. It rechecks the exact disabled +admission and unchanged request timestamp, rotates the one-use intent, ticket, +and notification ID atomically, enforces a five-minute cooldown, and caps the +request cycle at two reissues. The cap and latest provider/client receipts live +in a private token-free sidecar, leaving the established pending-delivery and +reviewed grant record shapes unchanged for rollback. Acknowledgement and reissue share the same +per-FID serialization: acknowledgement-first blocks reissue, while +reissue-first makes the prior capability stale. + +The request-CAS reducers enforce admission kind and the exact request +cycle/timestamp. The reviewed Hermes workflow supplies the notification-open +gate; the general administrator role remains intentionally capable of other +privileged maintenance and is not cryptographically restricted by this flow. Queue-before-webhook races are retained without a token for at most 24 hours, signed opt-outs erase token material immediately, invalid tokens are purged, @@ -360,14 +391,15 @@ The reviewed payloads are: ```txt normal admission: -notificationId: warpkeep-access-approved-v2-r -title: Admission approved -body: The Hegemony is finalizing your Realm access. Your keep will open shortly. +notificationId: warpkeep-access-grant-v3-i +title: Welcome to the Hegemony Empire +body: The gates have answered your name. Cross the threshold, Founder—your legacy awaits. +targetUrl: https://warpkeep.com/?miniApp=true#warpkeep-grant-v1= already-live reconciliation: notificationId: warpkeep-access-approved-v1-e -title: The Hegemony admits you -body: Your keep awaits in Genesis 001. Enter the living Realm. +title: Welcome to the Hegemony Empire +body: The gates have answered your name. Cross the threshold, Founder—your legacy awaits. targetUrl: https://warpkeep.com/?miniApp=true ``` @@ -375,14 +407,26 @@ The titles and bodies are within Farcaster's bounds, contain no identity or private state, and accurately describe their generation. Copy changes require a reviewed Worker rollout. -For a notification launch, the browser retains only -`location.type === "notification"` and a notification ID matching either -`warpkeep-access-approved-v2-r` or the rollback-compatible -`warpkeep-access-approved-v1-e` within the 128-character -limit. Host title and body are discarded. Warpkeep then shows a short -confirmation state and runs normal Quick Auth, current admission, Terms, and -canonical-keep checks. A pending or changed account stays pending; the -notification itself never grants access or creates another keep. +For a normal admission launch, the browser captures only the exact +`warpkeep-grant-v1` fragment ticket, removes it from visible history immediately, +and keeps it in one module-local call stack. It never enters React state, +storage, analytics, logs, or a network request other than the authenticated +grant endpoint. The bridge re-verifies the signed FID and current request; the +Durable Object accepts an exact one-use ticket only after provider acceptance. +It also requires the exact `warpkeep-access-grant-v3-i…` ID from Farcaster's +immutable notification launch context; a ticket paired with launcher, legacy, +malformed, or different notification context is rejected without admission. +The frontend then polls admission at a bounded cadence while Hermes performs +the fresh request-CAS mutation. A stale or changed account remains pending, and +the notification itself cannot create a keep, bypass Terms, or grant gameplay +authority. Legacy admitted-epoch notifications retain their existing entry +behavior but cannot authorize the new pending-request flow. + +This exchange is served only at the additive +`/v2/access/admission-grant-context` path. The Worker must land before the +matching frontend; the live predecessor has no grant client, and the new client +never retries against the discarded ticket-only candidate path. That ordering +keeps cached predecessor bundles inert while the route converges. **CHECK ADMISSION** is a typed, read-only presentation around credentialed `/v2/session/refresh`, not a new Farcaster channel or access-request mutation. diff --git a/docs/farcaster-notification-playbook.md b/docs/farcaster-notification-playbook.md new file mode 100644 index 00000000..d3678df8 --- /dev/null +++ b/docs/farcaster-notification-playbook.md @@ -0,0 +1,290 @@ +# Reliable Farcaster Mini App notifications + +This is the public implementation and operations guide behind Warpkeep's first +confirmed Farcaster Mini App admission alert. The alert was visibly observed by +the owner canary on 4 August 2026. A public-safe operator receipt classified the +legacy admitted generation as `already-sent` after six bounded attempts with no +authority-verification failure. That canary proved the real transport could +work; because it was an already-admitted reconciliation, it did not prove the +required notification-before-admission ordering. The accompanying +notification-first path closes that gap for future admissions. + +The useful lesson was simple: a notification is not one API call. It is a small +protocol with separate consent, provider, launch, identity, and +application-authority states. + +The design below is intentionally reusable. Warpkeep's game-specific admission +rules are one example of a higher-stakes action that should happen only after +the notification flow has been verified. + +## The five states that matter + +| State | Evidence | What it proves | +| --- | --- | --- | +| Consent recorded | A valid signed `miniapp_added` or `notifications_enabled` webhook containing notification details | A Farcaster client issued a token for this Mini App, client, and FID | +| Provider accepted | The exact token appears in `successfulTokens` | The notification server accepted the handoff | +| Alert launch observed | `context.location.type === "notification"` and its `notificationId` matches | The host launched the Mini App from that notification context | +| Identity verified | A server-verified Quick Auth JWT has the expected domain and FID | The current authenticated Farcaster identity matches the workflow | +| Application action committed | The app's authority layer accepts a compare-and-swap transition | The protected application state actually changed | + +Do not collapse these into one status. In particular, `successfulTokens` does +not prove device delivery, OS display, a human read, or a click. Farcaster's +notification guide documents provider response categories separately from the +later notification launch context. + +## The working sequence + +```mermaid +sequenceDiagram + actor Player + participant Host as Farcaster client + participant Bridge as Notification server + participant Store as Private token/state store + participant Authority as Application authority + + Player->>Host: Add Mini App / enable notifications + Host->>Bridge: Signed add or enable webhook + Bridge->>Bridge: Verify JSON Farcaster Signature and app key + Bridge->>Store: Replace token + exact delivery URL + Bridge-->>Host: 200 + + Authority->>Bridge: Queue exact workflow generation + Bridge->>Authority: Re-read eligible state + Bridge->>Host: POST notification payload + private token + Host-->>Bridge: successfulTokens / invalidTokens / rateLimitedTokens + Bridge->>Store: Record provider outcome without token material + + Player->>Host: Open alert + Host->>Bridge: Mini App launch with notification context + Bridge->>Bridge: Match notification ID + one-use capability + fresh Quick Auth FID + Bridge->>Store: Consume capability once + Bridge->>Authority: Re-read generation and compare-and-swap + Authority-->>Player: Commit protected action +``` + +Warpkeep does not admit a player merely because the provider accepted a +notification. Provider acceptance creates a one-use, time-bounded grant intent. +The alert opens the Mini App with: + +- a unique `notificationId` in Farcaster's immutable notification launch + context; +- a separate one-use capability in the target URL fragment, removed from the + visible URL before rendering; and +- a freshly requested Quick Auth token that the server validates for the same + FID and domain. + +Only after all three match does the notification service record a client +acknowledgement. The operator then re-reads the unchanged access-request +generation and uses a compare-and-swap admission reducer. A stale request, +different FID, launcher context, reused ticket, expired intent, or changed +authority state fails closed. + +## 1. Publish the exact webhook URL + +Notifications require `webhookUrl` in the Mini App manifest served from the +registered production domain: + +```json +{ + "miniapp": { + "version": "1", + "name": "Example", + "homeUrl": "https://example.com", + "webhookUrl": "https://api.example.com/farcaster/webhook" + } +} +``` + +The production domain matters. Farcaster documents that `addMiniApp()` works +against the deployed domain matching the manifest, not a development tunnel. + +## 2. Treat webhooks as signed state transitions + +Handle the four current events: + +- `miniapp_added`: store notification details when present; their presence is + optional; +- `notifications_enabled`: replace the existing token and URL; +- `notifications_disabled`: invalidate and erase the token immediately; +- `miniapp_removed`: invalidate and erase every token for that FID/client pair. + +Verify the JSON Farcaster Signature and current app-key authority before using +the FID or notification details. The official `@farcaster/miniapp-node` package +provides `parseWebhookEvent`; its app-key validation callback still needs a +current Farcaster network view. Return `200` only after the relevant state is +durably stored. Clients may retry non-200 webhooks, so make each envelope +idempotent. + +Store the token and the exact URL only on the server. A notification token is a +secret permission scoped to the Farcaster client, Mini App, and user FID. Never +put it in browser state, public tables, analytics, query strings, or logs. + +Warpkeep additionally verifies app-key state through independent Hub views and +independent Optimism RPC views. That is application hardening, not a Farcaster +requirement. Its delivery pause suppresses sends but deliberately continues to +accept valid enable, disable, add, and remove events so consent state cannot be +lost during a rollout. + +## 3. Send the notification + +POST the payload to the exact URL supplied with the token: + +```json +{ + "notificationId": "access-grant-", + "title": "Welcome", + "body": "Open the app to continue.", + "targetUrl": "https://example.com/#grant=", + "tokens": [""] +} +``` + +Current Farcaster limits are: + +- `notificationId`: 128 characters; +- title: 32 characters; +- body: 128 characters; +- `targetUrl`: 1,024 characters and the exact registered hostname; +- tokens: at most 100 per request. + +The hostname comparison includes subdomains. A mismatch can permanently +invalidate the affected token. Validate the stored destination against a +server-side allowlist before every send; never accept an arbitrary delivery URL +from an operator request or browser. + +Use a stable `notificationId` while retrying the same logical alert. Farcaster +combines FID and notification ID as a 24-hour idempotency key. Generate a new ID +only when intentionally issuing a new alert. Warpcast currently documents one +notification per 30 seconds and 100 per day per token; other clients may apply +their own limits. + +## 4. Classify the provider response exactly + +An HTTP 200 response has three primary token lists: + +- `successfulTokens`: record provider acceptance; +- `invalidTokens`: erase those tokens and require a new signed enable event; +- `rateLimitedTokens`: retain consent and retry after backoff. + +Validate that the response accounts for the token exactly once. Bound response +size and time, reject redirects, and distinguish transport failures from +application-authority verification failures. An authority outage should not +consume the entire outbound-delivery retry budget. + +Warpkeep uses six bounded delivery attempts with backoff and a 24-hour pending +lifetime. Invalid tokens are removed immediately. A provider-accepted but +unopened grant can be deliberately reissued no more than twice for the exact +request generation, after a five-minute quiet period. Reissue rotates the +intent, notification ID, and capability; ordinary status polling never sends a +new alert. + +## 5. Verify the launch separately + +When a player opens an alert, the host sets: + +```ts +sdk.context.location = { + type: 'notification', + notification: { + notificationId, + title, + body, + }, +} +``` + +Compare `notificationId` with server-side intent state. Do not authorize from +`context.user`: Farcaster's context documentation explicitly treats user and +client context as untrusted presentation data. + +Authenticate the request independently. Quick Auth returns a signed JWT whose +`sub` is the FID; validate it on the server for the registered domain. For +Warpkeep's admission acknowledgement, a normal browser session cannot replace +Quick Auth, and the client requests a fresh host token for the first attempt. +That is a Warpkeep security policy rather than a platform requirement. + +For sensitive workflows, bind the notification ID to an additional one-use +capability and the exact server-side workflow generation. Consume it once, +serialize acknowledgement against reissue, and perform the final authority +mutation with compare-and-swap. + +## Token-free diagnostics + +Useful diagnostics do not need notification secrets. Warpkeep exposes a +protected operator projection containing only bounded state such as: + +- system state: enabled or paused; +- subscription state and active count; +- workflow generation: pending request or admitted epoch; +- delivery state: queued, retrying, accepted, exhausted, or absent; +- grant state: created, provider accepted, or client acknowledged; +- attempt and verification-failure counts; +- coarse failure category and next retry time; +- provider-acceptance and client-acknowledgement timestamps. + +Do not log notification tokens, delivery URLs, one-use capabilities, +notification IDs, webhook envelopes, Quick Auth JWTs, provider bodies, profile +data, IP addresses, or administrator credentials. Keep detailed receipts in a +private audit record; public reports should use counts, lifecycle states, code +coordinates, and redacted timestamps only. + +### Failure map + +| Observation | Likely boundary | Safe response | +| --- | --- | --- | +| No signed enable event | Manifest, add flow, or client consent | Verify production manifest; ask the user to add/enable once | +| `not-subscribed` | No active server-side token | Do not send or admit; wait for a signed enable event | +| Invalid token | Consent changed or token expired | Erase it; never retry until a new enable event | +| Rate limited | Provider quota | Keep consent and back off | +| Domain/target mismatch | Configuration defect | Stop sends; correct the exact registered hostname | +| Provider accepted, alert not observed | Device/client delivery is unproven | Preserve state; use a bounded deliberate reissue, not polling | +| Alert opens as launcher/cast context | Not the intended notification launch | Refuse the protected action and ask the user to reopen the alert | +| Notification ID mismatch | Stale or different alert | Reject and keep authority unchanged | +| Quick Auth FID mismatch | Host account changed or stale identity | Reject, reacquire authentication, and never fall back to a cookie | +| Client acknowledged, authority unchanged | Request generation changed or CAS failed | Re-read application state; do not replay the old capability | + +## Canary checklist + +1. Validate the production manifest and exact webhook URL. +2. Start with outbound delivery paused. +3. Enable notifications in the real client. +4. Prove the signed enable event was verified and the token stored privately. +5. Disable notifications and prove the token is erased. +6. Re-enable and prove a new signed token state replaces the old one. +7. Queue one synthetic or owner-owned workflow generation. +8. Enable outbound delivery and inspect token-free provider classification. +9. Confirm visible delivery separately; this requires a real client observation. +10. Open the alert and prove matching notification context. +11. Prove server-validated same-FID Quick Auth and one-use consumption. +12. Commit the protected action only after re-reading the exact authority state. +13. Retry the capability and prove replay rejection. +14. Exercise invalid-token, rate-limit, timeout, stale-context, and changed-state + fixtures locally. +15. Pause delivery again and prove signed opt-out still works. + +## Warpkeep source map + +- Manifest: [`public/.well-known/farcaster.json`](../public/.well-known/farcaster.json) +- Signed webhook verification: [`services/auth-bridge/src/miniAppWebhook.ts`](../services/auth-bridge/src/miniAppWebhook.ts) +- Private delivery state, retries, grants, and diagnostics: [`services/auth-bridge/src/admissionNotifications.ts`](../services/auth-bridge/src/admissionNotifications.ts) +- Browser host/context projection: [`src/farcaster/miniapp/miniAppRuntime.ts`](../src/farcaster/miniapp/miniAppRuntime.ts) +- Fresh identity-bound acknowledgement: [`src/farcaster/useAdmissionGrantAcknowledgement.ts`](../src/farcaster/useAdmissionGrantAcknowledgement.ts) +- Operator queue, inspection, reissue, and admission sequence: [`scripts/hermes-admin.ts`](../scripts/hermes-admin.ts) +- Request compare-and-swap authority: [`spacetimedb/src/reducers/accessRequests.ts`](../spacetimedb/src/reducers/accessRequests.ts) +- Delivery lifecycle tests: [`services/auth-bridge/test/admissionNotifications.test.ts`](../services/auth-bridge/test/admissionNotifications.test.ts) +- Webhook verification tests: [`services/auth-bridge/test/miniAppWebhook.test.ts`](../services/auth-bridge/test/miniAppWebhook.test.ts) + +## Official references + +- [Sending Notifications](https://miniapps.farcaster.xyz/docs/guides/notifications) +- [Mini Apps specification](https://miniapps.farcaster.xyz/docs/specification) +- [Notification launch context](https://miniapps.farcaster.xyz/docs/sdk/context) +- [Quick Auth](https://miniapps.farcaster.xyz/docs/sdk/quick-auth) +- [`getToken`](https://miniapps.farcaster.xyz/docs/sdk/quick-auth/get-token) +- [`addMiniApp`](https://miniapps.farcaster.xyz/docs/sdk/actions/add-miniapp) + +The public claim is deliberately narrow: Warpkeep stores consent from signed +webhooks, sends a one-use admission link, records provider acceptance, and on +launch binds the matching notification ID and capability to a server-verified +same-FID Quick Auth identity. This proves the admission protocol state. It does +not prove that an operating system displayed—or a person read—the alert. diff --git a/docs/operations/alpha-activation.md b/docs/operations/alpha-activation.md index 442a7828..b8bf5d66 100644 --- a/docs/operations/alpha-activation.md +++ b/docs/operations/alpha-activation.md @@ -261,12 +261,22 @@ a SpacetimeDB schema change. Roll them out in this order: literal value `true` in a reviewed frontend release; it does not enable the Worker or grant admission. 7. Give Hermes both isolated secrets through its private environment. For - `allow-fid` and confirmed `admit-founder`, Hermes must queue the exact pending - request generation before requesting an administrator token. If the player - opted in, require Farcaster provider acceptance before mutating admission; - `queued` or `delivery-exhausted` aborts unchanged. `not-subscribed` is an - explicit audited fallback for a player without consent. Keep - `notify-admitted` only for idempotent already-live reconciliation. + `allow-fid` and confirmed `admit-founder`, inspect the exact pending request + using a short administrator session, disconnect it, then queue the matching + notification. Require both Farcaster provider acceptance and an authenticated + same-FID client acknowledgement carrying the exact notification launch ID + paired with the fragment ticket. Provider acceptance alone is not a client + open. Only then mint a fresh administrator token, + re-run the request and aggregate preconditions, and call the matching + request-CAS reducer. `queued`, `not-subscribed`, `delivery-exhausted`, legacy + receipts, expiry, or a changed request abort unchanged. Keep `notify-admitted` + only for idempotent already-live reconciliation. + +The context-bound grant endpoint is an additive route. Deploy and attest the +Worker containing `/v2/access/admission-grant-context` before publishing the +matching frontend, and prove the discarded `/v2/access/admission-grant` +candidate path remains unavailable. Never weaken exact body validation to make +a mixed rollout appear successful. ### Owner canary and end-to-end acceptance @@ -295,24 +305,27 @@ it. setup-requested state. The access request timestamp and state must not change. 5. Confirm one new signed subscription pair through the same fixed events, then - admit the account through the existing reviewed Hermes dry-run, mutation, - and postflight sequence. Require the operator receipt to show provider - acceptance for the exact pending-request generation before the SpacetimeDB - mutation is submitted. Provider acceptance proves Farcaster handoff, not - device presentation or that the player opened the alert. + begin the reviewed Hermes admission. Require the operator to stop at + `awaiting-client`; provider acceptance proves only Farcaster handoff. Open the + exact alert in the same account and require the exact launch ID and ticket to + reach `client-acknowledged` before a new + administrator token is minted or the request-CAS mutation is submitted. 6. Require one approval notification for that request generation. Its target - must be exactly `https://warpkeep.com/?miniApp=true`. Tap it and verify the - calm confirmation state, fresh Quick Auth, current admission, current Terms - when required, and entry through the existing canonical keep. No - notification context may create a second keep or bypass Terms. + must be the exact production URL plus a `#warpkeep-grant-v1=` one-use + capability. Tap it and verify immediate fragment scrubbing, fresh Quick Auth, + the bounded finalization state, current Terms when required, and entry through + the existing canonical keep. No notification context may create a second keep + or bypass Terms. 7. Disable notifications or remove Warpkeep again, require the fixed unsubscribe events, and confirm Realm access remains unchanged. Repeat the complete acceptance on current Farcaster iOS and Android before declaring the client rollout complete. -The normal pending-request notification is `Admission approved` with -`The Hegemony is finalizing your Realm access. Your keep will open shortly.` The older -`The Hegemony admits you` payload remains only for already-live reconciliation. +Both the normal pending-request notification and the exceptional already-live +reconciliation use `Welcome to the Hegemony Empire` with `The gates have +answered your name. Cross the threshold, Founder—your legacy awaits.` Their +notification IDs and target URLs remain distinct so presentation copy cannot +erase the underlying lifecycle boundary. Both are bounded and privacy-safe. Any copy change requires a separate reviewed Worker rollout. diff --git a/docs/releases/farcaster-notification-playbook-v1.md b/docs/releases/farcaster-notification-playbook-v1.md new file mode 100644 index 00000000..1592bb15 --- /dev/null +++ b/docs/releases/farcaster-notification-playbook-v1.md @@ -0,0 +1,47 @@ +# Farcaster Mini App Notifications — Production Playbook + +Warpkeep has completed an owner-controlled Farcaster notification canary and is +publishing the implementation lessons as a reusable guide for other Mini App +developers. The visible canary used the legacy already-admitted reconciliation +path; it proved real transport, while the accompanying notification-first path +corrects the admission ordering for future requests. + +Warpkeep's alert now carries the Hegemony voice without exposing a world or +realm identifier: + +> **Welcome to the Hegemony Empire** +> +> The gates have answered your name. Cross the threshold, Founder—your legacy awaits. + +The transport success was observed in the real Farcaster client. The +notification-first admission repair then separates five facts that are often +accidentally treated as one: + +1. signed notification consent was recorded; +2. the Farcaster notification provider accepted the token handoff; +3. the Mini App was launched with the matching notification ID; +4. Quick Auth verified the same FID and domain; and +5. the application's authority layer committed the intended state transition. + +The guide includes the webhook lifecycle, safe token storage, provider response +handling, deduplication and rate limits, notification-context verification, +Quick Auth binding, one-use grants, retry controls, token-free diagnostics, a +failure map, and a production canary checklist. + +Read the full guide: + +- [Reliable Farcaster Mini App notifications](https://github.com/ael-dev3/Warpkeep/blob/farcaster-notifications-v1/docs/farcaster-notification-playbook.md) + +The accompanying source keeps provider acceptance separate from admission. A +visible alert was confirmed by the owner canary, but the release does not claim +that an API response can prove OS display or human reading. The protected action +still requires the exact launch context, same-FID authentication, and current +application state. + +Useful official references: + +- [Sending Notifications](https://miniapps.farcaster.xyz/docs/guides/notifications) +- [Notification launch context](https://miniapps.farcaster.xyz/docs/sdk/context) +- [Quick Auth](https://miniapps.farcaster.xyz/docs/sdk/quick-auth) + +This is a technical publication, not a new Warpkeep game version. diff --git a/docs/releases/versioning.md b/docs/releases/versioning.md index e74f4add..dad91d0b 100644 --- a/docs/releases/versioning.md +++ b/docs/releases/versioning.md @@ -26,5 +26,13 @@ and backend protocol versions are separate compatibility values. 4. Create a `vX.Y.Z` tag and GitHub Release. 5. Summarize player-facing changes in [`CHANGELOG.md`](../../CHANGELOG.md). +## Technical publications + +A reusable engineering guide may receive a descriptive tag such as +`farcaster-notifications-v1` and a separately linkable GitHub Release. These +publications do not change the client version, are not marked as the latest +product release, and must say plainly that they are technical documentation. +The latest `vX.Y.Z` release remains the canonical live game version. + Draft branches and pull requests describe work in progress. They are not part of the live Alpha until they are merged and deliberately deployed. diff --git a/package.json b/package.json index ba4cde2a..b36d5acc 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,8 @@ "stdb:expand-world-v3": "tsx scripts/hermes-admin.ts expand-world-v3", "stdb:admit-founder": "tsx scripts/hermes-admin.ts admit-founder", "stdb:notify-admitted": "tsx scripts/hermes-admin.ts notify-admitted", + "stdb:reissue-admission-notification": "tsx scripts/hermes-admin.ts reissue-admission-notification", + "stdb:inspect-admission-notification": "tsx scripts/hermes-admin.ts inspect-admission-notification", "stdb:allow-fid": "tsx scripts/hermes-admin.ts allow-fid", "stdb:disable-fid": "tsx scripts/hermes-admin.ts disable-fid", "stdb:bump-auth-epoch": "tsx scripts/hermes-admin.ts bump-auth-epoch", diff --git a/scripts/hermes-admin.ts b/scripts/hermes-admin.ts index ea7e3719..95a6169a 100644 --- a/scripts/hermes-admin.ts +++ b/scripts/hermes-admin.ts @@ -3,6 +3,11 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; +import type { + AdmissionNotificationDiagnostics, + AdmissionNotificationReissueResult, + AdmissionNotificationRetryReason, +} from '../services/auth-bridge/src/types'; import { DbConnection } from '../src/spacetime/module_bindings'; import { WARPKEEP_ENTRY_AGREEMENT_ACCEPTANCE_RECORDS_PER_FID_MAXIMUM, @@ -88,6 +93,8 @@ type Command = | 'reset-access-request' | 'admit-founder' | 'notify-admitted' + | 'reissue-admission-notification' + | 'inspect-admission-notification' | 'allow-fid' | 'disable-fid' | 'bump-auth-epoch' @@ -115,8 +122,43 @@ const CONNECT_TIMEOUT_MS = 30_000; const OPERATION_TIMEOUT_MS = 15_000; const MAX_ADMIN_TOKEN_RESPONSE_BYTES = 32 * 1_024; const ADMISSION_NOTIFICATION_PATH = 'v1/admin/admission-notification'; +const ADMISSION_NOTIFICATION_REISSUE_PATH = 'v1/admin/admission-notification-reissue'; const ADMISSION_NOTIFICATION_STATUS_PATH = 'v1/admin/admission-notification-status'; +const MAX_ADMISSION_NOTIFICATION_REISSUE_COOLDOWN_SECONDS = 5 * 60; +const ADMISSION_NOTIFICATION_DIAGNOSTIC_REQUIRED_KEYS = Object.freeze([ + 'version', + 'systemState', + 'subscriptionState', + 'status', + 'activeSubscriptionCount', + 'activeClientFids', + 'activeAttemptCount', + 'pendingAttemptCount', + 'retryingAttemptCount', + 'sentAttemptCount', + 'exhaustedAttemptCount', + 'deliveryAttemptCount', + 'verificationFailureCount', + 'grantState', + 'deliveryState', + 'retryReasons', +] as const); +const ADMISSION_NOTIFICATION_DIAGNOSTIC_OPTIONAL_KEYS = Object.freeze([ + 'generation', + 'authEpoch', + 'deliveryQueuedAt', + 'deliveryExpiresAt', + 'grantCreatedAt', + 'grantExpiresAt', + 'providerAcceptedAt', + 'clientAcknowledgedAt', + 'lastAttemptAt', + 'lastFailureReason', + 'nextAttemptAt', +] as const); const ADMISSION_NOTIFICATION_SETTLEMENT_WAIT_MILLISECONDS = 35_000; +const ADMISSION_GRANT_ACK_POLL_MILLISECONDS = 5_000; +const ADMISSION_GRANT_ACK_MAX_POLLS = 120; const ADMIN_TOKEN_CLOCK_SAFETY_MILLISECONDS = 20_000; const MAX_RESOURCE_BACKFILL_FOUNDERS = 100n; const GENESIS_GENERATION_V2_WORLD_CELLS = 1_261n; @@ -217,7 +259,8 @@ export const FOUNDER_ADMISSION_TARGET_CONFIGURATION_DIGEST = createHash('sha256' databaseName: LEGACY_DATABASE_ALIAS, databaseIdentity: DEFAULT_DATABASE_IDENTITY, bridgeUrl: DEFAULT_BRIDGE, - reducer: 'admin_admit_founder_v1', + statusProcedure: 'admin_get_access_request_admission_status_v1', + reducer: 'admin_admit_founder_for_access_request_v2', }), 'utf8') .digest('hex'); export const ACCESS_REQUEST_RESET_TARGET_CONFIGURATION_DIGEST = createHash('sha256') @@ -383,6 +426,8 @@ function commandFrom(value: string | undefined): Command { || value === 'reset-access-request' || value === 'admit-founder' || value === 'notify-admitted' + || value === 'reissue-admission-notification' + || value === 'inspect-admission-notification' || value === 'allow-fid' || value === 'disable-fid' || value === 'bump-auth-epoch' @@ -403,7 +448,7 @@ function commandFrom(value: string | undefined): Command { } fail( 'Usage: hermes-admin.ts ' - + ' ' + + ' ' + '[...args] [--dry-run] [--confirm]. admit-founder requires private stdin: ' + '--input-stdin --dry-run creates a reviewed plan; --input-stdin --confirm consumes it; ' + 'allow-fid only re-enables an existing complete founder. list-access-requests accepts ' @@ -465,16 +510,20 @@ export function parseHermesArguments(arguments_: readonly string[] = process.arg || command === 'inspect-publish-pre-v12' || command === 'inspect-publish-post-v12' || command === 'list-access-requests' - || command === 'inspect-access-request-reset'; + || command === 'inspect-access-request-reset' + || command === 'inspect-admission-notification'; const expectedPositionals = command === 'reset-access-request' ? 3 : command === 'inspect-access-request-reset' ? 2 + : command === 'inspect-admission-notification' + ? 2 : command === 'allow-fid' || command === 'disable-fid' || command === 'bump-auth-epoch' ? 3 : command === 'notify-admitted' + || command === 'reissue-admission-notification' ? 2 : command === 'backfill-resources' || command === 'seed-alpha-component' ? 2 @@ -508,14 +557,21 @@ export function parseHermesArguments(arguments_: readonly string[] = process.arg if (flags.has('--dry-run') === flags.has('--confirm')) { fail('Profiled admission requires exactly one of --dry-run or --confirm.'); } - } else if (command === 'notify-admitted') { + } else if ( + command === 'notify-admitted' + || command === 'reissue-admission-notification' + ) { if ( flags.has('--input-stdin') || flags.has('--json') || flags.has('--dry-run') || !flags.has('--confirm') ) { - fail('Admission notification reconciliation requires exactly --confirm.'); + fail( + command === 'reissue-admission-notification' + ? 'Admission notification reissue requires exactly --confirm.' + : 'Admission notification reconciliation requires exactly --confirm.', + ); } } else if (command === 'reset-access-request') { if (flags.has('--json')) { @@ -726,6 +782,24 @@ type AccessRequestResetStatus = Readonly<{ requestedAtMicros: bigint | undefined; }>; +type AccessRequestAdmissionStatus = Readonly<{ + admissionState: 'missing' | 'enabled' | 'disabled'; + authEpoch: number; + requestState: 'not_requested' | 'pending' | 'resolved'; + requestCycle: bigint | undefined; + requestedAtMicros: bigint | undefined; +}>; + +type PendingAccessRequestAdmissionStatus< + State extends 'missing' | 'disabled' = 'missing' | 'disabled', +> = Readonly<{ + admissionState: State; + authEpoch: number; + requestState: 'pending'; + requestCycle: bigint; + requestedAtMicros: bigint; +}>; + function exactObjectKeys( value: Record, expected: readonly string[], @@ -991,6 +1065,121 @@ export function projectAccessRequestResetStatus(value: unknown): AccessRequestRe }); } +/** Strict private request-CAS projection used by notification-gated Hermes. */ +export function projectAccessRequestAdmissionStatus( + value: unknown, +): AccessRequestAdmissionStatus { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail('Access request admission status was invalid.'); + } + const status = value as Record; + exactObjectKeys( + status, + ACCESS_REQUEST_RESET_STATUS_KEYS, + 'Access request admission status returned unexpected fields.', + ); + if ( + status.admissionState !== 'missing' + && status.admissionState !== 'enabled' + && status.admissionState !== 'disabled' + ) { + fail('Access request admission status returned an invalid admission state.'); + } + if ( + typeof status.authEpoch !== 'number' + || !Number.isInteger(status.authEpoch) + || (status.admissionState === 'missing' + ? status.authEpoch !== 0 + : status.authEpoch < 1 || status.authEpoch > 0xffff_ffff) + ) { + fail('Access request admission status returned an invalid auth epoch.'); + } + const requestCycle = status.requestCycle === undefined + ? undefined + : requireU64( + status.requestCycle, + true, + 'Access request admission status returned an invalid request cycle.', + ); + const requestedAtMicros = status.requestedAtMicros === undefined + ? undefined + : requireU64( + status.requestedAtMicros, + false, + 'Access request admission status returned an invalid timestamp.', + ); + if ((requestCycle === undefined) !== (requestedAtMicros === undefined)) { + fail('Access request admission status returned an incomplete request tuple.'); + } + const maximumStoredRequestCycle = status.admissionState === 'disabled' + ? BigInt(status.authEpoch) + 1n + : BigInt(status.authEpoch); + if (requestCycle !== undefined && requestCycle > maximumStoredRequestCycle) { + fail('Access request admission status returned an impossible future request cycle.'); + } + if ( + status.requestState !== 'not_requested' + && status.requestState !== 'pending' + && status.requestState !== 'resolved' + ) { + fail('Access request admission status returned an invalid request state.'); + } + const currentCycle = status.admissionState === 'missing' + ? 0n + : status.admissionState === 'disabled' + ? BigInt(status.authEpoch) + 1n + : undefined; + const expectedState = requestCycle === undefined + ? 'not_requested' + : currentCycle !== undefined && requestCycle === currentCycle + ? 'pending' + : 'resolved'; + if (status.requestState !== expectedState) { + fail('Access request admission status returned an inconsistent request state.'); + } + return Object.freeze({ + admissionState: status.admissionState, + authEpoch: status.authEpoch, + requestState: status.requestState, + requestCycle, + requestedAtMicros, + }); +} + +export function requirePendingAdmissionRequest( + status: AccessRequestAdmissionStatus, + expectedAdmissionState: State, +): PendingAccessRequestAdmissionStatus { + if ( + status.admissionState !== expectedAdmissionState + || status.requestState !== 'pending' + || status.requestCycle === undefined + || status.requestedAtMicros === undefined + ) { + fail('Admission requires one exact pending access request of the expected kind.'); + } + return Object.freeze({ ...status }) as PendingAccessRequestAdmissionStatus; +} + +export function requireUnchangedPendingAdmissionRequest< + State extends 'missing' | 'disabled', +>( + before: PendingAccessRequestAdmissionStatus | AccessRequestAdmissionStatus, + after: AccessRequestAdmissionStatus, + expectedAdmissionState: State, +): PendingAccessRequestAdmissionStatus { + const exactBefore = requirePendingAdmissionRequest(before, expectedAdmissionState); + const exactAfter = requirePendingAdmissionRequest(after, expectedAdmissionState); + if ( + exactAfter.requestCycle !== exactBefore.requestCycle + || exactAfter.requestedAtMicros !== exactBefore.requestedAtMicros + || exactAfter.authEpoch !== exactBefore.authEpoch + ) { + fail('The exact pending access request changed before admission.'); + } + return exactAfter; +} + function accessRequestTimestamp(micros: bigint): string { if (micros > MAX_JAVASCRIPT_DATE_MICROS) { fail('Access request procedure returned an invalid timestamp.'); @@ -1358,14 +1547,17 @@ export function verifyFounderAdmissionResourcePostconditionV4( export function verifyFounderReenablePrecondition( world: GenesisExpansionStatusV3, resources: ResourceAggregateV4, - target: AccessRequestResetStatus, + target: AccessRequestAdmissionStatus, ): Readonly<{ world: GenesisExpansionStatusV3; resources: ResourceAggregateV4; - target: AccessRequestResetStatus; + target: AccessRequestAdmissionStatus; }> { verifyFounderAdmissionCheckpointV3(world, false); verifyExpectedResourceAggregateV4(resources, world.allowedFids); + if (target.admissionState === 'disabled' && target.authEpoch >= 0xffff_ffff) { + fail('Existing founder re-enable cannot rotate an exhausted auth epoch.'); + } if ( target.admissionState !== 'disabled' || target.requestState !== 'pending' @@ -1384,7 +1576,7 @@ export function verifyFounderReenablePrecondition( export function verifyFounderReenablePostcondition( world: GenesisExpansionStatusV3, resources: ResourceAggregateV4, - target: AccessRequestResetStatus, + target: AccessRequestAdmissionStatus, before: ReturnType, ): void { verifyFounderAdmissionCheckpointV3(world, false); @@ -1427,6 +1619,29 @@ export function verifyFounderReenablePostcondition( } } +export function verifyFounderAdmissionRequestPostcondition( + target: AccessRequestAdmissionStatus, + before: AccessRequestAdmissionStatus, +): void { + if ( + before.admissionState !== 'missing' + || before.authEpoch !== 0 + || before.requestState !== 'pending' + || before.requestCycle !== 0n + || before.requestedAtMicros === undefined + || target.admissionState !== 'enabled' + || target.authEpoch !== 1 + || target.requestState !== 'resolved' + || target.requestCycle !== before.requestCycle + || target.requestedAtMicros !== before.requestedAtMicros + ) { + fail( + 'Founder admission request postcondition failed. The mutation outcome may be ' + + 'indeterminate; perform a fresh bounded read-only inspection before any retry.', + ); + } +} + export function verifyGenesisExpansionPreconditionV3( status: GenesisExpansionStatusV3, ): GenesisExpansionStatusV3 { @@ -1638,9 +1853,261 @@ export async function requestAdminToken( export type AdmissionNotificationStatus = | 'queued' | 'already-sent' + | 'awaiting-client' + | 'client-acknowledged' | 'delivery-exhausted' | 'not-subscribed'; +function isAdmissionNotificationDiagnosticRecord( + value: unknown, +): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function hasExactAdmissionNotificationDiagnosticKeys( + value: Record, +): boolean { + const keys = Object.keys(value); + return ADMISSION_NOTIFICATION_DIAGNOSTIC_REQUIRED_KEYS.every(key => ( + Object.prototype.hasOwnProperty.call(value, key) + )) && keys.every(key => ( + ADMISSION_NOTIFICATION_DIAGNOSTIC_REQUIRED_KEYS.includes( + key as typeof ADMISSION_NOTIFICATION_DIAGNOSTIC_REQUIRED_KEYS[number], + ) + || ADMISSION_NOTIFICATION_DIAGNOSTIC_OPTIONAL_KEYS.includes( + key as typeof ADMISSION_NOTIFICATION_DIAGNOSTIC_OPTIONAL_KEYS[number], + ) + )); +} + +function isAdmissionNotificationDiagnosticTimestamp(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function isAdmissionNotificationDiagnosticAuthEpoch(value: unknown): value is number { + return typeof value === 'number' + && Number.isSafeInteger(value) + && value >= 1 + && value <= 0xffff_ffff; +} + +function isAdmissionNotificationClientFid(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; +} + +function isAdmissionNotificationRetryReason( + value: unknown, +): value is AdmissionNotificationRetryReason { + return value === 'admission-verification' + || value === 'request-verification' + || value === 'transport' + || value === 'transport-timeout' + || value === 'transport-fetch-rejected' + || value === 'upstream-status' + || value === 'upstream-redirect' + || value === 'upstream-client-status' + || value === 'upstream-server-status' + || value === 'invalid-response' + || value === 'response-content-type' + || value === 'response-size' + || value === 'response-body' + || value === 'response-json' + || value === 'response-schema' + || value === 'rate-limited' + || value === 'provider-domain-mismatch' + || value === 'provider-target-url-mismatch' + || value === 'provider-no-webhook-url' + || value === 'provider-invalid-token' + || value === 'provider-unknown'; +} + +/** + * Parse the bridge's exact token-free v2 operator projection. This intentionally + * reconstructs an allowlisted object instead of returning the transport body. + */ +export function projectAdmissionNotificationDiagnostics( + value: unknown, +): AdmissionNotificationDiagnostics { + if ( + !isAdmissionNotificationDiagnosticRecord(value) + || !hasExactAdmissionNotificationDiagnosticKeys(value) + || value.version !== 2 + || (value.systemState !== 'enabled' && value.systemState !== 'paused') + || (value.subscriptionState !== 'active' && value.subscriptionState !== 'absent') + || ( + value.status !== 'queued' + && value.status !== 'already-sent' + && value.status !== 'awaiting-client' + && value.status !== 'client-acknowledged' + && value.status !== 'delivery-exhausted' + && value.status !== 'not-subscribed' + ) + || ( + value.generation !== undefined + && value.generation !== 'admitted' + && value.generation !== 'pending-request' + ) + || (value.authEpoch !== undefined + && !isAdmissionNotificationDiagnosticAuthEpoch(value.authEpoch)) + || (value.generation === 'pending-request' && value.authEpoch !== undefined) + || (value.generation === 'admitted' + && !isAdmissionNotificationDiagnosticAuthEpoch(value.authEpoch)) + || (value.generation !== 'admitted' && value.authEpoch !== undefined) + || ![ + value.activeSubscriptionCount, + value.activeAttemptCount, + value.pendingAttemptCount, + value.retryingAttemptCount, + value.sentAttemptCount, + value.exhaustedAttemptCount, + value.deliveryAttemptCount, + value.verificationFailureCount, + ].every(candidate => ( + typeof candidate === 'number' + && Number.isSafeInteger(candidate) + && candidate >= 0 + )) + || !Array.isArray(value.activeClientFids) + || value.activeClientFids.some(candidate => !isAdmissionNotificationClientFid(candidate)) + || new Set(value.activeClientFids).size !== value.activeClientFids.length + || value.activeClientFids.some((candidate, index) => ( + index > 0 && candidate <= (value.activeClientFids as number[])[index - 1] + )) + || value.activeSubscriptionCount !== value.activeClientFids.length + || (value.subscriptionState === 'active') !== (value.activeSubscriptionCount > 0) + || value.activeAttemptCount !== (value.pendingAttemptCount as number) + + (value.retryingAttemptCount as number) + + (value.sentAttemptCount as number) + + (value.exhaustedAttemptCount as number) + || ( + value.grantState !== 'none' + && value.grantState !== 'created' + && value.grantState !== 'provider-accepted' + && value.grantState !== 'client-acknowledged' + ) + || ( + value.deliveryState !== 'idle' + && value.deliveryState !== 'pending' + && value.deliveryState !== 'retry-scheduled' + && value.deliveryState !== 'succeeded' + && value.deliveryState !== 'exhausted' + ) + || (value.deliveryState === 'idle' && value.activeAttemptCount !== 0) + || (value.deliveryState === 'pending' && ( + value.activeAttemptCount === 0 + || value.pendingAttemptCount === 0 + || value.retryingAttemptCount !== 0 + )) + || (value.deliveryState === 'retry-scheduled' + && value.retryingAttemptCount === 0) + || (value.deliveryState === 'succeeded' && ( + value.activeAttemptCount === 0 + || value.sentAttemptCount !== value.activeAttemptCount + )) + || (value.deliveryState === 'exhausted' && ( + value.activeAttemptCount === 0 + || value.exhaustedAttemptCount === 0 + || (value.sentAttemptCount as number) + (value.exhaustedAttemptCount as number) + !== value.activeAttemptCount + )) + || (value.deliveryQueuedAt !== undefined + && !isAdmissionNotificationDiagnosticTimestamp(value.deliveryQueuedAt)) + || (value.deliveryExpiresAt !== undefined + && !isAdmissionNotificationDiagnosticTimestamp(value.deliveryExpiresAt)) + || (value.deliveryQueuedAt === undefined) !== (value.deliveryExpiresAt === undefined) + || (value.deliveryState === 'idle') !== (value.deliveryQueuedAt === undefined) + || (value.deliveryQueuedAt !== undefined + && (value.deliveryExpiresAt as number) <= value.deliveryQueuedAt) + || (value.grantCreatedAt !== undefined + && !isAdmissionNotificationDiagnosticTimestamp(value.grantCreatedAt)) + || (value.grantExpiresAt !== undefined + && !isAdmissionNotificationDiagnosticTimestamp(value.grantExpiresAt)) + || (value.grantCreatedAt === undefined) !== (value.grantExpiresAt === undefined) + || (value.grantState === 'none') !== (value.grantCreatedAt === undefined) + || (value.grantCreatedAt !== undefined + && (value.grantExpiresAt as number) <= value.grantCreatedAt) + || (value.providerAcceptedAt !== undefined + && !isAdmissionNotificationDiagnosticTimestamp(value.providerAcceptedAt)) + || (value.clientAcknowledgedAt !== undefined + && !isAdmissionNotificationDiagnosticTimestamp(value.clientAcknowledgedAt)) + || (value.grantState === 'created' && value.providerAcceptedAt !== undefined) + || ( + value.grantState === 'provider-accepted' + && (value.providerAcceptedAt === undefined || value.clientAcknowledgedAt !== undefined) + ) + || ( + value.grantState === 'client-acknowledged' + && (value.providerAcceptedAt === undefined || value.clientAcknowledgedAt === undefined) + ) + || (value.grantState === 'none' && ( + value.providerAcceptedAt !== undefined || value.clientAcknowledgedAt !== undefined + )) + || (value.providerAcceptedAt !== undefined && ( + value.grantCreatedAt === undefined + || value.providerAcceptedAt < value.grantCreatedAt + || value.providerAcceptedAt >= (value.grantExpiresAt as number) + )) + || (value.clientAcknowledgedAt !== undefined && ( + value.providerAcceptedAt === undefined + || value.clientAcknowledgedAt < value.providerAcceptedAt + || value.clientAcknowledgedAt >= (value.grantExpiresAt as number) + )) + || !Array.isArray(value.retryReasons) + || value.retryReasons.some(reason => !isAdmissionNotificationRetryReason(reason)) + || new Set(value.retryReasons).size !== value.retryReasons.length + || (value.lastAttemptAt !== undefined + && !isAdmissionNotificationDiagnosticTimestamp(value.lastAttemptAt)) + || (value.lastFailureReason !== undefined + && !isAdmissionNotificationRetryReason(value.lastFailureReason)) + || (value.nextAttemptAt !== undefined + && !isAdmissionNotificationDiagnosticTimestamp(value.nextAttemptAt)) + ) { + fail('The Warpkeep admission notification bridge returned invalid diagnostics.'); + } + + return Object.freeze({ + version: 2, + systemState: value.systemState, + subscriptionState: value.subscriptionState, + status: value.status, + ...(value.generation === undefined ? {} : { generation: value.generation }), + ...(value.authEpoch === undefined ? {} : { authEpoch: value.authEpoch }), + activeSubscriptionCount: value.activeSubscriptionCount as number, + activeClientFids: Object.freeze([...value.activeClientFids] as number[]), + activeAttemptCount: value.activeAttemptCount as number, + pendingAttemptCount: value.pendingAttemptCount as number, + retryingAttemptCount: value.retryingAttemptCount as number, + sentAttemptCount: value.sentAttemptCount as number, + exhaustedAttemptCount: value.exhaustedAttemptCount as number, + deliveryAttemptCount: value.deliveryAttemptCount as number, + verificationFailureCount: value.verificationFailureCount as number, + ...(value.deliveryQueuedAt === undefined ? {} : { + deliveryQueuedAt: value.deliveryQueuedAt, + deliveryExpiresAt: value.deliveryExpiresAt as number, + }), + grantState: value.grantState, + ...(value.grantCreatedAt === undefined ? {} : { + grantCreatedAt: value.grantCreatedAt, + grantExpiresAt: value.grantExpiresAt as number, + }), + ...(value.providerAcceptedAt === undefined + ? {} + : { providerAcceptedAt: value.providerAcceptedAt }), + ...(value.clientAcknowledgedAt === undefined + ? {} + : { clientAcknowledgedAt: value.clientAcknowledgedAt }), + deliveryState: value.deliveryState, + retryReasons: Object.freeze([ + ...value.retryReasons, + ] as AdmissionNotificationRetryReason[]), + ...(value.lastAttemptAt === undefined ? {} : { lastAttemptAt: value.lastAttemptAt }), + ...(value.lastFailureReason === undefined + ? {} + : { lastFailureReason: value.lastFailureReason }), + ...(value.nextAttemptAt === undefined ? {} : { nextAttemptAt: value.nextAttemptAt }), + }); +} + export function readNotificationOperatorSecret(value: string | undefined): string { const bytes = new TextEncoder().encode(value ?? ''); try { @@ -1696,6 +2163,8 @@ export async function requestAdmissionNotification( if ( status !== 'queued' && status !== 'already-sent' + && status !== 'awaiting-client' + && status !== 'client-acknowledged' && status !== 'delivery-exhausted' && status !== 'not-subscribed' ) { @@ -1704,12 +2173,108 @@ export async function requestAdmissionNotification( return status; } +/** + * Ask the bridge to replace one provider-accepted, unacknowledged pending + * request grant. The bridge owns cooldowns, caps, exact request revalidation, + * and serialized invalidation; Hermes projects only the allowlisted outcome. + */ +export async function requestAdmissionNotificationReissue( + bridgeUrl: string, + fid: bigint, + secret: string, + fetchImpl: typeof fetch = fetch, +): Promise { + if (fid < 1n || fid > BigInt(Number.MAX_SAFE_INTEGER)) { + fail('A positive, JavaScript-safe decimal FID is required.'); + } + readNotificationOperatorSecret(secret); + let response: Response; + try { + response = await fetchImpl(new URL(ADMISSION_NOTIFICATION_REISSUE_PATH, `${bridgeUrl}/`), { + method: 'POST', + headers: { + authorization: `Bearer ${secret}`, + accept: 'application/json', + 'content-type': 'application/json', + 'cache-control': 'no-store', + }, + body: JSON.stringify({ fid: fid.toString() }), + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(10_000), + }); + } catch { + fail('Could not reach the Warpkeep admission notification reissue bridge.'); + } + if (!response.ok) { + fail('The Warpkeep admission notification reissue bridge rejected the request.'); + } + const body = await readBoundedAdminResponse(response); + if (!body || typeof body !== 'object' || Array.isArray(body)) { + fail('The Warpkeep admission notification reissue bridge returned an invalid response.'); + } + const keys = Object.keys(body); + const status = (body as { status?: unknown }).status; + if (status === 'reissued') { + const deliveryStatus = (body as { deliveryStatus?: unknown }).deliveryStatus; + if ( + keys.length !== 2 + || !Object.prototype.hasOwnProperty.call(body, 'status') + || !Object.prototype.hasOwnProperty.call(body, 'deliveryStatus') + || ( + deliveryStatus !== 'queued' + && deliveryStatus !== 'already-sent' + && deliveryStatus !== 'awaiting-client' + && deliveryStatus !== 'client-acknowledged' + && deliveryStatus !== 'delivery-exhausted' + && deliveryStatus !== 'not-subscribed' + ) + ) { + fail('The Warpkeep admission notification reissue bridge returned an invalid response.'); + } + return Object.freeze({ status, deliveryStatus }); + } + if (status === 'cooldown') { + const retryAfterSeconds = (body as { retryAfterSeconds?: unknown }).retryAfterSeconds; + if ( + keys.length !== 2 + || !Object.prototype.hasOwnProperty.call(body, 'status') + || !Object.prototype.hasOwnProperty.call(body, 'retryAfterSeconds') + || typeof retryAfterSeconds !== 'number' + || !Number.isSafeInteger(retryAfterSeconds) + || retryAfterSeconds < 1 + || retryAfterSeconds > MAX_ADMISSION_NOTIFICATION_REISSUE_COOLDOWN_SECONDS + ) { + fail('The Warpkeep admission notification reissue bridge returned an invalid response.'); + } + return Object.freeze({ status, retryAfterSeconds }); + } + if ( + keys.length === 1 + && Object.prototype.hasOwnProperty.call(body, 'status') + && ( + status === 'limit-reached' + || status === 'client-acknowledged' + || status === 'not-ready' + || status === 'not-subscribed' + || status === 'stale' + || status === 'paused' + ) + ) { + return Object.freeze({ status }); + } + fail('The Warpkeep admission notification reissue bridge returned an invalid response.'); +} + export async function inspectAdmissionNotification( bridgeUrl: string, fid: bigint, secret: string, fetchImpl: typeof fetch = fetch, -): Promise { +): Promise { + if (fid < 1n || fid > BigInt(Number.MAX_SAFE_INTEGER)) { + fail('A positive, JavaScript-safe decimal FID is required.'); + } readNotificationOperatorSecret(secret); let response: Response; try { @@ -1731,18 +2296,7 @@ export async function inspectAdmissionNotification( } if (!response.ok) fail('The Warpkeep admission notification bridge rejected inspection.'); const body = await readBoundedAdminResponse(response); - const status = body && typeof body === 'object' && !Array.isArray(body) - ? (body as { status?: unknown }).status - : undefined; - if ( - status !== 'queued' - && status !== 'already-sent' - && status !== 'delivery-exhausted' - && status !== 'not-subscribed' - ) { - fail('The Warpkeep admission notification bridge returned invalid diagnostics.'); - } - return status; + return projectAdmissionNotificationDiagnostics(body); } export async function requireNotificationBeforeAdmission( @@ -1761,6 +2315,17 @@ export async function requireNotificationBeforeAdmission( // exact pending request is still current after the wait. status = await requestAdmissionNotification(bridgeUrl, fid, secret, fetchImpl); } + for ( + let poll = 0; + status === 'awaiting-client' && poll < ADMISSION_GRANT_ACK_MAX_POLLS; + poll += 1 + ) { + await sleep(ADMISSION_GRANT_ACK_POLL_MILLISECONDS); + // The operator queue route re-resolves both admission and the exact + // pending request before returning the acknowledgement state. A generic + // Durable Object snapshot is never enough to unlock authority. + status = await requestAdmissionNotification(bridgeUrl, fid, secret, fetchImpl); + } if (status === 'queued') { fail( 'Farcaster has not accepted the pending admission notification. ' @@ -1773,10 +2338,31 @@ export async function requireNotificationBeforeAdmission( + 'Admission remains unchanged; reconcile notification consent before retrying.', ); } + if (status === 'not-subscribed') { + fail( + 'Farcaster notifications are not enabled for this identity. ' + + 'Admission remains unchanged until the player enables notifications.', + ); + } + if (status === 'already-sent') { + fail( + 'Only a legacy notification receipt is available. Admission remains unchanged; ' + + 'a fresh notification grant is required.', + ); + } + if (status === 'awaiting-client') { + fail( + 'The notification was accepted but has not been opened by the verified player. ' + + 'Admission remains unchanged.', + ); + } + if (status !== 'client-acknowledged') { + fail('The notification grant did not reach a safe admission state.'); + } console.log(JSON.stringify({ admissionNotification: status, - providerAcceptanceRequired: status !== 'not-subscribed', - providerAcceptedBeforeAdmission: status === 'already-sent', + providerAcceptedBeforeAdmission: true, + authenticatedNotificationOpenBeforeAdmission: true, })); return status; } @@ -1817,6 +2403,22 @@ export function requireCredentialedProductionTarget( } } +export function requireAdmissionNotificationInspectionProductionTarget( + bridgeUrl: string, +): void { + if (bridgeUrl !== DEFAULT_BRIDGE) { + fail('Admission notification inspection requires the canonical Warpkeep bridge.'); + } +} + +export function requireAdmissionNotificationReissueProductionTarget( + bridgeUrl: string, +): void { + if (bridgeUrl !== DEFAULT_BRIDGE) { + fail('Admission notification reissue requires the canonical Warpkeep bridge.'); + } +} + /** New founder admission may target only the attested immutable database identity. */ export function requireFounderAdmissionProductionTarget(database: string): void { if (database !== DEFAULT_DATABASE_IDENTITY) { @@ -1921,6 +2523,54 @@ export function connect( }); } +type NotificationGatedReconnectDependencies = Readonly<{ + waitForNotification?: typeof requireNotificationBeforeAdmission; + requestToken?: typeof requestAdminToken; + connectToDatabase?: typeof connect; +}>; + +/** + * Never retain a privileged Spacetime connection or expiring admin JWT while + * waiting for a player to open a Farcaster notification. The click gate uses + * only its separate notification operator credential; fresh database authority + * is minted after acknowledgement and immediately before the exact CAS read. + */ +export async function reconnectAfterAdmissionNotification( + input: Readonly<{ + connection: DbConnection; + bridgeUrl: string; + fid: bigint; + notificationOperatorSecret: string | undefined; + adminSecret: string; + uri: string; + database: string; + }>, + dependencies: NotificationGatedReconnectDependencies = {}, +): Promise { + disconnectSilently(input.connection); + await ( + dependencies.waitForNotification + ?? requireNotificationBeforeAdmission + )( + input.bridgeUrl, + input.fid, + input.notificationOperatorSecret, + ); + let freshToken = ''; + try { + freshToken = await ( + dependencies.requestToken + ?? requestAdminToken + )(input.bridgeUrl, input.adminSecret); + return await ( + dependencies.connectToDatabase + ?? connect + )(input.uri, input.database, freshToken); + } finally { + freshToken = ''; + } +} + export async function readStatus( connection: DbConnection, version: AlphaStatusVersion = 'v1', @@ -2063,7 +2713,37 @@ async function main() { } = parseHermesArguments(); const notificationOperatorSecret = process.env.WARPKEEP_NOTIFICATION_OPERATOR_SECRET; delete process.env.WARPKEEP_NOTIFICATION_OPERATOR_SECRET; - configureHermesMachineOutput(machineReadableInspection); + configureHermesMachineOutput( + machineReadableInspection || command === 'inspect-admission-notification', + ); + if (command === 'reissue-admission-notification') { + const bridgeUrl = readHttpsUrl( + process.env.WARPKEEP_AUTH_BRIDGE_URL, + 'WARPKEEP_AUTH_BRIDGE_URL', + ); + requireAdmissionNotificationReissueProductionTarget(bridgeUrl); + const result = await requestAdmissionNotificationReissue( + bridgeUrl, + readFid(positional[1]), + readNotificationOperatorSecret(notificationOperatorSecret), + ); + console.log(JSON.stringify({ admissionNotificationReissue: result })); + return; + } + if (command === 'inspect-admission-notification') { + const bridgeUrl = readHttpsUrl( + process.env.WARPKEEP_AUTH_BRIDGE_URL, + 'WARPKEEP_AUTH_BRIDGE_URL', + ); + requireAdmissionNotificationInspectionProductionTarget(bridgeUrl); + const diagnostics = await inspectAdmissionNotification( + bridgeUrl, + readFid(positional[1]), + readNotificationOperatorSecret(notificationOperatorSecret), + ); + console.log(JSON.stringify(diagnostics)); + return; + } // Durable data migrations and new founder admission always require a visible // command-line confirmation. // The legacy noninteractive switch remains available to older bounded @@ -2298,8 +2978,15 @@ async function main() { ? '1' : process.env.WARPKEEP_ADMIN_TOKEN_SECRET_STDIN, ); - const token = await requestAdminToken(bridgeUrl, secret); - const connection = await connect(uri, database, token); + let token = await requestAdminToken(bridgeUrl, secret); + let connection: DbConnection; + try { + connection = await connect(uri, database, token); + } finally { + // The SDK connection owns its authenticated transport after connect. Do + // not retain a second immutable admin JWT in the long-running operator. + token = ''; + } let founderAdmissionClaimed = false; let accessRequestResetClaimed = false; try { @@ -2546,41 +3233,73 @@ async function main() { && admissionPlan !== undefined && admissionPlanReference !== undefined ) { - const before = verifyFounderAdmissionPreconditionV3( + const initialTarget = requirePendingAdmissionRequest( + projectAccessRequestAdmissionStatus( + await withOperationTimeout( + connection.procedures.adminGetAccessRequestAdmissionStatusV1({ fid }), + ), + ), + 'missing', + ); + const initialWorld = verifyFounderAdmissionPreconditionV3( await readStatus(connection, 'v3') as GenesisExpansionStatusV3, ); - const beforeResources = verifyFounderAdmissionResourcePreconditionV4( + verifyFounderAdmissionResourcePreconditionV4( await readStatus(connection, 'v4') as ResourceAggregateV4, - before.allowedFids, - ); - const targetAuthEpoch = await withOperationTimeout( - connection.procedures.adminGetFidAuthEpoch({ fid }), + initialWorld.allowedFids, ); - if (targetAuthEpoch !== 0) { - fail('Profiled admission requires a founder FID that has not been admitted before.'); - } - // All local, credential, connection, plan, profile, capacity, and - // persistent graph checks have passed. Bind provider acceptance to the - // still-current request immediately before the one admission mutation. - await requireNotificationBeforeAdmission( + normalizeAdmissionReadyTrustedProfile(admissionProfile); + + connection = await reconnectAfterAdmissionNotification({ + connection, bridgeUrl, fid, notificationOperatorSecret, + adminSecret: secret, + uri, + database, + }); + const freshTarget = requireUnchangedPendingAdmissionRequest( + initialTarget, + projectAccessRequestAdmissionStatus( + await withOperationTimeout( + connection.procedures.adminGetAccessRequestAdmissionStatusV1({ fid }), + ), + ), + 'missing', ); + const before = verifyFounderAdmissionPreconditionV3( + await readStatus(connection, 'v3') as GenesisExpansionStatusV3, + ); + const beforeResources = verifyFounderAdmissionResourcePreconditionV4( + await readStatus(connection, 'v4') as ResourceAggregateV4, + before.allowedFids, + ); + const freshAdmissionProfile = normalizeAdmissionReadyTrustedProfile(admissionProfile); claimReviewedFounderAdmissionPlan({ plan: admissionPlan, sha256: admissionPlanReference.sha256, }); founderAdmissionClaimed = true; - await withOperationTimeout(connection.reducers.adminAdmitFounderV1({ + await withOperationTimeout(connection.reducers.adminAdmitFounderForAccessRequestV2({ fid, note, - canonicalUsername: admissionProfile.canonicalUsername, - displayName: admissionProfile.displayName, - pfpUrl: admissionProfile.pfpUrl, - publicBio: admissionProfile.publicBio, + expectedRequestCycle: freshTarget.requestCycle, + expectedRequestedAtMicros: freshTarget.requestedAtMicros, + canonicalUsername: freshAdmissionProfile.canonicalUsername, + displayName: freshAdmissionProfile.displayName, + pfpUrl: freshAdmissionProfile.pfpUrl, + publicBio: freshAdmissionProfile.publicBio, profilePolicyVersion: FARCASTER_PROFILE_POLICY_VERSION, })); + verifyFounderAdmissionRequestPostcondition( + projectAccessRequestAdmissionStatus( + await withOperationTimeout( + connection.procedures.adminGetAccessRequestAdmissionStatusV1({ fid }), + ), + ), + freshTarget, + ); verifyFounderAdmissionPostconditionV3( await readStatus(connection, 'v3') as GenesisExpansionStatusV3, before, @@ -2592,28 +3311,51 @@ async function main() { founderAdmissionClaimed = false; mutationStatusHandled = true; } else if (command === 'allow-fid' && fid !== undefined && note !== undefined) { - const beforeTarget = projectAccessRequestResetStatus( + const initialTarget = projectAccessRequestAdmissionStatus( await withOperationTimeout( - connection.procedures.adminGetAccessRequestResetStatusV1({ fid }), + connection.procedures.adminGetAccessRequestAdmissionStatusV1({ fid }), ), ); - const before = verifyFounderReenablePrecondition( + verifyFounderReenablePrecondition( await readStatus(connection, 'v3', false, undefined, false) as GenesisExpansionStatusV3, await readStatus(connection, 'v4', false, undefined, false) as ResourceAggregateV4, - beforeTarget, + initialTarget, ); - await requireNotificationBeforeAdmission( + connection = await reconnectAfterAdmissionNotification({ + connection, bridgeUrl, fid, notificationOperatorSecret, + adminSecret: secret, + uri, + database, + }); + const freshTarget = requireUnchangedPendingAdmissionRequest( + initialTarget, + projectAccessRequestAdmissionStatus( + await withOperationTimeout( + connection.procedures.adminGetAccessRequestAdmissionStatusV1({ fid }), + ), + ), + 'disabled', + ); + const before = verifyFounderReenablePrecondition( + await readStatus(connection, 'v3', false, undefined, false) as GenesisExpansionStatusV3, + await readStatus(connection, 'v4', false, undefined, false) as ResourceAggregateV4, + freshTarget, ); - await withOperationTimeout(connection.reducers.adminAllowFid({ fid, note })); + await withOperationTimeout(connection.reducers.adminAllowFidForAccessRequestV1({ + fid, + note, + expectedRequestCycle: freshTarget.requestCycle, + expectedRequestedAtMicros: freshTarget.requestedAtMicros, + })); verifyFounderReenablePostcondition( await readStatus(connection, 'v3', false, undefined, false) as GenesisExpansionStatusV3, await readStatus(connection, 'v4', false, undefined, false) as ResourceAggregateV4, - projectAccessRequestResetStatus( + projectAccessRequestAdmissionStatus( await withOperationTimeout( - connection.procedures.adminGetAccessRequestResetStatusV1({ fid }), + connection.procedures.adminGetAccessRequestAdmissionStatusV1({ fid }), ), ), before, diff --git a/scripts/verify-alpha-production.mjs b/scripts/verify-alpha-production.mjs index 274c6211..da585203 100644 --- a/scripts/verify-alpha-production.mjs +++ b/scripts/verify-alpha-production.mjs @@ -63,6 +63,7 @@ const AUTH_V2_QUICK_AUTH_PATH = '/v2/farcaster/quick-auth/exchange'; const AUTH_V2_ACCESS_REQUEST_PATHS = Object.freeze([ '/v2/access/status', '/v2/access/request', + '/v2/access/admission-grant-context', ]); const AUTH_V2_PAUSED_PATHS = new Set([ ...AUTH_V2_CREDENTIAL_PATHS.slice(0, 3), diff --git a/services/auth-bridge/README.md b/services/auth-bridge/README.md index 9529d23e..9c927d6e 100644 --- a/services/auth-bridge/README.md +++ b/services/auth-bridge/README.md @@ -33,13 +33,17 @@ future rollout step requires exact-head verification and recorded authority. | `POST` | `/v2/farcaster/quick-auth/exchange` | Verifies an exact-domain Mini App bearer and returns tokenless-pending or short-lived authorized access without a cookie. | | `POST` | `/v2/session/refresh` | Rotates the session reference and returns a fresh access token only for an authorized family. | | `POST` | `/v2/session/logout` | Revokes the server-side family and expires the cookie; fails closed if durable revocation cannot be confirmed. | +| `POST` | `/v2/access/status` | Caller-private, authenticated status for the verified FID's current access-request cycle. | +| `POST` | `/v2/access/request` | Submits one idempotent private access request for the verified FID. | +| `POST` | `/v2/access/admission-grant-context` | Acknowledges one provider-accepted admission intent after same-FID Quick Auth and an exact Farcaster notification-launch match; it never mutates admission. | | `POST` | `/v1/qa/challenge` | Server-only, zero-body 60-second challenge for the one registered read-only QA device. Disabled by default. | | `POST` | `/v1/qa/realm-snapshot` | Server-only proof exchange returning one bounded aggregate Realm attestation; the v1 path is a compatibility name. | | `POST` | `/v1/admin/token` | Server-only five-minute Hermes/admin JWT. | | `POST` | `/v1/admin/auth-epoch-probe` | Server-only, input-free structured resolver check. | | `POST` | `/v1/admin/config-attestation` | Server-only digest of security-relevant runtime configuration. | | `POST` | `/v1/farcaster/miniapp/webhook` | Verifies signed add/remove and notification enable/disable events; returns exact `200`. | -| `POST` | `/v1/admin/admission-notification` | Separate-secret Hermes hook; queues one alert for the exact pending request, or reconciles an already-live admission epoch. | +| `POST` | `/v1/admin/admission-notification` | Separate-secret Hermes hook; queues one alert for the exact pending request, reports client acknowledgement, or reconciles an already-live admission epoch. | +| `POST` | `/v1/admin/admission-notification-reissue` | Separate-secret, explicit operator fallback that rotates one unopened pending-request alert under a durable cooldown and per-request cap. | | `POST` | `/v1/admin/admission-notification-status` | Separate-secret, token-free delivery diagnostics for one exact FID. | The legacy public `/v1/farcaster/challenge` and `/v1/farcaster/exchange` routes @@ -55,6 +59,17 @@ accept only a completed zero-byte stream, validate every present secrets such as `channelToken`, custody fields, verification lists, and relay metadata. +Pending-request delivery is a two-party gate. Provider acceptance stores an +`awaiting-client` intent and sends an unguessable one-use capability in the URL +fragment. The browser scrubs the fragment before rendering and submits it only +with a verified same-FID session or Quick Auth bearer. Successful acknowledgement +stores a domain-separated digest instead of the raw ticket and yields +`client-acknowledged`; no bridge route grants admission. The reviewed Hermes +workflow then reconnects with fresh administrator authority, rechecks the exact +request tuple, and calls a request-CAS reducer. General administrator authority +is intentionally broader than this operator workflow, so operational review and +least-privilege credential handling remain part of the boundary. + The QA routes are a separate service boundary, not browser/player authentication. They reject every request carrying an `Origin`, emit no CORS headers, reject query parameters and unknown JSON fields, and remain unavailable @@ -386,20 +401,48 @@ requires two exact Hub origins in `MINIAPP_NOTIFICATION_HUB_URLS`, an exact managed `NOTIFICATION_OPERATOR_SECRET` that differs from the admin, session, and signing secrets. Raw notification tokens stay in one private per-FID object, are never returned to the browser or stored in SpacetimeDB, and expire -within 366 days. Signed opt-outs remain accepted while delivery is paused and -erase raw token material immediately. The deployed v1 consent record retains +within 366 days. Signed opt-ins and opt-outs remain accepted while delivery is +paused: opt-ins persist without sending, while opt-outs erase raw token +material immediately. The deployed v1 consent record retains its rollback-compatible shape; pending-request work and receipts use a separate -private v2 record. Each send rechecks either the exact current pending-request +private v2 record. Bounded reissue counts, the latest provider handoff, and +client acknowledgement live in a third token-free sidecar so the established +v2 and reviewed v3 record shapes remain readable by a rollback Worker. Each send rechecks either the exact current pending-request timestamp while admission is disabled, or the exact current live admission epoch. Stable notification IDs, retry ceilings, replay tombstones, and bounded -generation receipts make retries idempotent. -The operator-only status projection contains only queue state, generation kind, -aggregate attempt counts, static retry categories, and bounded retry timing. It -never returns a request timestamp, notification token, delivery URL, webhook -payload, or provider response. Delivery parsing accepts Farcaster's optional additive +generation receipts make retries idempotent. Farcaster provider acceptance is +only transport handoff. The browser must present both the private fragment +ticket and the exact immutable notification launch ID under fresh same-FID +Quick Auth; the Durable Object compares both before recording a client +acknowledgement. A reused WebView with stale launch context fails closed and +asks the player to reopen the exact alert. +The operator-only versioned status projection contains only the notification +kill-switch state, subscription and queue state, generation kind, grant and +delivery lifecycle, aggregate attempt counts, static retry categories, and +bounded retry timing. It remains available while delivery is paused and never +returns a request timestamp, notification ID, notification token, grant ticket, +delivery URL, webhook payload, provider response, or free-form error. Delivery +parsing accepts Farcaster's optional additive `failedTokens` field, ignores harmless provider metadata, and still rejects invalid reasons, contradictory known outcome categories, and token mismatches. +If Farcaster accepted a pending-request alert but the player could not open it, +an operator may run +`npm run stdb:reissue-admission-notification -- --confirm`. The separate +operator-secret route revalidates the exact disabled admission and pending +request timestamp, then atomically replaces the old one-use grant. It waits at +least five minutes between attempts and permits at most two reissues for one +request. Ordinary queue polling never resends a provider-accepted grant. Its +responses are limited to `reissued`, `cooldown`, `limit-reached`, +`client-acknowledged`, `not-ready`, `not-subscribed`, `stale`, or `paused` and +contain no ticket, notification ID, token, URL, or provider body. + +The context-bound acknowledgement uses the additive +`/v2/access/admission-grant-context` route; the earlier candidate path was +never a production contract. Deploy the Worker route before the matching +frontend. Existing production bundles do not call either grant route, and the +new client never falls back to a ticket-only body. + The production browser and Pages activation gate separately pin the exact bridge and issuer `https://auth.warpkeep.com`, audience `warpkeep-spacetimedb`, and the same Maincloud/database pair. Development remains explicitly configurable and is diff --git a/services/auth-bridge/src/admissionNotifications.ts b/services/auth-bridge/src/admissionNotifications.ts index dfbcd754..d945ac35 100644 --- a/services/auth-bridge/src/admissionNotifications.ts +++ b/services/auth-bridge/src/admissionNotifications.ts @@ -1,5 +1,5 @@ import { readBridgeConfig, type BridgeConfig } from './config' -import { signEs256Jwt } from './jwt' +import { randomId, signEs256Jwt } from './jwt' import { AUTH_EPOCH_RESOLVER_TIMEOUT_MILLISECONDS, SpacetimeHttpAuthEpochResolver, @@ -10,9 +10,12 @@ import { } from './spacetimeAccessRequestResolver' import type { AccessRequestResolver, + AdmissionNotificationAcknowledgementStatus, AdmissionNotificationGeneration, AdmissionNotificationQueueInput, AdmissionNotificationQueueStatus, + AdmissionNotificationReissueInput, + AdmissionNotificationReissueResult, AdmissionNotificationDiagnostics, AdmissionNotificationRetryReason, AdmissionNotificationStore, @@ -26,6 +29,8 @@ import type { const INTERNAL_ORIGIN = 'https://admission-notification.internal' const STATE_KEY = 'admission-notification-v1' const PENDING_STATE_RECORD = 'admission-notification-pending-v2' +const PENDING_GRANT_RECORD = 'admission-notification-grant-v3' +const PENDING_GRANT_REISSUE_RECORD = 'admission-notification-grant-reissue-v1' const DIAGNOSTICS_RECORD = 'admission-notification-diagnostics-v1' const STATE_VERSION = 1 const MAX_SUBSCRIPTIONS = 8 @@ -33,17 +38,18 @@ const MAX_SEEN_EVENTS = 32 const MAX_REVOKED_TOKEN_IDS = 32 const MAX_DELIVERY_ATTEMPTS = 6 const MAX_VERIFICATION_FAILURES = 64 +const MAX_PENDING_GRANT_REISSUES = 2 const DELIVERY_LIFETIME_MILLISECONDS = 24 * 60 * 60 * 1_000 +const PENDING_GRANT_REISSUE_COOLDOWN_MILLISECONDS = 5 * 60 * 1_000 const DELIVERY_TIMEOUT_MILLISECONDS = 15_000 const DELIVERY_RESPONSE_MAX_BYTES = 64 * 1_024 const MAX_NOTIFICATION_TOKEN_BYTES = 2 * 1_024 const SUBSCRIPTION_MAX_LIFETIME_MILLISECONDS = 366 * 24 * 60 * 60 * 1_000 const TARGET_URL = 'https://warpkeep.com/?miniApp=true' -const ADMITTED_NOTIFICATION_TITLE = 'The Hegemony admits you' -const ADMITTED_NOTIFICATION_BODY = 'Your keep awaits in Genesis 001. Enter the living Realm.' -const PENDING_NOTIFICATION_TITLE = 'Admission approved' -const PENDING_NOTIFICATION_BODY = - 'The Hegemony is finalizing your Realm access. Your keep will open shortly.' +const GRANT_TARGET_FRAGMENT = 'warpkeep-grant-v1' +const HEGEMONY_WELCOME_NOTIFICATION_TITLE = 'Welcome to the Hegemony Empire' +const HEGEMONY_WELCOME_NOTIFICATION_BODY = + 'The gates have answered your name. Cross the threshold, Founder—your legacy awaits.' const RETRY_DELAYS_MILLISECONDS = Object.freeze([ 30_000, 2 * 60_000, @@ -77,6 +83,8 @@ type DeliveryAttempt = Readonly<{ type PersistedNotificationDiagnostics = Readonly<{ generation: AdmissionNotificationGeneration retryReasons: readonly AdmissionNotificationRetryReason[] + deliveryAttemptCount: number + verificationFailureCount: number lastAttemptAt?: number lastFailureReason?: AdmissionNotificationRetryReason }> @@ -133,6 +141,41 @@ type PersistedPendingNotificationState = Readonly<{ }> }> +type PendingGrantIntentBase = Readonly<{ + version: 1 + fid: string + requestedAtMicros: number + intentId: string + createdAt: number + expiresAt: number +}> + +type PersistedPendingGrantIntent = PendingGrantIntentBase & ( + | Readonly<{ + ticket: string + ticketHash?: never + providerAcceptedAt?: number + acknowledgedAt?: never + }> + | Readonly<{ + ticket?: never + ticketHash: string + providerAcceptedAt: number + acknowledgedAt: number + }> +) + +type PersistedPendingGrantReissueState = Readonly<{ + version: 1 + fid: string + requestedAtMicros: number + initialGrantCreatedAt: number + reissueCount: number + lastReissuedAt?: number + providerAcceptedAt?: number + clientAcknowledgedAt?: number +}> + type NotificationDependencies = Readonly<{ fetchImpl?: typeof fetch now?: () => number @@ -231,6 +274,18 @@ function isTokenId(value: unknown): value is string { return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value) } +function isGrantIntentId(value: unknown): value is string { + return typeof value === 'string' && /^[A-Za-z0-9_-]{22}$/.test(value) +} + +function isGrantTicket(value: unknown): value is string { + return typeof value === 'string' && /^[A-Za-z0-9_-]{43}$/.test(value) +} + +function isGrantTicketHash(value: unknown): value is string { + return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value) +} + function isStoredDeliveryUrl(value: unknown): value is string { if (typeof value !== 'string' || value.length < 1 || value.length > 2_048) return false try { @@ -358,6 +413,8 @@ function readPersistedDiagnostics(value: unknown): PersistedNotificationDiagnost 'requestedAtMicros', 'lastAttemptAt', 'lastFailureReason', + 'deliveryAttemptCount', + 'verificationFailureCount', ], ) || !Array.isArray(value.retryReasons) @@ -365,6 +422,16 @@ function readPersistedDiagnostics(value: unknown): PersistedNotificationDiagnost || new Set(value.retryReasons).size !== value.retryReasons.length || (value.lastAttemptAt !== undefined && !isTimestamp(value.lastAttemptAt)) || (value.lastFailureReason !== undefined && !isRetryReason(value.lastFailureReason)) + || (value.deliveryAttemptCount !== undefined && ( + typeof value.deliveryAttemptCount !== 'number' + || !Number.isSafeInteger(value.deliveryAttemptCount) + || value.deliveryAttemptCount < 0 + )) + || (value.verificationFailureCount !== undefined && ( + typeof value.verificationFailureCount !== 'number' + || !Number.isSafeInteger(value.verificationFailureCount) + || value.verificationFailureCount < 0 + )) ) return null const generation = value.generation === undefined && isAuthEpoch(value.authEpoch) ? Object.freeze({ kind: 'admitted' as const, authEpoch: value.authEpoch }) @@ -384,6 +451,8 @@ function readPersistedDiagnostics(value: unknown): PersistedNotificationDiagnost return Object.freeze({ generation, retryReasons: Object.freeze([...value.retryReasons] as AdmissionNotificationRetryReason[]), + deliveryAttemptCount: value.deliveryAttemptCount ?? 0, + verificationFailureCount: value.verificationFailureCount ?? 0, ...(value.lastAttemptAt === undefined ? {} : { lastAttemptAt: value.lastAttemptAt }), ...(value.lastFailureReason === undefined ? {} @@ -547,6 +616,208 @@ function readPendingState(value: unknown): PersistedPendingNotificationState | n }) } +function readPendingGrantIntent(value: unknown): PersistedPendingGrantIntent | null { + if (value === undefined) return null + const hasRawTicket = isRecord(value) && Object.prototype.hasOwnProperty.call(value, 'ticket') + if ( + !isRecord(value) + || !exactKeys( + value, + [ + 'version', + 'fid', + 'requestedAtMicros', + 'intentId', + 'createdAt', + 'expiresAt', + ...(hasRawTicket ? ['ticket'] : ['ticketHash', 'providerAcceptedAt', 'acknowledgedAt']), + ], + hasRawTicket ? ['providerAcceptedAt'] : [], + ) + || value.version !== 1 + || !isSafeFid(value.fid) + || !isRequestedAtMicros(value.requestedAtMicros) + || !isGrantIntentId(value.intentId) + || (hasRawTicket ? !isGrantTicket(value.ticket) : !isGrantTicketHash(value.ticketHash)) + || !isTimestamp(value.createdAt) + || !isTimestamp(value.expiresAt) + || value.expiresAt <= value.createdAt + || value.expiresAt - value.createdAt !== DELIVERY_LIFETIME_MILLISECONDS + || (value.providerAcceptedAt !== undefined && ( + !isTimestamp(value.providerAcceptedAt) + || value.providerAcceptedAt < value.createdAt + || value.providerAcceptedAt >= value.expiresAt + )) + || (!hasRawTicket && ( + !isTimestamp(value.providerAcceptedAt) + || !isTimestamp(value.acknowledgedAt) + || value.acknowledgedAt < value.providerAcceptedAt + || value.acknowledgedAt >= value.expiresAt + )) + ) throw new Error('Invalid pending admission grant intent.') + return Object.freeze({ + version: 1, + fid: value.fid, + requestedAtMicros: value.requestedAtMicros, + intentId: value.intentId, + createdAt: value.createdAt, + expiresAt: value.expiresAt, + ...(hasRawTicket + ? { + ticket: value.ticket as string, + ...(value.providerAcceptedAt === undefined + ? {} + : { providerAcceptedAt: value.providerAcceptedAt as number }), + } + : { + ticketHash: value.ticketHash as string, + providerAcceptedAt: value.providerAcceptedAt as number, + acknowledgedAt: value.acknowledgedAt as number, + }), + }) +} + +function readPendingGrantReissueState( + value: unknown, +): PersistedPendingGrantReissueState | null { + if (value === undefined) return null + if ( + !isRecord(value) + || !exactKeys( + value, + [ + 'version', + 'fid', + 'requestedAtMicros', + 'initialGrantCreatedAt', + 'reissueCount', + ], + ['lastReissuedAt', 'providerAcceptedAt', 'clientAcknowledgedAt'], + ) + || value.version !== 1 + || !isSafeFid(value.fid) + || !isRequestedAtMicros(value.requestedAtMicros) + || !isTimestamp(value.initialGrantCreatedAt) + || typeof value.reissueCount !== 'number' + || !Number.isSafeInteger(value.reissueCount) + || value.reissueCount < 0 + || value.reissueCount > MAX_PENDING_GRANT_REISSUES + || (value.reissueCount === 0) !== (value.lastReissuedAt === undefined) + || (value.lastReissuedAt !== undefined && ( + !isTimestamp(value.lastReissuedAt) + || value.lastReissuedAt < value.initialGrantCreatedAt + )) + || (value.providerAcceptedAt !== undefined && ( + !isTimestamp(value.providerAcceptedAt) + || value.providerAcceptedAt < value.initialGrantCreatedAt + || ( + value.lastReissuedAt !== undefined + && value.providerAcceptedAt < value.lastReissuedAt + ) + )) + || (value.clientAcknowledgedAt !== undefined && ( + !isTimestamp(value.clientAcknowledgedAt) + || value.providerAcceptedAt === undefined + || value.clientAcknowledgedAt < value.providerAcceptedAt + )) + ) throw new Error('Invalid pending admission grant reissue state.') + return Object.freeze({ + version: 1, + fid: value.fid, + requestedAtMicros: value.requestedAtMicros, + initialGrantCreatedAt: value.initialGrantCreatedAt, + reissueCount: value.reissueCount, + ...(value.lastReissuedAt === undefined + ? {} + : { lastReissuedAt: value.lastReissuedAt }), + ...(value.providerAcceptedAt === undefined + ? {} + : { providerAcceptedAt: value.providerAcceptedAt }), + ...(value.clientAcknowledgedAt === undefined + ? {} + : { clientAcknowledgedAt: value.clientAcknowledgedAt }), + }) +} + +function grantMatchesGeneration( + grant: PersistedPendingGrantIntent | null, + generation: AdmissionNotificationGeneration, + fid?: string, +): grant is PersistedPendingGrantIntent { + return grant !== null + && generation.kind === 'pending-request' + && grant.requestedAtMicros === generation.requestedAtMicros + && (fid === undefined || grant.fid === fid) +} + +function reissueStateMatchesGeneration( + state: PersistedPendingGrantReissueState | null, + generation: AdmissionNotificationGeneration, + fid?: string, +): state is PersistedPendingGrantReissueState { + return state !== null + && generation.kind === 'pending-request' + && state.requestedAtMicros === generation.requestedAtMicros + && (fid === undefined || state.fid === fid) +} + +function createPendingGrantIntent( + fid: string, + requestedAtMicros: number, + now: number, +): PersistedPendingGrantIntent { + return Object.freeze({ + version: 1, + fid, + requestedAtMicros, + intentId: randomId(16), + ticket: randomId(32), + createdAt: now, + expiresAt: now + DELIVERY_LIFETIME_MILLISECONDS, + }) +} + +function createPendingGrantReissueState( + fid: string, + requestedAtMicros: number, + initialGrantCreatedAt: number, +): PersistedPendingGrantReissueState { + return Object.freeze({ + version: 1, + fid, + requestedAtMicros, + initialGrantCreatedAt, + reissueCount: 0, + }) +} + +async function grantTicketHash(ticket: string): Promise { + const bytes = new TextEncoder().encode(`warpkeep-grant-ticket-v1\0${ticket}`) + try { + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)) + return Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('') + } finally { + bytes.fill(0) + } +} + +async function timingSafeGrantTicketMatch( + candidate: string, + grantIntent: PersistedPendingGrantIntent, +): Promise { + const [actual, target] = await Promise.all([ + grantTicketHash(candidate), + typeof grantIntent.ticket === 'string' + ? grantTicketHash(grantIntent.ticket) + : Promise.resolve(grantIntent.ticketHash), + ]) + let difference = 0 + for (let index = 0; index < actual.length; index += 1) { + difference |= actual.charCodeAt(index) ^ target.charCodeAt(index) + } + return difference === 0 +} + async function readCombinedState( storage: DurableObjectState['storage'], ): Promise { @@ -624,7 +895,7 @@ async function objectName(fid: string): Promise { } } -function internalUrl(path: 'event' | 'queue' | 'status'): string { +function internalUrl(path: 'event' | 'queue' | 'reissue' | 'status' | 'ack'): string { return `${INTERNAL_ORIGIN}/${path}` } @@ -637,6 +908,8 @@ async function readQueueStatus(response: Response): Promise { + if (!response.ok) throw new Error('Admission notification store unavailable.') + const value: unknown = await response.json() + if (!isRecord(value)) { + throw new Error('Admission notification store returned invalid state.') + } + if (value.status === 'reissued') { + if ( + !exactKeys(value, ['status', 'deliveryStatus']) + || ( + value.deliveryStatus !== 'queued' + && value.deliveryStatus !== 'already-sent' + && value.deliveryStatus !== 'awaiting-client' + && value.deliveryStatus !== 'client-acknowledged' + && value.deliveryStatus !== 'delivery-exhausted' + && value.deliveryStatus !== 'not-subscribed' + ) + ) throw new Error('Admission notification store returned invalid state.') + return Object.freeze({ status: 'reissued', deliveryStatus: value.deliveryStatus }) + } + if (value.status === 'cooldown') { + if ( + !exactKeys(value, ['status', 'retryAfterSeconds']) + || typeof value.retryAfterSeconds !== 'number' + || !Number.isSafeInteger(value.retryAfterSeconds) + || value.retryAfterSeconds < 1 + || value.retryAfterSeconds > Math.ceil( + PENDING_GRANT_REISSUE_COOLDOWN_MILLISECONDS / 1_000, + ) + ) throw new Error('Admission notification store returned invalid state.') + return Object.freeze({ status: 'cooldown', retryAfterSeconds: value.retryAfterSeconds }) + } + if ( + exactKeys(value, ['status']) + && ( + value.status === 'limit-reached' + || value.status === 'client-acknowledged' + || value.status === 'not-ready' + || value.status === 'not-subscribed' + || value.status === 'stale' + || value.status === 'paused' + ) + ) return Object.freeze({ status: value.status }) + throw new Error('Admission notification store returned invalid state.') +} + async function readDiagnostics(response: Response): Promise { if (!response.ok) throw new Error('Admission notification store unavailable.') const value: unknown = await response.json() @@ -653,12 +972,46 @@ async function readDiagnostics(response: Response): Promise ( + typeof candidate === 'number' + && Number.isSafeInteger(candidate) + && candidate >= 0 + )) + || !Array.isArray(value.activeClientFids) + || value.activeClientFids.some(candidate => !isAppFid(candidate)) + || new Set(value.activeClientFids).size !== value.activeClientFids.length + || value.activeClientFids.some((candidate, index) => ( + index > 0 && candidate <= (value.activeClientFids as number[])[index - 1] + )) + || value.activeSubscriptionCount !== value.activeClientFids.length + || (value.subscriptionState === 'active') !== (value.activeSubscriptionCount > 0) + || value.activeAttemptCount !== (value.pendingAttemptCount as number) + + (value.retryingAttemptCount as number) + + (value.sentAttemptCount as number) + + (value.exhaustedAttemptCount as number) + || ( + value.grantState !== 'none' + && value.grantState !== 'created' + && value.grantState !== 'provider-accepted' + && value.grantState !== 'client-acknowledged' + ) + || ( + value.deliveryState !== 'idle' + && value.deliveryState !== 'pending' + && value.deliveryState !== 'retry-scheduled' + && value.deliveryState !== 'succeeded' + && value.deliveryState !== 'exhausted' + ) + || (value.deliveryState === 'idle' && value.activeAttemptCount !== 0) + || (value.deliveryState === 'pending' && ( + value.activeAttemptCount === 0 + || value.pendingAttemptCount === 0 + || value.retryingAttemptCount !== 0 + )) + || (value.deliveryState === 'retry-scheduled' + && value.retryingAttemptCount === 0) + || (value.deliveryState === 'succeeded' && ( + value.activeAttemptCount === 0 + || value.sentAttemptCount !== value.activeAttemptCount + )) + || (value.deliveryState === 'exhausted' && ( + value.activeAttemptCount === 0 + || value.exhaustedAttemptCount === 0 + || (value.sentAttemptCount as number) + (value.exhaustedAttemptCount as number) + !== value.activeAttemptCount + )) + || (value.deliveryQueuedAt !== undefined && !isTimestamp(value.deliveryQueuedAt)) + || (value.deliveryExpiresAt !== undefined && !isTimestamp(value.deliveryExpiresAt)) + || (value.deliveryQueuedAt === undefined) !== (value.deliveryExpiresAt === undefined) + || (value.deliveryState === 'idle') !== (value.deliveryQueuedAt === undefined) + || (value.deliveryQueuedAt !== undefined && value.deliveryExpiresAt! <= value.deliveryQueuedAt) + || (value.grantCreatedAt !== undefined && !isTimestamp(value.grantCreatedAt)) + || (value.grantExpiresAt !== undefined && !isTimestamp(value.grantExpiresAt)) + || (value.grantCreatedAt === undefined) !== (value.grantExpiresAt === undefined) + || (value.grantState === 'none') !== (value.grantCreatedAt === undefined) + || (value.grantCreatedAt !== undefined && value.grantExpiresAt! <= value.grantCreatedAt) + || (value.providerAcceptedAt !== undefined && !isTimestamp(value.providerAcceptedAt)) + || (value.clientAcknowledgedAt !== undefined && !isTimestamp(value.clientAcknowledgedAt)) + || (value.grantState === 'created' && value.providerAcceptedAt !== undefined) + || ( + value.grantState === 'provider-accepted' + && (value.providerAcceptedAt === undefined || value.clientAcknowledgedAt !== undefined) + ) + || ( + value.grantState === 'client-acknowledged' + && (value.providerAcceptedAt === undefined || value.clientAcknowledgedAt === undefined) + ) + || (value.grantState === 'none' && ( + value.providerAcceptedAt !== undefined || value.clientAcknowledgedAt !== undefined + )) + || (value.providerAcceptedAt !== undefined && ( + value.grantCreatedAt === undefined + || value.providerAcceptedAt < value.grantCreatedAt + || value.providerAcceptedAt >= value.grantExpiresAt! + )) + || (value.clientAcknowledgedAt !== undefined && ( + value.providerAcceptedAt === undefined + || value.clientAcknowledgedAt < value.providerAcceptedAt + || value.clientAcknowledgedAt >= value.grantExpiresAt! + )) || !Array.isArray(value.retryReasons) || value.retryReasons.some(reason => !isRetryReason(reason)) || new Set(value.retryReasons).size !== value.retryReasons.length @@ -686,11 +1125,37 @@ async function readDiagnostics(response: Response): Promise { + if (!response.ok) throw new Error('Admission notification store unavailable.') + const value: unknown = await response.json() + if ( + !isRecord(value) + || !exactKeys(value, ['status']) + || ( + value.status !== 'accepted' + && value.status !== 'not-ready' + && value.status !== 'stale' + && value.status !== 'context-mismatch' + ) + ) throw new Error('Admission notification store returned invalid state.') + return value.status +} + export class DurableObjectAdmissionNotificationStore implements AdmissionNotificationStore { constructor(private readonly namespace: DurableObjectNamespace) {} @@ -730,6 +1213,30 @@ export class DurableObjectAdmissionNotificationStore implements AdmissionNotific return readQueueStatus(response) } + async reissueAdmission( + input: AdmissionNotificationReissueInput, + ): Promise { + const response = await (await this.stub(input.fid)).fetch(internalUrl('reissue'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input), + }) + return readReissueStatus(response) + } + + async acknowledge( + fid: string, + ticket: string, + notificationId: string, + ): Promise { + const response = await (await this.stub(fid)).fetch(internalUrl('ack'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fid, ticket, notificationId }), + }) + return readAcknowledgementStatus(response) + } + async inspect(fid: string): Promise { const response = await (await this.stub(fid)).fetch(internalUrl('status'), { method: 'POST', @@ -794,6 +1301,14 @@ function validQueueInput(value: unknown): value is AdmissionNotificationQueueInp && isRequestedAtMicros(value.requestedAtMicros) } +function validReissueInput(value: unknown): value is AdmissionNotificationReissueInput { + return isRecord(value) + && exactKeys(value, ['fid', 'requestedAtMicros', 'reissuedAt']) + && isSafeFid(value.fid) + && isRequestedAtMicros(value.requestedAtMicros) + && isTimestamp(value.reissuedAt) +} + function withSeenEvent( state: PersistedNotificationState, eventId: string, @@ -973,6 +1488,8 @@ async function persistAndSchedule( storage: DurableObjectState['storage'], state: PersistedNotificationState, now: number, + grantIntent?: PersistedPendingGrantIntent | null, + reissueState?: PersistedPendingGrantReissueState, ): Promise { const legacyState = legacyStateForPersistence(state) const pendingState = pendingStateForPersistence(state) @@ -980,8 +1497,21 @@ async function persistAndSchedule( await transaction.put(STATE_KEY, legacyState) if (pendingState) await transaction.put(PENDING_STATE_RECORD, pendingState) else await transaction.delete(PENDING_STATE_RECORD) + if (grantIntent === null) await transaction.delete(PENDING_GRANT_RECORD) + else if (grantIntent !== undefined) await transaction.put(PENDING_GRANT_RECORD, grantIntent) + if (reissueState !== undefined) { + await transaction.put(PENDING_GRANT_REISSUE_RECORD, reissueState) + } }) - const alarmAt = nextAlarmAt(state, now) + const retainedGrant = grantIntent === undefined + ? readPendingGrantIntent(await storage.get(PENDING_GRANT_RECORD)) + : grantIntent + const stateAlarmAt = nextAlarmAt(state, now) + const alarmAt = retainedGrant && retainedGrant.expiresAt > now + ? stateAlarmAt === null + ? retainedGrant.expiresAt + : Math.min(stateAlarmAt, retainedGrant.expiresAt) + : stateAlarmAt if (alarmAt === null) await storage.deleteAlarm?.() else await storage.setAlarm(alarmAt) } @@ -1070,6 +1600,8 @@ async function recordDiagnostics( storage: DurableObjectState['storage'], generation: AdmissionNotificationGeneration, retryReasons: readonly AdmissionNotificationRetryReason[], + deliveryAttemptCount: number, + verificationFailureCount: number, lastAttemptAt?: number, lastFailureReason?: AdmissionNotificationRetryReason, ): Promise { @@ -1086,6 +1618,18 @@ async function recordDiagnostics( ? { authEpoch: generation.authEpoch } : { requestedAtMicros: generation.requestedAtMicros }), retryReasons: Object.freeze(Array.from(combined).sort()), + deliveryAttemptCount: Math.max( + deliveryAttemptCount, + existing && generationEquals(existing.generation, generation) + ? existing.deliveryAttemptCount + : 0, + ), + verificationFailureCount: Math.max( + verificationFailureCount, + existing && generationEquals(existing.generation, generation) + ? existing.verificationFailureCount + : 0, + ), ...(lastAttemptAt === undefined ? existing && generationEquals(existing.generation, generation) && existing.lastAttemptAt !== undefined @@ -1102,10 +1646,29 @@ async function recordDiagnostics( })) } -function notificationId(delivery: AdmissionDelivery): string { +function notificationId( + delivery: AdmissionDelivery, + grantIntent: PersistedPendingGrantIntent | null, +): string { return delivery.kind === 'admitted' ? `warpkeep-access-approved-v1-e${delivery.authEpoch}` - : `warpkeep-access-approved-v2-r${delivery.requestedAtMicros}` + : grantMatchesGeneration(grantIntent, delivery) + ? `warpkeep-access-grant-v3-i${grantIntent.intentId}` + : responseFailure('invalid-response') +} + +function targetUrl( + delivery: AdmissionDelivery, + grantIntent: PersistedPendingGrantIntent | null, +): string { + if (delivery.kind === 'admitted') return TARGET_URL + if ( + !grantMatchesGeneration(grantIntent, delivery) + || typeof grantIntent.ticket !== 'string' + ) { + return responseFailure('invalid-response') + } + return `${TARGET_URL}#${GRANT_TARGET_FRAGMENT}=${grantIntent.ticket}` } class NotificationResponseError extends Error { @@ -1258,6 +1821,7 @@ function deliveryResult(value: unknown, requestedToken: string): DeliveryOutcome async function sendOne( subscription: Subscription, delivery: AdmissionDelivery, + grantIntent: PersistedPendingGrantIntent | null, fetchImpl: typeof fetch, ): Promise { let response: Response @@ -1270,14 +1834,10 @@ async function sendOne( 'content-type': 'application/json', }, body: JSON.stringify({ - notificationId: notificationId(delivery), - title: delivery.kind === 'admitted' - ? ADMITTED_NOTIFICATION_TITLE - : PENDING_NOTIFICATION_TITLE, - body: delivery.kind === 'admitted' - ? ADMITTED_NOTIFICATION_BODY - : PENDING_NOTIFICATION_BODY, - targetUrl: TARGET_URL, + notificationId: notificationId(delivery, grantIntent), + title: HEGEMONY_WELCOME_NOTIFICATION_TITLE, + body: HEGEMONY_WELCOME_NOTIFICATION_BODY, + targetUrl: targetUrl(delivery, grantIntent), tokens: [subscription.token], }), cache: 'no-store', @@ -1409,16 +1969,30 @@ function exhaustedForGeneration( : state.lastExhaustedRequestAtMicros === generation.requestedAtMicros } -function queueStatus(state: PersistedNotificationState): AdmissionNotificationQueueStatus { - if (state.delivery && sentForGeneration(state, deliveryGeneration(state.delivery))) { +function queueStatus( + state: PersistedNotificationState, + grantIntent: PersistedPendingGrantIntent | null = null, +): AdmissionNotificationQueueStatus { + if (!state.delivery || state.delivery.kind === 'pending-request') { + if (grantIntent?.acknowledgedAt !== undefined) return 'client-acknowledged' + if (grantIntent?.providerAcceptedAt !== undefined) return 'awaiting-client' + } + if ( + state.delivery?.kind === 'admitted' + && sentForGeneration(state, deliveryGeneration(state.delivery)) + ) { return 'already-sent' } - if (state.delivery && exhaustedForGeneration(state, deliveryGeneration(state.delivery))) { + if ( + state.delivery?.kind === 'admitted' + && exhaustedForGeneration(state, deliveryGeneration(state.delivery)) + ) { return 'delivery-exhausted' } if (!state.delivery || state.subscriptions.length === 0) return 'not-subscribed' if ( - state.delivery.attempts.length > 0 + state.delivery?.kind === 'admitted' + && state.delivery.attempts.length > 0 && state.delivery.attempts.every(attempt => attempt.status === 'sent') ) return 'already-sent' if ( @@ -1430,15 +2004,48 @@ function queueStatus(state: PersistedNotificationState): AdmissionNotificationQu return 'queued' } +function pendingGenerationStatus( + state: PersistedNotificationState, + generation: Readonly<{ kind: 'pending-request'; requestedAtMicros: number }>, + grantIntent: PersistedPendingGrantIntent | null, + reissueState: PersistedPendingGrantReissueState, +): AdmissionNotificationQueueStatus { + if (reissueState.clientAcknowledgedAt !== undefined) return 'client-acknowledged' + if (grantMatchesGeneration(grantIntent, generation, state.fid)) { + if (grantIntent.acknowledgedAt !== undefined) return 'client-acknowledged' + if (grantIntent.providerAcceptedAt !== undefined) return 'awaiting-client' + if ( + state.delivery?.kind === 'pending-request' + && state.delivery.requestedAtMicros === generation.requestedAtMicros + ) return queueStatus(state, grantIntent) + } + return 'delivery-exhausted' +} + function diagnosticsForState( state: PersistedNotificationState | null, persistedDiagnostics: PersistedNotificationDiagnostics | null, + grantIntent: PersistedPendingGrantIntent | null, + reissueState: PersistedPendingGrantReissueState | null, + notificationsEnabled: boolean, ): AdmissionNotificationDiagnostics { if (!state) { return Object.freeze({ + version: 2, + systemState: notificationsEnabled ? 'enabled' : 'paused', + subscriptionState: 'absent', status: 'not-subscribed', + activeSubscriptionCount: 0, + activeClientFids: Object.freeze([]), + activeAttemptCount: 0, + pendingAttemptCount: 0, + retryingAttemptCount: 0, + sentAttemptCount: 0, + exhaustedAttemptCount: 0, deliveryAttemptCount: 0, verificationFailureCount: 0, + grantState: 'none', + deliveryState: 'idle', retryReasons: Object.freeze([]), }) } @@ -1446,9 +2053,27 @@ function diagnosticsForState( const attempts = delivery?.attempts ?? [] const generation = delivery ? deliveryGeneration(delivery) - : persistedDiagnostics?.generation + : grantIntent + ? Object.freeze({ + kind: 'pending-request' as const, + requestedAtMicros: grantIntent.requestedAtMicros, + }) + : reissueState + ? Object.freeze({ + kind: 'pending-request' as const, + requestedAtMicros: reissueState.requestedAtMicros, + }) + : persistedDiagnostics?.generation const status = delivery - ? queueStatus(state) + ? queueStatus(state, grantIntent) + : grantIntent?.acknowledgedAt !== undefined + ? 'client-acknowledged' + : reissueState?.clientAcknowledgedAt !== undefined + ? 'client-acknowledged' + : grantIntent?.providerAcceptedAt !== undefined + ? 'awaiting-client' + : reissueState !== null + ? 'delivery-exhausted' : generation === undefined ? 'not-subscribed' : sentForGeneration(state, generation) @@ -1465,15 +2090,69 @@ function diagnosticsForState( ? persistedDiagnostics : undefined const retryReasons = matchingDiagnostics?.retryReasons ?? Object.freeze([]) + const activeClientFids = Object.freeze( + state.subscriptions.map(subscription => subscription.appFid).sort((left, right) => left - right), + ) + const pendingAttemptCount = attempts.filter(attempt => attempt.status === 'pending').length + const retryingAttemptCount = attempts.filter(attempt => attempt.status === 'retrying').length + const sentAttemptCount = attempts.filter(attempt => attempt.status === 'sent').length + const exhaustedAttemptCount = attempts.filter(attempt => attempt.status === 'exhausted').length + const grantState = grantIntent?.acknowledgedAt !== undefined + ? 'client-acknowledged' as const + : grantIntent?.providerAcceptedAt !== undefined + ? 'provider-accepted' as const + : grantIntent + ? 'created' as const + : 'none' as const + const deliveryState = !delivery + ? 'idle' as const + : retryingAttemptCount > 0 + ? 'retry-scheduled' as const + : attempts.length > 0 && sentAttemptCount === attempts.length + ? 'succeeded' as const + : attempts.length > 0 + && sentAttemptCount + exhaustedAttemptCount === attempts.length + && exhaustedAttemptCount > 0 + ? 'exhausted' as const + : 'pending' as const return Object.freeze({ + version: 2, + systemState: notificationsEnabled ? 'enabled' : 'paused', + subscriptionState: state.subscriptions.length > 0 ? 'active' : 'absent', status, ...(generation === undefined ? {} : { generation: generation.kind }), ...(generation?.kind === 'admitted' ? { authEpoch: generation.authEpoch } : {}), - deliveryAttemptCount: attempts.reduce((sum, attempt) => sum + attempt.attempts, 0), - verificationFailureCount: attempts.reduce( - (sum, attempt) => sum + attempt.verificationFailures, - 0, + activeSubscriptionCount: state.subscriptions.length, + activeClientFids, + activeAttemptCount: attempts.length, + pendingAttemptCount, + retryingAttemptCount, + sentAttemptCount, + exhaustedAttemptCount, + deliveryAttemptCount: Math.max( + attempts.reduce((sum, attempt) => sum + attempt.attempts, 0), + matchingDiagnostics?.deliveryAttemptCount ?? 0, + ), + verificationFailureCount: Math.max( + attempts.reduce((sum, attempt) => sum + attempt.verificationFailures, 0), + matchingDiagnostics?.verificationFailureCount ?? 0, ), + ...(delivery === undefined ? {} : { + deliveryQueuedAt: delivery.queuedAt, + deliveryExpiresAt: delivery.expiresAt, + }), + grantState, + ...(grantIntent === null ? {} : { + grantCreatedAt: grantIntent.createdAt, + grantExpiresAt: grantIntent.expiresAt, + ...(grantIntent.providerAcceptedAt === undefined + ? {} + : { providerAcceptedAt: grantIntent.providerAcceptedAt }), + ...(grantIntent.acknowledgedAt === undefined + ? {} + : { clientAcknowledgedAt: grantIntent.acknowledgedAt }), + }), + deliveryState, retryReasons, ...(matchingDiagnostics?.lastAttemptAt === undefined ? {} @@ -1568,6 +2247,29 @@ export class AdmissionNotification { await persistAndSchedule(this.state.storage, next, now) return next } + let grantIntent = readPendingGrantIntent( + await this.state.storage.get(PENDING_GRANT_RECORD), + ) + let reissueState = readPendingGrantReissueState( + await this.state.storage.get(PENDING_GRANT_REISSUE_RECORD), + ) + if ( + delivery.kind === 'pending-request' + && !grantMatchesGeneration(grantIntent, delivery, state.fid) + ) { + const next = withNextRevision(Object.freeze({ ...pruned, delivery: undefined })) + await persistAndSchedule(this.state.storage, next, now, null) + return next + } + if ( + delivery.kind === 'pending-request' + && reissueState + && !reissueStateMatchesGeneration(reissueState, delivery, state.fid) + ) { + const next = withNextRevision(Object.freeze({ ...pruned, delivery: undefined })) + await persistAndSchedule(this.state.storage, next, now, null) + return next + } if (now >= delivery.expiresAt) { const exhausted = delivery.attempts.some(attempt => attempt.status === 'exhausted') const next = withNextRevision(Object.freeze({ @@ -1589,7 +2291,12 @@ export class AdmissionNotification { } : {}), })) - await persistAndSchedule(this.state.storage, next, now) + await persistAndSchedule( + this.state.storage, + next, + now, + delivery.kind === 'pending-request' ? null : undefined, + ) return next } @@ -1598,7 +2305,12 @@ export class AdmissionNotification { // remains revocable through signed disable webhooks, but no stale queue // can spring back to life when delivery is enabled again. const next = withNextRevision(Object.freeze({ ...pruned, delivery: undefined })) - await persistAndSchedule(this.state.storage, next, now) + await persistAndSchedule( + this.state.storage, + next, + now, + delivery.kind === 'pending-request' ? null : undefined, + ) return next } @@ -1609,6 +2321,7 @@ export class AdmissionNotification { let invalidatedGeneration = false let latestAttemptAt: number | undefined let latestFailureReason: AdmissionNotificationRetryReason | undefined + let outboundAttemptCount = 0 const attempts: DeliveryAttempt[] = [] const retryReasons: AdmissionNotificationRetryReason[] = [] const resolver = this.configuredAdmissionResolver ?? defaultAdmissionResolver(config) @@ -1666,12 +2379,40 @@ export class AdmissionNotification { ...afterAdmissionCheck, delivery: undefined, })) - await persistAndSchedule(this.state.storage, cancelled, now) + await persistAndSchedule( + this.state.storage, + cancelled, + now, + delivery.kind === 'pending-request' ? null : undefined, + ) return cancelled } - const outcome = await sendOne(subscription, delivery, this.fetchImpl) + const outcome = await sendOne(subscription, delivery, grantIntent, this.fetchImpl) + outboundAttemptCount += 1 latestAttemptAt = now if (outcome.result === 'successful') { + if ( + delivery.kind === 'pending-request' + && grantMatchesGeneration(grantIntent, delivery, state.fid) + && grantIntent.providerAcceptedAt === undefined + ) { + grantIntent = Object.freeze({ ...grantIntent, providerAcceptedAt: now }) + const currentReissueState = reissueStateMatchesGeneration( + reissueState, + delivery, + state.fid, + ) + ? reissueState + : createPendingGrantReissueState( + state.fid, + delivery.requestedAtMicros, + grantIntent.createdAt, + ) + reissueState = Object.freeze({ + ...currentReissueState, + providerAcceptedAt: now, + }) + } attempts.push(Object.freeze({ appFid: attempt.appFid, tokenId: attempt.tokenId, @@ -1711,20 +2452,15 @@ export class AdmissionNotification { ...nextBase, subscriptions: Object.freeze(subscriptions), delivery: Object.freeze({ ...delivery, attempts: Object.freeze(attempts) }), - ...(attempts.length > 0 && attempts.every(attempt => attempt.status === 'sent') - ? delivery.kind === 'admitted' - ? { - lastSentAuthEpoch: Math.max( - nextBase.lastSentAuthEpoch ?? 0, - delivery.authEpoch, - ), - } - : { - lastSentRequestAtMicros: Math.max( - nextBase.lastSentRequestAtMicros ?? 0, - delivery.requestedAtMicros, - ), - } + ...(delivery.kind === 'admitted' + && attempts.length > 0 + && attempts.every(attempt => attempt.status === 'sent') + ? { + lastSentAuthEpoch: Math.max( + nextBase.lastSentAuthEpoch ?? 0, + delivery.authEpoch, + ), + } : {}), ...(invalidatedGeneration || ( attempts.length > 0 @@ -1748,17 +2484,25 @@ export class AdmissionNotification { } : {}), })) - // Keep a token-free queued admission until its bounded expiry. This closes - // the legitimate race where Hermes commits admission just before the host's - // notification-enabled webhook reaches us; a later enable event can still - // attach its one delivery attempt. Explicit disable/remove events erase raw - // token material immediately in the event path below. - await persistAndSchedule(this.state.storage, next, now) + // Keep the bounded delivery while the client grant remains unacknowledged. + // Provider acceptance is only transport evidence; it never writes the + // legacy pending-request success receipt that older operator code treated + // as admission authorization. + await persistAndSchedule( + this.state.storage, + next, + now, + delivery.kind === 'pending-request' ? grantIntent : undefined, + delivery.kind === 'pending-request' ? reissueState ?? undefined : undefined, + ) try { await recordDiagnostics( this.state.storage, deliveryGeneration(delivery), retryReasons, + delivery.attempts.reduce((sum, attempt) => sum + attempt.attempts, 0) + + outboundAttemptCount, + attempts.reduce((sum, attempt) => sum + attempt.verificationFailures, 0), latestAttemptAt, latestFailureReason, ) @@ -1811,7 +2555,21 @@ export class AdmissionNotification { const diagnostics = readPersistedDiagnostics( await this.state.storage.get(DIAGNOSTICS_RECORD), ) - return new Response(JSON.stringify(diagnosticsForState(existing, diagnostics)), { + const grantIntent = readPendingGrantIntent( + await this.state.storage.get(PENDING_GRANT_RECORD), + ) + const reissueState = readPendingGrantReissueState( + await this.state.storage.get(PENDING_GRANT_REISSUE_RECORD), + ) + return new Response(JSON.stringify( + diagnosticsForState( + existing ? pruneSubscriptions(existing, config, this.currentTime()) : null, + diagnostics, + grantIntent, + reissueState, + config.approvalNotificationsEnabled, + ), + ), { status: 200, headers: { 'content-type': 'application/json; charset=utf-8', @@ -1820,14 +2578,373 @@ export class AdmissionNotification { }) } + if (url.pathname === '/ack') { + if ( + !isRecord(value) + || !exactKeys(value, ['fid', 'ticket', 'notificationId']) + || !isSafeFid(value.fid) + || !isGrantTicket(value.ticket) + || typeof value.notificationId !== 'string' + || !/^warpkeep-access-grant-v3-i[A-Za-z0-9_-]{22}$/.test( + value.notificationId, + ) + ) return new Response(null, { status: 400 }) + const neutral = (status: AdmissionNotificationAcknowledgementStatus) => ( + new Response(JSON.stringify({ status }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }) + ) + const now = this.currentTime() + const existing = await readCombinedState(this.state.storage) + const grantIntent = readPendingGrantIntent( + await this.state.storage.get(PENDING_GRANT_RECORD), + ) + if ( + !existing + || existing.fid !== value.fid + || !grantIntent + || grantIntent.fid !== value.fid + || !(await timingSafeGrantTicketMatch(value.ticket, grantIntent)) + ) return neutral('stale') + if ( + value.notificationId + !== `warpkeep-access-grant-v3-i${grantIntent.intentId}` + ) return neutral('context-mismatch') + if (!config.approvalNotificationsEnabled) { + const delivery = existing.delivery?.kind === 'pending-request' + && existing.delivery.requestedAtMicros === grantIntent.requestedAtMicros + ? undefined + : existing.delivery + const next = withNextRevision(Object.freeze({ ...existing, delivery })) + await persistAndSchedule(this.state.storage, next, now, null) + return neutral('stale') + } + if (grantIntent.expiresAt <= now) { + const delivery = existing.delivery?.kind === 'pending-request' + && existing.delivery.requestedAtMicros === grantIntent.requestedAtMicros + ? undefined + : existing.delivery + const next = withNextRevision(Object.freeze({ ...existing, delivery })) + await persistAndSchedule(this.state.storage, next, now, null) + return neutral('stale') + } + if (grantIntent.providerAcceptedAt === undefined) return neutral('not-ready') + + const invalidateGrant = async (): Promise => { + const delivery = existing.delivery?.kind === 'pending-request' + && existing.delivery.requestedAtMicros === grantIntent.requestedAtMicros + ? undefined + : existing.delivery + const next = withNextRevision(Object.freeze({ ...existing, delivery })) + await persistAndSchedule(this.state.storage, next, now, null) + return neutral('stale') + } + + const resolver = this.configuredAdmissionResolver ?? defaultAdmissionResolver(config) + const requestResolver = this.configuredAccessRequestResolver + ?? defaultAccessRequestResolver(config) + try { + const admission = await resolver.resolve(existing.fid) + if (admission.state === 'enabled') return invalidateGrant() + const request = await requestResolver.getStatus(existing.fid) + if ( + request.status !== 'requested' + || request.requestedAtMicros !== grantIntent.requestedAtMicros + ) return invalidateGrant() + } catch { + return neutral('not-ready') + } + + const latest = await readCombinedState(this.state.storage) + const latestGrant = readPendingGrantIntent( + await this.state.storage.get(PENDING_GRANT_RECORD), + ) + const latestReissueState = readPendingGrantReissueState( + await this.state.storage.get(PENDING_GRANT_REISSUE_RECORD), + ) + if ( + !latest + || latest.revision !== existing.revision + || !latestGrant + || latestGrant.intentId !== grantIntent.intentId + || latestGrant.providerAcceptedAt === undefined + || ( + latestReissueState !== null + && !reissueStateMatchesGeneration( + latestReissueState, + { + kind: 'pending-request', + requestedAtMicros: latestGrant.requestedAtMicros, + }, + latestGrant.fid, + ) + ) + || !(await timingSafeGrantTicketMatch(value.ticket, latestGrant)) + ) return neutral('stale') + if ( + value.notificationId + !== `warpkeep-access-grant-v3-i${latestGrant.intentId}` + ) return neutral('context-mismatch') + if (latestGrant.acknowledgedAt !== undefined) return neutral('accepted') + const delivery = latest.delivery?.kind === 'pending-request' + && latest.delivery.requestedAtMicros === latestGrant.requestedAtMicros + ? undefined + : latest.delivery + const next = withNextRevision(Object.freeze({ ...latest, delivery })) + const nextReissueState = Object.freeze({ + ...(latestReissueState ?? createPendingGrantReissueState( + latestGrant.fid, + latestGrant.requestedAtMicros, + latestGrant.createdAt, + )), + providerAcceptedAt: latestGrant.providerAcceptedAt, + clientAcknowledgedAt: now, + }) + await persistAndSchedule(this.state.storage, next, now, Object.freeze({ + version: 1 as const, + fid: latestGrant.fid, + requestedAtMicros: latestGrant.requestedAtMicros, + intentId: latestGrant.intentId, + createdAt: latestGrant.createdAt, + expiresAt: latestGrant.expiresAt, + providerAcceptedAt: latestGrant.providerAcceptedAt, + acknowledgedAt: now, + ticketHash: await grantTicketHash(value.ticket), + }), nextReissueState) + return neutral('accepted') + } + + if (url.pathname === '/reissue') { + if (!validReissueInput(value)) return new Response(null, { status: 400 }) + const respond = (result: AdmissionNotificationReissueResult): Response => ( + new Response(JSON.stringify(result), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }) + ) + if (!config.approvalNotificationsEnabled) { + return respond(Object.freeze({ status: 'paused' })) + } + const now = this.currentTime() + if (Math.abs(now - value.reissuedAt) > 60_000) return new Response(null, { status: 400 }) + let existing = await readCombinedState(this.state.storage) + let grantIntent = readPendingGrantIntent( + await this.state.storage.get(PENDING_GRANT_RECORD), + ) + let reissueState = readPendingGrantReissueState( + await this.state.storage.get(PENDING_GRANT_REISSUE_RECORD), + ) + if (!existing || existing.fid !== value.fid) { + return respond(Object.freeze({ status: 'stale' })) + } + + const invalidateCurrentGrant = async (): Promise => { + const delivery = existing!.delivery?.kind === 'pending-request' + && existing!.delivery.requestedAtMicros === value.requestedAtMicros + ? undefined + : existing!.delivery + existing = withNextRevision(Object.freeze({ ...existing!, delivery })) + await persistAndSchedule(this.state.storage, existing, now, null) + return Object.freeze({ status: 'stale' }) + } + + if ( + reissueState + && ( + reissueState.fid !== value.fid + || reissueState.requestedAtMicros !== value.requestedAtMicros + ) + ) { + return respond(Object.freeze({ status: 'stale' })) + } + + const generation = Object.freeze({ + kind: 'pending-request' as const, + requestedAtMicros: value.requestedAtMicros, + }) + if (!reissueState) { + if (!grantMatchesGeneration(grantIntent, generation, value.fid)) { + return respond(Object.freeze({ status: 'stale' })) + } + reissueState = Object.freeze({ + ...createPendingGrantReissueState( + value.fid, + value.requestedAtMicros, + grantIntent.createdAt, + ), + ...(grantIntent.providerAcceptedAt === undefined + ? {} + : { providerAcceptedAt: grantIntent.providerAcceptedAt }), + ...(grantIntent.acknowledgedAt === undefined + ? {} + : { clientAcknowledgedAt: grantIntent.acknowledgedAt }), + }) + existing = withNextRevision(existing) + await persistAndSchedule( + this.state.storage, + existing, + now, + grantIntent, + reissueState, + ) + } else if (grantMatchesGeneration(grantIntent, generation, value.fid)) { + const shouldRecordProvider = reissueState.providerAcceptedAt === undefined + && grantIntent.providerAcceptedAt !== undefined + const shouldRecordAcknowledgement = reissueState.clientAcknowledgedAt === undefined + && grantIntent.acknowledgedAt !== undefined + if (shouldRecordProvider || shouldRecordAcknowledgement) { + reissueState = Object.freeze({ + ...reissueState, + ...(shouldRecordProvider + ? { providerAcceptedAt: grantIntent.providerAcceptedAt } + : {}), + ...(shouldRecordAcknowledgement + ? { clientAcknowledgedAt: grantIntent.acknowledgedAt } + : {}), + }) + existing = withNextRevision(existing) + await persistAndSchedule( + this.state.storage, + existing, + now, + grantIntent, + reissueState, + ) + } + } + + if (reissueState.clientAcknowledgedAt !== undefined) { + return respond(Object.freeze({ status: 'client-acknowledged' })) + } + if (reissueState.providerAcceptedAt === undefined) { + return respond(Object.freeze({ status: 'not-ready' })) + } + + const pruned = pruneSubscriptions(existing, config, now) + if (pruned.subscriptions.length === 0) { + if (pruned !== existing) { + existing = withNextRevision(pruned) + await persistAndSchedule(this.state.storage, existing, now) + } + return respond(Object.freeze({ status: 'not-subscribed' })) + } + const expectedRevision = existing.revision + const expectedIntentId = grantMatchesGeneration(grantIntent, generation, value.fid) + ? grantIntent.intentId + : undefined + const expectedReissueCount = reissueState.reissueCount + const expectedLastReissuedAt = reissueState.lastReissuedAt + const expectedProviderAcceptedAt = reissueState.providerAcceptedAt + const resolver = this.configuredAdmissionResolver ?? defaultAdmissionResolver(config) + const requestResolver = this.configuredAccessRequestResolver + ?? defaultAccessRequestResolver(config) + try { + const admission = await resolver.resolve(value.fid) + if (admission.state !== 'disabled') return respond(await invalidateCurrentGrant()) + const requestStatus = await requestResolver.getStatus(value.fid) + if ( + requestStatus.status !== 'requested' + || requestStatus.requestedAtMicros !== value.requestedAtMicros + ) return respond(await invalidateCurrentGrant()) + } catch { + return new Response(null, { status: 503 }) + } + + const latest = await readCombinedState(this.state.storage) + const latestGrant = readPendingGrantIntent( + await this.state.storage.get(PENDING_GRANT_RECORD), + ) + const latestReissueState = readPendingGrantReissueState( + await this.state.storage.get(PENDING_GRANT_REISSUE_RECORD), + ) + if ( + !latest + || latest.revision !== expectedRevision + || !reissueStateMatchesGeneration(latestReissueState, generation, value.fid) + || latestReissueState.reissueCount !== expectedReissueCount + || latestReissueState.lastReissuedAt !== expectedLastReissuedAt + || latestReissueState.providerAcceptedAt !== expectedProviderAcceptedAt + || latestReissueState.clientAcknowledgedAt !== undefined + || ( + expectedIntentId === undefined + ? grantMatchesGeneration(latestGrant, generation, value.fid) + : !grantMatchesGeneration(latestGrant, generation, value.fid) + || latestGrant.intentId !== expectedIntentId + ) + ) return respond(Object.freeze({ status: 'stale' })) + existing = latest + grantIntent = latestGrant + reissueState = latestReissueState + if (reissueState.reissueCount >= MAX_PENDING_GRANT_REISSUES) { + return respond(Object.freeze({ status: 'limit-reached' })) + } + // A delayed retry can reach Farcaster well after the reissue was + // created. Start the next quiet window at the latest provider handoff, + // not merely at intent creation, so transport backoff cannot collapse + // two player-visible notifications together. + const cooldownStartedAt = reissueState.providerAcceptedAt! + const retryAt = cooldownStartedAt + PENDING_GRANT_REISSUE_COOLDOWN_MILLISECONDS + if (now < retryAt) { + return respond(Object.freeze({ + status: 'cooldown', + retryAfterSeconds: Math.ceil((retryAt - now) / 1_000), + })) + } + + const nextGrant = createPendingGrantIntent(value.fid, value.requestedAtMicros, now) + const { + providerAcceptedAt: _providerAcceptedAt, + clientAcknowledgedAt: _clientAcknowledgedAt, + ...retainedReissueState + } = reissueState + const nextReissueState = Object.freeze({ + ...retainedReissueState, + reissueCount: reissueState.reissueCount + 1, + lastReissuedAt: now, + }) + const delivery: AdmissionDelivery = Object.freeze({ + kind: 'pending-request', + requestedAtMicros: value.requestedAtMicros, + queuedAt: now, + expiresAt: nextGrant.expiresAt, + attempts: Object.freeze([]), + }) + let next = withNextRevision(Object.freeze({ + ...existing, + delivery: Object.freeze({ + ...delivery, + attempts: attemptsForSubscriptions(delivery, existing.subscriptions), + }), + })) + await persistAndSchedule( + this.state.storage, + next, + now, + nextGrant, + nextReissueState, + ) + next = await this.attemptDelivery(next) + const deliveredGrant = readPendingGrantIntent( + await this.state.storage.get(PENDING_GRANT_RECORD), + ) + return respond(Object.freeze({ + status: 'reissued', + deliveryStatus: queueStatus(next, deliveredGrant), + })) + } + if (url.pathname === '/event') { if (!validVerifiedEvent(value, config)) return new Response(null, { status: 400 }) // An add event without notification details records no consent and must // not cause the player FID or any host metadata to be persisted. if (value.event.type === 'observed') return new Response(null, { status: 204 }) - if (value.event.type === 'enabled' && !config.approvalNotificationsEnabled) { - return new Response(null, { status: 503 }) - } const now = this.currentTime() const existing = await readCombinedState(this.state.storage) if (existing && existing.fid !== value.fid) return new Response(null, { status: 409 }) @@ -1913,7 +3030,14 @@ export class AdmissionNotification { } next = withNextRevision(next) - await persistAndSchedule(this.state.storage, next, now) + await persistAndSchedule( + this.state.storage, + next, + now, + value.event.type === 'disabled' && next.subscriptions.length === 0 + ? null + : undefined, + ) if (value.event.type === 'enabled' && next.delivery) { await this.attemptDelivery(next) } @@ -1925,7 +3049,7 @@ export class AdmissionNotification { if (!config.approvalNotificationsEnabled) return new Response(null, { status: 503 }) const now = this.currentTime() if (Math.abs(now - value.queuedAt) > 60_000) return new Response(null, { status: 400 }) - const existing = await readCombinedState(this.state.storage) + let existing = await readCombinedState(this.state.storage) if (existing && existing.fid !== value.fid) return new Response(null, { status: 409 }) let next = existing ?? emptyState(value.fid, now) const generation: AdmissionNotificationGeneration = value.kind === 'pending-request' @@ -1934,13 +3058,22 @@ export class AdmissionNotification { requestedAtMicros: value.requestedAtMicros, }) : Object.freeze({ kind: 'admitted', authEpoch: value.authEpoch }) - if (sentForGeneration(next, generation)) { + let grantIntent = readPendingGrantIntent( + await this.state.storage.get(PENDING_GRANT_RECORD), + ) + let reissueState = readPendingGrantReissueState( + await this.state.storage.get(PENDING_GRANT_REISSUE_RECORD), + ) + if (generation.kind === 'admitted' && sentForGeneration(next, generation)) { return new Response(JSON.stringify({ status: 'already-sent' }), { status: 200, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, }) } - if (exhaustedForGeneration(next, generation)) { + if ( + generation.kind === 'admitted' + && exhaustedForGeneration(next, generation) + ) { return new Response(JSON.stringify({ status: 'delivery-exhausted' }), { status: 200, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, @@ -1960,12 +3093,173 @@ export class AdmissionNotification { ) { return new Response(null, { status: 409 }) } + const respond = (status: AdmissionNotificationQueueStatus): Response => ( + new Response(JSON.stringify({ status }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }) + ) + + let newGrant = false + if (generation.kind === 'pending-request') { + if ( + (reissueState && reissueState.requestedAtMicros > generation.requestedAtMicros) + || (grantIntent && grantIntent.requestedAtMicros > generation.requestedAtMicros) + ) return new Response(null, { status: 409 }) + + if (!reissueState && grantMatchesGeneration(grantIntent, generation, value.fid)) { + reissueState = Object.freeze({ + ...createPendingGrantReissueState( + value.fid, + generation.requestedAtMicros, + grantIntent.createdAt, + ), + ...(grantIntent.providerAcceptedAt === undefined + ? {} + : { providerAcceptedAt: grantIntent.providerAcceptedAt }), + ...(grantIntent.acknowledgedAt === undefined + ? {} + : { clientAcknowledgedAt: grantIntent.acknowledgedAt }), + }) + next = withNextRevision(next) + await persistAndSchedule( + this.state.storage, + next, + now, + grantIntent, + reissueState, + ) + existing = next + } + + const observedReissueState = reissueState + if (reissueStateMatchesGeneration(reissueState, generation, value.fid)) { + if ( + grantIntent + && !grantMatchesGeneration(grantIntent, generation, value.fid) + ) return new Response(null, { status: 409 }) + + if ( + grantMatchesGeneration(grantIntent, generation, value.fid) + && grantIntent.expiresAt <= now + ) { + if ( + next.delivery?.kind === 'pending-request' + && next.delivery.requestedAtMicros === generation.requestedAtMicros + ) next = Object.freeze({ ...next, delivery: undefined }) + next = withNextRevision(next) + await persistAndSchedule(this.state.storage, next, now, null) + grantIntent = null + } + + if (!grantMatchesGeneration(grantIntent, generation, value.fid)) { + if ( + next.delivery?.kind === 'pending-request' + && next.delivery.requestedAtMicros === generation.requestedAtMicros + ) { + next = withNextRevision(Object.freeze({ ...next, delivery: undefined })) + await persistAndSchedule(this.state.storage, next, now, null) + } + return respond(pendingGenerationStatus( + next, + generation, + grantIntent, + reissueState, + )) + } + + if ( + reissueState.providerAcceptedAt === undefined + && grantIntent.providerAcceptedAt !== undefined + || reissueState.clientAcknowledgedAt === undefined + && grantIntent.acknowledgedAt !== undefined + ) { + reissueState = Object.freeze({ + ...reissueState, + ...(grantIntent.providerAcceptedAt === undefined + ? {} + : { providerAcceptedAt: grantIntent.providerAcceptedAt }), + ...(grantIntent.acknowledgedAt === undefined + ? {} + : { clientAcknowledgedAt: grantIntent.acknowledgedAt }), + }) + next = withNextRevision(next) + await persistAndSchedule( + this.state.storage, + next, + now, + grantIntent, + reissueState, + ) + } + if ( + grantIntent.providerAcceptedAt !== undefined + || grantIntent.acknowledgedAt !== undefined + || next.delivery?.kind !== 'pending-request' + || next.delivery.requestedAtMicros !== generation.requestedAtMicros + ) return respond(pendingGenerationStatus(next, generation, grantIntent, reissueState)) + } else { + if ( + observedReissueState + && generation.requestedAtMicros <= observedReissueState.requestedAtMicros + ) return new Response(null, { status: 409 }) + + const expectedRevision = next.revision + const expectedStatePersisted = existing !== null + const expectedGrantIntentId = grantIntent?.intentId + const expectedReissueRequest = observedReissueState?.requestedAtMicros + const resolver = this.configuredAdmissionResolver ?? defaultAdmissionResolver(config) + const requestResolver = this.configuredAccessRequestResolver + ?? defaultAccessRequestResolver(config) + try { + const admission = await resolver.resolve(value.fid) + if (admission.state !== 'disabled') return new Response(null, { status: 409 }) + const requestStatus = await requestResolver.getStatus(value.fid) + if ( + requestStatus.status !== 'requested' + || requestStatus.requestedAtMicros !== generation.requestedAtMicros + ) return new Response(null, { status: 409 }) + } catch { + return new Response(null, { status: 503 }) + } + const latest = await readCombinedState(this.state.storage) + const latestGrant = readPendingGrantIntent( + await this.state.storage.get(PENDING_GRANT_RECORD), + ) + const latestReissueState = readPendingGrantReissueState( + await this.state.storage.get(PENDING_GRANT_REISSUE_RECORD), + ) + if ( + (expectedStatePersisted + ? !latest || latest.revision !== expectedRevision + : latest !== null) + || latestGrant?.intentId !== expectedGrantIntentId + || latestReissueState?.requestedAtMicros !== expectedReissueRequest + ) return new Response(null, { status: 409 }) + next = latest ?? next + grantIntent = createPendingGrantIntent( + value.fid, + generation.requestedAtMicros, + now, + ) + reissueState = createPendingGrantReissueState( + value.fid, + generation.requestedAtMicros, + now, + ) + newGrant = true + } + } const diagnostics = readPersistedDiagnostics( await this.state.storage.get(DIAGNOSTICS_RECORD), ) next = recoverLegacyTransportBackoff(next, diagnostics, generation, now) if ( - !next.delivery + newGrant + || !next.delivery || !generationEquals(deliveryGeneration(next.delivery), generation) ) { const delivery: AdmissionDelivery = Object.freeze({ @@ -1991,12 +3285,18 @@ export class AdmissionNotification { }) } next = withNextRevision(next) - await persistAndSchedule(this.state.storage, next, now) + await persistAndSchedule( + this.state.storage, + next, + now, + generation.kind === 'pending-request' ? grantIntent : undefined, + generation.kind === 'pending-request' ? reissueState ?? undefined : undefined, + ) next = await this.attemptDelivery(next) - return new Response(JSON.stringify({ status: queueStatus(next) }), { - status: 200, - headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, - }) + grantIntent = readPendingGrantIntent( + await this.state.storage.get(PENDING_GRANT_RECORD), + ) + return respond(queueStatus(next, grantIntent)) } return new Response(null, { status: 404 }) @@ -2007,7 +3307,7 @@ export class AdmissionNotification { } private async handleAlarm(): Promise { - const state = await readCombinedState(this.state.storage) + let state = await readCombinedState(this.state.storage) if (!state) { await purgePersistedState(this.state.storage) return @@ -2017,6 +3317,18 @@ export class AdmissionNotification { await purgePersistedState(this.state.storage) return } + let grantIntent = readPendingGrantIntent( + await this.state.storage.get(PENDING_GRANT_RECORD), + ) + if (grantIntent && grantIntent.expiresAt <= now) { + const delivery = state.delivery?.kind === 'pending-request' + && state.delivery.requestedAtMicros === grantIntent.requestedAtMicros + ? undefined + : state.delivery + state = withNextRevision(Object.freeze({ ...state, delivery })) + await persistAndSchedule(this.state.storage, state, now, null) + grantIntent = null + } try { this.config() } catch { @@ -2026,15 +3338,23 @@ export class AdmissionNotification { await this.state.storage.setAlarm(state.delivery ? Math.min( state.delivery.expiresAt, + grantIntent?.expiresAt ?? state.retentionExpiresAt, state.retentionExpiresAt, now + RETRY_DELAYS_MILLISECONDS[0], ) - : state.retentionExpiresAt) + : Math.min( + grantIntent?.expiresAt ?? state.retentionExpiresAt, + state.retentionExpiresAt, + )) return } const next = await this.attemptDelivery(state) + const retainedReissueState = readPendingGrantReissueState( + await this.state.storage.get(PENDING_GRANT_REISSUE_RECORD), + ) if ( !next.delivery + && retainedReissueState === null && next.subscriptions.length === 0 && next.revokedTokenIds.length === 0 && next.lastSentAuthEpoch === undefined diff --git a/services/auth-bridge/src/app.ts b/services/auth-bridge/src/app.ts index 48cc36aa..6e4bc8ec 100644 --- a/services/auth-bridge/src/app.ts +++ b/services/auth-bridge/src/app.ts @@ -131,8 +131,13 @@ const V2_REFRESH_PATH = '/v2/session/refresh' const V2_LOGOUT_PATH = '/v2/session/logout' const V2_ACCESS_STATUS_PATH = '/v2/access/status' const V2_ACCESS_REQUEST_PATH = '/v2/access/request' +// This context-bound contract was never served on the legacy candidate path. +// A distinct additive route lets the Worker land before the frontend without +// creating an exact-body incompatibility for cached clients. +const V2_ADMISSION_GRANT_CONTEXT_PATH = '/v2/access/admission-grant-context' export const MINIAPP_WEBHOOK_PATH = '/v1/farcaster/miniapp/webhook' export const ADMISSION_NOTIFICATION_PATH = '/v1/admin/admission-notification' +export const ADMISSION_NOTIFICATION_REISSUE_PATH = '/v1/admin/admission-notification-reissue' export const ADMISSION_NOTIFICATION_STATUS_PATH = '/v1/admin/admission-notification-status' const LEGACY_CHALLENGE_PATH = '/v1/farcaster/challenge' const LEGACY_EXCHANGE_PATH = '/v1/farcaster/exchange' @@ -299,8 +304,10 @@ function isCredentialedPath(pathname: string): boolean { || pathname === V2_LOGOUT_PATH } -function isAccessRequestPath(pathname: string): boolean { - return pathname === V2_ACCESS_STATUS_PATH || pathname === V2_ACCESS_REQUEST_PATH +function isAccessCredentialPath(pathname: string): boolean { + return pathname === V2_ACCESS_STATUS_PATH + || pathname === V2_ACCESS_REQUEST_PATH + || pathname === V2_ADMISSION_GRANT_CONTEXT_PATH } function publicCorsHeaders(request: Request, config: BridgeConfig, pathname = new URL(request.url).pathname): HeadersInit { @@ -330,7 +337,7 @@ function routeCorsHeaders(request: Request, config: BridgeConfig, pathname = new const origin = request.headers.get('origin') return origin === QUICK_AUTH_BROWSER_ORIGIN ? quickAuthCorsHeaders(origin) : {} } - if (isAccessRequestPath(pathname)) { + if (isAccessCredentialPath(pathname)) { const origin = request.headers.get('origin') if (accessRequestUsesBearer(request)) { return origin === QUICK_AUTH_BROWSER_ORIGIN @@ -383,6 +390,7 @@ function isServerOnlyAdminPath(pathname: string): boolean { || pathname === AUTH_EPOCH_PROBE_PATH || pathname === CONFIG_ATTESTATION_PATH || pathname === ADMISSION_NOTIFICATION_PATH + || pathname === ADMISSION_NOTIFICATION_REISSUE_PATH || pathname === ADMISSION_NOTIFICATION_STATUS_PATH } @@ -401,7 +409,7 @@ function isPublicAuthPath(pathname: string): boolean { || pathname === V2_EXCHANGE_PATH || pathname === V2_QUICK_AUTH_EXCHANGE_PATH || pathname === V2_REFRESH_PATH - || isAccessRequestPath(pathname) + || isAccessCredentialPath(pathname) } function isLegacyAuthPath(pathname: string): boolean { @@ -1096,6 +1104,8 @@ async function configurationAttestation( authEpochResolverTimeoutMilliseconds: AUTH_EPOCH_RESOLVER_TIMEOUT_MILLISECONDS, accessRequestStatusPath: V2_ACCESS_STATUS_PATH, accessRequestSubmitPath: V2_ACCESS_REQUEST_PATH, + admissionGrantPath: V2_ADMISSION_GRANT_CONTEXT_PATH, + admissionNotificationReissuePath: ADMISSION_NOTIFICATION_REISSUE_PATH, accessRequestResolverTokenTtlSeconds: INTERNAL_ACCESS_REQUEST_RESOLVER_TOKEN_TTL_SECONDS, accessRequestResolverTimeoutMilliseconds: ACCESS_REQUEST_RESOLVER_TIMEOUT_MILLISECONDS, accessRequestStatusProcedure: SPACETIMEDB_ACCESS_REQUEST_STATUS_PROCEDURE, @@ -1560,7 +1570,7 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri 'The read-only QA observer is disabled.', ) } - if (request.method === 'OPTIONS' && isAccessRequestPath(url.pathname)) { + if (request.method === 'OPTIONS' && isAccessCredentialPath(url.pathname)) { return allowedAccessRequestPreflight(request, config) } if (request.method === 'OPTIONS' && url.pathname === V2_QUICK_AUTH_EXCHANGE_PATH) { @@ -1631,13 +1641,6 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri } throw error } - if (!config.approvalNotificationsEnabled && event.event.type === 'enabled') { - throw new HttpError( - 503, - 'approval_notifications_paused', - 'Admission notifications are temporarily unavailable.', - ) - } try { await ( dependencies.admissionNotificationStore @@ -1924,8 +1927,15 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri return json(responseBody, 200, quickAuthCorsHeaders(origin)) } - if (request.method === 'POST' && isAccessRequestPath(url.pathname)) { + if (request.method === 'POST' && isAccessCredentialPath(url.pathname)) { const credentialMode = accessCredentialMode(request) + if ( + url.pathname === V2_ADMISSION_GRANT_CONTEXT_PATH + && credentialMode !== 'quick-auth' + ) { + logger.event('admission_grant_ack_quick_auth_required') + throw invalidAccessCredential() + } const origin = credentialMode === 'quick-auth' ? requireQuickAuthBrowserOrigin(request) : requireAllowedBrowserOrigin(request, config) @@ -1943,7 +1953,31 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri dependencies.rateLimiter, logger, ) - requireExactKeys(await parseObjectBody(request), []) + const accessBody = await parseObjectBody(request) + let admissionGrantTicket: string | undefined + let admissionGrantNotificationId: string | undefined + if (url.pathname === V2_ADMISSION_GRANT_CONTEXT_PATH) { + requireExactKeys(accessBody, ['ticket', 'notificationId']) + if ( + typeof accessBody.ticket !== 'string' + || !/^[A-Za-z0-9_-]{43}$/.test(accessBody.ticket) + || typeof accessBody.notificationId !== 'string' + || !/^warpkeep-access-grant-v3-i[A-Za-z0-9_-]{22}$/.test( + accessBody.notificationId, + ) + ) { + logger.event('admission_grant_ack_context_rejected') + throw new HttpError( + 400, + 'admission_grant_invalid', + 'This admission grant is invalid or expired.', + ) + } + admissionGrantTicket = accessBody.ticket + admissionGrantNotificationId = accessBody.notificationId + } else { + requireExactKeys(accessBody, []) + } const expectedFid = requireExpectedAccessFid( request, config.accessExpectedFidRequired, @@ -2089,6 +2123,78 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri ) } + if (url.pathname === V2_ADMISSION_GRANT_CONTEXT_PATH) { + const notificationStore = dependencies.admissionNotificationStore + ?? defaultAdmissionNotificationStore(env) + if (admission.state === 'enabled') { + // Best-effort capability cleanup for an admission committed by a + // concurrent operator. Entry authority is already established, + // so notification storage availability cannot revoke it. + try { + await notificationStore.acknowledge( + verifiedFid, + admissionGrantTicket!, + admissionGrantNotificationId!, + ) + } catch { + // The bounded grant expires independently in Durable Object storage. + } + return json( + { version: 1, status: 'already-admitted' }, + 200, + accessRequestCorsHeaders(origin, credentialMode), + ) + } + if (sessionRecord?.state === 'bound') throw invalidSessionError() + if (credentialMode === 'quick-auth') { + const checkedAt = now() + if (!Number.isSafeInteger(checkedAt) || checkedAt < 0) { + throw invalidAccessCredential() + } + try { + verifiedQuickAuthClaims( + verifiedQuickAuthPayload, + Math.floor(checkedAt / 1_000), + ) + } catch { + throw invalidAccessCredential() + } + } + let acknowledgement: 'accepted' | 'not-ready' | 'stale' | 'context-mismatch' + try { + acknowledgement = await notificationStore.acknowledge( + verifiedFid, + admissionGrantTicket!, + admissionGrantNotificationId!, + ) + } catch { + throw new HttpError( + 503, + 'admission_grant_unavailable', + 'Admission finalization is temporarily unavailable.', + ) + } + if (acknowledgement === 'accepted') { + logger.event('admission_grant_acknowledged') + } else if (acknowledgement === 'not-ready') { + logger.event('admission_grant_ack_not_ready') + } else if (acknowledgement === 'context-mismatch') { + logger.event('admission_grant_ack_context_mismatch') + } else { + logger.event('admission_grant_ack_stale') + } + return json( + { + version: 1, + status: acknowledgement === 'context-mismatch' + ? 'stale' + : acknowledgement, + }, + 200, + accessRequestCorsHeaders(origin, credentialMode), + ) + } + let result: AccessRequestResolution if (admission.state === 'enabled') { result = Object.freeze({ status: 'already-admitted' }) @@ -2537,15 +2643,22 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri ) } if (status === 'already-sent') logger.event('admission_notification_succeeded') - else if (status === 'delivery-exhausted') logger.event('admission_notification_exhausted') - else if (status === 'not-subscribed') logger.event('admission_notification_not_subscribed') - else logger.event('admission_notification_queued') + else if (status === 'awaiting-client') { + logger.event('admission_notification_provider_accepted') + } else if (status === 'client-acknowledged') { + logger.event('admission_notification_client_acknowledged') + } else if (status === 'delivery-exhausted') { + logger.event('admission_notification_exhausted') + } else if (status === 'not-subscribed') { + logger.event('admission_notification_not_subscribed') + } else logger.event('admission_notification_queued') return json({ status }, status === 'queued' ? 202 : 200) } - if (request.method === 'POST' && url.pathname === ADMISSION_NOTIFICATION_STATUS_PATH) { + if (request.method === 'POST' && url.pathname === ADMISSION_NOTIFICATION_REISSUE_PATH) { requireAdminNoOrigin(request) if (!config.approvalNotificationsEnabled) { + logger.event('admission_notification_reissue_rejected') throw new HttpError( 503, 'approval_notifications_paused', @@ -2565,6 +2678,113 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri const notificationConfig = config.miniAppNotifications if (!notificationConfig) throw new ConfigurationError() const credential = adminCredential(request) + if (!credential || !(await timingSafeSecretMatch( + credential, + notificationConfig.operatorSecret, + ))) { + logger.event('admission_notification_reissue_rejected') + throw new HttpError( + 401, + 'invalid_notification_credentials', + 'Notification operator credentials are invalid.', + ) + } + const body = await parseObjectBody(request) + requireExactKeys(body, ['fid']) + const fid = canonicalNotificationFid(body.fid) + let admission: AdmissionResolution + try { + admission = await ( + dependencies.authEpochResolver + ?? defaultAuthEpochResolver(config) + ).resolve(fid) + } catch (error) { + logAuthEpochFailure(logger, error) + throw new HttpError( + 503, + 'authorization_unavailable', + 'Authorization is temporarily unavailable.', + ) + } + if (admission.state !== 'disabled') { + logger.event('admission_notification_reissue_rejected') + throw new HttpError( + 409, + 'admission_not_disabled', + 'The account is not in the exact disabled admission state.', + ) + } + let requestStatus: AccessRequestResolution + try { + requestStatus = await ( + dependencies.accessRequestResolver + ?? defaultAccessRequestResolver(config) + ).getStatus(fid) + } catch (error) { + logAccessRequestFailure(logger, error) + throw new HttpError( + 503, + 'access_request_unavailable', + 'The access request ledger is temporarily unavailable.', + ) + } + if (requestStatus.status !== 'requested') { + logger.event('admission_notification_reissue_rejected') + throw new HttpError( + 409, + 'access_request_not_pending', + 'No pending access request is available for notification.', + ) + } + const reissuedAt = now() + if (!Number.isSafeInteger(reissuedAt) || reissuedAt < 0) throw new ConfigurationError() + const store = dependencies.admissionNotificationStore + ?? defaultAdmissionNotificationStore(env) + if (!store.reissueAdmission) { + throw new HttpError( + 503, + 'admission_notification_unavailable', + 'Admission notification delivery is temporarily unavailable.', + ) + } + let result + try { + result = await store.reissueAdmission({ + fid, + requestedAtMicros: requestStatus.requestedAtMicros, + reissuedAt, + }) + } catch { + throw new HttpError( + 503, + 'admission_notification_unavailable', + 'Admission notification delivery is temporarily unavailable.', + ) + } + if (result.status === 'reissued') logger.event('admission_notification_reissued') + else if (result.status === 'cooldown') { + logger.event('admission_notification_reissue_cooldown') + } else if (result.status === 'limit-reached') { + logger.event('admission_notification_reissue_limit') + } else logger.event('admission_notification_reissue_rejected') + return json(result) + } + + if (request.method === 'POST' && url.pathname === ADMISSION_NOTIFICATION_STATUS_PATH) { + requireAdminNoOrigin(request) + if (url.search) { + throw new HttpError(400, 'notification_query_not_allowed', 'This endpoint does not accept query parameters.') + } + await enforceRateLimit( + request, + 'admission-notification', + env, + dependencies.rateLimiter, + logger, + ) + const notificationConfig = config.miniAppNotifications + if (!notificationConfig) throw new ConfigurationError() + const credential = adminCredential(request) if (!credential || !(await timingSafeSecretMatch( credential, notificationConfig.operatorSecret, @@ -2669,6 +2889,7 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri quickAuthMaxIssuerLifetimeSeconds: QUICK_AUTH_MAX_ISSUER_LIFETIME_SECONDS, accessRequestStatusPath: V2_ACCESS_STATUS_PATH, accessRequestSubmitPath: V2_ACCESS_REQUEST_PATH, + admissionGrantPath: V2_ADMISSION_GRANT_CONTEXT_PATH, accessRequestResolverTokenTtlSeconds: INTERNAL_ACCESS_REQUEST_RESOLVER_TOKEN_TTL_SECONDS, accessRequestResolverTimeoutMilliseconds: ACCESS_REQUEST_RESOLVER_TIMEOUT_MILLISECONDS, accessRequestStatusProcedure: SPACETIMEDB_ACCESS_REQUEST_STATUS_PROCEDURE, @@ -2678,6 +2899,7 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri config.miniAppNotifications?.clients.map(client => client.appFid) ?? [], miniAppWebhookPath: MINIAPP_WEBHOOK_PATH, admissionNotificationPath: ADMISSION_NOTIFICATION_PATH, + admissionNotificationReissuePath: ADMISSION_NOTIFICATION_REISSUE_PATH, admissionNotificationStatusPath: ADMISSION_NOTIFICATION_STATUS_PATH, publicAuthEnabled: config.publicAuthEnabled, accessExpectedFidRequired: config.accessExpectedFidRequired, @@ -2699,7 +2921,11 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri throw new HttpError(404, 'not_found', 'Route not found.') } catch (error) { if (url.pathname === V2_QUICK_AUTH_EXCHANGE_PATH) logger.event('quick_auth_rejected') - if (isAccessRequestPath(url.pathname)) logger.event('access_request_rejected') + if (url.pathname === V2_ADMISSION_GRANT_CONTEXT_PATH) { + logger.event('admission_grant_ack_rejected') + } else if (isAccessCredentialPath(url.pathname)) { + logger.event('access_request_rejected') + } if (error instanceof HttpError) { if (url.pathname === QA_OBSERVER_CHALLENGE_PATH) logger.event('qa_challenge_rejected') if (url.pathname === QA_OBSERVER_SNAPSHOT_PATH) logger.event('qa_snapshot_rejected') diff --git a/services/auth-bridge/src/rateLimit.ts b/services/auth-bridge/src/rateLimit.ts index b53eaf69..5159e7b8 100644 --- a/services/auth-bridge/src/rateLimit.ts +++ b/services/auth-bridge/src/rateLimit.ts @@ -21,7 +21,10 @@ const POLICIES: Readonly> = Object.freeze({ // 120-user launch bottleneck; signature, app-key and per-FID replay checks // still run before any consent state is accepted. 'miniapp-webhook': 2_000, - 'admission-notification': 12, + // Protected operator polling waits for a verified notification open before + // committing admission. Five-second polling for one bounded ten-minute + // grant needs room inside the shared five-minute limiter window. + 'admission-notification': 72, 'admin-token': 6, 'qa-challenge': 12, 'qa-snapshot': 60, diff --git a/services/auth-bridge/src/types.ts b/services/auth-bridge/src/types.ts index a2053fb5..f6c729df 100644 --- a/services/auth-bridge/src/types.ts +++ b/services/auth-bridge/src/types.ts @@ -172,6 +172,19 @@ export type SafeLogEvent = | 'admission_notification_not_subscribed' | 'admission_notification_rejected' | 'admission_notification_inspected' + | 'admission_notification_provider_accepted' + | 'admission_notification_client_acknowledged' + | 'admission_notification_reissued' + | 'admission_notification_reissue_cooldown' + | 'admission_notification_reissue_limit' + | 'admission_notification_reissue_rejected' + | 'admission_grant_acknowledged' + | 'admission_grant_ack_rejected' + | 'admission_grant_ack_context_rejected' + | 'admission_grant_ack_context_mismatch' + | 'admission_grant_ack_quick_auth_required' + | 'admission_grant_ack_not_ready' + | 'admission_grant_ack_stale' | 'rate_limited' | 'rate_limit_failed' | 'configuration_error' @@ -312,9 +325,17 @@ export interface MiniAppWebhookVerifier { export type AdmissionNotificationQueueStatus = | 'queued' | 'already-sent' + | 'awaiting-client' + | 'client-acknowledged' | 'delivery-exhausted' | 'not-subscribed' +export type AdmissionNotificationAcknowledgementStatus = + | 'accepted' + | 'not-ready' + | 'stale' + | 'context-mismatch' + export type AdmissionNotificationGeneration = | Readonly<{ kind: 'admitted' @@ -330,6 +351,31 @@ export type AdmissionNotificationQueueInput = Readonly<{ queuedAt: number }> & AdmissionNotificationGeneration +export type AdmissionNotificationReissueInput = Readonly<{ + fid: string + requestedAtMicros: number + reissuedAt: number +}> + +export type AdmissionNotificationReissueResult = + | Readonly<{ + status: 'reissued' + deliveryStatus: AdmissionNotificationQueueStatus + }> + | Readonly<{ + status: 'cooldown' + retryAfterSeconds: number + }> + | Readonly<{ + status: + | 'limit-reached' + | 'client-acknowledged' + | 'not-ready' + | 'not-subscribed' + | 'stale' + | 'paused' + }> + export type AdmissionNotificationRetryReason = | 'admission-verification' | 'request-verification' @@ -354,11 +400,29 @@ export type AdmissionNotificationRetryReason = | 'provider-unknown' export type AdmissionNotificationDiagnostics = Readonly<{ + version: 2 + systemState: 'enabled' | 'paused' + subscriptionState: 'active' | 'absent' status: AdmissionNotificationQueueStatus generation?: AdmissionNotificationGeneration['kind'] authEpoch?: number + activeSubscriptionCount: number + activeClientFids: readonly number[] + activeAttemptCount: number + pendingAttemptCount: number + retryingAttemptCount: number + sentAttemptCount: number + exhaustedAttemptCount: number deliveryAttemptCount: number verificationFailureCount: number + deliveryQueuedAt?: number + deliveryExpiresAt?: number + grantState: 'none' | 'created' | 'provider-accepted' | 'client-acknowledged' + grantCreatedAt?: number + grantExpiresAt?: number + providerAcceptedAt?: number + clientAcknowledgedAt?: number + deliveryState: 'idle' | 'pending' | 'retry-scheduled' | 'succeeded' | 'exhausted' retryReasons: readonly AdmissionNotificationRetryReason[] lastAttemptAt?: number lastFailureReason?: AdmissionNotificationRetryReason @@ -369,6 +433,14 @@ export type AdmissionNotificationDiagnostics = Readonly<{ export interface AdmissionNotificationStore { applyEvent(event: VerifiedMiniAppWebhookEvent): Promise queueAdmission(input: AdmissionNotificationQueueInput): Promise + reissueAdmission?( + input: AdmissionNotificationReissueInput, + ): Promise + acknowledge( + fid: string, + ticket: string, + notificationId: string, + ): Promise /** Operator-only, token-free delivery state used for bounded diagnosis. */ inspect?(fid: string): Promise } diff --git a/services/auth-bridge/test-workerd/authBridge.workerd.test.ts b/services/auth-bridge/test-workerd/authBridge.workerd.test.ts index 17f3a729..2b2273b0 100644 --- a/services/auth-bridge/test-workerd/authBridge.workerd.test.ts +++ b/services/auth-bridge/test-workerd/authBridge.workerd.test.ts @@ -489,6 +489,7 @@ describe('auth bridge production bindings in workerd', () => { admissionNotificationStore: { applyEvent, queueAdmission: vi.fn(async () => 'queued' as const), + acknowledge: vi.fn(async () => 'stale' as const), }, rateLimiter: { check: async () => ({ allowed: true }) }, logger: { event: event => events.push(event) }, diff --git a/services/auth-bridge/test/admissionNotifications.test.ts b/services/auth-bridge/test/admissionNotifications.test.ts index 81ad8ed4..77ec8732 100644 --- a/services/auth-bridge/test/admissionNotifications.test.ts +++ b/services/auth-bridge/test/admissionNotifications.test.ts @@ -19,6 +19,8 @@ const TOKEN = 'test-notification-token-with-enough-entropy' const INTERNAL_ORIGIN = 'https://admission-notification.internal' const STATE_KEY = 'admission-notification-v1' const PENDING_STATE_RECORD = 'admission-notification-pending-v2' +const PENDING_GRANT_RECORD = 'admission-notification-grant-v3' +const PENDING_GRANT_REISSUE_RECORD = 'admission-notification-grant-reissue-v1' const DIAGNOSTICS_RECORD = 'admission-notification-diagnostics-v1' class FakeStorage implements DurableObjectStorage { @@ -118,7 +120,10 @@ function disabledEvent(eventId = 'b'.repeat(64)): VerifiedMiniAppWebhookEvent { return { eventId, fid: FID, appFid: APP_FID, event: { type: 'disabled' } } } -function internalRequest(path: 'event' | 'queue' | 'status', body: unknown): Request { +function internalRequest( + path: 'event' | 'queue' | 'reissue' | 'status' | 'ack', + body: unknown, +): Request { return new Request(`${INTERNAL_ORIGIN}/${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -209,6 +214,66 @@ async function inspect(notification: AdmissionNotification): Promise { return notification.fetch(internalRequest('status', { fid: FID })) } +async function acknowledge( + notification: AdmissionNotification, + ticket: string, + notificationId: string, +): Promise { + return notification.fetch(internalRequest('ack', { fid: FID, ticket, notificationId })) +} + +async function reissue( + notification: AdmissionNotification, + requestedAtMicros: number, + reissuedAt: number, +): Promise { + return notification.fetch(internalRequest('reissue', { + fid: FID, + requestedAtMicros, + reissuedAt, + })) +} + +function grantNotificationId(grant: Readonly<{ intentId: string }>): string { + return `warpkeep-access-grant-v3-i${grant.intentId}` +} + +function pendingGrant(storage: FakeStorage): { + intentId: string + ticket: string + requestedAtMicros: number + providerAcceptedAt?: number + acknowledgedAt?: number +} { + return storage.values.get(PENDING_GRANT_RECORD) as { + intentId: string + ticket: string + requestedAtMicros: number + providerAcceptedAt?: number + acknowledgedAt?: number + } +} + +function pendingReissueState(storage: FakeStorage): { + fid: string + requestedAtMicros: number + initialGrantCreatedAt: number + reissueCount: number + lastReissuedAt?: number + providerAcceptedAt?: number + clientAcknowledgedAt?: number +} { + return storage.values.get(PENDING_GRANT_REISSUE_RECORD) as { + fid: string + requestedAtMicros: number + initialGrantCreatedAt: number + reissueCount: number + lastReissuedAt?: number + providerAcceptedAt?: number + clientAcknowledgedAt?: number + } +} + describe('admission notification consent and delivery lifecycle', () => { it('closes the queue-before-consent race and keeps a retained auth-epoch receipt', async () => { const fetchImpl = vi.fn(async () => successfulDelivery()) @@ -226,13 +291,14 @@ describe('admission notification consent and delivery lifecycle', () => { const payload = JSON.parse(String(deliveryInit?.body)) expect(payload).toEqual({ notificationId: 'warpkeep-access-approved-v1-e7', - title: 'The Hegemony admits you', - body: 'Your keep awaits in Genesis 001. Enter the living Realm.', + title: 'Welcome to the Hegemony Empire', + body: 'The gates have answered your name. Cross the threshold, Founder—your legacy awaits.', targetUrl: 'https://warpkeep.com/?miniApp=true', tokens: [TOKEN], }) - expect(payload.title).toHaveLength(23) - expect(payload.body).toHaveLength(56) + expect(payload.title).toHaveLength(30) + expect(payload.body).toHaveLength(83) + expect(`${payload.title} ${payload.body}`).not.toMatch(/genesis|realm/i) const duplicate = await queue(h.notification) await expect(duplicate.json()).resolves.toEqual({ status: 'already-sent' }) @@ -243,7 +309,7 @@ describe('admission notification consent and delivery lifecycle', () => { expect(stored(h.storage)).not.toContain('"lastFailureReason"') }) - it('gets provider acceptance for the exact pending request before admission exists', async () => { + it('stages a unique click grant after provider acceptance for the exact pending request', async () => { const requestedAtMicros = 1_799_999_999_000_000 const fetchImpl = vi.fn(async () => successfulDelivery()) const accessRequestResolver = { @@ -260,18 +326,23 @@ describe('admission notification consent and delivery lifecycle', () => { await applyEvent(h.notification, enabledEvent()) const response = await queuePending(h.notification, requestedAtMicros) - await expect(response.json()).resolves.toEqual({ status: 'already-sent' }) + await expect(response.json()).resolves.toEqual({ status: 'awaiting-client' }) expect(accessRequestResolver.getStatus).toHaveBeenCalledWith(FID) expect(fetchImpl).toHaveBeenCalledOnce() const payload = JSON.parse(String(fetchImpl.mock.calls[0][1]?.body)) + const grant = pendingGrant(h.storage) + expect(grant.intentId).toMatch(/^[A-Za-z0-9_-]{22}$/) + expect(grant.ticket).toMatch(/^[A-Za-z0-9_-]{43}$/) expect(payload).toMatchObject({ - notificationId: `warpkeep-access-approved-v2-r${requestedAtMicros}`, - title: 'Admission approved', - body: 'The Hegemony is finalizing your Realm access. Your keep will open shortly.', + notificationId: `warpkeep-access-grant-v3-i${grant.intentId}`, + title: 'Welcome to the Hegemony Empire', + body: 'The gates have answered your name. Cross the threshold, Founder—your legacy awaits.', + targetUrl: `https://warpkeep.com/?miniApp=true#warpkeep-grant-v1=${grant.ticket}`, }) - expect(pendingStored(h.storage)).toContain( - `"lastSentRequestAtMicros":${requestedAtMicros}`, - ) + expect(payload.title).toHaveLength(30) + expect(payload.body).toHaveLength(83) + expect(`${payload.title} ${payload.body}`).not.toMatch(/genesis|realm/i) + expect(pendingStored(h.storage)).not.toContain('lastSentRequestAtMicros') expect(pendingStored(h.storage)).not.toContain(TOKEN) const legacy = h.storage.values.get(STATE_KEY) as Record expect(Object.keys(legacy).sort()).toEqual([ @@ -286,9 +357,481 @@ describe('admission notification consent and delivery lifecycle', () => { expect(stored(h.storage)).not.toContain('pending-request') expect(stored(h.storage)).not.toContain('lastSentRequestAtMicros') await expect((await inspect(h.notification)).json()).resolves.toMatchObject({ - status: 'already-sent', + version: 2, + systemState: 'enabled', + subscriptionState: 'active', + status: 'awaiting-client', generation: 'pending-request', + activeSubscriptionCount: 1, + activeClientFids: [9_152], + activeAttemptCount: 1, + sentAttemptCount: 1, + deliveryAttemptCount: 1, + deliveryQueuedAt: NOW, + deliveryExpiresAt: NOW + 24 * 60 * 60 * 1_000, + grantState: 'provider-accepted', + grantCreatedAt: NOW, + grantExpiresAt: NOW + 24 * 60 * 60 * 1_000, + providerAcceptedAt: NOW, + deliveryState: 'succeeded', + }) + expect(await (await inspect(h.notification)).text()).not.toContain(grant.ticket) + }) + + it('reissues only through the bounded operator transition while ordinary polling stays inert', async () => { + const requestedAtMicros = 1_799_999_999_000_000 + const fetchImpl = vi.fn(async () => successfulDelivery()) + const h = createHarness({ + fetchImpl, + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + }, + }) + await applyEvent(h.notification, enabledEvent()) + await queuePending(h.notification, requestedAtMicros) + const originalGrant = pendingGrant(h.storage) + expect(pendingReissueState(h.storage)).toMatchObject({ + requestedAtMicros, + reissueCount: 0, + providerAcceptedAt: NOW, + }) + expect(Object.keys(h.storage.values.get(PENDING_GRANT_RECORD) as object).sort()).toEqual([ + 'createdAt', + 'expiresAt', + 'fid', + 'intentId', + 'providerAcceptedAt', + 'requestedAtMicros', + 'ticket', + 'version', + ]) + + await expect((await queuePending( + h.notification, + requestedAtMicros, + )).json()).resolves.toEqual({ status: 'awaiting-client' }) + expect(fetchImpl).toHaveBeenCalledOnce() + await expect((await reissue( + h.notification, + requestedAtMicros, + NOW, + )).json()).resolves.toEqual({ status: 'cooldown', retryAfterSeconds: 300 }) + expect(fetchImpl).toHaveBeenCalledOnce() + + h.setNow(NOW + 5 * 60 * 1_000) + await expect((await reissue( + h.notification, + requestedAtMicros, + NOW + 5 * 60 * 1_000, + )).json()).resolves.toEqual({ + status: 'reissued', + deliveryStatus: 'awaiting-client', + }) + expect(fetchImpl).toHaveBeenCalledTimes(2) + const nextGrant = pendingGrant(h.storage) + expect(nextGrant.intentId).not.toBe(originalGrant.intentId) + expect(nextGrant.ticket).not.toBe(originalGrant.ticket) + expect(nextGrant).toMatchObject({ + createdAt: NOW + 5 * 60 * 1_000, + expiresAt: NOW + 5 * 60 * 1_000 + 24 * 60 * 60 * 1_000, + providerAcceptedAt: NOW + 5 * 60 * 1_000, + }) + expect(Object.keys(h.storage.values.get(PENDING_GRANT_RECORD) as object).sort()).toEqual([ + 'createdAt', + 'expiresAt', + 'fid', + 'intentId', + 'providerAcceptedAt', + 'requestedAtMicros', + 'ticket', + 'version', + ]) + expect(pendingReissueState(h.storage)).toMatchObject({ + requestedAtMicros, + reissueCount: 1, + lastReissuedAt: NOW + 5 * 60 * 1_000, + providerAcceptedAt: NOW + 5 * 60 * 1_000, + }) + const pendingRecord = h.storage.values.get(PENDING_STATE_RECORD) as { + delivery: { queuedAt: number; expiresAt: number } + } + expect(Object.keys(pendingRecord).sort()).toEqual(['delivery', 'fid', 'version']) + expect(Object.keys(pendingRecord.delivery).sort()).toEqual([ + 'attempts', + 'expiresAt', + 'queuedAt', + 'requestedAtMicros', + ]) + expect(pendingRecord.delivery.expiresAt - pendingRecord.delivery.queuedAt) + .toBe(24 * 60 * 60 * 1_000) + await expect((await acknowledge( + h.notification, + originalGrant.ticket, + grantNotificationId(originalGrant), + )).json()).resolves.toEqual({ status: 'stale' }) + const diagnosticsText = await (await inspect(h.notification)).text() + expect(diagnosticsText).not.toContain(originalGrant.ticket) + expect(diagnosticsText).not.toContain(nextGrant.ticket) + expect(diagnosticsText).not.toContain('reissueCount') + const sidecarText = JSON.stringify(h.storage.values.get(PENDING_GRANT_REISSUE_RECORD)) + expect(sidecarText).not.toContain(originalGrant.ticket) + expect(sidecarText).not.toContain(nextGrant.ticket) + expect(sidecarText).not.toContain(originalGrant.intentId) + expect(sidecarText).not.toContain(nextGrant.intentId) + expect(sidecarText).not.toContain(TOKEN) + }) + + it('caps reissues at two for one exact pending request', async () => { + const requestedAtMicros = 1_799_999_999_000_000 + const fetchImpl = vi.fn(async () => successfulDelivery()) + const h = createHarness({ + fetchImpl, + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + }, + }) + await applyEvent(h.notification, enabledEvent()) + await queuePending(h.notification, requestedAtMicros) + for (const offset of [5, 10]) { + const reissuedAt = NOW + offset * 60 * 1_000 + h.setNow(reissuedAt) + await expect((await reissue( + h.notification, + requestedAtMicros, + reissuedAt, + )).json()).resolves.toMatchObject({ status: 'reissued' }) + } + h.setNow(NOW + 15 * 60 * 1_000) + await expect((await reissue( + h.notification, + requestedAtMicros, + NOW + 15 * 60 * 1_000, + )).json()).resolves.toEqual({ status: 'limit-reached' }) + expect(fetchImpl).toHaveBeenCalledTimes(3) + expect(pendingReissueState(h.storage).reissueCount).toBe(2) + }) + + it('starts each reissue cooldown at the latest provider handoff', async () => { + const requestedAtMicros = 1_799_999_999_000_000 + let deliveryCall = 0 + const fetchImpl = vi.fn(async () => { + deliveryCall += 1 + if (deliveryCall === 2) { + return Response.json({ + result: { + successfulTokens: [], + invalidTokens: [], + rateLimitedTokens: [TOKEN], + }, + }) + } + return successfulDelivery() + }) + const h = createHarness({ + fetchImpl, + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + }, + }) + await applyEvent(h.notification, enabledEvent()) + await queuePending(h.notification, requestedAtMicros) + + h.setNow(NOW + 5 * 60 * 1_000) + await expect((await reissue( + h.notification, + requestedAtMicros, + NOW + 5 * 60 * 1_000, + )).json()).resolves.toMatchObject({ + status: 'reissued', + deliveryStatus: 'queued', + }) + expect(pendingReissueState(h.storage).providerAcceptedAt).toBeUndefined() + + h.setNow(NOW + 5 * 60 * 1_000 + 30_000) + await h.notification.alarm() + expect(pendingReissueState(h.storage).providerAcceptedAt) + .toBe(NOW + 5 * 60 * 1_000 + 30_000) + + h.setNow(NOW + 10 * 60 * 1_000) + await expect((await reissue( + h.notification, + requestedAtMicros, + NOW + 10 * 60 * 1_000, + )).json()).resolves.toEqual({ status: 'cooldown', retryAfterSeconds: 30 }) + expect(fetchImpl).toHaveBeenCalledTimes(3) + + h.setNow(NOW + 10 * 60 * 1_000 + 30_000) + await expect((await reissue( + h.notification, + requestedAtMicros, + NOW + 10 * 60 * 1_000 + 30_000, + )).json()).resolves.toMatchObject({ status: 'reissued' }) + expect(fetchImpl).toHaveBeenCalledTimes(4) + }) + + it('retains the sidecar across pause and keeps ordinary resume polling inert', async () => { + const requestedAtMicros = 1_799_999_999_000_000 + let notificationsEnabled = true + const fetchImpl = vi.fn(async () => successfulDelivery()) + const h = createHarness({ + fetchImpl, + configReader: () => config(notificationsEnabled), + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + }, + }) + await applyEvent(h.notification, enabledEvent()) + await queuePending(h.notification, requestedAtMicros) + const initialSidecar = pendingReissueState(h.storage) + + h.setNow(NOW + 5 * 60 * 1_000) + notificationsEnabled = false + await h.notification.alarm() + expect(h.storage.values.has(PENDING_GRANT_RECORD)).toBe(false) + expect(pendingReissueState(h.storage)).toEqual(initialSidecar) + await expect((await reissue( + h.notification, + requestedAtMicros, + NOW + 5 * 60 * 1_000, + )).json()).resolves.toEqual({ status: 'paused' }) + + notificationsEnabled = true + await expect((await queuePending( + h.notification, + requestedAtMicros, + NOW + 5 * 60 * 1_000, + )).json()).resolves.toEqual({ status: 'delivery-exhausted' }) + expect(fetchImpl).toHaveBeenCalledOnce() + await expect((await reissue( + h.notification, + requestedAtMicros, + NOW + 5 * 60 * 1_000, + )).json()).resolves.toMatchObject({ status: 'reissued' }) + expect(fetchImpl).toHaveBeenCalledTimes(2) + expect(pendingReissueState(h.storage).reissueCount).toBe(1) + }) + + it('retains the exact-request cap after repeated raw-grant expiry beyond 24 hours', async () => { + const requestedAtMicros = 1_799_999_999_000_000 + const fetchImpl = vi.fn(async () => successfulDelivery()) + const h = createHarness({ + fetchImpl, + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + }, + }) + await applyEvent(h.notification, enabledEvent()) + await queuePending(h.notification, requestedAtMicros) + + let currentTime = NOW + 24 * 60 * 60 * 1_000 + for (let expectedCount = 1; expectedCount <= 2; expectedCount += 1) { + h.setNow(currentTime) + await h.notification.alarm() + expect(h.storage.values.has(PENDING_GRANT_RECORD)).toBe(false) + await expect((await queuePending( + h.notification, + requestedAtMicros, + currentTime, + )).json()).resolves.toEqual({ status: 'delivery-exhausted' }) + await expect((await reissue( + h.notification, + requestedAtMicros, + currentTime, + )).json()).resolves.toMatchObject({ status: 'reissued' }) + expect(pendingReissueState(h.storage).reissueCount).toBe(expectedCount) + currentTime += 24 * 60 * 60 * 1_000 + } + h.setNow(currentTime) + await h.notification.alarm() + await expect((await reissue( + h.notification, + requestedAtMicros, + currentTime, + )).json()).resolves.toEqual({ status: 'limit-reached' }) + expect(fetchImpl).toHaveBeenCalledTimes(3) + expect(pendingReissueState(h.storage).reissueCount).toBe(2) + }) + + it('retains provider receipt and acknowledgement across opt-out and re-enable', async () => { + const requestedAtMicros = 1_799_999_999_000_000 + const fetchImpl = vi.fn(async (_input, init) => { + const token = (JSON.parse(String(init?.body)) as { tokens: string[] }).tokens[0] + return successfulDelivery(token) + }) + const h = createHarness({ + fetchImpl, + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + }, + }) + await applyEvent(h.notification, enabledEvent()) + await queuePending(h.notification, requestedAtMicros) + const initialProviderAcceptedAt = pendingReissueState(h.storage).providerAcceptedAt + await applyEvent(h.notification, disabledEvent()) + expect(h.storage.values.has(PENDING_GRANT_RECORD)).toBe(false) + expect(pendingReissueState(h.storage).providerAcceptedAt).toBe(initialProviderAcceptedAt) + + h.setNow(NOW + 5 * 60 * 1_000) + await applyEvent(h.notification, enabledEvent( + 'e'.repeat(64), + `${TOKEN}-replacement`, + )) + await expect((await queuePending( + h.notification, + requestedAtMicros, + NOW + 5 * 60 * 1_000, + )).json()).resolves.toEqual({ status: 'delivery-exhausted' }) + expect(fetchImpl).toHaveBeenCalledOnce() + await expect((await reissue( + h.notification, + requestedAtMicros, + NOW + 5 * 60 * 1_000, + )).json()).resolves.toMatchObject({ status: 'reissued' }) + const reissuedGrant = pendingGrant(h.storage) + await acknowledge( + h.notification, + reissuedGrant.ticket, + grantNotificationId(reissuedGrant), + ) + expect(pendingReissueState(h.storage).clientAcknowledgedAt) + .toBe(NOW + 5 * 60 * 1_000) + await applyEvent(h.notification, disabledEvent('f'.repeat(64))) + expect(h.storage.values.has(PENDING_GRANT_RECORD)).toBe(false) + h.setNow(NOW + 10 * 60 * 1_000) + await expect((await reissue( + h.notification, + requestedAtMicros, + NOW + 10 * 60 * 1_000, + )).json()).resolves.toEqual({ status: 'client-acknowledged' }) + }) + + it('serializes acknowledgement and reissue so exactly one grant wins', async () => { + const requestedAtMicros = 1_799_999_999_000_000 + const makeHarness = () => createHarness({ + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + }, + }) + + const ackFirst = makeHarness() + await applyEvent(ackFirst.notification, enabledEvent()) + await queuePending(ackFirst.notification, requestedAtMicros) + const acknowledgedGrant = pendingGrant(ackFirst.storage) + await acknowledge( + ackFirst.notification, + acknowledgedGrant.ticket, + grantNotificationId(acknowledgedGrant), + ) + ackFirst.setNow(NOW + 5 * 60 * 1_000) + await expect((await reissue( + ackFirst.notification, + requestedAtMicros, + NOW + 5 * 60 * 1_000, + )).json()).resolves.toEqual({ status: 'client-acknowledged' }) + + const reissueFirst = makeHarness() + await applyEvent(reissueFirst.notification, enabledEvent('c'.repeat(64))) + await queuePending(reissueFirst.notification, requestedAtMicros) + const staleGrant = pendingGrant(reissueFirst.storage) + reissueFirst.setNow(NOW + 5 * 60 * 1_000) + await reissue( + reissueFirst.notification, + requestedAtMicros, + NOW + 5 * 60 * 1_000, + ) + await expect((await acknowledge( + reissueFirst.notification, + staleGrant.ticket, + grantNotificationId(staleGrant), + )).json()).resolves.toEqual({ status: 'stale' }) + const winningGrant = pendingGrant(reissueFirst.storage) + await expect((await acknowledge( + reissueFirst.notification, + winningGrant.ticket, + grantNotificationId(winningGrant), + )).json()).resolves.toEqual({ status: 'accepted' }) + }) + + it('fails closed on paused, reset, or non-disabled authority without resending', async () => { + const requestedAtMicros = 1_799_999_999_000_000 + let admissionState: 'disabled' | 'missing' = 'disabled' + let liveRequestedAtMicros = requestedAtMicros + let notificationsEnabled = true + const fetchImpl = vi.fn(async () => successfulDelivery()) + const h = createHarness({ + fetchImpl, + configReader: () => config(notificationsEnabled), + resolver: { + resolve: vi.fn(async () => ({ state: admissionState, authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ + status: 'requested', + requestedAtMicros: liveRequestedAtMicros, + } as const)), + submit: vi.fn(async () => ({ status: 'not-requested' } as const)), + }, }) + await applyEvent(h.notification, enabledEvent()) + await queuePending(h.notification, requestedAtMicros) + h.setNow(NOW + 5 * 60 * 1_000) + + notificationsEnabled = false + await expect((await reissue( + h.notification, + requestedAtMicros, + NOW + 5 * 60 * 1_000, + )).json()).resolves.toEqual({ status: 'paused' }) + notificationsEnabled = true + admissionState = 'missing' + await expect((await reissue( + h.notification, + requestedAtMicros, + NOW + 5 * 60 * 1_000, + )).json()).resolves.toEqual({ status: 'stale' }) + expect(fetchImpl).toHaveBeenCalledOnce() + expect(h.storage.values.has(PENDING_GRANT_RECORD)).toBe(false) + + admissionState = 'disabled' + liveRequestedAtMicros += 1_000 + await queuePending(h.notification, requestedAtMicros + 1_000, NOW + 5 * 60 * 1_000) + const queuedFetchCount = fetchImpl.mock.calls.length + h.setNow(NOW + 10 * 60 * 1_000) + liveRequestedAtMicros += 1_000 + await expect((await reissue( + h.notification, + requestedAtMicros + 1_000, + NOW + 10 * 60 * 1_000, + )).json()).resolves.toEqual({ status: 'stale' }) + expect(fetchImpl).toHaveBeenCalledTimes(queuedFetchCount) }) it('does not reuse a pending-request receipt for a later application', async () => { @@ -309,7 +852,7 @@ describe('admission notification consent and delivery lifecycle', () => { requestedAtMicros += 1_000 const second = await queuePending(h.notification, requestedAtMicros, NOW + 1) - await expect(second.json()).resolves.toEqual({ status: 'already-sent' }) + await expect(second.json()).resolves.toEqual({ status: 'awaiting-client' }) expect(fetchImpl).toHaveBeenCalledTimes(2) const notificationIds = fetchImpl.mock.calls.map(call => ( JSON.parse(String(call[1]?.body)) as { notificationId: string } @@ -317,6 +860,232 @@ describe('admission notification consent and delivery lifecycle', () => { expect(new Set(notificationIds).size).toBe(2) }) + it('acknowledges a provider-accepted grant once and accepts an exact replay', async () => { + const requestedAtMicros = 1_799_999_999_000_000 + const h = createHarness({ + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + }, + }) + await applyEvent(h.notification, enabledEvent()) + await queuePending(h.notification, requestedAtMicros) + const grant = pendingGrant(h.storage) + + await expect((await acknowledge( + h.notification, + 'Z'.repeat(43), + grantNotificationId(grant), + )).json()).resolves.toEqual({ + status: 'stale', + }) + await expect((await acknowledge( + h.notification, + grant.ticket, + `warpkeep-access-grant-v3-i${'X'.repeat(22)}`, + )).json()).resolves.toEqual({ + status: 'context-mismatch', + }) + expect(pendingGrant(h.storage).acknowledgedAt).toBeUndefined() + await expect((await acknowledge( + h.notification, + grant.ticket, + grantNotificationId(grant), + )).json()).resolves.toEqual({ + status: 'accepted', + }) + await expect((await acknowledge( + h.notification, + grant.ticket, + grantNotificationId(grant), + )).json()).resolves.toEqual({ + status: 'accepted', + }) + expect(pendingGrant(h.storage).acknowledgedAt).toBe(NOW) + expect(JSON.stringify(h.storage.values.get(PENDING_GRANT_RECORD))).not.toContain(grant.ticket) + expect(JSON.stringify(h.storage.values.get(PENDING_GRANT_RECORD))).toContain('ticketHash') + expect(h.storage.values.has(PENDING_STATE_RECORD)).toBe(false) + const diagnostics = await (await inspect(h.notification)).text() + expect(diagnostics).not.toContain(grant.ticket) + expect(JSON.parse(diagnostics)).toMatchObject({ + status: 'client-acknowledged', + generation: 'pending-request', + subscriptionState: 'active', + activeSubscriptionCount: 1, + activeAttemptCount: 0, + grantState: 'client-acknowledged', + grantCreatedAt: NOW, + providerAcceptedAt: NOW, + clientAcknowledgedAt: NOW, + deliveryState: 'idle', + }) + }) + + it('invalidates an acknowledged replay after the exact request is reset', async () => { + let requestedAtMicros = 1_799_999_999_000_000 + const h = createHarness({ + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + }, + }) + await applyEvent(h.notification, enabledEvent()) + await queuePending(h.notification, requestedAtMicros) + const grant = pendingGrant(h.storage) + const ticket = grant.ticket + + await expect((await acknowledge( + h.notification, + ticket, + grantNotificationId(grant), + )).json()).resolves.toEqual({ + status: 'accepted', + }) + requestedAtMicros += 1_000 + await expect((await acknowledge( + h.notification, + ticket, + grantNotificationId(grant), + )).json()).resolves.toEqual({ + status: 'stale', + }) + expect(h.storage.values.has(PENDING_GRANT_RECORD)).toBe(false) + }) + + it('does not acknowledge a grant before the provider accepts it', async () => { + const requestedAtMicros = 1_799_999_999_000_000 + const h = createHarness({ + fetchImpl: vi.fn(async () => Response.json({ + result: { + successfulTokens: [], + invalidTokens: [], + rateLimitedTokens: [TOKEN], + }, + })), + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + }, + }) + await applyEvent(h.notification, enabledEvent()) + await queuePending(h.notification, requestedAtMicros) + + const grant = pendingGrant(h.storage) + await expect((await acknowledge( + h.notification, + grant.ticket, + grantNotificationId(grant), + )).json()).resolves.toEqual({ status: 'not-ready' }) + await expect((await inspect(h.notification)).json()).resolves.toMatchObject({ + status: 'queued', + }) + }) + + it('erases the raw grant capability on notification opt-out and expiry', async () => { + const requestedAtMicros = 1_799_999_999_000_000 + const createPendingHarness = () => createHarness({ + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + }, + }) + + const optedOut = createPendingHarness() + await applyEvent(optedOut.notification, enabledEvent()) + await queuePending(optedOut.notification, requestedAtMicros) + expect(pendingGrant(optedOut.storage).ticket).toMatch(/^[A-Za-z0-9_-]{43}$/) + await applyEvent(optedOut.notification, disabledEvent()) + expect(optedOut.storage.values.has(PENDING_GRANT_RECORD)).toBe(false) + + const expired = createPendingHarness() + await applyEvent(expired.notification, enabledEvent()) + await queuePending(expired.notification, requestedAtMicros) + expired.setNow(NOW + 24 * 60 * 60 * 1_000) + await expired.notification.alarm() + expect(expired.storage.values.has(PENDING_GRANT_RECORD)).toBe(false) + }) + + it('rejects the prior ticket after the exact access request is reset', async () => { + let requestedAtMicros = 1_799_999_999_000_000 + const h = createHarness({ + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + }, + }) + await applyEvent(h.notification, enabledEvent()) + await queuePending(h.notification, requestedAtMicros) + const oldGrant = pendingGrant(h.storage) + + requestedAtMicros += 1_000 + await expect((await acknowledge( + h.notification, + oldGrant.ticket, + grantNotificationId(oldGrant), + )).json()).resolves.toEqual({ + status: 'stale', + }) + expect(h.storage.values.has(PENDING_GRANT_RECORD)).toBe(false) + await queuePending(h.notification, requestedAtMicros, NOW + 1) + const nextGrant = pendingGrant(h.storage) + expect(nextGrant.intentId).not.toBe(oldGrant.intentId) + expect(nextGrant.ticket).not.toBe(oldGrant.ticket) + await expect((await acknowledge( + h.notification, + oldGrant.ticket, + grantNotificationId(oldGrant), + )).json()).resolves.toEqual({ + status: 'stale', + }) + }) + + it('does not let a rollback-era pending success receipt authorize a v3 grant', async () => { + const requestedAtMicros = 1_799_999_999_000_000 + const fetchImpl = vi.fn(async () => successfulDelivery()) + const h = createHarness({ + fetchImpl, + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + }, + }) + await applyEvent(h.notification, enabledEvent()) + h.storage.values.set(PENDING_STATE_RECORD, { + version: 1, + fid: FID, + lastSentRequestAtMicros: requestedAtMicros, + }) + + await expect((await queuePending( + h.notification, + requestedAtMicros, + )).json()).resolves.toEqual({ status: 'awaiting-client' }) + expect(fetchImpl).toHaveBeenCalledOnce() + const payload = JSON.parse(String(fetchImpl.mock.calls[0][1]?.body)) as { + notificationId: string + } + expect(payload.notificationId).toMatch(/^warpkeep-access-grant-v3-i[A-Za-z0-9_-]{22}$/) + expect(pendingGrant(h.storage).ticket).toMatch(/^[A-Za-z0-9_-]{43}$/) + }) + it('cancels a staged delivery when the exact pending request no longer matches', async () => { const requestedAtMicros = 1_799_999_999_000_000 const fetchImpl = vi.fn(async () => successfulDelivery()) @@ -336,9 +1105,12 @@ describe('admission notification consent and delivery lifecycle', () => { await applyEvent(h.notification, enabledEvent()) const response = await queuePending(h.notification, requestedAtMicros) - await expect(response.json()).resolves.toEqual({ status: 'not-subscribed' }) + expect(response.status).toBe(409) + expect(await response.text()).toBe('') expect(fetchImpl).not.toHaveBeenCalled() expect(stored(h.storage)).not.toContain('"delivery"') + expect(h.storage.values.has(PENDING_GRANT_RECORD)).toBe(false) + expect(h.storage.values.has(PENDING_GRANT_REISSUE_RECORD)).toBe(false) }) it('heals a rollback conflict before processing a signed opt-out', async () => { @@ -471,6 +1243,27 @@ describe('admission notification consent and delivery lifecycle', () => { expect(h.storage.values.has(STATE_KEY)).toBe(false) }) + it('retains verified consent while outbound delivery is paused', async () => { + const fetchImpl = vi.fn(async () => successfulDelivery()) + const h = createHarness({ + fetchImpl, + configReader: () => config(false), + }) + + const enabled = await applyEvent(h.notification, enabledEvent()) + expect(enabled.status).toBe(204) + expect(fetchImpl).not.toHaveBeenCalled() + expect(stored(h.storage)).toContain(TOKEN) + await expect((await inspect(h.notification)).json()).resolves.toMatchObject({ + version: 2, + systemState: 'paused', + subscriptionState: 'active', + activeSubscriptionCount: 1, + activeClientFids: [9_152], + deliveryState: 'idle', + }) + }) + it('keeps active delivery recoverable across a transient configuration outage', async () => { let configured = true let deliveryAttempt = 0 @@ -487,6 +1280,14 @@ describe('admission notification consent and delivery lifecycle', () => { }, }) await applyEvent(h.notification, enabledEvent()) + await expect((await inspect(h.notification)).json()).resolves.toMatchObject({ + status: 'not-subscribed', + subscriptionState: 'active', + activeSubscriptionCount: 1, + activeClientFids: [9_152], + grantState: 'none', + deliveryState: 'idle', + }) await queue(h.notification) expect(fetchImpl).toHaveBeenCalledOnce() @@ -816,9 +1617,21 @@ describe('admission notification consent and delivery lifecycle', () => { const h = createHarness({ resolver }) await expect((await inspect(h.notification)).json()).resolves.toEqual({ + version: 2, + systemState: 'enabled', + subscriptionState: 'absent', status: 'not-subscribed', + activeSubscriptionCount: 0, + activeClientFids: [], + activeAttemptCount: 0, + pendingAttemptCount: 0, + retryingAttemptCount: 0, + sentAttemptCount: 0, + exhaustedAttemptCount: 0, deliveryAttemptCount: 0, verificationFailureCount: 0, + grantState: 'none', + deliveryState: 'idle', retryReasons: [], }) await applyEvent(h.notification, enabledEvent()) @@ -829,11 +1642,25 @@ describe('admission notification consent and delivery lifecycle', () => { const text = await response.text() expect(text).not.toContain(TOKEN) expect(JSON.parse(text)).toEqual({ + version: 2, + systemState: 'enabled', + subscriptionState: 'active', status: 'queued', generation: 'admitted', authEpoch: 7, + activeSubscriptionCount: 1, + activeClientFids: [9_152], + activeAttemptCount: 1, + pendingAttemptCount: 0, + retryingAttemptCount: 1, + sentAttemptCount: 0, + exhaustedAttemptCount: 0, deliveryAttemptCount: 0, verificationFailureCount: 1, + deliveryQueuedAt: NOW, + deliveryExpiresAt: NOW + 24 * 60 * 60 * 1_000, + grantState: 'none', + deliveryState: 'retry-scheduled', retryReasons: ['admission-verification'], nextAttemptAt: NOW + 30_000, }) @@ -890,7 +1717,7 @@ describe('admission notification consent and delivery lifecycle', () => { await expect((await inspect(h.notification)).json()).resolves.toMatchObject({ status: 'delivery-exhausted', authEpoch: 8, - deliveryAttemptCount: 0, + deliveryAttemptCount: 6, verificationFailureCount: 0, }) }) diff --git a/services/auth-bridge/test/app.test.ts b/services/auth-bridge/test/app.test.ts index 4c2d16c2..2e6a7f0e 100644 --- a/services/auth-bridge/test/app.test.ts +++ b/services/auth-bridge/test/app.test.ts @@ -2,6 +2,7 @@ import { createSiweMessage } from 'viem/siwe' import { Errors as QuickAuthErrors } from '@farcaster/quick-auth' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { + ADMISSION_NOTIFICATION_REISSUE_PATH, ADMISSION_NOTIFICATION_STATUS_PATH, FARCASTER_VERIFICATION_TIMEOUT_MILLISECONDS, QUICK_AUTH_MAX_ISSUER_LIFETIME_SECONDS, @@ -49,6 +50,8 @@ const QUICK_AUTH_ISSUER = 'https://auth.farcaster.xyz' const QUICK_AUTH_PATH = '/v2/farcaster/quick-auth/exchange' const ACCESS_STATUS_PATH = '/v2/access/status' const ACCESS_REQUEST_PATH = '/v2/access/request' +const ADMISSION_GRANT_PATH = '/v2/access/admission-grant-context' +const RETIRED_ADMISSION_GRANT_CANDIDATE_PATH = '/v2/access/admission-grant' const syntheticQuickAuthSegment = (value: object) => btoa(JSON.stringify(value)) .replaceAll('+', '-') .replaceAll('/', '_') @@ -68,6 +71,7 @@ const SERVER_ONLY_ADMIN_PATHS = [ '/v1/admin/auth-epoch-probe', '/v1/admin/config-attestation', ADMISSION_NOTIFICATION_PATH, + ADMISSION_NOTIFICATION_REISSUE_PATH, ADMISSION_NOTIFICATION_STATUS_PATH, ] as const const BINDING_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' @@ -235,7 +239,10 @@ function quickAuthRequest( } function accessBearerRequest( - path: typeof ACCESS_STATUS_PATH | typeof ACCESS_REQUEST_PATH, + path: typeof ACCESS_STATUS_PATH + | typeof ACCESS_REQUEST_PATH + | typeof ADMISSION_GRANT_PATH + | typeof RETIRED_ADMISSION_GRANT_CANDIDATE_PATH, body: unknown = {}, init: RequestInit = {}, ): Request { @@ -1373,6 +1380,8 @@ describe('Warpkeep auth bridge', () => { quickAuthMaxIssuerLifetimeSeconds: 60 * 60, accessRequestStatusPath: '/v2/access/status', accessRequestSubmitPath: '/v2/access/request', + admissionGrantPath: '/v2/access/admission-grant-context', + admissionNotificationReissuePath: ADMISSION_NOTIFICATION_REISSUE_PATH, accessRequestResolverTokenTtlSeconds: 15, accessRequestResolverTimeoutMilliseconds: 5_000, accessRequestStatusProcedure: 'access_request_get_status_v1', @@ -1433,6 +1442,8 @@ describe('Warpkeep auth bridge', () => { authEpochResolverTimeoutMilliseconds: 5_000, accessRequestStatusPath: '/v2/access/status', accessRequestSubmitPath: '/v2/access/request', + admissionGrantPath: '/v2/access/admission-grant-context', + admissionNotificationReissuePath: ADMISSION_NOTIFICATION_REISSUE_PATH, accessRequestResolverTokenTtlSeconds: 15, accessRequestResolverTimeoutMilliseconds: 5_000, accessRequestStatusProcedure: 'access_request_get_status_v1', @@ -2419,6 +2430,7 @@ describe('Warpkeep auth bridge', () => { }) describe('Farcaster admission notifications', () => { + const rejectGrant = vi.fn(async () => 'stale' as const) const verifiedEnableEvent = Object.freeze({ eventId: 'a'.repeat(64), fid: FID, @@ -2432,36 +2444,73 @@ describe('Warpkeep auth bridge', () => { }), }) - it('keeps both endpoints independently fail-closed while rollout is paused', async () => { + it('keeps mutations paused while preserving token-free operator diagnostics', async () => { const verify = vi.fn(async () => verifiedEnableEvent) const applyEvent = vi.fn(async () => undefined) const queueAdmission = vi.fn(async () => 'queued' as const) + const inspect = vi.fn(async () => Object.freeze({ + version: 2 as const, + systemState: 'paused' as const, + subscriptionState: 'absent' as const, + status: 'not-subscribed' as const, + activeSubscriptionCount: 0, + activeClientFids: Object.freeze([]), + activeAttemptCount: 0, + pendingAttemptCount: 0, + retryingAttemptCount: 0, + sentAttemptCount: 0, + exhaustedAttemptCount: 0, + deliveryAttemptCount: 0, + verificationFailureCount: 0, + grantState: 'none' as const, + deliveryState: 'idle' as const, + retryReasons: Object.freeze([]), + })) const h = harness({ miniAppWebhookVerifier: { verify }, - admissionNotificationStore: { applyEvent, queueAdmission }, + admissionNotificationStore: { + applyEvent, + queueAdmission, + acknowledge: rejectGrant, + inspect, + }, }) - for (const candidate of [ + const webhook = await h.app.fetch( request(MINIAPP_WEBHOOK_PATH, { header: 'h', payload: 'p', signature: 's' }), - request(ADMISSION_NOTIFICATION_PATH, { fid: FID }, { - headers: { authorization: `Bearer ${NOTIFICATION_OPERATOR_SECRET}` }, - }), - request(ADMISSION_NOTIFICATION_STATUS_PATH, { fid: FID }, { - headers: { authorization: `Bearer ${NOTIFICATION_OPERATOR_SECRET}` }, - }), - ]) { - const response = await h.app.fetch(candidate, env()) - expect(response.status).toBe(503) - await expect(response.json()).resolves.toMatchObject({ - error: { code: 'approval_notifications_paused' }, - }) - expect(response.headers.has('access-control-allow-origin')).toBe(false) - } - // The webhook must still be authenticated while paused so a genuine - // disable/remove can erase consent; enabled events remain unpersisted. + notificationEnv({ APPROVAL_NOTIFICATIONS_ENABLED: 'false' }), + ) + expect(webhook.status).toBe(200) + expect(await webhook.text()).toBe('') + expect(webhook.headers.has('access-control-allow-origin')).toBe(false) + + const queue = await h.app.fetch(request(ADMISSION_NOTIFICATION_PATH, { fid: FID }, { + headers: { authorization: `Bearer ${NOTIFICATION_OPERATOR_SECRET}` }, + }), notificationEnv({ APPROVAL_NOTIFICATIONS_ENABLED: 'false' })) + expect(queue.status).toBe(503) + await expect(queue.json()).resolves.toMatchObject({ + error: { code: 'approval_notifications_paused' }, + }) + expect(queue.headers.has('access-control-allow-origin')).toBe(false) + + // Consent webhooks remain verified and durable while outbound delivery + // is paused; disable/remove can therefore revoke the same record later. expect(verify).toHaveBeenCalledOnce() - expect(applyEvent).not.toHaveBeenCalled() + expect(applyEvent).toHaveBeenCalledExactlyOnceWith(verifiedEnableEvent) expect(queueAdmission).not.toHaveBeenCalled() + + const diagnostics = await h.app.fetch(request( + ADMISSION_NOTIFICATION_STATUS_PATH, + { fid: FID }, + { headers: { authorization: `Bearer ${NOTIFICATION_OPERATOR_SECRET}` } }, + ), notificationEnv({ APPROVAL_NOTIFICATIONS_ENABLED: 'false' })) + expect(diagnostics.status).toBe(200) + await expect(diagnostics.json()).resolves.toMatchObject({ + version: 2, + systemState: 'paused', + subscriptionState: 'absent', + }) + expect(inspect).toHaveBeenCalledWith(FID) }) it('accepts only a server-to-server verified webhook and returns no token material', async () => { @@ -2472,6 +2521,7 @@ describe('Warpkeep auth bridge', () => { admissionNotificationStore: { applyEvent, queueAdmission: vi.fn(async () => 'queued' as const), + acknowledge: rejectGrant, }, }) const signedEnvelope = { header: 'header', payload: 'payload', signature: 'signature' } @@ -2512,6 +2562,7 @@ describe('Warpkeep auth bridge', () => { admissionNotificationStore: { applyEvent, queueAdmission: vi.fn(async () => 'not-subscribed' as const), + acknowledge: rejectGrant, }, }) const response = await h.app.fetch(request( @@ -2552,6 +2603,7 @@ describe('Warpkeep auth bridge', () => { admissionNotificationStore: { applyEvent, queueAdmission: vi.fn(async () => 'queued' as const), + acknowledge: rejectGrant, }, }) const response = await h.app.fetch(request( @@ -2570,6 +2622,7 @@ describe('Warpkeep auth bridge', () => { const store: AdmissionNotificationStore = { applyEvent: vi.fn(async () => undefined), queueAdmission, + acknowledge: rejectGrant, } const h = harness({ admissionNotificationStore: store }) @@ -2608,6 +2661,175 @@ describe('Warpkeep auth bridge', () => { expect(JSON.stringify(acceptedBody)).not.toContain(NOTIFICATION_OPERATOR_SECRET) }) + it('reissues only for the exact disabled pending request under the operator credential', async () => { + const requestedAtMicros = 1_785_414_896_000_000 + const reissueAdmission = vi.fn(async () => Object.freeze({ + status: 'reissued' as const, + deliveryStatus: 'awaiting-client' as const, + })) + const h = harness({ + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ + status: 'requested' as const, + requestedAtMicros, + })), + submit: vi.fn(async () => ({ status: 'not-requested' } as const)), + }, + admissionNotificationStore: { + applyEvent: vi.fn(async () => undefined), + queueAdmission: vi.fn(async () => 'queued' as const), + reissueAdmission, + acknowledge: rejectGrant, + }, + }) + + for (const headers of [ + new Headers({ authorization: `Bearer ${ADMIN_SECRET}` }), + new Headers({ + authorization: `Bearer ${NOTIFICATION_OPERATOR_SECRET}`, + origin: ORIGIN, + }), + ]) { + const rejected = await h.app.fetch(request( + ADMISSION_NOTIFICATION_REISSUE_PATH, + { fid: FID }, + { headers }, + ), notificationEnv()) + expect(rejected.status).toBe(headers.has('origin') ? 403 : 401) + } + expect(reissueAdmission).not.toHaveBeenCalled() + + const accepted = await h.app.fetch(request( + ADMISSION_NOTIFICATION_REISSUE_PATH, + { fid: FID }, + { headers: { authorization: `Bearer ${NOTIFICATION_OPERATOR_SECRET}` } }, + ), notificationEnv()) + expect(accepted.status).toBe(200) + const responseBody = await accepted.json() + expect(responseBody).toEqual({ + status: 'reissued', + deliveryStatus: 'awaiting-client', + }) + expect(reissueAdmission).toHaveBeenCalledExactlyOnceWith({ + fid: FID, + requestedAtMicros, + reissuedAt: expect.any(Number), + }) + expect(h.events).toContain('admission_notification_reissued') + expect(JSON.stringify(responseBody)).not.toContain('ticket') + expect(JSON.stringify(responseBody)).not.toContain('notificationId') + }) + + it('fails reissue closed for non-disabled authority, stale requests, and the pause switch', async () => { + const requestedAtMicros = 1_785_414_896_000_000 + const reissueAdmission = vi.fn(async () => Object.freeze({ + status: 'reissued' as const, + deliveryStatus: 'awaiting-client' as const, + })) + const store: AdmissionNotificationStore = { + applyEvent: vi.fn(async () => undefined), + queueAdmission: vi.fn(async () => 'queued' as const), + reissueAdmission, + acknowledge: rejectGrant, + } + const headers = { authorization: `Bearer ${NOTIFICATION_OPERATOR_SECRET}` } + + for (const state of ['missing', 'enabled'] as const) { + const h = harness({ + resolver: { + resolve: vi.fn(async () => state === 'enabled' + ? ({ state, authEpoch: 7 } as const) + : ({ state, authEpoch: 0 } as const)), + }, + admissionNotificationStore: store, + }) + const response = await h.app.fetch(request( + ADMISSION_NOTIFICATION_REISSUE_PATH, + { fid: FID }, + { headers }, + ), notificationEnv()) + expect(response.status).toBe(409) + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'admission_not_disabled' }, + }) + } + + const staleRequest = harness({ + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'not-requested' } as const)), + submit: vi.fn(async () => ({ status: 'not-requested' } as const)), + }, + admissionNotificationStore: store, + }) + const staleResponse = await staleRequest.app.fetch(request( + ADMISSION_NOTIFICATION_REISSUE_PATH, + { fid: FID }, + { headers }, + ), notificationEnv()) + expect(staleResponse.status).toBe(409) + await expect(staleResponse.json()).resolves.toMatchObject({ + error: { code: 'access_request_not_pending' }, + }) + + const paused = harness({ admissionNotificationStore: store }) + const pausedResponse = await paused.app.fetch(request( + ADMISSION_NOTIFICATION_REISSUE_PATH, + { fid: FID }, + { headers }, + ), notificationEnv({ APPROVAL_NOTIFICATIONS_ENABLED: 'false' })) + expect(pausedResponse.status).toBe(503) + await expect(pausedResponse.json()).resolves.toMatchObject({ + error: { code: 'approval_notifications_paused' }, + }) + expect(reissueAdmission).not.toHaveBeenCalled() + expect(paused.events).toContain('admission_notification_reissue_rejected') + }) + + it('returns bounded reissue cooldown and limit statuses without secret material', async () => { + const requestedAtMicros = 1_785_414_896_000_000 + for (const [result, event] of [ + [Object.freeze({ status: 'cooldown' as const, retryAfterSeconds: 300 }), + 'admission_notification_reissue_cooldown' as const], + [Object.freeze({ status: 'limit-reached' as const }), + 'admission_notification_reissue_limit' as const], + ] as const) { + const h = harness({ + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ + status: 'requested' as const, + requestedAtMicros, + })), + submit: vi.fn(async () => ({ status: 'not-requested' } as const)), + }, + admissionNotificationStore: { + applyEvent: vi.fn(async () => undefined), + queueAdmission: vi.fn(async () => 'queued' as const), + reissueAdmission: vi.fn(async () => result), + acknowledge: rejectGrant, + }, + }) + const response = await h.app.fetch(request( + ADMISSION_NOTIFICATION_REISSUE_PATH, + { fid: FID }, + { headers: { authorization: `Bearer ${NOTIFICATION_OPERATOR_SECRET}` } }, + ), notificationEnv()) + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual(result) + expect(h.events).toContain(event) + expect(JSON.stringify(body)).not.toContain(NOTIFICATION_OPERATOR_SECRET) + } + }) + it('does not queue for a missing or disabled identity without a pending request', async () => { const queueAdmission = vi.fn(async () => 'queued' as const) const h = harness({ @@ -2615,6 +2837,7 @@ describe('Warpkeep auth bridge', () => { admissionNotificationStore: { applyEvent: vi.fn(async () => undefined), queueAdmission, + acknowledge: rejectGrant, }, }) const response = await h.app.fetch(request( @@ -2645,6 +2868,7 @@ describe('Warpkeep auth bridge', () => { admissionNotificationStore: { applyEvent: vi.fn(async () => undefined), queueAdmission, + acknowledge: rejectGrant, }, }) @@ -2668,10 +2892,25 @@ describe('Warpkeep auth bridge', () => { it('exposes only token-free diagnostics to the separate operator credential', async () => { const inspect = vi.fn(async () => Object.freeze({ + version: 2 as const, + systemState: 'enabled' as const, + subscriptionState: 'active' as const, status: 'queued' as const, + generation: 'admitted' as const, authEpoch: 7, + activeSubscriptionCount: 1, + activeClientFids: Object.freeze([9_152]), + activeAttemptCount: 1, + pendingAttemptCount: 0, + retryingAttemptCount: 1, + sentAttemptCount: 0, + exhaustedAttemptCount: 0, deliveryAttemptCount: 1, verificationFailureCount: 0, + deliveryQueuedAt: 1_800_000_000_000, + deliveryExpiresAt: 1_800_086_400_000, + grantState: 'none' as const, + deliveryState: 'retry-scheduled' as const, retryReasons: Object.freeze(['invalid-response'] as const), nextAttemptAt: 1_800_000_030_000, })) @@ -2679,6 +2918,7 @@ describe('Warpkeep auth bridge', () => { admissionNotificationStore: { applyEvent: vi.fn(async () => undefined), queueAdmission: vi.fn(async () => 'queued' as const), + acknowledge: rejectGrant, inspect, }, }) @@ -2707,10 +2947,25 @@ describe('Warpkeep auth bridge', () => { expect(accepted.status).toBe(200) const body = await accepted.json() expect(body).toEqual({ + version: 2, + systemState: 'enabled', + subscriptionState: 'active', status: 'queued', + generation: 'admitted', authEpoch: 7, + activeSubscriptionCount: 1, + activeClientFids: [9_152], + activeAttemptCount: 1, + pendingAttemptCount: 0, + retryingAttemptCount: 1, + sentAttemptCount: 0, + exhaustedAttemptCount: 0, deliveryAttemptCount: 1, verificationFailureCount: 0, + deliveryQueuedAt: 1_800_000_000_000, + deliveryExpiresAt: 1_800_086_400_000, + grantState: 'none', + deliveryState: 'retry-scheduled', retryReasons: ['invalid-response'], nextAttemptAt: 1_800_000_030_000, }) @@ -2722,6 +2977,160 @@ describe('Warpkeep auth bridge', () => { }) describe('neutral access requests', () => { + it('acknowledges only an exact memory-only grant for the verified pending identity', async () => { + const ticket = 'A'.repeat(43) + const notificationId = `warpkeep-access-grant-v3-i${'N'.repeat(22)}` + const acknowledge = vi.fn(async () => 'accepted' as const) + const h = harness({ + epoch: 0, + admissionNotificationStore: { + applyEvent: vi.fn(async () => undefined), + queueAdmission: vi.fn(async () => 'queued' as const), + acknowledge, + }, + }) + + const response = await h.app.fetch(accessBearerRequest( + ADMISSION_GRANT_PATH, + { ticket, notificationId }, + ), env()) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + version: 1, + status: 'accepted', + }) + expect(acknowledge).toHaveBeenCalledOnce() + expect(acknowledge).toHaveBeenCalledWith(FID, ticket, notificationId) + expect(h.quickAuthVerifier.verifyJwt).toHaveBeenCalledOnce() + expect(h.resolver.resolve).toHaveBeenCalledWith(FID) + expect(h.events).toContain('admission_grant_acknowledged') + expect(JSON.stringify(h.events)).not.toContain(ticket) + expect(response.headers.get('access-control-allow-origin')).toBe(QUICK_AUTH_ORIGIN) + expect(response.headers.has('access-control-allow-credentials')).toBe(false) + }) + + it('returns existing authority while best-effort erasing its exact notification capability', async () => { + const ticket = 'C'.repeat(43) + const notificationId = `warpkeep-access-grant-v3-i${'C'.repeat(22)}` + const acknowledge = vi.fn(async () => 'stale' as const) + const h = harness({ + epoch: 7, + admissionNotificationStore: { + applyEvent: vi.fn(async () => undefined), + queueAdmission: vi.fn(async () => 'already-sent' as const), + acknowledge, + }, + }) + + const response = await h.app.fetch(accessBearerRequest( + ADMISSION_GRANT_PATH, + { ticket, notificationId }, + ), env()) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + version: 1, + status: 'already-admitted', + }) + expect(acknowledge).toHaveBeenCalledWith(FID, ticket, notificationId) + expect(JSON.stringify(h.events)).not.toContain(ticket) + }) + + it('rejects malformed or identity-drifted grant capabilities before the store', async () => { + const acknowledge = vi.fn(async () => 'accepted' as const) + const h = harness({ + epoch: 0, + admissionNotificationStore: { + applyEvent: vi.fn(async () => undefined), + queueAdmission: vi.fn(async () => 'queued' as const), + acknowledge, + }, + }) + + const malformed = await h.app.fetch(accessBearerRequest( + ADMISSION_GRANT_PATH, + { ticket: 'short' }, + ), env()) + const retiredCandidatePath = await h.app.fetch(accessBearerRequest( + RETIRED_ADMISSION_GRANT_CANDIDATE_PATH, + { ticket: 'B'.repeat(43) }, + ), env()) + const notificationId = `warpkeep-access-grant-v3-i${'D'.repeat(22)}` + const drifted = await h.app.fetch(accessBearerRequest( + ADMISSION_GRANT_PATH, + { ticket: 'B'.repeat(43), notificationId }, + { headers: { 'x-warpkeep-expected-fid': '54321' } }, + ), env()) + + expect(malformed.status).toBe(400) + expect(retiredCandidatePath.status).toBe(404) + expect(drifted.status).toBe(409) + expect(acknowledge).not.toHaveBeenCalled() + expect(h.events).toContain('admission_grant_ack_context_rejected') + expect(JSON.stringify(h.events)).not.toContain('B'.repeat(43)) + }) + + it('requires host Quick Auth and rejects a browser-session grant exchange', async () => { + const acknowledge = vi.fn(async () => 'accepted' as const) + const h = harness({ + epoch: 0, + admissionNotificationStore: { + applyEvent: vi.fn(async () => undefined), + queueAdmission: vi.fn(async () => 'queued' as const), + acknowledge, + }, + }) + const notificationId = `warpkeep-access-grant-v3-i${'F'.repeat(22)}` + const response = await h.app.fetch(request( + ADMISSION_GRANT_PATH, + { ticket: 'F'.repeat(43), notificationId }, + { + headers: { + origin: ORIGIN, + cookie: '__Host-warpkeep_session=untrusted', + 'x-warpkeep-expected-fid': FID, + }, + }, + ), env()) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ + error: { + code: 'access_auth_invalid', + message: 'Farcaster authentication could not be verified.', + }, + }) + expect(acknowledge).not.toHaveBeenCalled() + expect(h.events).toContain('admission_grant_ack_quick_auth_required') + }) + + it('projects a server-side notification-context mismatch as neutral stale state', async () => { + const ticket = 'M'.repeat(43) + const notificationId = `warpkeep-access-grant-v3-i${'Q'.repeat(22)}` + const acknowledge = vi.fn(async () => 'context-mismatch' as const) + const h = harness({ + epoch: 0, + admissionNotificationStore: { + applyEvent: vi.fn(async () => undefined), + queueAdmission: vi.fn(async () => 'queued' as const), + acknowledge, + }, + }) + + const response = await h.app.fetch(accessBearerRequest( + ADMISSION_GRANT_PATH, + { ticket, notificationId }, + ), env()) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ version: 1, status: 'stale' }) + expect(acknowledge).toHaveBeenCalledWith(FID, ticket, notificationId) + expect(h.events).toContain('admission_grant_ack_context_mismatch') + expect(JSON.stringify(h.events)).not.toContain(notificationId) + expect(JSON.stringify(h.events)).not.toContain(ticket) + }) + it('reuses exact Quick Auth and returns only neutral status projections', async () => { const getStatus = vi.fn(async () => ({ status: 'not-requested' } as const)) const submit = vi.fn(async () => ({ diff --git a/services/auth-bridge/test/rateLimit.test.ts b/services/auth-bridge/test/rateLimit.test.ts index 24d6fbe0..5204f74f 100644 --- a/services/auth-bridge/test/rateLimit.test.ts +++ b/services/auth-bridge/test/rateLimit.test.ts @@ -19,7 +19,7 @@ const LIMITS: Readonly> = { 'session-refresh': 30, 'access-request': 12, 'miniapp-webhook': 2_000, - 'admission-notification': 12, + 'admission-notification': 72, 'admin-token': 6, 'qa-challenge': 12, 'qa-snapshot': 60, diff --git a/spacetimedb/src/index.ts b/spacetimedb/src/index.ts index 2d658f21..10ff3cf7 100644 --- a/spacetimedb/src/index.ts +++ b/spacetimedb/src/index.ts @@ -15,7 +15,9 @@ export { adminSeedWorld, adminExpandGenesisWorldV3, adminAllowFid, + adminAllowFidForAccessRequestV1, adminAdmitFounderV1, + adminAdmitFounderForAccessRequestV2, adminDisableFid, adminBumpAuthEpoch, adminGetAlphaStatus, @@ -34,6 +36,7 @@ export { accessRequestGetStatusV1, accessRequestSubmitV1, adminListAccessRequestsV1, + adminGetAccessRequestAdmissionStatusV1, adminGetAccessRequestResetStatusV1, adminResetAccessRequestV1, } from './reducers/accessRequests'; diff --git a/spacetimedb/src/reducers/accessRequests.ts b/spacetimedb/src/reducers/accessRequests.ts index 55574aac..1a8c841b 100644 --- a/spacetimedb/src/reducers/accessRequests.ts +++ b/spacetimedb/src/reducers/accessRequests.ts @@ -52,6 +52,17 @@ const adminAccessRequestResetStatusV1 = t.object('AdminAccessRequestResetStatusV requestedAtMicros: t.option(t.u64()), }); +const adminAccessRequestAdmissionStatusV1 = t.object( + 'AdminAccessRequestAdmissionStatusV1', + { + admissionState: t.string(), + authEpoch: t.u32(), + requestState: t.string(), + requestCycle: t.option(t.u64()), + requestedAtMicros: t.option(t.u64()), + }, +); + type AdmissionState = AuthResolverAdmission['state']; function resolveAdmissionState( @@ -162,6 +173,64 @@ function adminResetStatus( }; } +/** + * Exact read-only admission/request view used by Hermes to prepare a request- + * CAS transition. The module does not observe notification delivery or opens. + * Missing identities use epoch and cycle zero; existing identities retain their + * private auth epoch. Impossible future cycles fail closed. + */ +function adminAdmissionStatus( + tx: Parameters[0], + fid: bigint, +) { + const allowed = tx.db.allowedFid.fid.find(fid); + const admissionState = resolveAdmissionState(allowed); + let authEpoch = 0; + if (allowed !== null) { + if ( + !Number.isInteger(allowed.authEpoch) + || allowed.authEpoch < 1 + || allowed.authEpoch > MAX_AUTH_EPOCH + ) { + throw new SenderError('ACCESS_REQUEST_STATE_INTEGRITY'); + } + authEpoch = allowed.authEpoch; + assertGenesisFounderForFid(tx, fid); + assertGenesisResourceForFid(tx, fid); + } + + const request = tx.db.accessRequestV1.fid.find(fid); + const currentRequestCycle = requestCycleForAdmission(allowed, admissionState); + const maximumStoredRequestCycle = admissionState === 'disabled' + ? BigInt(authEpoch) + 1n + : BigInt(authEpoch); + if ( + request !== null + && ( + (allowed === null && request.requestCycle !== 0n) + || ( + allowed !== null + && request.requestCycle > maximumStoredRequestCycle + ) + ) + ) { + throw new SenderError('ACCESS_REQUEST_STATE_INTEGRITY'); + } + const requestState = request === null + ? 'not_requested' + : currentRequestCycle !== undefined + && request.requestCycle === currentRequestCycle + ? 'pending' + : 'resolved'; + return { + admissionState, + authEpoch, + requestState, + requestCycle: request?.requestCycle, + requestedAtMicros: request === null ? undefined : requestedAtMicros(request), + }; +} + /** * Caller-private status. The sole FID comes from the bridge-issued resolver * token; there is deliberately no browser-controlled FID argument. @@ -349,6 +418,24 @@ export const adminListAccessRequestsV1 = warpkeep.procedure( }), ); +/** + * Admin-only exact state for one request-CAS admission decision used by the + * notification-gated Hermes operator. The module itself does not observe the + * notification. The product contains no profile, note, token, or external + * identity. + */ +export const adminGetAccessRequestAdmissionStatusV1 = warpkeep.procedure( + { name: 'admin_get_access_request_admission_status_v1' }, + { fid: t.u64() }, + adminAccessRequestAdmissionStatusV1, + (ctx, { fid }) => + ctx.withTx(tx => { + requireAdmin(tx); + requireSupportedFid(fid); + return adminAdmissionStatus(tx, fid); + }), +); + /** * Exact admin-private pre/post view for the bounded founder reset operator. * It returns only authority state and the request tuple needed for CAS; no diff --git a/spacetimedb/src/reducers/admin.ts b/spacetimedb/src/reducers/admin.ts index 1023b929..a03596aa 100644 --- a/spacetimedb/src/reducers/admin.ts +++ b/spacetimedb/src/reducers/admin.ts @@ -110,7 +110,9 @@ function applyAllowedFidTransition( fid: bigint; note: string; adminSubject: string; - auditAction: 'allow_fid' | 'admit_founder_v1'; + auditAction: 'allow_fid' | 'admit_founder_v1' + | 'allow_fid_for_access_request_v1' + | 'admit_founder_for_access_request_v2'; }>, ): void { const existing = ctx.db.allowedFid.fid.find(input.fid); @@ -161,6 +163,33 @@ function applyAllowedFidTransition( } } +/** + * Exact access-request compare-and-swap guard shared by the request-CAS + * admission reducers used by notification-gated Hermes. The module does not + * observe a notification; it enforces only the authoritative admission kind + * and database-derived request tuple. This helper performs no writes. + */ +function requireExactAccessRequest( + ctx: Parameters[0], + fid: bigint, + expectedRequestCycle: bigint, + expectedRequestedAtMicros: bigint, + requiredRequestCycle: bigint, +): void { + const request = ctx.db.accessRequestV1.fid.find(fid); + const storedRequestedAtMicros = request?.requestedAt.microsSinceUnixEpoch; + if ( + expectedRequestCycle !== requiredRequestCycle + || request === null + || request.requestCycle !== expectedRequestCycle + || storedRequestedAtMicros === undefined + || storedRequestedAtMicros <= 0n + || storedRequestedAtMicros !== expectedRequestedAtMicros + ) { + throw new SenderError('ACCESS_REQUEST_ADMISSION_CAS_MISMATCH'); + } +} + function assertExactGenesisDynamicGraph(ctx: Parameters[0]) { assertGenesisFoundingGraph(ctx); const resource = inspectGenesisResourceGraph(ctx); @@ -878,6 +907,121 @@ export const adminUpsertRealmProfileV1 = warpkeep.reducer( }, ); +/** + * Owner-only request-CAS re-admission used by notification-gated Hermes. This + * reducer does not observe notification delivery or acknowledgement; it + * enforces only the exact disabled admission kind and request tuple. Founder + * and resource state remain permanent. + */ +export const adminAllowFidForAccessRequestV1 = warpkeep.reducer( + { name: 'admin_allow_fid_for_access_request_v1' }, + { + fid: t.u64(), + note: t.string(), + expectedRequestCycle: t.u64(), + expectedRequestedAtMicros: t.u64(), + }, + (ctx, { + fid, + note, + expectedRequestCycle, + expectedRequestedAtMicros, + }) => { + const admin = requireAdmin(ctx); + requireSupportedFid(fid); + const cleanNote = cleanAdminNote(note); + const existing = ctx.db.allowedFid.fid.find(fid); + if (existing === null || existing.enabled) { + throw new SenderError('ACCESS_REQUEST_ADMISSION_CAS_MISMATCH'); + } + requireExactAccessRequest( + ctx, + fid, + expectedRequestCycle, + expectedRequestedAtMicros, + BigInt(existing.authEpoch) + 1n, + ); + + assertGenesisFounderForFid(ctx, fid); + assertGenesisResourceForFid(ctx, fid); + applyAllowedFidTransition(ctx, { + fid, + note: cleanNote, + adminSubject: admin.subject, + auditAction: 'allow_fid_for_access_request_v1', + }); + assertGenesisFounderForFid(ctx, fid); + assertGenesisResourceForFid(ctx, fid); + grantDailyMarkIfActive(ctx, fid); + }, +); + +/** + * Owner-only first-founding request CAS used by notification-gated Hermes. This + * reducer does not observe a notification; the absent admission row and exact + * cycle-zero request tuple are its authority preconditions. Trusted profile + * validation, atomic founding, graph assertions, and daily Marks behavior match + * admin_admit_founder_v1. + */ +export const adminAdmitFounderForAccessRequestV2 = warpkeep.reducer( + { name: 'admin_admit_founder_for_access_request_v2' }, + { + fid: t.u64(), + note: t.string(), + expectedRequestCycle: t.u64(), + expectedRequestedAtMicros: t.u64(), + canonicalUsername: t.string(), + displayName: t.option(t.string()), + pfpUrl: t.string(), + publicBio: t.option(t.string()), + profilePolicyVersion: t.string(), + }, + (ctx, input) => { + const admin = requireAdmin(ctx); + requireSupportedFid(input.fid); + const cleanNote = cleanAdminNote(input.note); + if (input.profilePolicyVersion !== FARCASTER_PROFILE_POLICY_VERSION) { + throw new SenderError('PROFILE_POLICY_MISMATCH'); + } + + let normalized; + try { + normalized = normalizeAdmissionReadyTrustedProfile(input); + } catch (error) { + if (error instanceof ProfileAuthorityPolicyError) throw new SenderError(error.code); + throw error; + } + + if (ctx.db.allowedFid.fid.find(input.fid) !== null) { + throw new SenderError('ACCESS_REQUEST_ADMISSION_CAS_MISMATCH'); + } + requireExactAccessRequest( + ctx, + input.fid, + input.expectedRequestCycle, + input.expectedRequestedAtMicros, + 0n, + ); + + applyAllowedFidTransition(ctx, { + fid: input.fid, + note: cleanNote, + adminSubject: admin.subject, + auditAction: 'admit_founder_for_access_request_v2', + }); + ensureGenesisFounder(ctx, input.fid, normalized); + const verifiedProfile = ctx.db.realmProfileV1.fid.find(input.fid); + if ( + verifiedProfile === null + || !admissionProfileIsComplete(verifiedProfile) + || !trustedProfilesEqual(verifiedProfile, normalized) + ) throw new SenderError('FOUNDER_PROFILE_INCOMPLETE'); + assertGenesisFounderForFid(ctx, input.fid); + assertGenesisResourceForFid(ctx, input.fid); + grantDailyMarkIfActive(ctx, input.fid); + }, +); + /** Burn and wallet-attribution mutation wires were retired in Alpha 0.3.33. */ export const adminDisableFid = warpkeep.reducer( { name: 'admin_disable_fid' }, diff --git a/spacetimedb/src/schema.ts b/spacetimedb/src/schema.ts index dc82a505..e489e541 100644 --- a/spacetimedb/src/schema.ts +++ b/spacetimedb/src/schema.ts @@ -1396,6 +1396,8 @@ for (const name of [ 'admin_get_alpha_status_v2', 'admin_get_alpha_status_v3', 'admin_admit_founder_v1', + 'admin_admit_founder_for_access_request_v2', + 'admin_allow_fid_for_access_request_v1', 'admin_upsert_realm_profile_v1', 'accept_alpha_terms_v1', 'get_my_resource_state_v1', @@ -1440,6 +1442,7 @@ for (const name of [ 'access_request_get_status_v1', 'access_request_submit_v1', 'admin_list_access_requests_v1', + 'admin_get_access_request_admission_status_v1', 'admin_get_access_request_reset_status_v1', 'admin_reset_access_request_v1', 'admin_get_daily_marks_status_v1', diff --git a/spacetimedb/tests/accessRequestAdmissionReducers.test.ts b/spacetimedb/tests/accessRequestAdmissionReducers.test.ts new file mode 100644 index 00000000..bdef96e1 --- /dev/null +++ b/spacetimedb/tests/accessRequestAdmissionReducers.test.ts @@ -0,0 +1,212 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import test from 'node:test'; + +function source(path: string): string { + return readFileSync(new URL(path, import.meta.url), 'utf8'); +} + +function section(text: string, startMarker: string, endMarker: string): string { + const start = text.indexOf(startMarker); + const end = text.indexOf(endMarker, start + startMarker.length); + assert.ok(start >= 0 && end > start, `missing source section ${startMarker}`); + return text.slice(start, end); +} + +test('request-CAS admission uses one exact read-only request tuple guard', () => { + const admin = source('../src/reducers/admin.ts'); + const guard = section( + admin, + 'function requireExactAccessRequest(', + '\n}\n\nfunction assertExactGenesisDynamicGraph', + ); + + assert.match(guard, /accessRequestV1\.fid\.find\(fid\)/); + assert.match(guard, /request\?\.requestedAt\.microsSinceUnixEpoch/); + assert.match(guard, /expectedRequestCycle !== requiredRequestCycle/); + assert.match(guard, /request\.requestCycle !== expectedRequestCycle/); + assert.match(guard, /storedRequestedAtMicros <= 0n/); + assert.match(guard, /storedRequestedAtMicros !== expectedRequestedAtMicros/); + assert.match(guard, /ACCESS_REQUEST_ADMISSION_CAS_MISMATCH/); + assert.doesNotMatch(guard, /\.(?:insert|update|delete)\s*\(/); +}); + +test('Hermes exact admission status covers missing and existing identities without mutation', () => { + const requests = source('../src/reducers/accessRequests.ts'); + const product = section( + requests, + "const adminAccessRequestAdmissionStatusV1 = t.object(", + '\n);\n\n', + ); + assert.deepEqual( + [...product.matchAll(/^\s{4}([A-Za-z][A-Za-z0-9]*):/gm)].map(match => match[1]), + [ + 'admissionState', + 'authEpoch', + 'requestState', + 'requestCycle', + 'requestedAtMicros', + ], + ); + assert.match(product, /authEpoch: t\.u32\(\)/); + assert.match(product, /requestCycle: t\.option\(t\.u64\(\)\)/); + assert.match(product, /requestedAtMicros: t\.option\(t\.u64\(\)\)/); + + const status = section( + requests, + 'function adminAdmissionStatus(', + '/**\n * Caller-private status.', + ); + assert.match(status, /const admissionState = resolveAdmissionState\(allowed\)/); + assert.match(status, /let authEpoch = 0/); + assert.match(status, /allowed\.authEpoch < 1/); + assert.match(status, /allowed\.authEpoch > MAX_AUTH_EPOCH/); + assert.match(status, /assertGenesisFounderForFid\(tx, fid\)/); + assert.match(status, /assertGenesisResourceForFid\(tx, fid\)/); + assert.match(status, /requestCycleForAdmission\(allowed, admissionState\)/); + assert.match(status, /admissionState === 'disabled'/); + assert.match(status, /BigInt\(authEpoch\) \+ 1n/); + assert.match(status, /allowed === null && request\.requestCycle !== 0n/); + assert.match(status, /request\.requestCycle > maximumStoredRequestCycle/); + assert.match(status, /requestedAtMicros\(request\)/); + assert.match(status, /'not_requested'/); + assert.match(status, /'pending'/); + assert.match(status, /'resolved'/); + assert.doesNotMatch(status, /\.(?:insert|update|delete)\s*\(/); + + const procedure = section( + requests, + 'export const adminGetAccessRequestAdmissionStatusV1', + '/**\n * Exact admin-private pre/post view', + ); + assert.match(procedure, /name: 'admin_get_access_request_admission_status_v1'/); + assert.match(procedure, /\{ fid: t\.u64\(\) \}/); + assert.match(procedure, /requireAdmin\(tx\)/); + assert.match(procedure, /requireSupportedFid\(fid\)/); + assert.match(procedure, /return adminAdmissionStatus\(tx, fid\)/); + assert.doesNotMatch(procedure, /\.(?:insert|update|delete)\s*\(/); + assert.doesNotMatch(procedure, /realmProfileV1|adminAudit|allowedFid\.iter|accessRequestV1\.iter/); +}); + +test('existing-founder request CAS requires disabled state and preserves the founder graph', () => { + const admin = source('../src/reducers/admin.ts'); + const reducer = section( + admin, + 'export const adminAllowFidForAccessRequestV1', + '/**\n * Owner-only first-founding request CAS', + ); + + assert.match(reducer, /name: 'admin_allow_fid_for_access_request_v1'/); + assert.match(reducer, /expectedRequestCycle: t\.u64\(\)/); + assert.match(reducer, /expectedRequestedAtMicros: t\.u64\(\)/); + assert.match(reducer, /const admin = requireAdmin\(ctx\)/); + assert.match(reducer, /requireSupportedFid\(fid\)/); + assert.match(reducer, /existing === null \|\| existing\.enabled/); + assert.match(reducer, /BigInt\(existing\.authEpoch\) \+ 1n/); + assert.match(reducer, /requireExactAccessRequest\(/); + assert.ok( + reducer.indexOf('requireExactAccessRequest(') + < reducer.indexOf('applyAllowedFidTransition(ctx'), + ); + assert.match(reducer, /auditAction: 'allow_fid_for_access_request_v1'/); + assert.equal( + reducer.match(/assertGenesisFounderForFid\(ctx, fid\)/g)?.length, + 2, + ); + assert.equal( + reducer.match(/assertGenesisResourceForFid\(ctx, fid\)/g)?.length, + 2, + ); + assert.match(reducer, /grantDailyMarkIfActive\(ctx, fid\)/); + assert.ok( + reducer.indexOf('applyAllowedFidTransition(ctx') + < reducer.indexOf('grantDailyMarkIfActive(ctx, fid)'), + ); + assert.doesNotMatch(reducer, /ensureGenesisFounder/); +}); + +test('first-time profiled request CAS requires missing state and cycle zero', () => { + const admin = source('../src/reducers/admin.ts'); + const reducer = section( + admin, + 'export const adminAdmitFounderForAccessRequestV2', + '/** Burn and wallet-attribution mutation wires', + ); + + assert.match(reducer, /name: 'admin_admit_founder_for_access_request_v2'/); + assert.match(reducer, /expectedRequestCycle: t\.u64\(\)/); + assert.match(reducer, /expectedRequestedAtMicros: t\.u64\(\)/); + assert.match(reducer, /canonicalUsername: t\.string\(\)/); + assert.match(reducer, /pfpUrl: t\.string\(\)/); + assert.match(reducer, /const admin = requireAdmin\(ctx\)/); + assert.match(reducer, /requireSupportedFid\(input\.fid\)/); + assert.match(reducer, /normalizeAdmissionReadyTrustedProfile\(input\)/); + assert.match(reducer, /allowedFid\.fid\.find\(input\.fid\) !== null/); + assert.match( + reducer, + /requireExactAccessRequest\([\s\S]*input\.expectedRequestCycle,[\s\S]*input\.expectedRequestedAtMicros,[\s\S]*0n/, + ); + assert.ok( + reducer.indexOf('requireExactAccessRequest(') + < reducer.indexOf('applyAllowedFidTransition(ctx'), + ); + assert.match(reducer, /auditAction: 'admit_founder_for_access_request_v2'/); + assert.match(reducer, /ensureGenesisFounder\(ctx, input\.fid, normalized\)/); + assert.match(reducer, /admissionProfileIsComplete\(verifiedProfile\)/); + assert.match(reducer, /trustedProfilesEqual\(verifiedProfile, normalized\)/); + assert.match(reducer, /assertGenesisFounderForFid\(ctx, input\.fid\)/); + assert.match(reducer, /assertGenesisResourceForFid\(ctx, input\.fid\)/); + assert.match(reducer, /grantDailyMarkIfActive\(ctx, input\.fid\)/); + assert.ok( + reducer.indexOf('ensureGenesisFounder(ctx, input.fid, normalized)') + < reducer.indexOf('grantDailyMarkIfActive(ctx, input.fid)'), + ); +}); + +test('legacy admission reducers remain separate compatibility wires', () => { + const admin = source('../src/reducers/admin.ts'); + const allow = section( + admin, + 'export const adminAllowFid =', + '/**\n * Atomic owner-only founding path.', + ); + const admit = section( + admin, + 'export const adminAdmitFounderV1 =', + '/** Trusted local-operator profile projection', + ); + + assert.match(allow, /name: 'admin_allow_fid'/); + assert.match(admit, /name: 'admin_admit_founder_v1'/); + assert.doesNotMatch(allow, /expectedRequestCycle|expectedRequestedAtMicros/); + assert.doesNotMatch(admit, /expectedRequestCycle|expectedRequestedAtMicros/); +}); + +test('CAS reducers are exported, explicitly pinned, and represented in generated bindings', () => { + const schema = source('../src/schema.ts'); + const moduleIndex = source('../src/index.ts'); + for (const [wire, sourceName] of [ + ['admin_allow_fid_for_access_request_v1', 'adminAllowFidForAccessRequestV1'], + ['admin_admit_founder_for_access_request_v2', 'adminAdmitFounderForAccessRequestV2'], + ] as const) { + assert.equal(schema.match(new RegExp(`'${wire}'`, 'g'))?.length, 1); + assert.match(moduleIndex, new RegExp(`\\b${sourceName}\\b`)); + } + + assert.equal( + existsSync(new URL('../../src/spacetime/module_bindings/admin_allow_fid_for_access_request_v_1_reducer.ts', import.meta.url)), + true, + ); + assert.equal( + existsSync(new URL('../../src/spacetime/module_bindings/admin_admit_founder_for_access_request_v_2_reducer.ts', import.meta.url)), + true, + ); + + const procedureWire = 'admin_get_access_request_admission_status_v1'; + assert.equal(schema.match(new RegExp(`'${procedureWire}'`, 'g'))?.length, 1); + assert.match(moduleIndex, /\badminGetAccessRequestAdmissionStatusV1\b/); + assert.equal( + existsSync(new URL('../../src/spacetime/module_bindings/admin_get_access_request_admission_status_v_1_procedure.ts', import.meta.url)), + true, + ); +}); diff --git a/spacetimedb/tests/foundingReducers.test.ts b/spacetimedb/tests/foundingReducers.test.ts index 47e50855..bdc1a36f 100644 --- a/spacetimedb/tests/foundingReducers.test.ts +++ b/spacetimedb/tests/foundingReducers.test.ts @@ -92,7 +92,7 @@ test('profiled admission validates before writes and atomically creates the comp test('profile maintenance accepts canonical clears without touching ownership or castle state', () => { const admin = source('../src/reducers/admin.ts'); const start = admin.indexOf('export const adminUpsertRealmProfileV1'); - const end = admin.indexOf('/** Burn and wallet-attribution mutation wires', start); + const end = admin.indexOf('/**\n * Owner-only request-CAS re-admission', start); const reducer = admin.slice(start, end); assert.match(reducer, /normalizeTrustedPublicProfile\(input\)/); diff --git a/src/components/WarpkeepExperience.tsx b/src/components/WarpkeepExperience.tsx index 7bf35cab..6e10199c 100644 --- a/src/components/WarpkeepExperience.tsx +++ b/src/components/WarpkeepExperience.tsx @@ -264,6 +264,7 @@ export function WarpkeepExperience() { state: farcasterAuthState, accessRequest, admissionCheck, + admissionGrantAcknowledgement, restoreSession: restoreFarcasterSession, beginSignIn: beginFarcasterSignIn, cancelSignIn: cancelFarcasterSignIn, @@ -1366,6 +1367,7 @@ export function WarpkeepExperience() { }: AuthRailRenderControls) => (