diff --git a/docs/farcaster-integration.md b/docs/farcaster-integration.md index ad4266eb..75f4a22d 100644 --- a/docs/farcaster-integration.md +++ b/docs/farcaster-integration.md @@ -339,33 +339,45 @@ behind a feature gate. Raw notification tokens stay inside one private Cloudflare Durable Object per FID, never in React, browser storage, logs, URLs, public state, or SpacetimeDB. -After Hermes has committed and verified founder admission, it invokes a -separate-secret operator endpoint. That endpoint resolves current admission -again; the Durable Object repeats the exact epoch check immediately before each -delivery attempt. 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, retry attempts are bounded, and one epoch cannot notify -twice. `notify-admitted --confirm` is the idempotent recovery path if the -database commit succeeds but the notification side effect is interrupted. -Notification preference and delivery add no SpacetimeDB schema or browser -authority. - -The current Worker payload is deliberately retained for this frontend stage: +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. + +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, +retry attempts are bounded, and one request generation cannot notify twice. +`notify-admitted --confirm` remains an exact-epoch reconciliation command +for legacy or exceptional already-committed admissions; it is not the normal +admission sequence. Notification preference and delivery add no SpacetimeDB +schema or browser authority. + +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. + +already-live reconciliation: notificationId: warpkeep-access-approved-v1-e -title (23): The Hegemony admits you -body (56): Your keep awaits in Genesis 001. Enter the living Realm. +title: The Hegemony admits you +body: Your keep awaits in Genesis 001. Enter the living Realm. targetUrl: https://warpkeep.com/?miniApp=true ``` -The title and body are within Farcaster's bounds, contain no identity or -private state, and accurately describe the event. Changing them would require -a separate reviewed Worker rollout, so copy changes are not coupled to this -default-off client integration. +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 +`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 diff --git a/docs/operations/alpha-activation.md b/docs/operations/alpha-activation.md index bc54cdce..442a7828 100644 --- a/docs/operations/alpha-activation.md +++ b/docs/operations/alpha-activation.md @@ -260,10 +260,13 @@ a SpacetimeDB schema change. Roll them out in this order: canary events pass. Then change only that public presentation gate to the literal value `true` in a reviewed frontend release; it does not enable the Worker or grant admission. -7. Give Hermes the operator secret through its private environment. A committed - admission may call the notification route best-effort; if delivery cannot be - queued, preserve the admission result and reconcile later with - `npm run stdb:notify-admitted -- --confirm`. +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. ### Owner canary and end-to-end acceptance @@ -293,23 +296,23 @@ it. 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. Admission remains authoritative even if the - notification side effect fails. If the automatic side effect is ambiguous, - run `npm run stdb:notify-admitted -- --confirm` once; accept only - `queued`, `already-sent`, `delivery-exhausted`, or `not-subscribed`. -6. Require one approval notification for the resulting positive auth epoch. - 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. + 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. +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. 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 current Worker copy is intentionally unchanged during this frontend stage: -`The Hegemony admits you` (23 characters) and -`Your keep awaits in Genesis 001. Enter the living Realm.` (56 characters). +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 are bounded and privacy-safe. Any copy change requires a separate reviewed Worker rollout. diff --git a/scripts/hermes-admin.ts b/scripts/hermes-admin.ts index e3b35830..ea7e3719 100644 --- a/scripts/hermes-admin.ts +++ b/scripts/hermes-admin.ts @@ -115,6 +115,8 @@ 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_STATUS_PATH = 'v1/admin/admission-notification-status'; +const ADMISSION_NOTIFICATION_SETTLEMENT_WAIT_MILLISECONDS = 35_000; const ADMIN_TOKEN_CLOCK_SAFETY_MILLISECONDS = 20_000; const MAX_RESOURCE_BACKFILL_FOUNDERS = 100n; const GENESIS_GENERATION_V2_WORLD_CELLS = 1_261n; @@ -1353,6 +1355,78 @@ export function verifyFounderAdmissionResourcePostconditionV4( return verified; } +export function verifyFounderReenablePrecondition( + world: GenesisExpansionStatusV3, + resources: ResourceAggregateV4, + target: AccessRequestResetStatus, +): Readonly<{ + world: GenesisExpansionStatusV3; + resources: ResourceAggregateV4; + target: AccessRequestResetStatus; +}> { + verifyFounderAdmissionCheckpointV3(world, false); + verifyExpectedResourceAggregateV4(resources, world.allowedFids); + if ( + target.admissionState !== 'disabled' + || target.requestState !== 'pending' + || target.requestCycle !== BigInt(target.authEpoch) + 1n + || target.requestedAtMicros === undefined + ) { + fail('Existing founder re-enable requires one exact pending access request.'); + } + return Object.freeze({ + world: Object.freeze({ ...world }), + resources: Object.freeze({ ...resources }), + target: Object.freeze({ ...target }), + }); +} + +export function verifyFounderReenablePostcondition( + world: GenesisExpansionStatusV3, + resources: ResourceAggregateV4, + target: AccessRequestResetStatus, + before: ReturnType, +): void { + verifyFounderAdmissionCheckpointV3(world, false); + if ( + target.admissionState !== 'enabled' + || target.authEpoch !== before.target.authEpoch + 1 + || target.requestState !== 'resolved' + || target.requestCycle !== before.target.requestCycle + || target.requestedAtMicros !== before.target.requestedAtMicros + ) { + fail( + 'Existing founder re-enable postcondition failed. The mutation outcome may be ' + + 'indeterminate; perform a fresh bounded read-only inspection before any retry.', + ); + } + for (const field of Object.keys(before.world) as (keyof GenesisExpansionStatusV3)[]) { + const expected = field === 'enabledAllowedFids' + ? (before.world[field] as bigint) + 1n + : field === 'auditEntries' + ? (before.world[field] as bigint) + 1n + : before.world[field]; + if (world[field] !== expected) { + fail( + 'Existing founder re-enable changed an unexpected Realm aggregate. ' + + 'Do not retry before a bounded read-only investigation.', + ); + } + } + const verifiedResources = verifyExpectedResourceAggregateV4( + resources, + before.world.allowedFids, + ); + for (const field of Object.keys(before.resources) as (keyof ResourceAggregateV4)[]) { + if (verifiedResources[field] !== before.resources[field]) { + fail( + 'Existing founder re-enable changed persistent resource state. ' + + 'Do not retry before a bounded read-only investigation.', + ); + } + } +} + export function verifyGenesisExpansionPreconditionV3( status: GenesisExpansionStatusV3, ): GenesisExpansionStatusV3 { @@ -1630,30 +1704,81 @@ export async function requestAdmissionNotification( return status; } -async function notifyCommittedAdmission( +export async function inspectAdmissionNotification( bridgeUrl: string, fid: bigint, - secret: string | undefined, -): Promise { - if (secret === undefined) { - console.warn( - 'Admission committed; Farcaster notification was not queued because the local ' - + 'notification operator credential is unavailable. Run notify-admitted with --confirm.', - ); - return; - } + secret: string, + fetchImpl: typeof fetch = fetch, +): Promise { + readNotificationOperatorSecret(secret); + let response: Response; try { - const status = await requestAdmissionNotification(bridgeUrl, fid, secret); - console.log(JSON.stringify({ admissionNotification: status })); + response = await fetchImpl(new URL(ADMISSION_NOTIFICATION_STATUS_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 { - // The database mutation is already authoritative. Never turn a delivery - // side-effect failure into an apparent admission failure that invites an - // unsafe reducer retry; the exact-epoch reconciliation command is idempotent. - console.warn( - 'Admission committed; Farcaster notification was not queued. ' - + 'Run notify-admitted with --confirm after checking the bridge.', + fail('Could not reach the Warpkeep admission notification bridge.'); + } + 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; +} + +export async function requireNotificationBeforeAdmission( + bridgeUrl: string, + fid: bigint, + secretValue: string | undefined, + fetchImpl: typeof fetch = fetch, + sleep: AdminTokenSleeper = sleepForAdminTokenReadiness, +): Promise { + const secret = readNotificationOperatorSecret(secretValue); + let status = await requestAdmissionNotification(bridgeUrl, fid, secret, fetchImpl); + if (status === 'queued') { + await sleep(ADMISSION_NOTIFICATION_SETTLEMENT_WAIT_MILLISECONDS); + // Requeue through the authority-checking endpoint instead of trusting a + // generic status snapshot. This binds the go/no-go decision to whichever + // exact pending request is still current after the wait. + status = await requestAdmissionNotification(bridgeUrl, fid, secret, fetchImpl); + } + if (status === 'queued') { + fail( + 'Farcaster has not accepted the pending admission notification. ' + + 'Admission remains unchanged; retry after inspecting token-free bridge diagnostics.', ); } + if (status === 'delivery-exhausted') { + fail( + 'Farcaster notification delivery is exhausted. ' + + 'Admission remains unchanged; reconcile notification consent before retrying.', + ); + } + console.log(JSON.stringify({ + admissionNotification: status, + providerAcceptanceRequired: status !== 'not-subscribed', + providerAcceptedBeforeAdmission: status === 'already-sent', + })); + return status; } type AdminTokenSleeper = (milliseconds: number) => Promise; @@ -2428,6 +2553,20 @@ async function main() { await readStatus(connection, 'v4') as ResourceAggregateV4, before.allowedFids, ); + const targetAuthEpoch = await withOperationTimeout( + connection.procedures.adminGetFidAuthEpoch({ fid }), + ); + 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( + bridgeUrl, + fid, + notificationOperatorSecret, + ); claimReviewedFounderAdmissionPlan({ plan: admissionPlan, sha256: admissionPlanReference.sha256, @@ -2451,11 +2590,35 @@ async function main() { beforeResources, ); founderAdmissionClaimed = false; - await notifyCommittedAdmission(bridgeUrl, fid, notificationOperatorSecret); mutationStatusHandled = true; } else if (command === 'allow-fid' && fid !== undefined && note !== undefined) { + const beforeTarget = projectAccessRequestResetStatus( + await withOperationTimeout( + connection.procedures.adminGetAccessRequestResetStatusV1({ fid }), + ), + ); + const before = verifyFounderReenablePrecondition( + await readStatus(connection, 'v3', false, undefined, false) as GenesisExpansionStatusV3, + await readStatus(connection, 'v4', false, undefined, false) as ResourceAggregateV4, + beforeTarget, + ); + await requireNotificationBeforeAdmission( + bridgeUrl, + fid, + notificationOperatorSecret, + ); await withOperationTimeout(connection.reducers.adminAllowFid({ fid, note })); - await notifyCommittedAdmission(bridgeUrl, fid, notificationOperatorSecret); + verifyFounderReenablePostcondition( + await readStatus(connection, 'v3', false, undefined, false) as GenesisExpansionStatusV3, + await readStatus(connection, 'v4', false, undefined, false) as ResourceAggregateV4, + projectAccessRequestResetStatus( + await withOperationTimeout( + connection.procedures.adminGetAccessRequestResetStatusV1({ fid }), + ), + ), + before, + ); + mutationStatusHandled = true; } else if (command === 'disable-fid' && fid !== undefined && note !== undefined) { await withOperationTimeout(connection.reducers.adminDisableFid({ fid, note })); } else if (command === 'bump-auth-epoch' && fid !== undefined && note !== undefined) { diff --git a/services/auth-bridge/README.md b/services/auth-bridge/README.md index d1bef630..9529d23e 100644 --- a/services/auth-bridge/README.md +++ b/services/auth-bridge/README.md @@ -3,8 +3,9 @@ This Cloudflare Worker verifies ordinary-browser Farcaster SIWF proofs and Farcaster Mini App Quick Auth bearers, then issues ES256 OIDC access JWTs for Warpkeep's SpacetimeDB connection. It also verifies Farcaster's signed Mini App -notification lifecycle and can send one admission alert after a live epoch -recheck. It is isolated from the static browser app: +notification lifecycle, can stage one alert for the exact pending access +request before admission, and retains an exact-epoch reconciliation path. It +is isolated from the static browser app: browser code never receives a signing key, admin secret, Optimism RPC URL, resolver JWT, private Hermes JWT, or Maincloud credential. @@ -38,7 +39,7 @@ future rollout step requires exact-head verification and recorded authority. | `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; rechecks live admission and queues one exact-epoch alert. | +| `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-status` | Separate-secret, token-free delivery diagnostics for one exact FID. | The legacy public `/v1/farcaster/challenge` and `/v1/farcaster/exchange` routes @@ -386,13 +387,16 @@ 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. Each send rechecks the exact current -admission epoch; stable notification IDs, retry ceilings, replay tombstones, -and bounded epoch receipts make retries idempotent. -The operator-only status projection contains only queue state, the admission -epoch, aggregate attempt counts, static retry categories, and the next retry -time. It never returns a notification token, delivery URL, webhook payload, or -provider response. Delivery parsing accepts Farcaster's optional additive +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 +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 `failedTokens` field, ignores harmless provider metadata, and still rejects invalid reasons, contradictory known outcome categories, and token mismatches. diff --git a/services/auth-bridge/src/admissionNotifications.ts b/services/auth-bridge/src/admissionNotifications.ts index 0951482d..75ff3b6f 100644 --- a/services/auth-bridge/src/admissionNotifications.ts +++ b/services/auth-bridge/src/admissionNotifications.ts @@ -4,7 +4,14 @@ import { AUTH_EPOCH_RESOLVER_TIMEOUT_MILLISECONDS, SpacetimeHttpAuthEpochResolver, } from './spacetimeAuthEpochResolver' +import { + ACCESS_REQUEST_RESOLVER_TIMEOUT_MILLISECONDS, + SpacetimeHttpAccessRequestResolver, +} from './spacetimeAccessRequestResolver' import type { + AccessRequestResolver, + AdmissionNotificationGeneration, + AdmissionNotificationQueueInput, AdmissionNotificationQueueStatus, AdmissionNotificationDiagnostics, AdmissionNotificationRetryReason, @@ -18,6 +25,7 @@ import type { const INTERNAL_ORIGIN = 'https://admission-notification.internal' const STATE_KEY = 'admission-notification-v1' +const PENDING_STATE_RECORD = 'admission-notification-pending-v2' const DIAGNOSTICS_RECORD = 'admission-notification-diagnostics-v1' const STATE_VERSION = 1 const MAX_SUBSCRIPTIONS = 8 @@ -26,13 +34,16 @@ const MAX_REVOKED_TOKEN_IDS = 32 const MAX_DELIVERY_ATTEMPTS = 6 const MAX_VERIFICATION_FAILURES = 64 const DELIVERY_LIFETIME_MILLISECONDS = 24 * 60 * 60 * 1_000 -const DELIVERY_TIMEOUT_MILLISECONDS = 8_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 NOTIFICATION_TITLE = 'The Hegemony admits you' -const NOTIFICATION_BODY = 'Your keep awaits in Genesis 001. Enter the living Realm.' +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 RETRY_DELAYS_MILLISECONDS = Object.freeze([ 30_000, 2 * 60_000, @@ -63,15 +74,34 @@ type DeliveryAttempt = Readonly<{ }> type PersistedNotificationDiagnostics = Readonly<{ - authEpoch: number + generation: AdmissionNotificationGeneration retryReasons: readonly AdmissionNotificationRetryReason[] + lastAttemptAt?: number + lastFailureReason?: AdmissionNotificationRetryReason }> type AdmissionDelivery = Readonly<{ - authEpoch: number queuedAt: number expiresAt: number attempts: readonly DeliveryAttempt[] +}> & AdmissionNotificationGeneration + +type LegacyPersistedNotificationState = Readonly<{ + version: 1 + revision: number + fid: string + retentionExpiresAt: number + subscriptions: readonly Subscription[] + seenEventIds: readonly string[] + revokedTokenIds: readonly string[] + lastSentAuthEpoch?: number + lastExhaustedAuthEpoch?: number + delivery?: Readonly<{ + authEpoch: number + queuedAt: number + expiresAt: number + attempts: readonly DeliveryAttempt[] + }> }> type PersistedNotificationState = Readonly<{ @@ -84,24 +114,44 @@ type PersistedNotificationState = Readonly<{ revokedTokenIds: readonly string[] lastSentAuthEpoch?: number lastExhaustedAuthEpoch?: number + lastSentRequestAtMicros?: number + lastExhaustedRequestAtMicros?: number delivery?: AdmissionDelivery }> +type PersistedPendingNotificationState = Readonly<{ + version: 1 + fid: string + lastSentRequestAtMicros?: number + lastExhaustedRequestAtMicros?: number + delivery?: Readonly<{ + requestedAtMicros: number + queuedAt: number + expiresAt: number + attempts: readonly DeliveryAttempt[] + }> +}> + type NotificationDependencies = Readonly<{ fetchImpl?: typeof fetch now?: () => number configReader?: (env: WorkerEnv) => BridgeConfig admissionResolver?: AuthEpochResolver + accessRequestResolver?: AccessRequestResolver }> type DeliveryResult = | 'successful' | 'invalid' | 'retryable' + | 'terminal' type DeliveryOutcome = Readonly<{ result: DeliveryResult - retryReason?: Exclude + retryReason?: Exclude< + AdmissionNotificationRetryReason, + 'admission-verification' | 'request-verification' + > }> type FailedTokenReason = @@ -142,6 +192,10 @@ function isTimestamp(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 } +function isRequestedAtMicros(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 +} + function isAuthEpoch(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) @@ -200,13 +254,25 @@ function isDeliveryStatus(value: unknown): value is DeliveryAttemptStatus { function isRetryReason(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' } @@ -282,19 +348,59 @@ function readAttempt(value: unknown): DeliveryAttempt | null { function readPersistedDiagnostics(value: unknown): PersistedNotificationDiagnostics | null { if ( !isRecord(value) - || !exactKeys(value, ['authEpoch', 'retryReasons']) - || !isAuthEpoch(value.authEpoch) + || !exactKeys( + value, + ['retryReasons'], + [ + 'generation', + 'authEpoch', + 'requestedAtMicros', + 'lastAttemptAt', + 'lastFailureReason', + ], + ) || !Array.isArray(value.retryReasons) || value.retryReasons.some(reason => !isRetryReason(reason)) || new Set(value.retryReasons).size !== value.retryReasons.length + || (value.lastAttemptAt !== undefined && !isTimestamp(value.lastAttemptAt)) + || (value.lastFailureReason !== undefined && !isRetryReason(value.lastFailureReason)) ) return null + const generation = value.generation === undefined && isAuthEpoch(value.authEpoch) + ? Object.freeze({ kind: 'admitted' as const, authEpoch: value.authEpoch }) + : value.generation === 'admitted' + && isAuthEpoch(value.authEpoch) + && value.requestedAtMicros === undefined + ? Object.freeze({ kind: 'admitted' as const, authEpoch: value.authEpoch }) + : value.generation === 'pending-request' + && isRequestedAtMicros(value.requestedAtMicros) + && value.authEpoch === undefined + ? Object.freeze({ + kind: 'pending-request' as const, + requestedAtMicros: value.requestedAtMicros, + }) + : null + if (!generation) return null return Object.freeze({ - authEpoch: value.authEpoch, + generation, retryReasons: Object.freeze([...value.retryReasons] as AdmissionNotificationRetryReason[]), + ...(value.lastAttemptAt === undefined ? {} : { lastAttemptAt: value.lastAttemptAt }), + ...(value.lastFailureReason === undefined + ? {} + : { lastFailureReason: value.lastFailureReason }), }) } -function readDelivery(value: unknown): AdmissionDelivery | null { +function readDeliveryAttempts(value: unknown): readonly DeliveryAttempt[] | null { + if (!Array.isArray(value) || value.length > MAX_SUBSCRIPTIONS) return null + const attempts = value.map(readAttempt) + if ( + attempts.some(attempt => attempt === null) + || new Set(attempts.map(attempt => attempt!.appFid)).size !== attempts.length + ) return null + return Object.freeze(attempts as DeliveryAttempt[]) +} + +function readLegacyDelivery(value: unknown): AdmissionDelivery | null { if ( !isRecord(value) || !exactKeys(value, ['authEpoch', 'queuedAt', 'expiresAt', 'attempts']) @@ -303,23 +409,17 @@ function readDelivery(value: unknown): AdmissionDelivery | null { || !isTimestamp(value.expiresAt) || value.expiresAt <= value.queuedAt || value.expiresAt - value.queuedAt !== DELIVERY_LIFETIME_MILLISECONDS - || !Array.isArray(value.attempts) - || value.attempts.length > MAX_SUBSCRIPTIONS - ) { - return null - } - const attempts = value.attempts.map(readAttempt) - if ( - attempts.some(attempt => attempt === null) - || new Set(attempts.map(attempt => attempt!.appFid)).size !== attempts.length ) { return null } + const attempts = readDeliveryAttempts(value.attempts) + if (!attempts) return null return Object.freeze({ + kind: 'admitted', authEpoch: value.authEpoch, queuedAt: value.queuedAt, expiresAt: value.expiresAt, - attempts: Object.freeze(attempts as DeliveryAttempt[]), + attempts, }) } @@ -367,7 +467,7 @@ function readState(value: unknown): PersistedNotificationState | null { ) { throw new Error('Invalid admission notification state.') } - const delivery = value.delivery === undefined ? undefined : readDelivery(value.delivery) + const delivery = value.delivery === undefined ? undefined : readLegacyDelivery(value.delivery) if (value.delivery !== undefined && !delivery) { throw new Error('Invalid admission notification state.') } @@ -389,6 +489,105 @@ function readState(value: unknown): PersistedNotificationState | null { }) } +function readPendingState(value: unknown): PersistedPendingNotificationState | null { + if (value === undefined) return null + if ( + !isRecord(value) + || !exactKeys( + value, + ['version', 'fid'], + ['delivery', 'lastSentRequestAtMicros', 'lastExhaustedRequestAtMicros'], + ) + || value.version !== STATE_VERSION + || !isSafeFid(value.fid) + || ( + value.lastSentRequestAtMicros !== undefined + && !isRequestedAtMicros(value.lastSentRequestAtMicros) + ) + || ( + value.lastExhaustedRequestAtMicros !== undefined + && !isRequestedAtMicros(value.lastExhaustedRequestAtMicros) + ) + ) throw new Error('Invalid pending admission notification state.') + let delivery: PersistedPendingNotificationState['delivery'] + if (value.delivery !== undefined) { + if ( + !isRecord(value.delivery) + || !exactKeys( + value.delivery, + ['requestedAtMicros', 'queuedAt', 'expiresAt', 'attempts'], + ) + || !isRequestedAtMicros(value.delivery.requestedAtMicros) + || !isTimestamp(value.delivery.queuedAt) + || !isTimestamp(value.delivery.expiresAt) + || value.delivery.expiresAt <= value.delivery.queuedAt + || value.delivery.expiresAt - value.delivery.queuedAt + !== DELIVERY_LIFETIME_MILLISECONDS + ) throw new Error('Invalid pending admission notification state.') + const attempts = readDeliveryAttempts(value.delivery.attempts) + if (!attempts) throw new Error('Invalid pending admission notification state.') + delivery = Object.freeze({ + requestedAtMicros: value.delivery.requestedAtMicros, + queuedAt: value.delivery.queuedAt, + expiresAt: value.delivery.expiresAt, + attempts, + }) + } + return Object.freeze({ + version: 1, + fid: value.fid, + ...(value.lastSentRequestAtMicros === undefined + ? {} + : { lastSentRequestAtMicros: value.lastSentRequestAtMicros }), + ...(value.lastExhaustedRequestAtMicros === undefined + ? {} + : { lastExhaustedRequestAtMicros: value.lastExhaustedRequestAtMicros }), + ...(delivery ? { delivery } : {}), + }) +} + +async function readCombinedState( + storage: DurableObjectState['storage'], +): Promise { + const [legacyValue, pendingValue] = await Promise.all([ + storage.get(STATE_KEY), + storage.get(PENDING_STATE_RECORD), + ]) + const legacy = readState(legacyValue) + const pending = readPendingState(pendingValue) + if (!legacy) { + if (pending) throw new Error('Orphaned pending admission notification state.') + return null + } + if (pending && pending.fid !== legacy.fid) { + throw new Error('Mismatched pending admission notification state.') + } + // A rollback can legitimately leave pending-v2 work behind while the older + // Worker writes a new admitted-v1 delivery. The live admitted generation is + // authoritative in that conflict; the next write removes the stale pending + // delivery while retaining only its bounded token-free receipt. + return Object.freeze({ + ...legacy, + ...(pending?.lastSentRequestAtMicros === undefined + ? {} + : { lastSentRequestAtMicros: pending.lastSentRequestAtMicros }), + ...(pending?.lastExhaustedRequestAtMicros === undefined + ? {} + : { lastExhaustedRequestAtMicros: pending.lastExhaustedRequestAtMicros }), + ...(!legacy.delivery && pending?.delivery + ? { + delivery: Object.freeze({ + kind: 'pending-request' as const, + requestedAtMicros: pending.delivery.requestedAtMicros, + queuedAt: pending.delivery.queuedAt, + expiresAt: pending.delivery.expiresAt, + attempts: pending.delivery.attempts, + }), + } + : {}), + }) +} + function emptyState(fid: string, now: number): PersistedNotificationState { return Object.freeze({ version: 1, @@ -454,7 +653,7 @@ async function readDiagnostics(response: Response): Promise !isRetryReason(reason)) || new Set(value.retryReasons).size !== value.retryReasons.length + || (value.lastAttemptAt !== undefined && !isTimestamp(value.lastAttemptAt)) + || (value.lastFailureReason !== undefined && !isRetryReason(value.lastFailureReason)) || (value.nextAttemptAt !== undefined && !isTimestamp(value.nextAttemptAt)) ) { throw new Error('Admission notification store returned invalid diagnostics.') } return Object.freeze({ status: value.status, + ...(value.generation === undefined ? {} : { generation: value.generation }), ...(value.authEpoch === undefined ? {} : { authEpoch: value.authEpoch }), deliveryAttemptCount: value.deliveryAttemptCount as number, verificationFailureCount: value.verificationFailureCount as number, 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 }), }) } @@ -505,11 +718,9 @@ export class DurableObjectAdmissionNotificationStore implements AdmissionNotific } } - async queueAdmission(input: Readonly<{ - fid: string - authEpoch: number - queuedAt: number - }>): Promise { + async queueAdmission( + input: AdmissionNotificationQueueInput, + ): Promise { const response = await (await this.stub(input.fid)).fetch(internalUrl('queue'), { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -567,16 +778,19 @@ function validVerifiedEvent(value: unknown, config: BridgeConfig): value is Veri && configuredClient(config, value.appFid, value.event.details.url) } -function validQueueInput(value: unknown): value is Readonly<{ - fid: string - authEpoch: number - queuedAt: number -}> { - return isRecord(value) +function validQueueInput(value: unknown): value is AdmissionNotificationQueueInput { + if (!isRecord(value) || !isSafeFid(value.fid) || !isTimestamp(value.queuedAt)) return false + if ( + value.kind === undefined && exactKeys(value, ['fid', 'authEpoch', 'queuedAt']) - && isSafeFid(value.fid) && isAuthEpoch(value.authEpoch) - && isTimestamp(value.queuedAt) + ) return true + return value.kind === 'admitted' + && exactKeys(value, ['fid', 'kind', 'authEpoch', 'queuedAt']) + && isAuthEpoch(value.authEpoch) + || value.kind === 'pending-request' + && exactKeys(value, ['fid', 'kind', 'requestedAtMicros', 'queuedAt']) + && isRequestedAtMicros(value.requestedAtMicros) } function withSeenEvent( @@ -684,12 +898,88 @@ function nextAlarmAt(state: PersistedNotificationState, now: number): number | n return Math.min(...candidates, ...subscriptionExpiries, delivery.expiresAt) } +function attemptForPersistence(attempt: DeliveryAttempt): DeliveryAttempt { + return Object.freeze({ + appFid: attempt.appFid, + tokenId: attempt.tokenId, + status: attempt.status, + attempts: attempt.attempts, + verificationFailures: attempt.verificationFailures, + ...(attempt.nextAttemptAt === undefined ? {} : { nextAttemptAt: attempt.nextAttemptAt }), + }) +} + +function legacyStateForPersistence( + state: PersistedNotificationState, +): LegacyPersistedNotificationState { + return Object.freeze({ + version: 1, + revision: state.revision, + fid: state.fid, + retentionExpiresAt: state.retentionExpiresAt, + subscriptions: state.subscriptions, + seenEventIds: state.seenEventIds, + revokedTokenIds: state.revokedTokenIds, + ...(state.lastSentAuthEpoch === undefined + ? {} + : { lastSentAuthEpoch: state.lastSentAuthEpoch }), + ...(state.lastExhaustedAuthEpoch === undefined + ? {} + : { lastExhaustedAuthEpoch: state.lastExhaustedAuthEpoch }), + ...(state.delivery?.kind === 'admitted' + ? { + delivery: Object.freeze({ + authEpoch: state.delivery.authEpoch, + queuedAt: state.delivery.queuedAt, + expiresAt: state.delivery.expiresAt, + attempts: Object.freeze(state.delivery.attempts.map(attemptForPersistence)), + }), + } + : {}), + }) +} + +function pendingStateForPersistence( + state: PersistedNotificationState, +): PersistedPendingNotificationState | null { + const delivery = state.delivery?.kind === 'pending-request' + ? Object.freeze({ + requestedAtMicros: state.delivery.requestedAtMicros, + queuedAt: state.delivery.queuedAt, + expiresAt: state.delivery.expiresAt, + attempts: Object.freeze(state.delivery.attempts.map(attemptForPersistence)), + }) + : undefined + if ( + !delivery + && state.lastSentRequestAtMicros === undefined + && state.lastExhaustedRequestAtMicros === undefined + ) return null + return Object.freeze({ + version: 1, + fid: state.fid, + ...(state.lastSentRequestAtMicros === undefined + ? {} + : { lastSentRequestAtMicros: state.lastSentRequestAtMicros }), + ...(state.lastExhaustedRequestAtMicros === undefined + ? {} + : { lastExhaustedRequestAtMicros: state.lastExhaustedRequestAtMicros }), + ...(delivery ? { delivery } : {}), + }) +} + async function persistAndSchedule( storage: DurableObjectState['storage'], state: PersistedNotificationState, now: number, ): Promise { - await storage.put(STATE_KEY, state) + const legacyState = legacyStateForPersistence(state) + const pendingState = pendingStateForPersistence(state) + await storage.transaction(async transaction => { + await transaction.put(STATE_KEY, legacyState) + if (pendingState) await transaction.put(PENDING_STATE_RECORD, pendingState) + else await transaction.delete(PENDING_STATE_RECORD) + }) const alarmAt = nextAlarmAt(state, now) if (alarmAt === null) await storage.deleteAlarm?.() else await storage.setAlarm(alarmAt) @@ -700,36 +990,88 @@ async function purgePersistedState(storage: DurableObjectState['storage']): Prom await storage.deleteAll() } -async function recordRetryReasons( +function generationEquals( + left: AdmissionNotificationGeneration, + right: AdmissionNotificationGeneration, +): boolean { + return left.kind === right.kind + && (left.kind === 'admitted' + ? right.kind === 'admitted' && left.authEpoch === right.authEpoch + : right.kind === 'pending-request' + && left.requestedAtMicros === right.requestedAtMicros) +} + +function deliveryGeneration(delivery: AdmissionDelivery): AdmissionNotificationGeneration { + return delivery.kind === 'admitted' + ? Object.freeze({ kind: 'admitted', authEpoch: delivery.authEpoch }) + : Object.freeze({ + kind: 'pending-request', + requestedAtMicros: delivery.requestedAtMicros, + }) +} + +async function recordDiagnostics( storage: DurableObjectState['storage'], - authEpoch: number, + generation: AdmissionNotificationGeneration, retryReasons: readonly AdmissionNotificationRetryReason[], + lastAttemptAt?: number, + lastFailureReason?: AdmissionNotificationRetryReason, ): Promise { - if (retryReasons.length === 0) return const existing = readPersistedDiagnostics(await storage.get(DIAGNOSTICS_RECORD)) const combined = new Set( - existing?.authEpoch === authEpoch ? existing.retryReasons : [], + existing && generationEquals(existing.generation, generation) + ? existing.retryReasons + : [], ) retryReasons.forEach(reason => combined.add(reason)) await storage.put(DIAGNOSTICS_RECORD, Object.freeze({ - authEpoch, + generation: generation.kind, + ...(generation.kind === 'admitted' + ? { authEpoch: generation.authEpoch } + : { requestedAtMicros: generation.requestedAtMicros }), retryReasons: Object.freeze(Array.from(combined).sort()), + ...(lastAttemptAt === undefined + ? existing && generationEquals(existing.generation, generation) + && existing.lastAttemptAt !== undefined + ? { lastAttemptAt: existing.lastAttemptAt } + : {} + : { lastAttemptAt }), + ...(lastFailureReason === undefined + ? lastAttemptAt === undefined + && existing && generationEquals(existing.generation, generation) + && existing.lastFailureReason !== undefined + ? { lastFailureReason: existing.lastFailureReason } + : {} + : { lastFailureReason }), })) } -function notificationId(authEpoch: number): string { - return `warpkeep-access-approved-v1-e${authEpoch}` +function notificationId(delivery: AdmissionDelivery): string { + return delivery.kind === 'admitted' + ? `warpkeep-access-approved-v1-e${delivery.authEpoch}` + : `warpkeep-access-approved-v2-r${delivery.requestedAtMicros}` +} + +class NotificationResponseError extends Error { + constructor(readonly reason: AdmissionNotificationRetryReason) { + super('Invalid notification response.') + this.name = 'NotificationResponseError' + } +} + +function responseFailure(reason: AdmissionNotificationRetryReason): never { + throw new NotificationResponseError(reason) } async function boundedDeliveryJson(response: Response): Promise { - if (!response.body) throw new Error('Invalid notification response.') + if (!response.body) return responseFailure('response-body') const contentType = response.headers.get('content-type') ?? '' if (!/^application\/json(?:\s*;.*)?$/i.test(contentType)) { - throw new Error('Invalid notification response.') + return responseFailure('response-content-type') } const length = response.headers.get('content-length') if (length && (!/^\d+$/.test(length) || Number(length) > DELIVERY_RESPONSE_MAX_BYTES)) { - throw new Error('Invalid notification response.') + return responseFailure('response-size') } const reader = response.body.getReader() const chunks: Uint8Array[] = [] @@ -741,10 +1083,13 @@ async function boundedDeliveryJson(response: Response): Promise { total += value.byteLength if (total > DELIVERY_RESPONSE_MAX_BYTES) { try { await reader.cancel() } catch { /* Fail closed below. */ } - throw new Error('Invalid notification response.') + return responseFailure('response-size') } chunks.push(value) } + } catch (error) { + if (error instanceof NotificationResponseError) throw error + return responseFailure('response-body') } finally { try { reader.releaseLock() } catch { /* Reader cleanup is best effort. */ } } @@ -754,7 +1099,11 @@ async function boundedDeliveryJson(response: Response): Promise { bytes.set(chunk, offset) offset += chunk.byteLength } - return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) + try { + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) + } catch { + return responseFailure('response-json') + } } function tokenArray(value: unknown, requestedToken: string): boolean { @@ -789,7 +1138,10 @@ function failedTokenReason( function providerRetryReason( reason: Exclude, -): Exclude { +): Exclude< + AdmissionNotificationRetryReason, + 'admission-verification' | 'request-verification' +> { if (reason === 'domain_mismatch') return 'provider-domain-mismatch' if (reason === 'target_url_mismatch') return 'provider-target-url-mismatch' if (reason === 'no_webhook_url') return 'provider-no-webhook-url' @@ -818,14 +1170,29 @@ function deliveryResult(value: unknown, requestedToken: string): DeliveryOutcome } if (invalid === 1) { if (failedReason !== undefined && failedReason !== 'invalid_token') return null - return Object.freeze({ result: 'invalid' }) + return Object.freeze({ result: 'invalid', retryReason: 'provider-invalid-token' }) } if (rateLimited === 1) { return failedReason === undefined ? Object.freeze({ result: 'retryable', retryReason: 'rate-limited' }) : null } - if (failedReason === undefined || failedReason === 'invalid_token') return null + if (failedReason === undefined) return null + if (failedReason === 'invalid_token') { + return Object.freeze({ result: 'invalid', retryReason: 'provider-invalid-token' }) + } + if (failedReason === 'domain_mismatch' || failedReason === 'target_url_mismatch') { + return Object.freeze({ + result: 'invalid', + retryReason: providerRetryReason(failedReason), + }) + } + if (failedReason === 'no_webhook_url') { + return Object.freeze({ + result: 'terminal', + retryReason: 'provider-no-webhook-url', + }) + } return Object.freeze({ result: 'retryable', retryReason: providerRetryReason(failedReason), @@ -838,6 +1205,7 @@ async function sendOne( fetchImpl: typeof fetch, ): Promise { let response: Response + const signal = AbortSignal.timeout(DELIVERY_TIMEOUT_MILLISECONDS) try { response = await fetchImpl(subscription.url, { method: 'POST', @@ -846,32 +1214,62 @@ async function sendOne( 'content-type': 'application/json', }, body: JSON.stringify({ - notificationId: notificationId(delivery.authEpoch), - title: NOTIFICATION_TITLE, - body: NOTIFICATION_BODY, + 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, tokens: [subscription.token], }), cache: 'no-store', - redirect: 'error', - signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MILLISECONDS), + // Cloudflare rejects `redirect: "error"` before issuing the subrequest. + // Manual mode returns a 3xx for the fail-closed status classifier below + // without ever forwarding the private notification token elsewhere. + redirect: 'manual', + signal, }) } catch { - return Object.freeze({ result: 'retryable', retryReason: 'transport' }) - } - if (!response.ok) { return Object.freeze({ result: 'retryable', - retryReason: response.status === 429 ? 'rate-limited' : 'upstream-status', + retryReason: signal.aborted ? 'transport-timeout' : 'transport-fetch-rejected', + }) + } + if (response.status !== 200) { + try { await response.body?.cancel() } catch { /* Resource cleanup is best effort. */ } + if (response.status === 429) { + return Object.freeze({ result: 'retryable', retryReason: 'rate-limited' }) + } + if (response.status >= 300 && response.status < 400) { + return Object.freeze({ result: 'terminal', retryReason: 'upstream-redirect' }) + } + if (response.status >= 500) { + return Object.freeze({ result: 'retryable', retryReason: 'upstream-server-status' }) + } + return Object.freeze({ + result: 'terminal', + retryReason: 'upstream-client-status', }) } try { return deliveryResult( await boundedDeliveryJson(response), subscription.token, - ) ?? Object.freeze({ result: 'retryable', retryReason: 'invalid-response' }) - } catch { - return Object.freeze({ result: 'retryable', retryReason: 'invalid-response' }) + ) ?? Object.freeze({ result: 'retryable', retryReason: 'response-schema' }) + } catch (error) { + return Object.freeze({ + result: 'retryable', + retryReason: signal.aborted + ? 'transport-timeout' + : error instanceof NotificationResponseError + ? error.reason as Exclude< + AdmissionNotificationRetryReason, + 'admission-verification' | 'request-verification' + > + : 'invalid-response', + }) } } @@ -904,6 +1302,16 @@ function retryAttempt( }) } +function terminalAttempt(attempt: DeliveryAttempt): DeliveryAttempt { + return Object.freeze({ + ...attempt, + status: 'exhausted', + attempts: Math.min(MAX_DELIVERY_ATTEMPTS, attempt.attempts + 1), + verificationFailures: 0, + nextAttemptAt: undefined, + }) +} + function deferForAdmissionVerification( attempt: DeliveryAttempt, now: number, @@ -925,17 +1333,33 @@ function deferForAdmissionVerification( }) } +function sentForGeneration( + state: PersistedNotificationState, + generation: AdmissionNotificationGeneration, +): boolean { + return generation.kind === 'admitted' + ? state.lastSentAuthEpoch !== undefined + && state.lastSentAuthEpoch >= generation.authEpoch + : state.lastSentRequestAtMicros === generation.requestedAtMicros +} + +function exhaustedForGeneration( + state: PersistedNotificationState, + generation: AdmissionNotificationGeneration, +): boolean { + return generation.kind === 'admitted' + ? state.lastExhaustedAuthEpoch !== undefined + && state.lastExhaustedAuthEpoch >= generation.authEpoch + : state.lastExhaustedRequestAtMicros === generation.requestedAtMicros +} + function queueStatus(state: PersistedNotificationState): AdmissionNotificationQueueStatus { - if ( - state.delivery - && state.lastSentAuthEpoch !== undefined - && state.lastSentAuthEpoch >= state.delivery.authEpoch - ) return 'already-sent' - if ( - state.delivery - && state.lastExhaustedAuthEpoch !== undefined - && state.lastExhaustedAuthEpoch >= state.delivery.authEpoch - ) return 'delivery-exhausted' + if (state.delivery && sentForGeneration(state, deliveryGeneration(state.delivery))) { + return 'already-sent' + } + if (state.delivery && exhaustedForGeneration(state, deliveryGeneration(state.delivery))) { + return 'delivery-exhausted' + } if (!state.delivery || state.subscriptions.length === 0) return 'not-subscribed' if ( state.delivery.attempts.length > 0 @@ -964,36 +1388,43 @@ function diagnosticsForState( } const delivery = state.delivery const attempts = delivery?.attempts ?? [] - const receiptAuthEpoch = state.lastSentAuthEpoch === undefined - ? state.lastExhaustedAuthEpoch - : state.lastExhaustedAuthEpoch === undefined - ? state.lastSentAuthEpoch - : Math.max(state.lastSentAuthEpoch, state.lastExhaustedAuthEpoch) + const generation = delivery + ? deliveryGeneration(delivery) + : persistedDiagnostics?.generation const status = delivery ? queueStatus(state) - : receiptAuthEpoch === undefined + : generation === undefined ? 'not-subscribed' - : state.lastSentAuthEpoch === receiptAuthEpoch + : sentForGeneration(state, generation) ? 'already-sent' - : 'delivery-exhausted' + : exhaustedForGeneration(state, generation) + ? 'delivery-exhausted' + : 'not-subscribed' const nextAttemptAt = attempts.reduce((earliest, attempt) => { if (attempt.nextAttemptAt === undefined) return earliest return earliest === undefined ? attempt.nextAttemptAt : Math.min(earliest, attempt.nextAttemptAt) }, undefined) - const authEpoch = delivery?.authEpoch - ?? receiptAuthEpoch - const retryReasons = authEpoch !== undefined && persistedDiagnostics?.authEpoch === authEpoch - ? persistedDiagnostics.retryReasons - : Object.freeze([]) + const matchingDiagnostics = generation && persistedDiagnostics + && generationEquals(generation, persistedDiagnostics.generation) + ? persistedDiagnostics + : undefined + const retryReasons = matchingDiagnostics?.retryReasons ?? Object.freeze([]) return Object.freeze({ status, - ...(authEpoch === undefined ? {} : { authEpoch }), + ...(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, ), retryReasons, + ...(matchingDiagnostics?.lastAttemptAt === undefined + ? {} + : { lastAttemptAt: matchingDiagnostics.lastAttemptAt }), + ...(matchingDiagnostics?.lastFailureReason === undefined + ? {} + : { lastFailureReason: matchingDiagnostics.lastFailureReason }), ...(nextAttemptAt === undefined ? {} : { nextAttemptAt }), }) } @@ -1010,11 +1441,24 @@ function defaultAdmissionResolver(config: BridgeConfig): AuthEpochResolver { }) } +function defaultAccessRequestResolver(config: BridgeConfig): AccessRequestResolver { + return new SpacetimeHttpAccessRequestResolver({ + uri: config.spacetimeDbUri, + database: config.spacetimeDbDatabase, + issuer: config.issuer, + audience: config.audience, + timeoutMs: ACCESS_REQUEST_RESOLVER_TIMEOUT_MILLISECONDS, + }, { + signer: claims => signEs256Jwt(config, claims), + }) +} + export class AdmissionNotification { private readonly fetchImpl: typeof fetch private readonly now: () => number private readonly configReader: (env: WorkerEnv) => BridgeConfig private readonly configuredAdmissionResolver?: AuthEpochResolver + private readonly configuredAccessRequestResolver?: AccessRequestResolver private operationTail: Promise = Promise.resolve() constructor( @@ -1026,6 +1470,7 @@ export class AdmissionNotification { this.now = dependencies.now ?? Date.now this.configReader = dependencies.configReader ?? readBridgeConfig this.configuredAdmissionResolver = dependencies.admissionResolver + this.configuredAccessRequestResolver = dependencies.accessRequestResolver } private config(): BridgeConfig { @@ -1073,12 +1518,19 @@ export class AdmissionNotification { ...pruned, delivery: undefined, ...(exhausted - ? { - lastExhaustedAuthEpoch: Math.max( - pruned.lastExhaustedAuthEpoch ?? 0, - delivery.authEpoch, - ), - } + ? delivery.kind === 'admitted' + ? { + lastExhaustedAuthEpoch: Math.max( + pruned.lastExhaustedAuthEpoch ?? 0, + delivery.authEpoch, + ), + } + : { + lastExhaustedRequestAtMicros: Math.max( + pruned.lastExhaustedRequestAtMicros ?? 0, + delivery.requestedAtMicros, + ), + } : {}), })) await persistAndSchedule(this.state.storage, next, now) @@ -1098,9 +1550,14 @@ export class AdmissionNotification { // current deployment allowlist immediately before every network request. let subscriptions = [...pruned.subscriptions] let nextBase = pruned + let invalidatedGeneration = false + let latestAttemptAt: number | undefined + let latestFailureReason: AdmissionNotificationRetryReason | undefined const attempts: DeliveryAttempt[] = [] const retryReasons: AdmissionNotificationRetryReason[] = [] const resolver = this.configuredAdmissionResolver ?? defaultAdmissionResolver(config) + const requestResolver = this.configuredAccessRequestResolver + ?? defaultAccessRequestResolver(config) for (const attempt of delivery.attempts) { const subscription = subscriptions.find(candidate => ( candidate.appFid === attempt.appFid && candidate.tokenId === attempt.tokenId @@ -1118,27 +1575,37 @@ export class AdmissionNotification { // Defense in depth for storage corruption or a configuration swap while // a delivery generation is active. if (!configuredClient(config, subscription.appFid, subscription.url)) continue - const latest = readState(await this.state.storage.get(STATE_KEY)) + const latest = await readCombinedState(this.state.storage) if (!latest || latest.revision !== state.revision) { return latest ?? emptyState(state.fid, now) } - let admitted = false + let generationIsCurrent = false try { const admission = await resolver.resolve(state.fid) - admitted = admission.state === 'enabled' && admission.authEpoch === delivery.authEpoch + if (delivery.kind === 'admitted') { + generationIsCurrent = admission.state === 'enabled' + && admission.authEpoch === delivery.authEpoch + } else if (admission.state !== 'enabled') { + const request = await requestResolver.getStatus(state.fid) + generationIsCurrent = request.status === 'requested' + && request.requestedAtMicros === delivery.requestedAtMicros + } } catch { // Resolver availability is not a Farcaster delivery attempt. Back it // off separately so an upstream outage cannot permanently exhaust the // admission epoch before any notification request is made. - retryReasons.push('admission-verification') + const reason = delivery.kind === 'admitted' + ? 'admission-verification' + : 'request-verification' + retryReasons.push(reason) attempts.push(deferForAdmissionVerification(attempt, now, delivery.expiresAt)) continue } - const afterAdmissionCheck = readState(await this.state.storage.get(STATE_KEY)) + const afterAdmissionCheck = await readCombinedState(this.state.storage) if (!afterAdmissionCheck || afterAdmissionCheck.revision !== state.revision) { return afterAdmissionCheck ?? emptyState(state.fid, now) } - if (!admitted) { + if (!generationIsCurrent) { const cancelled = withNextRevision(Object.freeze({ ...afterAdmissionCheck, delivery: undefined, @@ -1147,6 +1614,7 @@ export class AdmissionNotification { return cancelled } const outcome = await sendOne(subscription, delivery, this.fetchImpl) + latestAttemptAt = now if (outcome.result === 'successful') { attempts.push(Object.freeze({ appFid: attempt.appFid, @@ -1156,10 +1624,20 @@ export class AdmissionNotification { verificationFailures: 0, })) } else if (outcome.result === 'invalid') { + if (outcome.retryReason) retryReasons.push(outcome.retryReason) + latestFailureReason = outcome.retryReason ?? 'invalid-response' + invalidatedGeneration = true subscriptions = subscriptions.filter(candidate => candidate.appFid !== attempt.appFid) nextBase = withRevokedTokenIds(nextBase, [attempt.tokenId]) + } else if (outcome.result === 'terminal') { + const reason = outcome.retryReason ?? 'invalid-response' + latestFailureReason = reason + retryReasons.push(reason) + attempts.push(terminalAttempt(attempt)) } else { - retryReasons.push(outcome.retryReason ?? 'invalid-response') + const reason = outcome.retryReason ?? 'invalid-response' + latestFailureReason = reason + retryReasons.push(reason) attempts.push(retryAttempt( attempt, now, @@ -1167,7 +1645,7 @@ export class AdmissionNotification { )) } } - const current = readState(await this.state.storage.get(STATE_KEY)) + const current = await readCombinedState(this.state.storage) if (!current || current.revision !== state.revision) { // A disable/remove or newer enable/queue event won the race while the // network request was in flight. Never resurrect or overwrite it. @@ -1178,24 +1656,40 @@ export class AdmissionNotification { subscriptions: Object.freeze(subscriptions), delivery: Object.freeze({ ...delivery, attempts: Object.freeze(attempts) }), ...(attempts.length > 0 && attempts.every(attempt => attempt.status === 'sent') - ? { - lastSentAuthEpoch: Math.max( - nextBase.lastSentAuthEpoch ?? 0, - delivery.authEpoch, - ), - } + ? delivery.kind === 'admitted' + ? { + lastSentAuthEpoch: Math.max( + nextBase.lastSentAuthEpoch ?? 0, + delivery.authEpoch, + ), + } + : { + lastSentRequestAtMicros: Math.max( + nextBase.lastSentRequestAtMicros ?? 0, + delivery.requestedAtMicros, + ), + } : {}), - ...(attempts.length > 0 + ...(invalidatedGeneration || ( + attempts.length > 0 && attempts.some(attempt => attempt.status === 'exhausted') && attempts.every(attempt => ( attempt.status === 'sent' || attempt.status === 'exhausted' )) - ? { - lastExhaustedAuthEpoch: Math.max( - nextBase.lastExhaustedAuthEpoch ?? 0, - delivery.authEpoch, - ), - } + ) + ? delivery.kind === 'admitted' + ? { + lastExhaustedAuthEpoch: Math.max( + nextBase.lastExhaustedAuthEpoch ?? 0, + delivery.authEpoch, + ), + } + : { + lastExhaustedRequestAtMicros: Math.max( + nextBase.lastExhaustedRequestAtMicros ?? 0, + delivery.requestedAtMicros, + ), + } : {}), })) // Keep a token-free queued admission until its bounded expiry. This closes @@ -1205,7 +1699,13 @@ export class AdmissionNotification { // token material immediately in the event path below. await persistAndSchedule(this.state.storage, next, now) try { - await recordRetryReasons(this.state.storage, delivery.authEpoch, retryReasons) + await recordDiagnostics( + this.state.storage, + deliveryGeneration(delivery), + retryReasons, + latestAttemptAt, + latestFailureReason, + ) } catch { // Diagnostics are subordinate to delivery state. Losing a static reason // must not turn an idempotently queued send into an apparent failure. @@ -1250,7 +1750,7 @@ export class AdmissionNotification { if (!isRecord(value) || !exactKeys(value, ['fid']) || !isSafeFid(value.fid)) { return new Response(null, { status: 400 }) } - const existing = readState(await this.state.storage.get(STATE_KEY)) + const existing = await readCombinedState(this.state.storage) if (existing && existing.fid !== value.fid) return new Response(null, { status: 409 }) const diagnostics = readPersistedDiagnostics( await this.state.storage.get(DIAGNOSTICS_RECORD), @@ -1273,7 +1773,7 @@ export class AdmissionNotification { return new Response(null, { status: 503 }) } const now = this.currentTime() - const existing = readState(await this.state.storage.get(STATE_KEY)) + const existing = await readCombinedState(this.state.storage) if (existing && existing.fid !== value.fid) return new Response(null, { status: 409 }) if ( value.event.type === 'disabled' @@ -1292,6 +1792,14 @@ export class AdmissionNotification { await persistAndSchedule(this.state.storage, next, now) return new Response(null, { status: 204 }) } + next = withRevokedTokenIds( + next, + next.subscriptions + .filter(candidate => ( + candidate.appFid === value.appFid && candidate.tokenId !== id + )) + .map(candidate => candidate.tokenId), + ) const subscription = Object.freeze({ appFid: value.appFid, url: value.event.details.url, @@ -1361,30 +1869,47 @@ 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 = readState(await this.state.storage.get(STATE_KEY)) + const 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) - if (next.lastSentAuthEpoch !== undefined && value.authEpoch <= next.lastSentAuthEpoch) { + const generation: AdmissionNotificationGeneration = value.kind === 'pending-request' + ? Object.freeze({ + kind: 'pending-request', + requestedAtMicros: value.requestedAtMicros, + }) + : Object.freeze({ kind: 'admitted', authEpoch: value.authEpoch }) + if (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 ( - next.lastExhaustedAuthEpoch !== undefined - && value.authEpoch <= next.lastExhaustedAuthEpoch - ) { + if (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' }, }) } - if (next.delivery && value.authEpoch < next.delivery.authEpoch) { + if ( + next.delivery?.kind === 'admitted' + && generation.kind === 'admitted' + && generation.authEpoch < next.delivery.authEpoch + ) { return new Response(null, { status: 409 }) } - if (!next.delivery || value.authEpoch > next.delivery.authEpoch) { + if ( + next.delivery?.kind === 'pending-request' + && generation.kind === 'pending-request' + && generation.requestedAtMicros < next.delivery.requestedAtMicros + ) { + return new Response(null, { status: 409 }) + } + if ( + !next.delivery + || !generationEquals(deliveryGeneration(next.delivery), generation) + ) { const delivery: AdmissionDelivery = Object.freeze({ - authEpoch: value.authEpoch, + ...generation, queuedAt: value.queuedAt, expiresAt: value.queuedAt + DELIVERY_LIFETIME_MILLISECONDS, attempts: Object.freeze([]), @@ -1422,7 +1947,7 @@ export class AdmissionNotification { } private async handleAlarm(): Promise { - const state = readState(await this.state.storage.get(STATE_KEY)) + const state = await readCombinedState(this.state.storage) if (!state) { await purgePersistedState(this.state.storage) return @@ -1436,7 +1961,15 @@ export class AdmissionNotification { this.config() } catch { // Configuration loss can suppress delivery but never unbound cleanup. - await this.state.storage.setAlarm(state.retentionExpiresAt) + // Keep active 24-hour work recoverable after a transient rollout fault; + // an idle consent record still sleeps until its retention boundary. + await this.state.storage.setAlarm(state.delivery + ? Math.min( + state.delivery.expiresAt, + state.retentionExpiresAt, + now + RETRY_DELAYS_MILLISECONDS[0], + ) + : state.retentionExpiresAt) return } const next = await this.attemptDelivery(state) @@ -1446,6 +1979,8 @@ export class AdmissionNotification { && next.revokedTokenIds.length === 0 && next.lastSentAuthEpoch === undefined && next.lastExhaustedAuthEpoch === undefined + && next.lastSentRequestAtMicros === undefined + && next.lastExhaustedRequestAtMicros === undefined ) { await purgePersistedState(this.state.storage) } diff --git a/services/auth-bridge/src/app.ts b/services/auth-bridge/src/app.ts index f5df1f1b..48cc36aa 100644 --- a/services/auth-bridge/src/app.ts +++ b/services/auth-bridge/src/app.ts @@ -2485,25 +2485,51 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri 'Authorization is temporarily unavailable.', ) } - if (admission.state !== 'enabled') { - logger.event('admission_notification_rejected') - throw new HttpError( - 409, - 'founder_not_admitted', - 'Admission is not active for this Farcaster identity.', - ) - } const queuedAt = now() if (!Number.isSafeInteger(queuedAt) || queuedAt < 0) { throw new ConfigurationError() } let status try { + const generation = admission.state === 'enabled' + ? Object.freeze({ + kind: 'admitted' as const, + authEpoch: admission.authEpoch, + }) + : await (async () => { + 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_rejected') + throw new HttpError( + 409, + 'access_request_not_pending', + 'No pending access request is available for notification.', + ) + } + return Object.freeze({ + kind: 'pending-request' as const, + requestedAtMicros: requestStatus.requestedAtMicros, + }) + })() status = await ( dependencies.admissionNotificationStore ?? defaultAdmissionNotificationStore(env) - ).queueAdmission({ fid, authEpoch: admission.authEpoch, queuedAt }) - } catch { + ).queueAdmission({ fid, queuedAt, ...generation }) + } catch (error) { + if (error instanceof HttpError) throw error throw new HttpError( 503, 'admission_notification_unavailable', diff --git a/services/auth-bridge/src/types.ts b/services/auth-bridge/src/types.ts index 1dafa1bc..a2053fb5 100644 --- a/services/auth-bridge/src/types.ts +++ b/services/auth-bridge/src/types.ts @@ -315,34 +315,60 @@ export type AdmissionNotificationQueueStatus = | 'delivery-exhausted' | 'not-subscribed' +export type AdmissionNotificationGeneration = + | Readonly<{ + kind: 'admitted' + authEpoch: number + }> + | Readonly<{ + kind: 'pending-request' + requestedAtMicros: number + }> + +export type AdmissionNotificationQueueInput = Readonly<{ + fid: string + queuedAt: number +}> & AdmissionNotificationGeneration + export type AdmissionNotificationRetryReason = | 'admission-verification' + | 'request-verification' | 'transport' + | 'transport-timeout' + | 'transport-fetch-rejected' | 'upstream-status' + | 'upstream-redirect' + | 'upstream-client-status' + | 'upstream-server-status' | 'invalid-response' + | 'response-content-type' + | 'response-size' + | 'response-body' + | 'response-json' + | 'response-schema' | 'rate-limited' | 'provider-domain-mismatch' | 'provider-target-url-mismatch' | 'provider-no-webhook-url' + | 'provider-invalid-token' | 'provider-unknown' export type AdmissionNotificationDiagnostics = Readonly<{ status: AdmissionNotificationQueueStatus + generation?: AdmissionNotificationGeneration['kind'] authEpoch?: number deliveryAttemptCount: number verificationFailureCount: number retryReasons: readonly AdmissionNotificationRetryReason[] + lastAttemptAt?: number + lastFailureReason?: AdmissionNotificationRetryReason nextAttemptAt?: number }> /** Raw notification tokens remain behind this server-only interface. */ export interface AdmissionNotificationStore { applyEvent(event: VerifiedMiniAppWebhookEvent): Promise - queueAdmission(input: Readonly<{ - fid: string - authEpoch: number - queuedAt: number - }>): Promise + queueAdmission(input: AdmissionNotificationQueueInput): 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 74edc4a7..17f3a729 100644 --- a/services/auth-bridge/test-workerd/authBridge.workerd.test.ts +++ b/services/auth-bridge/test-workerd/authBridge.workerd.test.ts @@ -4,6 +4,7 @@ import { encodeAbiParameters } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { createSiweMessage } from 'viem/siwe' import { describe, expect, it, vi } from 'vitest' +import { AdmissionNotification } from '../src/admissionNotifications' import { createAuthBridge } from '../src/app' import { DurableObjectQaObserverChallengeStore, @@ -14,6 +15,9 @@ import { createMiniAppWebhookVerifier } from '../src/miniAppWebhook' import type { AccessRequestResolver, AdmissionResolution, + DurableObjectState, + DurableObjectStorage, + DurableObjectTransaction, DurableObjectNamespace, SafeLogEvent, WorkerEnv, @@ -42,6 +46,45 @@ const BINDING_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' const BINDING_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' const WRONG_BINDING_VERIFIER = 'A'.repeat(43) const INTERNAL_ORIGIN = 'https://challenge-replay-guard.internal' +const NOTIFICATION_INTERNAL_ORIGIN = 'https://admission-notification.internal' + +class WorkerdMemoryStorage implements DurableObjectStorage { + readonly values = new Map() + alarm: number | Date | undefined + + async get(key: string): Promise { + return this.values.get(key) as T | undefined + } + + async put(key: string, value: T): Promise { + this.values.set(key, value) + } + + async delete(key: string): Promise { + return this.values.delete(key) + } + + async deleteAll(): Promise { + this.values.clear() + this.alarm = undefined + } + + async setAlarm(scheduledTime: number | Date): Promise { + this.alarm = scheduledTime + } + + async deleteAlarm(): Promise { + this.alarm = undefined + } + + async transaction(closure: (txn: DurableObjectTransaction) => Promise): Promise { + return closure({ + get: key => this.get(key), + put: (key, value) => this.put(key, value), + delete: key => this.delete(key), + }) + } +} const CONFIG: BridgeConfig = { issuer: 'https://auth.warpkeep.test', @@ -301,6 +344,84 @@ async function signedMiniAppWebhookFixture() { } describe('auth bridge production bindings in workerd', () => { + it('delivers through Cloudflare-compatible manual redirect handling in workerd', async () => { + const deliveryUrl = 'https://api.farcaster.xyz/v1/frame-notifications' + const token = 'workerd-notification-token-with-enough-entropy' + const notificationConfig: BridgeConfig = { + ...CONFIG, + approvalNotificationsEnabled: true, + miniAppNotifications: { + hubUrls: Object.freeze([ + 'https://rho.farcaster.xyz:3381/', + 'https://hub.pinata.cloud/', + ]), + clients: Object.freeze([{ appFid: 9_152, deliveryUrl }]), + operatorSecret: 'workerd-notification-secret-at-least-32-bytes', + }, + } + const fetchImpl = vi.fn(async (_input, init) => { + if (init?.redirect === 'error') { + throw new TypeError('workerd rejects redirect:error before subrequest dispatch') + } + expect(init?.redirect).toBe('manual') + return Response.json({ + result: { + successfulTokens: [token], + invalidTokens: [], + rateLimitedTokens: [], + failedTokens: [], + }, + }) + }) + const storage = new WorkerdMemoryStorage() + const notification = new AdmissionNotification( + { storage } as DurableObjectState, + {} as WorkerEnv, + { + now: () => 1_800_000_000_000, + fetchImpl, + configReader: () => notificationConfig, + admissionResolver: { + resolve: async () => ({ state: 'enabled', authEpoch: 7 }), + }, + accessRequestResolver: { + getStatus: async () => ({ status: 'not-requested' }), + submit: async () => ({ status: 'not-requested' }), + }, + }, + ) + const event = await notification.fetch(new Request( + `${NOTIFICATION_INTERNAL_ORIGIN}/event`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + eventId: 'a'.repeat(64), + fid: FID, + appFid: 9_152, + event: { type: 'enabled', details: { token, url: deliveryUrl } }, + }), + }, + )) + expect(event.status).toBe(204) + + const queued = await notification.fetch(new Request( + `${NOTIFICATION_INTERNAL_ORIGIN}/queue`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + fid: FID, + kind: 'admitted', + authEpoch: 7, + queuedAt: 1_800_000_000_000, + }), + }, + )) + await expect(queued.json()).resolves.toEqual({ status: 'already-sent' }) + expect(fetchImpl).toHaveBeenCalledOnce() + }) + it('verifies Farcaster Ed25519 JFS envelopes with the production workerd runtime', async () => { const fixture = await signedMiniAppWebhookFixture() const activeOnChainRpcVerifier = vi.fn(async () => true) diff --git a/services/auth-bridge/test/admissionNotifications.test.ts b/services/auth-bridge/test/admissionNotifications.test.ts index 2a037ac9..a0644a28 100644 --- a/services/auth-bridge/test/admissionNotifications.test.ts +++ b/services/auth-bridge/test/admissionNotifications.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { AdmissionNotification } from '../src/admissionNotifications' import type { BridgeConfig } from '../src/config' import type { + AccessRequestResolver, AuthEpochResolver, DurableObjectState, DurableObjectStorage, @@ -17,6 +18,7 @@ const DELIVERY_URL = 'https://api.farcaster.xyz/v1/frame-notifications' 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' class FakeStorage implements DurableObjectStorage { readonly values = new Map() @@ -137,9 +139,14 @@ function stored(storage: FakeStorage): string { return JSON.stringify(storage.values.get(STATE_KEY)) } +function pendingStored(storage: FakeStorage): string { + return JSON.stringify(storage.values.get(PENDING_STATE_RECORD)) +} + function createHarness(options: { fetchImpl?: typeof fetch resolver?: AuthEpochResolver + accessRequestResolver?: AccessRequestResolver configReader?: () => BridgeConfig } = {}) { const storage = new FakeStorage() @@ -155,6 +162,10 @@ function createHarness(options: { fetchImpl: options.fetchImpl ?? vi.fn(async () => successfulDelivery()), configReader: options.configReader ?? (() => config()), admissionResolver: resolver, + accessRequestResolver: options.accessRequestResolver ?? { + getStatus: vi.fn(async () => ({ status: 'not-requested' } as const)), + submit: vi.fn(async () => ({ status: 'not-requested' } as const)), + }, }, ) return { @@ -180,6 +191,19 @@ async function queue( return notification.fetch(internalRequest('queue', { fid: FID, authEpoch, queuedAt })) } +async function queuePending( + notification: AdmissionNotification, + requestedAtMicros: number, + queuedAt = NOW, +): Promise { + return notification.fetch(internalRequest('queue', { + fid: FID, + kind: 'pending-request', + requestedAtMicros, + queuedAt, + })) +} + async function inspect(notification: AdmissionNotification): Promise { return notification.fetch(internalRequest('status', { fid: FID })) } @@ -197,7 +221,7 @@ describe('admission notification consent and delivery lifecycle', () => { expect(fetchImpl).toHaveBeenCalledOnce() const [deliveryUrl, deliveryInit] = fetchImpl.mock.calls[0] expect(deliveryUrl).toBe(DELIVERY_URL) - expect(deliveryInit?.redirect).toBe('error') + expect(deliveryInit?.redirect).toBe('manual') const payload = JSON.parse(String(deliveryInit?.body)) expect(payload).toEqual({ notificationId: 'warpkeep-access-approved-v1-e7', @@ -213,6 +237,155 @@ describe('admission notification consent and delivery lifecycle', () => { await expect(duplicate.json()).resolves.toEqual({ status: 'already-sent' }) expect(fetchImpl).toHaveBeenCalledOnce() expect(stored(h.storage)).toContain('"lastSentAuthEpoch":7') + expect(stored(h.storage)).not.toContain('"kind"') + expect(stored(h.storage)).not.toContain('"lastAttemptAt"') + expect(stored(h.storage)).not.toContain('"lastFailureReason"') + }) + + it('gets provider acceptance for the exact pending request before admission exists', async () => { + const requestedAtMicros = 1_799_999_999_000_000 + const fetchImpl = vi.fn(async () => successfulDelivery()) + const accessRequestResolver = { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + } + const h = createHarness({ + fetchImpl, + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver, + }) + await applyEvent(h.notification, enabledEvent()) + + const response = await queuePending(h.notification, requestedAtMicros) + await expect(response.json()).resolves.toEqual({ status: 'already-sent' }) + expect(accessRequestResolver.getStatus).toHaveBeenCalledWith(FID) + expect(fetchImpl).toHaveBeenCalledOnce() + const payload = JSON.parse(String(fetchImpl.mock.calls[0][1]?.body)) + 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.', + }) + expect(pendingStored(h.storage)).toContain( + `"lastSentRequestAtMicros":${requestedAtMicros}`, + ) + expect(pendingStored(h.storage)).not.toContain(TOKEN) + const legacy = h.storage.values.get(STATE_KEY) as Record + expect(Object.keys(legacy).sort()).toEqual([ + 'fid', + 'retentionExpiresAt', + 'revision', + 'revokedTokenIds', + 'seenEventIds', + 'subscriptions', + 'version', + ]) + 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', + generation: 'pending-request', + }) + }) + + it('does not reuse a pending-request receipt for a later application', async () => { + let 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) + + requestedAtMicros += 1_000 + const second = await queuePending(h.notification, requestedAtMicros, NOW + 1) + await expect(second.json()).resolves.toEqual({ status: 'already-sent' }) + expect(fetchImpl).toHaveBeenCalledTimes(2) + const notificationIds = fetchImpl.mock.calls.map(call => ( + JSON.parse(String(call[1]?.body)) as { notificationId: string } + ).notificationId) + expect(new Set(notificationIds).size).toBe(2) + }) + + 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()) + const h = createHarness({ + fetchImpl, + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ + status: 'requested', + requestedAtMicros: requestedAtMicros + 1, + } as const)), + submit: vi.fn(async () => ({ status: 'not-requested' } as const)), + }, + }) + await applyEvent(h.notification, enabledEvent()) + + const response = await queuePending(h.notification, requestedAtMicros) + await expect(response.json()).resolves.toEqual({ status: 'not-subscribed' }) + expect(fetchImpl).not.toHaveBeenCalled() + expect(stored(h.storage)).not.toContain('"delivery"') + }) + + it('heals a rollback conflict before processing a signed opt-out', async () => { + const requestedAtMicros = 1_799_999_999_000_000 + const fetchImpl = vi.fn(async () => Response.json({ + result: { + successfulTokens: [], + invalidTokens: [], + rateLimitedTokens: [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) + expect(h.storage.values.has(PENDING_STATE_RECORD)).toBe(true) + + const legacy = h.storage.values.get(STATE_KEY) as Record + const subscriptions = legacy.subscriptions as Array> + h.storage.values.set(STATE_KEY, { + ...legacy, + revision: Number(legacy.revision) + 1, + delivery: { + authEpoch: 7, + queuedAt: NOW, + expiresAt: NOW + 24 * 60 * 60 * 1_000, + attempts: [{ + appFid: APP_FID, + tokenId: subscriptions[0].tokenId, + status: 'pending', + attempts: 0, + verificationFailures: 0, + }], + }, + }) + + expect((await inspect(h.notification)).status).toBe(200) + expect((await applyEvent(h.notification, disabledEvent())).status).toBe(204) + expect(stored(h.storage)).not.toContain(TOKEN) + expect(h.storage.values.has(PENDING_STATE_RECORD)).toBe(false) }) it('erases raw token material on opt-out and rejects a replay under a new envelope', async () => { @@ -233,6 +406,25 @@ describe('admission notification consent and delivery lifecycle', () => { expect(fetchImpl).not.toHaveBeenCalled() }) + it('tombstones a superseded token so an old signed enable cannot restore it', async () => { + const tokenA = 'test-notification-token-a-with-enough-entropy' + const tokenB = 'test-notification-token-b-with-enough-entropy' + const h = createHarness() + + await applyEvent(h.notification, enabledEvent('a'.repeat(64), tokenA)) + await applyEvent(h.notification, enabledEvent('b'.repeat(64), tokenB)) + expect(stored(h.storage)).not.toContain(tokenA) + expect(stored(h.storage)).toContain(tokenB) + + await applyEvent(h.notification, enabledEvent('c'.repeat(64), tokenA)) + expect(stored(h.storage)).not.toContain(tokenA) + expect(stored(h.storage)).toContain(tokenB) + + await applyEvent(h.notification, disabledEvent('d'.repeat(64))) + expect(stored(h.storage)).not.toContain(tokenA) + expect(stored(h.storage)).not.toContain(tokenB) + }) + it('serializes overlapping enable and disable events so opt-out wins arrival order', async () => { const h = createHarness() const [enabled, disabled] = await Promise.all([ @@ -278,6 +470,39 @@ describe('admission notification consent and delivery lifecycle', () => { expect(h.storage.values.has(STATE_KEY)).toBe(false) }) + it('keeps active delivery recoverable across a transient configuration outage', async () => { + let configured = true + let deliveryAttempt = 0 + const fetchImpl = vi.fn(async () => { + deliveryAttempt += 1 + if (deliveryAttempt === 1) throw new TypeError('synthetic transport failure') + return successfulDelivery() + }) + const h = createHarness({ + fetchImpl, + configReader: () => { + if (!configured) throw new Error('synthetic configuration outage') + return config() + }, + }) + await applyEvent(h.notification, enabledEvent()) + await queue(h.notification) + expect(fetchImpl).toHaveBeenCalledOnce() + + const firstAlarm = Number(h.storage.alarm) + h.setNow(firstAlarm) + configured = false + await h.notification.alarm() + const recoveryAlarm = Number(h.storage.alarm) + expect(recoveryAlarm).toBe(firstAlarm + 30_000) + + h.setNow(recoveryAlarm) + configured = true + await h.notification.alarm() + expect(fetchImpl).toHaveBeenCalledTimes(2) + expect(stored(h.storage)).toContain('"lastSentAuthEpoch":7') + }) + it('retries verifier outages from a pending alarm without exposing the token', async () => { const fetchImpl = vi.fn(async () => successfulDelivery()) const resolver = { @@ -335,12 +560,54 @@ describe('admission notification consent and delivery lifecycle', () => { await applyEvent(h.notification, enabledEvent()) const response = await queue(h.notification) - await expect(response.json()).resolves.toEqual({ status: 'not-subscribed' }) + await expect(response.json()).resolves.toEqual({ status: 'delivery-exhausted' }) expect(fetchImpl).toHaveBeenCalledOnce() expect(stored(h.storage)).not.toContain(TOKEN) expect(stored(h.storage)).toContain('revokedTokenIds') }) + it('classifies Cloudflare fetch rejection without retaining exception details', async () => { + const privateDetail = 'private-runtime-detail-that-must-not-persist' + const h = createHarness({ + fetchImpl: vi.fn(async (_input, init) => { + expect(init?.redirect).toBe('manual') + throw new TypeError(privateDetail) + }), + }) + await applyEvent(h.notification, enabledEvent()) + + await expect((await queue(h.notification)).json()).resolves.toEqual({ status: 'queued' }) + const text = await (await inspect(h.notification)).text() + expect(text).not.toContain(privateDetail) + expect(JSON.parse(text)).toMatchObject({ + retryReasons: ['transport-fetch-rejected'], + lastFailureReason: 'transport-fetch-rejected', + lastAttemptAt: NOW, + }) + }) + + it('rejects redirects without following or retrying them', async () => { + const h = createHarness({ + fetchImpl: vi.fn(async (_input, init) => { + expect(init?.redirect).toBe('manual') + return new Response(null, { + status: 302, + headers: { location: 'https://hostile.example/collect' }, + }) + }), + }) + await applyEvent(h.notification, enabledEvent()) + + await expect((await queue(h.notification)).json()).resolves.toEqual({ + status: 'delivery-exhausted', + }) + await expect((await inspect(h.notification)).json()).resolves.toMatchObject({ + retryReasons: ['upstream-redirect'], + lastFailureReason: 'upstream-redirect', + }) + expect(stored(h.storage)).toContain(TOKEN) + }) + it('accepts the current additive Farcaster response on a successful delivery', async () => { const fetchImpl = vi.fn(async () => Response.json({ result: { @@ -372,13 +639,35 @@ describe('admission notification consent and delivery lifecycle', () => { await applyEvent(h.notification, enabledEvent()) const response = await queue(h.notification) - await expect(response.json()).resolves.toEqual({ status: 'not-subscribed' }) + await expect(response.json()).resolves.toEqual({ status: 'delivery-exhausted' }) expect(fetchImpl).toHaveBeenCalledOnce() expect(stored(h.storage)).not.toContain(TOKEN) expect(stored(h.storage)).toContain('revokedTokenIds') }) - it('retains consent and records a bounded retry for a structured provider failure', async () => { + it('purges a token after a permanent target-domain mismatch', async () => { + const h = createHarness({ + fetchImpl: vi.fn(async () => Response.json({ + result: { + successfulTokens: [], + invalidTokens: [], + rateLimitedTokens: [], + failedTokens: [{ token: TOKEN, reason: 'target_url_mismatch' }], + }, + })), + }) + await applyEvent(h.notification, enabledEvent()) + + await expect((await queue(h.notification)).json()).resolves.toEqual({ + status: 'delivery-exhausted', + }) + expect(stored(h.storage)).not.toContain(TOKEN) + await expect((await inspect(h.notification)).json()).resolves.toMatchObject({ + retryReasons: ['provider-target-url-mismatch'], + }) + }) + + it('retains consent but exhausts a deterministic provider configuration failure', async () => { const fetchImpl = vi.fn(async () => Response.json({ result: { successfulTokens: [], @@ -391,11 +680,9 @@ describe('admission notification consent and delivery lifecycle', () => { await applyEvent(h.notification, enabledEvent()) const response = await queue(h.notification) - await expect(response.json()).resolves.toEqual({ status: 'queued' }) + await expect(response.json()).resolves.toEqual({ status: 'delivery-exhausted' }) expect(stored(h.storage)).toContain(TOKEN) - expect(stored(h.storage)).toContain('"status":"retrying"') - expect(stored(h.storage)).not.toContain('retryReason') - expect(stored(h.storage)).not.toContain('provider-no-webhook-url') + expect(stored(h.storage)).toContain('"status":"exhausted"') await expect((await inspect(h.notification)).json()).resolves.toMatchObject({ retryReasons: ['provider-no-webhook-url'], }) @@ -446,7 +733,7 @@ describe('admission notification consent and delivery lifecycle', () => { const response = await queue(h.notification) await expect(response.json()).resolves.toEqual({ status: 'queued' }) await expect((await inspect(h.notification)).json()).resolves.toMatchObject({ - retryReasons: ['invalid-response'], + retryReasons: ['response-schema'], }) } }) @@ -472,6 +759,7 @@ describe('admission notification consent and delivery lifecycle', () => { expect(text).not.toContain(TOKEN) expect(JSON.parse(text)).toEqual({ status: 'queued', + generation: 'admitted', authEpoch: 7, deliveryAttemptCount: 0, verificationFailureCount: 1, diff --git a/services/auth-bridge/test/app.test.ts b/services/auth-bridge/test/app.test.ts index 77156bc3..4c2d16c2 100644 --- a/services/auth-bridge/test/app.test.ts +++ b/services/auth-bridge/test/app.test.ts @@ -2600,6 +2600,7 @@ describe('Warpkeep auth bridge', () => { expect(h.resolver.resolve).toHaveBeenCalledWith(FID) expect(queueAdmission).toHaveBeenCalledWith({ fid: FID, + kind: 'admitted', authEpoch: 7, queuedAt: expect.any(Number), }) @@ -2607,7 +2608,7 @@ describe('Warpkeep auth bridge', () => { expect(JSON.stringify(acceptedBody)).not.toContain(NOTIFICATION_OPERATOR_SECRET) }) - it('does not queue for a missing or disabled admission', async () => { + 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({ epoch: 0, @@ -2623,11 +2624,48 @@ describe('Warpkeep auth bridge', () => { ), notificationEnv()) expect(response.status).toBe(409) await expect(response.json()).resolves.toMatchObject({ - error: { code: 'founder_not_admitted' }, + error: { code: 'access_request_not_pending' }, }) expect(queueAdmission).not.toHaveBeenCalled() }) + it('queues the exact pending request before admission becomes visible', async () => { + const requestedAtMicros = 1_785_414_896_000_000 + const queueAdmission = vi.fn(async () => 'already-sent' as const) + const getStatus = vi.fn(async () => ({ + status: 'requested' as const, + requestedAtMicros, + })) + const h = harness({ + epoch: 0, + accessRequestResolver: { + getStatus, + submit: vi.fn(async () => ({ status: 'not-requested' } as const)), + }, + admissionNotificationStore: { + applyEvent: vi.fn(async () => undefined), + queueAdmission, + }, + }) + + const response = await h.app.fetch(request( + ADMISSION_NOTIFICATION_PATH, + { fid: FID }, + { headers: { authorization: `Bearer ${NOTIFICATION_OPERATOR_SECRET}` } }, + ), notificationEnv()) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ status: 'already-sent' }) + expect(getStatus).toHaveBeenCalledWith(FID) + expect(queueAdmission).toHaveBeenCalledWith({ + fid: FID, + kind: 'pending-request', + requestedAtMicros, + queuedAt: expect.any(Number), + }) + expect(h.events).toContain('admission_notification_succeeded') + }) + it('exposes only token-free diagnostics to the separate operator credential', async () => { const inspect = vi.fn(async () => Object.freeze({ status: 'queued' as const, diff --git a/src/farcaster/miniapp/miniAppRuntime.ts b/src/farcaster/miniapp/miniAppRuntime.ts index e72b2585..abae0320 100644 --- a/src/farcaster/miniapp/miniAppRuntime.ts +++ b/src/farcaster/miniapp/miniAppRuntime.ts @@ -159,7 +159,7 @@ const MAX_NOTIFICATION_ID_LENGTH = 128; const COMPACT_JWT_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; const APPROVAL_NOTIFICATION_ID_PATTERN = - /^warpkeep-access-approved-v1-e[1-9]\d*$/; + /^warpkeep-access-approved-(?:v1-e|v2-r)[1-9]\d*$/; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); diff --git a/tests/WarpkeepExperienceRealm.test.tsx b/tests/WarpkeepExperienceRealm.test.tsx index e0eab116..91d1fa0a 100644 --- a/tests/WarpkeepExperienceRealm.test.tsx +++ b/tests/WarpkeepExperienceRealm.test.tsx @@ -728,7 +728,7 @@ describe('Warpkeep Farcaster Mini App direct entry', () => { const backend = createBackendRuntime(); vi.mocked(backend.runtime.readEntryAgreementStatus!).mockResolvedValue(true); const bridge = createQuickAuthBridge(createQuickAuthResponse()); - const sdk = miniAppSdk(true, 'warpkeep-access-approved-v1-e7'); + const sdk = miniAppSdk(true, 'warpkeep-access-approved-v2-r1800000000000000'); const { container } = renderExperience({ bridge, miniApp: { runtime: miniAppRuntime(), sdk }, diff --git a/tests/hermesAdminSecurity.test.ts b/tests/hermesAdminSecurity.test.ts index cec210ce..b360f172 100644 --- a/tests/hermesAdminSecurity.test.ts +++ b/tests/hermesAdminSecurity.test.ts @@ -22,6 +22,7 @@ import { readNotificationOperatorSecret, readStatus, requestAdmissionNotification, + requireNotificationBeforeAdmission, requestAdminToken, requireAccessRequestInspectionProductionTarget, requireAccessRequestResetProductionTarget, @@ -38,6 +39,8 @@ import { verifyFounderAdmissionPreconditionV3, verifyFounderAdmissionResourcePostconditionV4, verifyFounderAdmissionResourcePreconditionV4, + verifyFounderReenablePostcondition, + verifyFounderReenablePrecondition, verifyGenesisExpansionPostconditionV3, verifyGenesisExpansionPreconditionV3, verifyGenesisExpansionResourceCheckpointV4, @@ -645,6 +648,66 @@ describe('Hermes machine-readable output', () => { playerOwnershipsV2: before.playerOwnershipsV2 + 1n, }, before)).toThrow(/unrelated persistent aggregate state/i); }); + + it('binds an existing founder re-enable to one pending request and exact post-state', () => { + const worldBefore = foundedGenerationV3Status({ enabledAllowedFids: 2n }); + const resources = { + allowedFids: 3n, + castles: 3n, + markAccounts: 3n, + resourceAccounts: 3n, + missingResourceAccounts: 0n, + orphanedResourceAccounts: 0n, + resourceInvariantViolations: 0n, + protocolVersion: 3, + resourcePolicyVersion: GENESIS_RESOURCE_POLICY_VERSION, + }; + const targetBefore = projectAccessRequestResetStatus({ + admissionState: 'disabled', + authEpoch: 3, + requestState: 'pending', + requestCycle: 4n, + requestedAtMicros: 1_800_000_000_000_000n, + }); + const before = verifyFounderReenablePrecondition( + worldBefore, + resources, + targetBefore, + ); + expect(() => verifyFounderReenablePrecondition( + worldBefore, + resources, + { ...targetBefore, requestState: 'resolved' }, + )).toThrow(/exact pending access request/i); + + const targetAfter = projectAccessRequestResetStatus({ + admissionState: 'enabled', + authEpoch: 4, + requestState: 'resolved', + requestCycle: 4n, + requestedAtMicros: 1_800_000_000_000_000n, + }); + expect(() => verifyFounderReenablePostcondition( + { + ...worldBefore, + enabledAllowedFids: 3n, + auditEntries: worldBefore.auditEntries + 1n, + }, + resources, + targetAfter, + before, + )).not.toThrow(); + expect(() => verifyFounderReenablePostcondition( + { + ...worldBefore, + enabledAllowedFids: 3n, + auditEntries: worldBefore.auditEntries + 1n, + }, + { ...resources, resourceAccounts: 2n, missingResourceAccounts: 1n }, + targetAfter, + before, + )).toThrow(/resource/i); + }); }); describe('Hermes command-line boundary', () => { @@ -1235,6 +1298,7 @@ describe('Hermes atomic profiled admission boundary', () => { const readCredential = mainSource.indexOf('readAdminSecret('); const verifyV3Checkpoint = mainSource.indexOf('verifyFounderAdmissionPreconditionV3('); const verifyV4Checkpoint = mainSource.indexOf('verifyFounderAdmissionResourcePreconditionV4('); + const requireNotification = mainSource.indexOf('await requireNotificationBeforeAdmission('); const claimPlan = mainSource.indexOf('claimReviewedFounderAdmissionPlan({'); const submitAdmission = mainSource.indexOf('connection.reducers.adminAdmitFounderV1('); expect(resolveForPlan).toBeGreaterThan(-1); @@ -1243,7 +1307,8 @@ describe('Hermes atomic profiled admission boundary', () => { expect(readCredential).toBeGreaterThan(readPlan); expect(verifyV3Checkpoint).toBeGreaterThan(readCredential); expect(verifyV4Checkpoint).toBeGreaterThan(verifyV3Checkpoint); - expect(claimPlan).toBeGreaterThan(verifyV4Checkpoint); + expect(requireNotification).toBeGreaterThan(verifyV4Checkpoint); + expect(claimPlan).toBeGreaterThan(requireNotification); expect(submitAdmission).toBeGreaterThan(claimPlan); expect(mainSource).not.toContain('resolveAdmissionReadyFounderProfile(fid)'); @@ -1559,6 +1624,69 @@ describe('Hermes credential destination policy', () => { )).rejects.toThrow(/rejected the request/i); }); + it('waits for provider acceptance before allowing an opted-in admission mutation', async () => { + const sleep = vi.fn(async () => undefined); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + let requestCount = 0; + const fetchImpl = vi.fn(async (input) => { + expect(String(input)).toBe( + 'https://auth.warpkeep.com/v1/admin/admission-notification', + ); + requestCount += 1; + return requestCount === 1 + ? Response.json({ status: 'queued' }, { status: 202 }) + : Response.json({ status: 'already-sent' }); + }); + + await expect(requireNotificationBeforeAdmission( + 'https://auth.warpkeep.com', + 12_345n, + NOTIFICATION_SECRET, + fetchImpl, + sleep, + )).resolves.toBe('already-sent'); + + expect(sleep).toHaveBeenCalledWith(35_000); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(log).toHaveBeenCalledWith(JSON.stringify({ + admissionNotification: 'already-sent', + providerAcceptanceRequired: true, + providerAcceptedBeforeAdmission: true, + })); + log.mockRestore(); + }); + + it('permits an explicit no-consent receipt but blocks queued or exhausted delivery', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const notSubscribed = vi.fn(async () => Response.json({ + status: 'not-subscribed', + })); + await expect(requireNotificationBeforeAdmission( + 'https://auth.warpkeep.com', + 12_345n, + NOTIFICATION_SECRET, + notSubscribed, + vi.fn(async () => undefined), + )).resolves.toBe('not-subscribed'); + expect(log).toHaveBeenCalledWith(JSON.stringify({ + admissionNotification: 'not-subscribed', + providerAcceptanceRequired: false, + providerAcceptedBeforeAdmission: false, + })); + + const exhausted = vi.fn(async () => Response.json({ + status: 'delivery-exhausted', + })); + await expect(requireNotificationBeforeAdmission( + 'https://auth.warpkeep.com', + 12_345n, + NOTIFICATION_SECRET, + exhausted, + vi.fn(async () => undefined), + )).rejects.toThrow(/delivery is exhausted/i); + log.mockRestore(); + }); + it('rejects wrong-media and chunked oversized admin responses generically', async () => { const wrongMedia = async () => new Response(JSON.stringify({ token: 'header.payload.signature', diff --git a/tests/miniAppRuntime.test.ts b/tests/miniAppRuntime.test.ts index cdf1b928..561603fa 100644 --- a/tests/miniAppRuntime.test.ts +++ b/tests/miniAppRuntime.test.ts @@ -250,10 +250,25 @@ describe('Farcaster Mini App runtime sanitization', () => { expect(valid?.notificationId).toBe('warpkeep-access-approved-v1-e42'); expect(JSON.stringify(valid)).not.toContain('must-not-pass-through'); + const pendingRequest = sanitizeMiniAppContext({ + user: { fid: 539_854 }, + client: { clientFid: 9_150, added: true }, + location: { + type: 'notification', + notification: { + notificationId: 'warpkeep-access-approved-v2-r1800000000000000' + } + } + }, { width: 400, height: 800 }); + expect(pendingRequest?.notificationId) + .toBe('warpkeep-access-approved-v2-r1800000000000000'); + for (const notificationId of [ 'warpkeep-access-approved-v1-e0', 'warpkeep-access-approved-v1-e01', 'warpkeep-access-approved-v2-e1', + 'warpkeep-access-approved-v2-r0', + 'warpkeep-access-approved-v2-r01', `warpkeep-access-approved-v1-e${'1'.repeat(129)}` ]) { const context = sanitizeMiniAppContext({