diff --git a/docs/farcaster-integration.md b/docs/farcaster-integration.md index 75f4a22d..4be9d624 100644 --- a/docs/farcaster-integration.md +++ b/docs/farcaster-integration.md @@ -350,36 +350,35 @@ 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. +retry attempts are bounded, and one request generation uses one stable +notification ID and at most one active transport target. The current pending +access request is the sole player-visible admission notification generation. +Already-admitted reconciliation is non-delivering, and the former standalone +operator command has been retired. A genuinely new pending-request timestamp +after a reviewed reset is the only event that permits another admission alert. +Notification preference and delivery add no SpacetimeDB schema or browser +authority. The reviewed payloads are: ```txt -normal admission: +admission request: 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: The Hegemony admits you -body: Your keep awaits in Genesis 001. Enter the living Realm. +title: Welcome to the Hegemony Empire +body: The gates have answered your name. Cross the threshold, Founder—your legacy awaits. targetUrl: https://warpkeep.com/?miniApp=true ``` -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. +The title and body are within Farcaster's bounds and contain no realm name, +FID, username, other-player event, or private state. Copy changes require a +reviewed Worker rollout. For a notification launch, the browser retains only `location.type === "notification"` and a notification ID matching either -`warpkeep-access-approved-v2-r` or the rollback-compatible -`warpkeep-access-approved-v1-e` within the 128-character -limit. Host title and body are discarded. Warpkeep then shows a short +`warpkeep-access-approved-v2-r` or a previously delivered, +rollback-compatible `warpkeep-access-approved-v1-e` within the +128-character limit. The latter remains launch-compatible but can no longer be +queued or sent. Host title and body are discarded. Warpkeep then shows a short confirmation state and runs normal Quick Auth, current admission, Terms, and canonical-keep checks. A pending or changed account stays pending; the notification itself never grants access or creates another keep. diff --git a/docs/operations/alpha-activation.md b/docs/operations/alpha-activation.md index 442a7828..5e0465d5 100644 --- a/docs/operations/alpha-activation.md +++ b/docs/operations/alpha-activation.md @@ -265,8 +265,8 @@ a SpacetimeDB schema change. Roll them out in this order: 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. + explicit audited fallback for a player without consent. Never queue a + post-admission reconciliation notification; that legacy path is retired. ### Owner canary and end-to-end acceptance @@ -279,7 +279,7 @@ it. fact that the client presentation gate is still `false`. Do not record a real FID, webhook body, notification token, or delivery URL. 2. Use a dedicated owner-controlled account in the exact production Mini App. - Before any admission or `notify-admitted` action for that test cycle, accept + Before the admission action for that test cycle, accept Farcaster's native add prompt. In the bounded log window, require exactly the fixed events `miniapp_webhook_verified` and `miniapp_notification_subscribed`; no caller data is valid evidence. @@ -310,11 +310,11 @@ it. complete acceptance on current Farcaster iOS and Android before declaring the client rollout complete. -The normal pending-request notification is `Admission approved` with -`The Hegemony is finalizing your Realm access. Your keep will open shortly.` The older -`The Hegemony admits you` payload remains only for already-live reconciliation. -Both are bounded and privacy-safe. Any copy change requires a separate reviewed -Worker rollout. +The sole pending-request notification is `Welcome to the Hegemony Empire` with +`The gates have answered your name. Cross the threshold, Founder—your legacy awaits.` +It contains no realm name or player identity. The admitted-epoch payload is +retired and must be cancelled without delivery. Any copy change requires a +separate reviewed Worker rollout. For rollback, set `APPROVAL_NOTIFICATIONS_ENABLED=false` first, then return `VITE_WARPKEEP_ADMISSION_NOTIFICATIONS_ENABLED=false` and deploy the last diff --git a/package.json b/package.json index ba4cde2a..c0d24252 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,6 @@ "stdb:seed-world": "tsx scripts/hermes-admin.ts seed-world", "stdb:expand-world-v3": "tsx scripts/hermes-admin.ts expand-world-v3", "stdb:admit-founder": "tsx scripts/hermes-admin.ts admit-founder", - "stdb:notify-admitted": "tsx scripts/hermes-admin.ts notify-admitted", "stdb:allow-fid": "tsx scripts/hermes-admin.ts allow-fid", "stdb:disable-fid": "tsx scripts/hermes-admin.ts disable-fid", "stdb:bump-auth-epoch": "tsx scripts/hermes-admin.ts bump-auth-epoch", diff --git a/scripts/hermes-admin.ts b/scripts/hermes-admin.ts index ea7e3719..84d7e1fe 100644 --- a/scripts/hermes-admin.ts +++ b/scripts/hermes-admin.ts @@ -87,7 +87,6 @@ type Command = | 'inspect-access-request-reset' | 'reset-access-request' | 'admit-founder' - | 'notify-admitted' | 'allow-fid' | 'disable-fid' | 'bump-auth-epoch' @@ -382,7 +381,6 @@ function commandFrom(value: string | undefined): Command { || value === 'inspect-access-request-reset' || value === 'reset-access-request' || value === 'admit-founder' - || value === 'notify-admitted' || value === 'allow-fid' || value === 'disable-fid' || value === 'bump-auth-epoch' @@ -403,7 +401,7 @@ function commandFrom(value: string | undefined): Command { } fail( 'Usage: hermes-admin.ts ' - + ' ' + + ' ' + '[...args] [--dry-run] [--confirm]. admit-founder requires private stdin: ' + '--input-stdin --dry-run creates a reviewed plan; --input-stdin --confirm consumes it; ' + 'allow-fid only re-enables an existing complete founder. list-access-requests accepts ' @@ -474,8 +472,6 @@ export function parseHermesArguments(arguments_: readonly string[] = process.arg || command === 'disable-fid' || command === 'bump-auth-epoch' ? 3 - : command === 'notify-admitted' - ? 2 : command === 'backfill-resources' || command === 'seed-alpha-component' ? 2 : 1; @@ -508,15 +504,6 @@ export function parseHermesArguments(arguments_: readonly string[] = process.arg if (flags.has('--dry-run') === flags.has('--confirm')) { fail('Profiled admission requires exactly one of --dry-run or --confirm.'); } - } else if (command === 'notify-admitted') { - if ( - flags.has('--input-stdin') - || flags.has('--json') - || flags.has('--dry-run') - || !flags.has('--confirm') - ) { - fail('Admission notification reconciliation requires exactly --confirm.'); - } } else if (command === 'reset-access-request') { if (flags.has('--json')) { fail('Hermes command received a flag that is invalid for this operation.'); @@ -2073,7 +2060,6 @@ async function main() { && command !== 'expand-world-v3' && command !== 'reset-access-request' && command !== 'admit-founder' - && command !== 'notify-admitted' && command !== 'seed-alpha-component' && command !== 'activate-alpha-water' && process.env.WARPKEEP_HERMES_NONINTERACTIVE === 'yes' @@ -2091,7 +2077,6 @@ async function main() { let fid = command === 'allow-fid' || command === 'disable-fid' || command === 'bump-auth-epoch' - || command === 'notify-admitted' || command === 'inspect-access-request-reset' || (command === 'reset-access-request' && dryRun) ? readFid(positional[1]) @@ -2205,9 +2190,6 @@ async function main() { if (command === 'activate-alpha-water' && !dryRun && !confirmed) { fail('Refusing Water activation without --confirm.'); } - if (command === 'notify-admitted' && !confirmed) { - fail('Refusing admission notification reconciliation without --confirm.'); - } if (command === 'reset-access-request' && !dryRun && !confirmed) { fail('Refusing access request reset without --confirm.'); } @@ -2275,15 +2257,6 @@ async function main() { const bridgeUrl = prevalidatedBridgeUrl ?? readHttpsUrl(process.env.WARPKEEP_AUTH_BRIDGE_URL, 'WARPKEEP_AUTH_BRIDGE_URL'); requireCredentialedProductionTarget(uri, database, bridgeUrl); - if (command === 'notify-admitted' && fid !== undefined) { - const status = await requestAdmissionNotification( - bridgeUrl, - fid, - readNotificationOperatorSecret(notificationOperatorSecret), - ); - console.log(JSON.stringify({ admissionNotification: status })); - return; - } if ( command === 'reset-access-request' && process.env.WARPKEEP_ADMIN_TOKEN_SECRET !== undefined diff --git a/services/auth-bridge/README.md b/services/auth-bridge/README.md index 9529d23e..67798bb9 100644 --- a/services/auth-bridge/README.md +++ b/services/auth-bridge/README.md @@ -389,10 +389,16 @@ object, are never returned to the browser or stored in SpacetimeDB, and expire within 366 days. Signed opt-outs remain accepted while delivery is paused and erase raw token material immediately. The deployed v1 consent record retains 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. +private v2 record. The exact current pending-request timestamp while admission +is disabled is the only generation allowed to produce a player-visible alert. +Legacy admitted-epoch queues remain readable only so they can be cancelled +without delivery. One deterministic transport target, a stable request-scoped +notification ID, retry ceilings, replay tombstones, and a durable request +receipt prevent a request from fanning out or producing a post-admission alert. +Once selected, a request's transport target is immutable; opt-out, token +rotation, expiry, or client removal terminates that generation rather than +redirecting it. Terminal request timestamps are monotonic high-water marks, so +rollback or stale operator input cannot revive an older alert. 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 diff --git a/services/auth-bridge/src/admissionNotifications.ts b/services/auth-bridge/src/admissionNotifications.ts index dfbcd754..722779b5 100644 --- a/services/auth-bridge/src/admissionNotifications.ts +++ b/services/auth-bridge/src/admissionNotifications.ts @@ -39,11 +39,9 @@ const DELIVERY_RESPONSE_MAX_BYTES = 64 * 1_024 const MAX_NOTIFICATION_TOKEN_BYTES = 2 * 1_024 const SUBSCRIPTION_MAX_LIFETIME_MILLISECONDS = 366 * 24 * 60 * 60 * 1_000 const TARGET_URL = 'https://warpkeep.com/?miniApp=true' -const ADMITTED_NOTIFICATION_TITLE = 'The Hegemony admits you' -const ADMITTED_NOTIFICATION_BODY = 'Your keep awaits in Genesis 001. Enter the living Realm.' -const PENDING_NOTIFICATION_TITLE = 'Admission approved' -const PENDING_NOTIFICATION_BODY = - 'The Hegemony is finalizing your Realm access. Your keep will open shortly.' +const ADMISSION_NOTIFICATION_TITLE = 'Welcome to the Hegemony Empire' +const ADMISSION_NOTIFICATION_BODY = + 'The gates have answered your name. Cross the threshold, Founder—your legacy awaits.' const RETRY_DELAYS_MILLISECONDS = Object.freeze([ 30_000, 2 * 60_000, @@ -52,7 +50,6 @@ const RETRY_DELAYS_MILLISECONDS = Object.freeze([ 4 * 60 * 60_000, 12 * 60 * 60_000, ]) -const LEGACY_TRANSPORT_RETRY_MINIMUM_AGE_MILLISECONDS = 30_000 type DeliveryAttemptStatus = 'pending' | 'retrying' | 'sent' | 'exhausted' @@ -87,6 +84,17 @@ type AdmissionDelivery = Readonly<{ attempts: readonly DeliveryAttempt[] }> & AdmissionNotificationGeneration +type RetiredAdmittedQueueInput = Readonly<{ + fid: string + queuedAt: number + authEpoch: number + kind?: 'admitted' +}> + +type InternalAdmissionNotificationQueueInput = + | AdmissionNotificationQueueInput + | RetiredAdmittedQueueInput + type LegacyPersistedNotificationState = Readonly<{ version: 1 revision: number @@ -779,7 +787,7 @@ function validVerifiedEvent(value: unknown, config: BridgeConfig): value is Veri && configuredClient(config, value.appFid, value.event.details.url) } -function validQueueInput(value: unknown): value is AdmissionNotificationQueueInput { +function validQueueInput(value: unknown): value is InternalAdmissionNotificationQueueInput { if (!isRecord(value) || !isSafeFid(value.fid) || !isTimestamp(value.queuedAt)) return false if ( value.kind === undefined @@ -847,16 +855,6 @@ function pruneSubscriptions( return Object.freeze({ ...withTombstones, subscriptions: Object.freeze(subscriptions), - ...(state.delivery - ? { - delivery: Object.freeze({ - ...state.delivery, - attempts: Object.freeze(state.delivery.attempts.filter(attempt => ( - liveTokenIds.has(attempt.tokenId) - ))), - }), - } - : {}), }) } @@ -864,20 +862,29 @@ function attemptsForSubscriptions( delivery: AdmissionDelivery, subscriptions: readonly Subscription[], ): readonly DeliveryAttempt[] { - return Object.freeze(subscriptions.map(subscription => { - const existing = delivery.attempts.find(attempt => attempt.appFid === subscription.appFid) - if (existing?.status === 'sent') return existing - if (existing?.tokenId === subscription.tokenId) { - return existing - } - return Object.freeze({ - appFid: subscription.appFid, - tokenId: subscription.tokenId, - status: 'pending' as const, - attempts: 0, - verificationFailures: 0, - }) - })) + // A request generation belongs to one player and may have only one active + // transport target. Keep its first attempt stable even if a later webhook + // removes that token; attemptDelivery will terminate instead of retargeting. + // An unbound generation selects the newest configured subscription with a + // deterministic tie-break, preventing fan-out across Farcaster clients. + const existing = delivery.attempts.find(attempt => attempt.status === 'sent') + ?? delivery.attempts.find(attempt => attempt.status !== 'exhausted') + ?? delivery.attempts[0] + if (existing) return Object.freeze([existing]) + + const subscription = [...subscriptions].sort((left, right) => ( + right.enabledAt - left.enabledAt + || left.appFid - right.appFid + || left.tokenId.localeCompare(right.tokenId) + ))[0] + if (!subscription) return Object.freeze([]) + return Object.freeze([Object.freeze({ + appFid: subscription.appFid, + tokenId: subscription.tokenId, + status: 'pending' as const, + attempts: 0, + verificationFailures: 0, + })]) } function nextAlarmAt(state: PersistedNotificationState, now: number): number | null { @@ -1011,61 +1018,6 @@ function deliveryGeneration(delivery: AdmissionDelivery): AdmissionNotificationG }) } -/** - * Older Cloudflare deployments classified their runtime-level redirect failure - * as the broad `transport` reason. An authenticated operator replay may bring - * the exact legacy fifth-attempt generation forward after Farcaster's minimum - * retry interval. The legacy record did not store a last-attempt timestamp, so - * derive it from the persisted four-hour backoff invariant. The attempt counter - * and six-attempt ceiling never reset. Current deployments emit richer records, - * so this compatibility path cannot become a general-purpose backoff bypass. - */ -function recoverLegacyTransportBackoff( - state: PersistedNotificationState, - diagnostics: PersistedNotificationDiagnostics | null, - generation: AdmissionNotificationGeneration, - now: number, -): PersistedNotificationState { - if ( - !state.delivery - || state.delivery.kind !== 'admitted' - || generation.kind !== 'admitted' - || !generationEquals(deliveryGeneration(state.delivery), generation) - || !diagnostics - || !generationEquals(diagnostics.generation, generation) - || diagnostics.retryReasons.length !== 1 - || diagnostics.retryReasons[0] !== 'transport' - || diagnostics.lastFailureReason !== undefined - || diagnostics.lastAttemptAt !== undefined - || state.delivery.attempts.length === 0 - || state.delivery.attempts.some((attempt) => ( - attempt.status !== 'retrying' - || attempt.attempts !== MAX_DELIVERY_ATTEMPTS - 1 - || attempt.nextAttemptAt === undefined - || attempt.nextAttemptAt <= now - || attempt.nextAttemptAt - RETRY_DELAYS_MILLISECONDS[attempt.attempts - 1] - > now - LEGACY_TRANSPORT_RETRY_MINIMUM_AGE_MILLISECONDS - )) - ) return state - - const attempts = state.delivery.attempts.map((attempt) => { - return Object.freeze({ - appFid: attempt.appFid, - tokenId: attempt.tokenId, - status: 'pending' as const, - attempts: attempt.attempts, - verificationFailures: attempt.verificationFailures, - }) - }) - return Object.freeze({ - ...state, - delivery: Object.freeze({ - ...state.delivery, - attempts: Object.freeze(attempts), - }), - }) -} - async function recordDiagnostics( storage: DurableObjectState['storage'], generation: AdmissionNotificationGeneration, @@ -1271,12 +1223,8 @@ async function sendOne( }, body: JSON.stringify({ notificationId: notificationId(delivery), - title: delivery.kind === 'admitted' - ? ADMITTED_NOTIFICATION_TITLE - : PENDING_NOTIFICATION_TITLE, - body: delivery.kind === 'admitted' - ? ADMITTED_NOTIFICATION_BODY - : PENDING_NOTIFICATION_BODY, + title: ADMISSION_NOTIFICATION_TITLE, + body: ADMISSION_NOTIFICATION_BODY, targetUrl: TARGET_URL, tokens: [subscription.token], }), @@ -1368,6 +1316,15 @@ function terminalAttempt(attempt: DeliveryAttempt): DeliveryAttempt { }) } +function retireAttemptWithoutDelivery(attempt: DeliveryAttempt): DeliveryAttempt { + return Object.freeze({ + ...attempt, + status: 'exhausted', + verificationFailures: 0, + nextAttemptAt: undefined, + }) +} + function deferForAdmissionVerification( attempt: DeliveryAttempt, now: number, @@ -1396,7 +1353,8 @@ function sentForGeneration( return generation.kind === 'admitted' ? state.lastSentAuthEpoch !== undefined && state.lastSentAuthEpoch >= generation.authEpoch - : state.lastSentRequestAtMicros === generation.requestedAtMicros + : state.lastSentRequestAtMicros !== undefined + && state.lastSentRequestAtMicros >= generation.requestedAtMicros } function exhaustedForGeneration( @@ -1406,7 +1364,8 @@ function exhaustedForGeneration( return generation.kind === 'admitted' ? state.lastExhaustedAuthEpoch !== undefined && state.lastExhaustedAuthEpoch >= generation.authEpoch - : state.lastExhaustedRequestAtMicros === generation.requestedAtMicros + : state.lastExhaustedRequestAtMicros !== undefined + && state.lastExhaustedRequestAtMicros >= generation.requestedAtMicros } function queueStatus(state: PersistedNotificationState): AdmissionNotificationQueueStatus { @@ -1561,32 +1520,50 @@ export class AdmissionNotification { const now = this.currentTime() const config = this.config() const pruned = pruneSubscriptions(state, config, now) - const delivery = pruned.delivery + let delivery = pruned.delivery if (!delivery) { if (pruned === state) return state const next = withNextRevision(pruned) await persistAndSchedule(this.state.storage, next, now) return next } + if (delivery.kind === 'admitted') { + // Admitted-epoch notifications were a legacy reconciliation path. A + // player-visible alert is now owned exclusively by the exact pending + // access-request generation, so no persisted legacy alarm can emit a + // second notification after admission becomes authoritative. + const next = withNextRevision(Object.freeze({ + ...pruned, + delivery: undefined, + lastExhaustedAuthEpoch: Math.max( + pruned.lastExhaustedAuthEpoch ?? 0, + delivery.authEpoch, + ), + })) + await persistAndSchedule(this.state.storage, next, now) + return next + } + const generation = deliveryGeneration(delivery) + if (sentForGeneration(pruned, generation) || exhaustedForGeneration(pruned, generation)) { + // A terminal generation receipt always wins over webhook refreshes and + // stale alarms. Clear the retained delivery shell before any attempt can + // be rebuilt around a new token. + const next = withNextRevision(Object.freeze({ ...pruned, delivery: undefined })) + await persistAndSchedule(this.state.storage, next, now) + return next + } if (now >= delivery.expiresAt) { const exhausted = delivery.attempts.some(attempt => attempt.status === 'exhausted') const next = withNextRevision(Object.freeze({ ...pruned, delivery: undefined, ...(exhausted - ? delivery.kind === 'admitted' - ? { - lastExhaustedAuthEpoch: Math.max( - pruned.lastExhaustedAuthEpoch ?? 0, - delivery.authEpoch, - ), - } - : { - lastExhaustedRequestAtMicros: Math.max( - pruned.lastExhaustedRequestAtMicros ?? 0, - delivery.requestedAtMicros, - ), - } + ? { + lastExhaustedRequestAtMicros: Math.max( + pruned.lastExhaustedRequestAtMicros ?? 0, + delivery.requestedAtMicros, + ), + } : {}), })) await persistAndSchedule(this.state.storage, next, now) @@ -1602,10 +1579,16 @@ export class AdmissionNotification { return next } + delivery = Object.freeze({ + ...delivery, + attempts: attemptsForSubscriptions(delivery, pruned.subscriptions), + }) + const singleTargetState = Object.freeze({ ...pruned, delivery }) + // Persisted data is never trusted as an outbound destination. Reapply the // current deployment allowlist immediately before every network request. - let subscriptions = [...pruned.subscriptions] - let nextBase = pruned + let subscriptions = [...singleTargetState.subscriptions] + let nextBase: PersistedNotificationState = singleTargetState let invalidatedGeneration = false let latestAttemptAt: number | undefined let latestFailureReason: AdmissionNotificationRetryReason | undefined @@ -1618,7 +1601,14 @@ export class AdmissionNotification { const subscription = subscriptions.find(candidate => ( candidate.appFid === attempt.appFid && candidate.tokenId === attempt.tokenId )) - if (!subscription) continue + if (!subscription) { + // Once a request has selected a transport target, token rotation, + // opt-out, expiry, or client removal terminates that generation. Never + // reset its attempt counter or redirect it to another subscription. + invalidatedGeneration = true + attempts.push(retireAttemptWithoutDelivery(attempt)) + continue + } if ( attempt.status === 'sent' || attempt.status === 'exhausted' @@ -1638,10 +1628,7 @@ export class AdmissionNotification { let generationIsCurrent = false try { const admission = await resolver.resolve(state.fid) - if (delivery.kind === 'admitted') { - generationIsCurrent = admission.state === 'enabled' - && admission.authEpoch === delivery.authEpoch - } else if (admission.state !== 'enabled') { + if (admission.state !== 'enabled') { const request = await requestResolver.getStatus(state.fid) generationIsCurrent = request.status === 'requested' && request.requestedAtMicros === delivery.requestedAtMicros @@ -1650,9 +1637,7 @@ export class AdmissionNotification { // 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. - const reason = delivery.kind === 'admitted' - ? 'admission-verification' - : 'request-verification' + const reason = 'request-verification' retryReasons.push(reason) attempts.push(deferForAdmissionVerification(attempt, now, delivery.expiresAt)) continue @@ -1712,19 +1697,12 @@ export class AdmissionNotification { subscriptions: Object.freeze(subscriptions), delivery: Object.freeze({ ...delivery, attempts: Object.freeze(attempts) }), ...(attempts.length > 0 && attempts.every(attempt => attempt.status === 'sent') - ? delivery.kind === 'admitted' - ? { - lastSentAuthEpoch: Math.max( - nextBase.lastSentAuthEpoch ?? 0, - delivery.authEpoch, - ), - } - : { - lastSentRequestAtMicros: Math.max( - nextBase.lastSentRequestAtMicros ?? 0, - delivery.requestedAtMicros, - ), - } + ? { + lastSentRequestAtMicros: Math.max( + nextBase.lastSentRequestAtMicros ?? 0, + delivery.requestedAtMicros, + ), + } : {}), ...(invalidatedGeneration || ( attempts.length > 0 @@ -1733,19 +1711,12 @@ export class AdmissionNotification { attempt.status === 'sent' || attempt.status === 'exhausted' )) ) - ? delivery.kind === 'admitted' - ? { - lastExhaustedAuthEpoch: Math.max( - nextBase.lastExhaustedAuthEpoch ?? 0, - delivery.authEpoch, - ), - } - : { - lastExhaustedRequestAtMicros: Math.max( - nextBase.lastExhaustedRequestAtMicros ?? 0, - delivery.requestedAtMicros, - ), - } + ? { + lastExhaustedRequestAtMicros: Math.max( + nextBase.lastExhaustedRequestAtMicros ?? 0, + delivery.requestedAtMicros, + ), + } : {}), })) // Keep a token-free queued admission until its bounded expiry. This closes @@ -1899,16 +1870,6 @@ export class AdmissionNotification { now + SUBSCRIPTION_MAX_LIFETIME_MILLISECONDS, ), subscriptions, - ...(next.delivery && subscriptions.length > 0 - ? { - delivery: Object.freeze({ - ...next.delivery, - attempts: Object.freeze( - next.delivery.attempts.filter(attempt => attempt.appFid !== value.appFid), - ), - }), - } - : { delivery: undefined }), }) } @@ -1928,12 +1889,32 @@ export class AdmissionNotification { 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) - const generation: AdmissionNotificationGeneration = value.kind === 'pending-request' - ? Object.freeze({ - kind: 'pending-request', - requestedAtMicros: value.requestedAtMicros, - }) - : Object.freeze({ kind: 'admitted', authEpoch: value.authEpoch }) + if (value.kind !== 'pending-request') { + // Keep accepting the old internal shape long enough to retire any + // in-flight caller safely, but make it terminal without a provider + // request. This is the final defense against duplicate post-admission + // notifications from older operator tooling or queued Durable Objects. + next = withNextRevision(Object.freeze({ + ...next, + ...(next.delivery?.kind === 'admitted' ? { delivery: undefined } : {}), + lastExhaustedAuthEpoch: Math.max( + next.lastExhaustedAuthEpoch ?? 0, + value.authEpoch, + ), + })) + await persistAndSchedule(this.state.storage, next, now) + return new Response(JSON.stringify({ status: 'delivery-exhausted' }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }) + } + const generation: AdmissionNotificationGeneration = Object.freeze({ + kind: 'pending-request', + requestedAtMicros: value.requestedAtMicros, + }) if (sentForGeneration(next, generation)) { return new Response(JSON.stringify({ status: 'already-sent' }), { status: 200, @@ -1946,24 +1927,12 @@ export class AdmissionNotification { headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, }) } - if ( - next.delivery?.kind === 'admitted' - && generation.kind === 'admitted' - && generation.authEpoch < next.delivery.authEpoch - ) { - return new Response(null, { status: 409 }) - } if ( next.delivery?.kind === 'pending-request' - && generation.kind === 'pending-request' && generation.requestedAtMicros < next.delivery.requestedAtMicros ) { return new Response(null, { status: 409 }) } - const diagnostics = readPersistedDiagnostics( - await this.state.storage.get(DIAGNOSTICS_RECORD), - ) - next = recoverLegacyTransportBackoff(next, diagnostics, generation, now) if ( !next.delivery || !generationEquals(deliveryGeneration(next.delivery), generation) diff --git a/services/auth-bridge/src/app.ts b/services/auth-bridge/src/app.ts index 48cc36aa..a045fb87 100644 --- a/services/auth-bridge/src/app.ts +++ b/services/auth-bridge/src/app.ts @@ -2489,41 +2489,42 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri if (!Number.isSafeInteger(queuedAt) || queuedAt < 0) { throw new ConfigurationError() } + if (admission.state === 'enabled') { + logger.event('admission_notification_rejected') + throw new HttpError( + 409, + 'admission_notification_not_applicable', + 'Admission notifications are issued only for the current pending access request.', + ) + } + 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.', + ) + } + const generation = Object.freeze({ + kind: 'pending-request' as const, + requestedAtMicros: requestStatus.requestedAtMicros, + }) 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) diff --git a/services/auth-bridge/src/types.ts b/services/auth-bridge/src/types.ts index a2053fb5..352f19d8 100644 --- a/services/auth-bridge/src/types.ts +++ b/services/auth-bridge/src/types.ts @@ -328,7 +328,9 @@ export type AdmissionNotificationGeneration = export type AdmissionNotificationQueueInput = Readonly<{ fid: string queuedAt: number -}> & AdmissionNotificationGeneration + kind: 'pending-request' + requestedAtMicros: number +}> export type AdmissionNotificationRetryReason = | 'admission-verification' diff --git a/services/auth-bridge/test-workerd/authBridge.workerd.test.ts b/services/auth-bridge/test-workerd/authBridge.workerd.test.ts index 17f3a729..7f427c76 100644 --- a/services/auth-bridge/test-workerd/authBridge.workerd.test.ts +++ b/services/auth-bridge/test-workerd/authBridge.workerd.test.ts @@ -347,6 +347,7 @@ 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 requestedAtMicros = 1_799_999_999_000_000 const notificationConfig: BridgeConfig = { ...CONFIG, approvalNotificationsEnabled: true, @@ -382,11 +383,11 @@ describe('auth bridge production bindings in workerd', () => { fetchImpl, configReader: () => notificationConfig, admissionResolver: { - resolve: async () => ({ state: 'enabled', authEpoch: 7 }), + resolve: async () => ({ state: 'disabled', authEpoch: 0 }), }, accessRequestResolver: { - getStatus: async () => ({ status: 'not-requested' }), - submit: async () => ({ status: 'not-requested' }), + getStatus: async () => ({ status: 'requested', requestedAtMicros }), + submit: async () => ({ status: 'requested', requestedAtMicros }), }, }, ) @@ -412,8 +413,8 @@ describe('auth bridge production bindings in workerd', () => { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ fid: FID, - kind: 'admitted', - authEpoch: 7, + kind: 'pending-request', + requestedAtMicros, queuedAt: 1_800_000_000_000, }), }, diff --git a/services/auth-bridge/test/admissionNotifications.test.ts b/services/auth-bridge/test/admissionNotifications.test.ts index 81ad8ed4..6a0e05dd 100644 --- a/services/auth-bridge/test/admissionNotifications.test.ts +++ b/services/auth-bridge/test/admissionNotifications.test.ts @@ -12,6 +12,7 @@ import type { } from '../src/types' const NOW = 1_800_000_000_000 +const REQUESTED_AT_MICROS = 1_799_999_999_000_000 const FID = '12345' const APP_FID = 9_152 const DELIVERY_URL = 'https://api.farcaster.xyz/v1/frame-notifications' @@ -19,7 +20,6 @@ const TOKEN = 'test-notification-token-with-enough-entropy' const INTERNAL_ORIGIN = 'https://admission-notification.internal' const STATE_KEY = 'admission-notification-v1' const PENDING_STATE_RECORD = 'admission-notification-pending-v2' -const DIAGNOSTICS_RECORD = 'admission-notification-diagnostics-v1' class FakeStorage implements DurableObjectStorage { readonly values = new Map() @@ -102,14 +102,16 @@ function config(enabled = true): BridgeConfig { function enabledEvent( eventId = 'a'.repeat(64), token = TOKEN, + appFid = APP_FID, + url = DELIVERY_URL, ): VerifiedMiniAppWebhookEvent { return { eventId, fid: FID, - appFid: APP_FID, + appFid, event: { type: 'enabled', - details: { token, url: DELIVERY_URL }, + details: { token, url }, }, } } @@ -153,7 +155,7 @@ function createHarness(options: { const storage = new FakeStorage() let now = NOW const resolver = options.resolver ?? { - resolve: vi.fn(async () => ({ state: 'enabled', authEpoch: 7 } as const)), + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), } const notification = new AdmissionNotification( { storage } as DurableObjectState, @@ -164,8 +166,14 @@ function createHarness(options: { 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)), + getStatus: vi.fn(async () => ({ + status: 'requested', + requestedAtMicros: REQUESTED_AT_MICROS, + } as const)), + submit: vi.fn(async () => ({ + status: 'requested', + requestedAtMicros: REQUESTED_AT_MICROS, + } as const)), }, }, ) @@ -184,7 +192,7 @@ async function applyEvent( return notification.fetch(internalRequest('event', event)) } -async function queue( +async function queueAdmitted( notification: AdmissionNotification, authEpoch = 7, queuedAt = NOW, @@ -192,6 +200,14 @@ async function queue( return notification.fetch(internalRequest('queue', { fid: FID, authEpoch, queuedAt })) } +async function queue( + notification: AdmissionNotification, + requestedAtMicros = REQUESTED_AT_MICROS, + queuedAt = NOW, +): Promise { + return queuePending(notification, requestedAtMicros, queuedAt) +} + async function queuePending( notification: AdmissionNotification, requestedAtMicros: number, @@ -210,34 +226,30 @@ async function inspect(notification: AdmissionNotification): Promise { } describe('admission notification consent and delivery lifecycle', () => { - it('closes the queue-before-consent race and keeps a retained auth-epoch receipt', async () => { + it('retires direct admitted-generation queues without contacting Farcaster', async () => { const fetchImpl = vi.fn(async () => successfulDelivery()) const h = createHarness({ fetchImpl }) - const beforeConsent = await queue(h.notification) - await expect(beforeConsent.json()).resolves.toEqual({ status: 'not-subscribed' }) + const beforeConsent = await queueAdmitted(h.notification) + await expect(beforeConsent.json()).resolves.toEqual({ status: 'delivery-exhausted' }) expect(fetchImpl).not.toHaveBeenCalled() - expect((await applyEvent(h.notification, enabledEvent())).status).toBe(204) - expect(fetchImpl).toHaveBeenCalledOnce() - const [deliveryUrl, deliveryInit] = fetchImpl.mock.calls[0] - expect(deliveryUrl).toBe(DELIVERY_URL) - expect(deliveryInit?.redirect).toBe('manual') - const payload = JSON.parse(String(deliveryInit?.body)) - expect(payload).toEqual({ - notificationId: 'warpkeep-access-approved-v1-e7', - title: 'The Hegemony admits you', - body: 'Your keep awaits in Genesis 001. Enter the living Realm.', - targetUrl: 'https://warpkeep.com/?miniApp=true', - tokens: [TOKEN], + const explicitLegacyShape = await h.notification.fetch(internalRequest('queue', { + fid: FID, + kind: 'admitted', + authEpoch: 8, + queuedAt: NOW, + })) + await expect(explicitLegacyShape.json()).resolves.toEqual({ + status: 'delivery-exhausted', }) - expect(payload.title).toHaveLength(23) - expect(payload.body).toHaveLength(56) + expect(fetchImpl).not.toHaveBeenCalled() - const duplicate = await queue(h.notification) - await expect(duplicate.json()).resolves.toEqual({ status: 'already-sent' }) - expect(fetchImpl).toHaveBeenCalledOnce() - expect(stored(h.storage)).toContain('"lastSentAuthEpoch":7') + expect((await applyEvent(h.notification, enabledEvent())).status).toBe(204) + const duplicate = await queueAdmitted(h.notification) + await expect(duplicate.json()).resolves.toEqual({ status: 'delivery-exhausted' }) + expect(fetchImpl).not.toHaveBeenCalled() + expect(stored(h.storage)).toContain('"lastExhaustedAuthEpoch":8') expect(stored(h.storage)).not.toContain('"kind"') expect(stored(h.storage)).not.toContain('"lastAttemptAt"') expect(stored(h.storage)).not.toContain('"lastFailureReason"') @@ -264,11 +276,14 @@ describe('admission notification consent and delivery lifecycle', () => { expect(accessRequestResolver.getStatus).toHaveBeenCalledWith(FID) expect(fetchImpl).toHaveBeenCalledOnce() const payload = JSON.parse(String(fetchImpl.mock.calls[0][1]?.body)) - expect(payload).toMatchObject({ + expect(payload).toEqual({ notificationId: `warpkeep-access-approved-v2-r${requestedAtMicros}`, - title: 'Admission approved', - body: 'The Hegemony is finalizing your Realm access. Your keep will open shortly.', + title: 'Welcome to the Hegemony Empire', + body: 'The gates have answered your name. Cross the threshold, Founder—your legacy awaits.', + targetUrl: 'https://warpkeep.com/?miniApp=true', + tokens: [TOKEN], }) + expect(JSON.stringify(payload)).not.toMatch(/Genesis 001|living Realm|12345|other player/i) expect(pendingStored(h.storage)).toContain( `"lastSentRequestAtMicros":${requestedAtMicros}`, ) @@ -315,6 +330,195 @@ describe('admission notification consent and delivery lifecycle', () => { JSON.parse(String(call[1]?.body)) as { notificationId: string } ).notificationId) expect(new Set(notificationIds).size).toBe(2) + + const stale = await queuePending( + h.notification, + requestedAtMicros - 1_000, + NOW + 2, + ) + await expect(stale.json()).resolves.toEqual({ status: 'already-sent' }) + expect(fetchImpl).toHaveBeenCalledTimes(2) + }) + + it('serializes concurrent queues for one request into one stable notification', async () => { + const fetchImpl = vi.fn(async () => successfulDelivery()) + const h = createHarness({ fetchImpl }) + await applyEvent(h.notification, enabledEvent()) + + const responses = await Promise.all([ + queue(h.notification), + queue(h.notification), + queue(h.notification), + ]) + + await Promise.all(responses.map(async response => { + await expect(response.json()).resolves.toEqual({ status: 'already-sent' }) + })) + expect(fetchImpl).toHaveBeenCalledOnce() + const payload = JSON.parse(String(fetchImpl.mock.calls[0][1]?.body)) + expect(payload.notificationId).toBe( + `warpkeep-access-approved-v2-r${REQUESTED_AT_MICROS}`, + ) + }) + + it('keeps retries on the same request notification id', async () => { + let attempt = 0 + const fetchImpl = vi.fn(async () => { + attempt += 1 + return attempt === 1 + ? new Response(null, { status: 503 }) + : successfulDelivery() + }) + const h = createHarness({ fetchImpl }) + await applyEvent(h.notification, enabledEvent()) + await expect((await queue(h.notification)).json()).resolves.toEqual({ status: 'queued' }) + + h.setNow(Number(h.storage.alarm)) + await h.notification.alarm() + + expect(fetchImpl).toHaveBeenCalledTimes(2) + const ids = fetchImpl.mock.calls.map(call => ( + JSON.parse(String(call[1]?.body)) as { notificationId: string } + ).notificationId) + expect(new Set(ids)).toEqual(new Set([ + `warpkeep-access-approved-v2-r${REQUESTED_AT_MICROS}`, + ])) + }) + + it('does not revive a successful request after a token refresh', async () => { + const replacementToken = 'test-replacement-token-with-enough-entropy' + const fetchImpl = vi.fn(async () => successfulDelivery()) + const h = createHarness({ fetchImpl }) + await applyEvent(h.notification, enabledEvent()) + await queue(h.notification) + expect(fetchImpl).toHaveBeenCalledOnce() + + h.setNow(NOW + 1) + await applyEvent(h.notification, enabledEvent('b'.repeat(64), replacementToken)) + + expect(fetchImpl).toHaveBeenCalledOnce() + expect(stored(h.storage)).toContain(replacementToken) + expect(stored(h.storage)).not.toContain(TOKEN) + expect(pendingStored(h.storage)).toContain( + `"lastSentRequestAtMicros":${REQUESTED_AT_MICROS}`, + ) + expect(pendingStored(h.storage)).not.toContain('"delivery"') + }) + + it('does not revive an exhausted request after a token refresh', async () => { + const replacementToken = 'test-replacement-token-with-enough-entropy' + const fetchImpl = vi.fn(async () => Response.json({ + result: { + successfulTokens: [], + invalidTokens: [], + rateLimitedTokens: [], + failedTokens: [{ token: TOKEN, reason: 'no_webhook_url' }], + }, + })) + const h = createHarness({ fetchImpl }) + await applyEvent(h.notification, enabledEvent()) + await expect((await queue(h.notification)).json()).resolves.toEqual({ + status: 'delivery-exhausted', + }) + expect(fetchImpl).toHaveBeenCalledOnce() + + h.setNow(NOW + 1) + await applyEvent(h.notification, enabledEvent('b'.repeat(64), replacementToken)) + + expect(fetchImpl).toHaveBeenCalledOnce() + expect(pendingStored(h.storage)).toContain( + `"lastExhaustedRequestAtMicros":${REQUESTED_AT_MICROS}`, + ) + expect(pendingStored(h.storage)).not.toContain('"delivery"') + }) + + it('terminates an in-flight request instead of retargeting it after token rotation', async () => { + const replacementToken = 'test-replacement-token-with-enough-entropy' + const fetchImpl = vi.fn(async () => new Response(null, { status: 503 })) + const h = createHarness({ fetchImpl }) + await applyEvent(h.notification, enabledEvent()) + await expect((await queue(h.notification)).json()).resolves.toEqual({ status: 'queued' }) + expect(fetchImpl).toHaveBeenCalledOnce() + + h.setNow(NOW + 1) + await applyEvent(h.notification, enabledEvent('b'.repeat(64), replacementToken)) + + expect(fetchImpl).toHaveBeenCalledOnce() + await expect((await queue( + h.notification, + REQUESTED_AT_MICROS, + NOW + 1, + )).json()).resolves.toEqual({ status: 'delivery-exhausted' }) + expect(pendingStored(h.storage)).toContain( + `"lastExhaustedRequestAtMicros":${REQUESTED_AT_MICROS}`, + ) + expect(stored(h.storage)).toContain(replacementToken) + expect(stored(h.storage)).not.toContain(TOKEN) + }) + + it('uses only one deterministic transport target for a player request', async () => { + const secondAppFid = APP_FID + 1 + const secondUrl = 'https://client-two.example/notifications' + const secondToken = 'test-second-client-token-with-enough-entropy' + const fetchImpl = vi.fn(async (_input, init) => { + const payload = JSON.parse(String(init?.body)) as { tokens: string[] } + return successfulDelivery(payload.tokens[0]) + }) + const baseConfig = config() + const h = createHarness({ + fetchImpl, + configReader: () => ({ + ...baseConfig, + miniAppNotifications: { + ...baseConfig.miniAppNotifications!, + clients: Object.freeze([ + { appFid: APP_FID, deliveryUrl: DELIVERY_URL }, + { appFid: secondAppFid, deliveryUrl: secondUrl }, + ]), + }, + }), + }) + await applyEvent(h.notification, enabledEvent()) + h.setNow(NOW + 1) + await applyEvent(h.notification, enabledEvent( + 'b'.repeat(64), + secondToken, + secondAppFid, + secondUrl, + )) + + await expect((await queue(h.notification, REQUESTED_AT_MICROS, NOW + 1)).json()) + .resolves.toEqual({ status: 'already-sent' }) + + expect(fetchImpl).toHaveBeenCalledOnce() + const payload = JSON.parse(String(fetchImpl.mock.calls[0][1]?.body)) + expect(payload.tokens).toEqual([secondToken]) + expect(payload.tokens).not.toContain(TOKEN) + }) + + it('never accepts another FID or its token in the same durable object', async () => { + const otherFid = '67890' + const otherToken = 'test-other-player-token-with-enough-entropy' + const fetchImpl = vi.fn(async () => successfulDelivery()) + const h = createHarness({ fetchImpl }) + await applyEvent(h.notification, enabledEvent()) + await queue(h.notification) + + const otherEvent = { + ...enabledEvent('c'.repeat(64), otherToken), + fid: otherFid, + } + expect((await applyEvent(h.notification, otherEvent)).status).toBe(409) + const otherQueue = await h.notification.fetch(internalRequest('queue', { + fid: otherFid, + kind: 'pending-request', + requestedAtMicros: REQUESTED_AT_MICROS, + queuedAt: NOW, + })) + expect(otherQueue.status).toBe(409) + expect(fetchImpl).toHaveBeenCalledOnce() + expect(stored(h.storage)).not.toContain(otherFid) + expect(stored(h.storage)).not.toContain(otherToken) }) it('cancels a staged delivery when the exact pending request no longer matches', async () => { @@ -439,10 +643,10 @@ describe('admission notification consent and delivery lifecycle', () => { expect(stored(h.storage)).toContain('revokedTokenIds') }) - it('rechecks the exact live auth epoch and never sends after revocation', async () => { + it('cancels a pending notification once admission is already authoritative', async () => { const fetchImpl = vi.fn(async () => successfulDelivery()) const resolver = { - resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + resolve: vi.fn(async () => ({ state: 'enabled', authEpoch: 7 } as const)), } const h = createHarness({ fetchImpl, resolver }) await applyEvent(h.notification, enabledEvent()) @@ -463,7 +667,7 @@ describe('admission notification consent and delivery lifecycle', () => { }) const beforeConsent = await queue(h.notification) await expect(beforeConsent.json()).resolves.toEqual({ status: 'not-subscribed' }) - expect(stored(h.storage)).toContain('"delivery"') + expect(pendingStored(h.storage)).toContain('"delivery"') currentConfig = config(false) await h.notification.alarm() @@ -501,7 +705,9 @@ describe('admission notification consent and delivery lifecycle', () => { configured = true await h.notification.alarm() expect(fetchImpl).toHaveBeenCalledTimes(2) - expect(stored(h.storage)).toContain('"lastSentAuthEpoch":7') + expect(pendingStored(h.storage)).toContain( + `"lastSentRequestAtMicros":${REQUESTED_AT_MICROS}`, + ) }) it('retries verifier outages from a pending alarm without exposing the token', async () => { @@ -520,12 +726,12 @@ describe('admission notification consent and delivery lifecycle', () => { expect(String(response.headers)).not.toContain(TOKEN) }) - it('does not spend the delivery-attempt ceiling on admission resolver outages', async () => { + it('does not spend the delivery-attempt ceiling on request verification outages', async () => { let resolverAvailable = false const resolver = { resolve: vi.fn(async () => { if (!resolverAvailable) throw new Error('private resolver detail') - return { state: 'enabled', authEpoch: 7 } as const + return { state: 'disabled', authEpoch: 0 } as const }), } const fetchImpl = vi.fn(async () => successfulDelivery()) @@ -539,14 +745,16 @@ describe('admission notification consent and delivery lifecycle', () => { await h.notification.alarm() } expect(fetchImpl).not.toHaveBeenCalled() - expect(stored(h.storage)).not.toContain('lastExhaustedAuthEpoch') + expect(pendingStored(h.storage)).not.toContain('lastExhaustedRequestAtMicros') resolverAvailable = true const recoveryAlarm = Number(h.storage.alarm) h.setNow(recoveryAlarm) await h.notification.alarm() expect(fetchImpl).toHaveBeenCalledOnce() - expect(stored(h.storage)).toContain('"lastSentAuthEpoch":7') + expect(pendingStored(h.storage)).toContain( + `"lastSentRequestAtMicros":${REQUESTED_AT_MICROS}`, + ) }) it('purges a token that the Farcaster delivery service marks invalid', async () => { @@ -587,7 +795,7 @@ describe('admission notification consent and delivery lifecycle', () => { }) }) - it('lets an operator replay bring only a legacy transport backoff forward', async () => { + it('retires a persisted admitted retry without emitting it', async () => { const fetchImpl = vi.fn(async () => successfulDelivery()) const h = createHarness({ fetchImpl }) await applyEvent(h.notification, enabledEvent()) @@ -609,52 +817,10 @@ describe('admission notification consent and delivery lifecycle', () => { }], }, }) - h.storage.values.set(DIAGNOSTICS_RECORD, { - authEpoch: 7, - retryReasons: ['transport'], - }) - - await expect((await queue(h.notification)).json()).resolves.toEqual({ - status: 'already-sent', - }) - expect(fetchImpl).toHaveBeenCalledOnce() - expect(stored(h.storage)).toContain('"attempts":6') - expect(stored(h.storage)).toContain('"lastSentAuthEpoch":7') - }) - - it('does not accelerate a current transport retry classification', async () => { - const fetchImpl = vi.fn(async () => successfulDelivery()) - const h = createHarness({ fetchImpl }) - await applyEvent(h.notification, enabledEvent()) - const state = h.storage.values.get(STATE_KEY) as Record - const subscriptions = state.subscriptions as Array> - h.storage.values.set(STATE_KEY, { - ...state, - delivery: { - authEpoch: 7, - queuedAt: NOW - 60_000, - expiresAt: NOW - 60_000 + 24 * 60 * 60 * 1_000, - attempts: [{ - appFid: APP_FID, - tokenId: subscriptions[0].tokenId, - status: 'retrying', - attempts: 1, - verificationFailures: 0, - nextAttemptAt: NOW + 30_000, - }], - }, - }) - h.storage.values.set(DIAGNOSTICS_RECORD, { - generation: 'admitted', - authEpoch: 7, - retryReasons: ['transport-fetch-rejected'], - lastAttemptAt: NOW - 30_000, - lastFailureReason: 'transport-fetch-rejected', - }) - - await expect((await queue(h.notification)).json()).resolves.toEqual({ status: 'queued' }) + await h.notification.alarm() expect(fetchImpl).not.toHaveBeenCalled() - expect(stored(h.storage)).toContain('"attempts":1') + expect(stored(h.storage)).not.toContain('"delivery"') + expect(stored(h.storage)).toContain('"lastExhaustedAuthEpoch":7') }) it('rejects redirects without following or retrying them', async () => { @@ -694,7 +860,9 @@ describe('admission notification consent and delivery lifecycle', () => { const response = await queue(h.notification) await expect(response.json()).resolves.toEqual({ status: 'already-sent' }) expect(fetchImpl).toHaveBeenCalledOnce() - expect(stored(h.storage)).toContain('"lastSentAuthEpoch":7') + expect(pendingStored(h.storage)).toContain( + `"lastSentRequestAtMicros":${REQUESTED_AT_MICROS}`, + ) }) it('deduplicates the current mirrored invalid-token result before purging consent', async () => { @@ -753,7 +921,7 @@ describe('admission notification consent and delivery lifecycle', () => { const response = await queue(h.notification) await expect(response.json()).resolves.toEqual({ status: 'delivery-exhausted' }) expect(stored(h.storage)).toContain(TOKEN) - expect(stored(h.storage)).toContain('"status":"exhausted"') + expect(pendingStored(h.storage)).toContain('"status":"exhausted"') await expect((await inspect(h.notification)).json()).resolves.toMatchObject({ retryReasons: ['provider-no-webhook-url'], }) @@ -830,16 +998,15 @@ describe('admission notification consent and delivery lifecycle', () => { expect(text).not.toContain(TOKEN) expect(JSON.parse(text)).toEqual({ status: 'queued', - generation: 'admitted', - authEpoch: 7, + generation: 'pending-request', deliveryAttemptCount: 0, verificationFailureCount: 1, - retryReasons: ['admission-verification'], + retryReasons: ['request-verification'], nextAttemptAt: NOW + 30_000, }) }) - it('never resets the six-attempt ceiling for the same admission epoch', async () => { + it('never resets the six-attempt ceiling for the same access request', async () => { const fetchImpl = vi.fn(async () => new Response(null, { status: 503 })) const h = createHarness({ fetchImpl }) await applyEvent(h.notification, enabledEvent()) @@ -854,31 +1021,34 @@ describe('admission notification consent and delivery lifecycle', () => { await h.notification.alarm() } expect(fetchImpl).toHaveBeenCalledTimes(6) - expect(stored(h.storage)).toContain('"lastExhaustedAuthEpoch":7') + expect(pendingStored(h.storage)).toContain( + `"lastExhaustedRequestAtMicros":${REQUESTED_AT_MICROS}`, + ) - const duplicate = await queue(h.notification, 7, currentTime) + const duplicate = await queue(h.notification, REQUESTED_AT_MICROS, currentTime) await expect(duplicate.json()).resolves.toEqual({ status: 'delivery-exhausted' }) expect(fetchImpl).toHaveBeenCalledTimes(6) }) - it('reports the newest terminal receipt after an older successful epoch', async () => { + it('reports the newest terminal receipt after an older successful request', async () => { let failDelivery = false - let resolverEpoch = 7 + let requestedAtMicros = REQUESTED_AT_MICROS const fetchImpl = vi.fn(async () => ( failDelivery ? new Response(null, { status: 503 }) : successfulDelivery() )) const h = createHarness({ fetchImpl, - resolver: { - resolve: vi.fn(async () => ({ state: 'enabled', authEpoch: resolverEpoch } 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 queue(h.notification, 7) + await queue(h.notification, requestedAtMicros) failDelivery = true - resolverEpoch = 8 - await queue(h.notification, 8) + requestedAtMicros += 1_000 + await queue(h.notification, requestedAtMicros, NOW + 1) for (let attempt = 1; attempt < 6; attempt += 1) { const alarm = Number(h.storage.alarm) h.setNow(alarm) @@ -889,7 +1059,6 @@ describe('admission notification consent and delivery lifecycle', () => { await expect((await inspect(h.notification)).json()).resolves.toMatchObject({ status: 'delivery-exhausted', - authEpoch: 8, deliveryAttemptCount: 0, verificationFailureCount: 0, }) diff --git a/services/auth-bridge/test/app.test.ts b/services/auth-bridge/test/app.test.ts index 4c2d16c2..4b8f96be 100644 --- a/services/auth-bridge/test/app.test.ts +++ b/services/auth-bridge/test/app.test.ts @@ -2565,7 +2565,7 @@ describe('Warpkeep auth bridge', () => { } }) - it('uses the separate operator secret and rechecks live admission before queuing', async () => { + it('uses the separate operator secret and rejects post-admission notification queues', async () => { const queueAdmission = vi.fn(async () => 'queued' as const) const store: AdmissionNotificationStore = { applyEvent: vi.fn(async () => undefined), @@ -2594,17 +2594,14 @@ describe('Warpkeep auth bridge', () => { { fid: FID }, { headers: { authorization: `Bearer ${NOTIFICATION_OPERATOR_SECRET}` } }, ), notificationEnv()) - expect(accepted.status).toBe(202) + expect(accepted.status).toBe(409) const acceptedBody = await accepted.json() - expect(acceptedBody).toEqual({ status: 'queued' }) - expect(h.resolver.resolve).toHaveBeenCalledWith(FID) - expect(queueAdmission).toHaveBeenCalledWith({ - fid: FID, - kind: 'admitted', - authEpoch: 7, - queuedAt: expect.any(Number), + expect(acceptedBody).toMatchObject({ + error: { code: 'admission_notification_not_applicable' }, }) - expect(h.events).toContain('admission_notification_queued') + expect(h.resolver.resolve).toHaveBeenCalledWith(FID) + expect(queueAdmission).not.toHaveBeenCalled() + expect(h.events).toContain('admission_notification_rejected') expect(JSON.stringify(acceptedBody)).not.toContain(NOTIFICATION_OPERATOR_SECRET) }) @@ -2666,6 +2663,48 @@ describe('Warpkeep auth bridge', () => { expect(h.events).toContain('admission_notification_succeeded') }) + it('never creates a second generation after the pending request has notified', async () => { + const requestedAtMicros = 1_785_414_896_000_000 + let admitted = false + const queueAdmission = vi.fn(async () => 'already-sent' as const) + const h = harness({ + resolver: { + resolve: vi.fn(async () => admitted + ? ({ state: 'enabled', authEpoch: 8 } as const) + : ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'not-requested' } as const)), + }, + admissionNotificationStore: { + applyEvent: vi.fn(async () => undefined), + queueAdmission, + }, + }) + const notificationRequest = () => request( + ADMISSION_NOTIFICATION_PATH, + { fid: FID }, + { headers: { authorization: `Bearer ${NOTIFICATION_OPERATOR_SECRET}` } }, + ) + + expect((await h.app.fetch(notificationRequest(), notificationEnv())).status).toBe(200) + admitted = true + const afterAdmission = await h.app.fetch(notificationRequest(), notificationEnv()) + + expect(afterAdmission.status).toBe(409) + await expect(afterAdmission.json()).resolves.toMatchObject({ + error: { code: 'admission_notification_not_applicable' }, + }) + expect(queueAdmission).toHaveBeenCalledTimes(1) + expect(queueAdmission).toHaveBeenCalledWith({ + fid: FID, + kind: 'pending-request', + requestedAtMicros, + queuedAt: expect.any(Number), + }) + }) + 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/tests/hermesAdminSecurity.test.ts b/tests/hermesAdminSecurity.test.ts index b360f172..11d8185c 100644 --- a/tests/hermesAdminSecurity.test.ts +++ b/tests/hermesAdminSecurity.test.ts @@ -778,17 +778,14 @@ describe('Hermes command-line boundary', () => { inspection: true, machineReadableInspection: true, }); - expect(parseHermesArguments(['notify-admitted', '123', '--confirm'])).toMatchObject({ - command: 'notify-admitted', - confirmedByFlag: true, - inspection: false, - }); - expect(() => parseHermesArguments(['notify-admitted', '123'])) - .toThrow(/exactly --confirm/i); - expect(() => parseHermesArguments(['notify-admitted', '123', '--dry-run'])) - .toThrow(/exactly --confirm/i); - expect(() => parseHermesArguments(['notify-admitted', '123', '--confirm', '--json'])) - .toThrow(/invalid for this operation/i); + for (const retired of [ + ['notify-admitted', '123', '--confirm'], + ['notify-admitted', '123'], + ['notify-admitted', '123', '--dry-run'], + ['notify-admitted', '123', '--confirm', '--json'], + ]) { + expect(() => parseHermesArguments(retired)).toThrow(/Usage: hermes-admin/i); + } expect(() => parseHermesArguments(['admit-founder', '123', 'note', '--dry-run'])) .toThrow(/unexpected number/i); expect(() => parseHermesArguments(['admit-founder', '--dry-run'])) @@ -1317,8 +1314,8 @@ describe('Hermes atomic profiled admission boundary', () => { ) as { scripts: Record }; expect(packageManifest.scripts['stdb:admit-founder']) .toBe('tsx scripts/hermes-admin.ts admit-founder'); - expect(packageManifest.scripts['stdb:notify-admitted']) - .toBe('tsx scripts/hermes-admin.ts notify-admitted'); + expect(packageManifest.scripts['stdb:notify-admitted']).toBeUndefined(); + expect(mainSource).not.toContain("| 'notify-admitted'"); }); it('does not accept a founder identity or note in argv', () => { @@ -1332,6 +1329,17 @@ describe('Hermes atomic profiled admission boundary', () => { expect(`${result.stdout}${result.stderr}`).not.toContain('controlled fixture'); }); + it('rejects the retired post-admission notification command before credentials', () => { + const result = runHermes(['notify-admitted', '123', '--confirm'], { + WARPKEEP_AUTH_BRIDGE_URL: undefined, + WARPKEEP_ADMIN_TOKEN_SECRET: undefined, + WARPKEEP_NOTIFICATION_OPERATOR_SECRET: undefined, + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Usage: hermes-admin'); + expect(result.stdout).not.toContain('Warpkeep Hermes target'); + }); + it('does not let the legacy noninteractive switch authorize a new founder', () => { const result = runHermes(['admit-founder', '--input-stdin'], { WARPKEEP_HERMES_NONINTERACTIVE: 'yes',