From 6b334ae5f7caaa83f7af3fd55c1acc93f9e70ca2 Mon Sep 17 00:00:00 2001 From: ael-dev3 Date: Tue, 4 Aug 2026 05:21:42 +0200 Subject: [PATCH 1/4] Gate admissions on notification acknowledgement --- docs/farcaster-integration.md | 49 +- docs/operations/alpha-activation.md | 34 +- scripts/hermes-admin.ts | 378 ++++++++++-- scripts/verify-alpha-production.mjs | 1 + services/auth-bridge/README.md | 16 +- .../auth-bridge/src/admissionNotifications.ts | 554 ++++++++++++++++-- services/auth-bridge/src/app.ts | 114 +++- services/auth-bridge/src/rateLimit.ts | 5 +- services/auth-bridge/src/types.ts | 14 + .../test-workerd/authBridge.workerd.test.ts | 1 + .../test/admissionNotifications.test.ts | 226 ++++++- services/auth-bridge/test/app.test.ts | 100 +++- services/auth-bridge/test/rateLimit.test.ts | 2 +- spacetimedb/src/index.ts | 3 + spacetimedb/src/reducers/accessRequests.ts | 87 +++ spacetimedb/src/reducers/admin.ts | 146 ++++- spacetimedb/src/schema.ts | 3 + .../accessRequestAdmissionReducers.test.ts | 212 +++++++ spacetimedb/tests/foundingReducers.test.ts | 2 +- src/components/WarpkeepExperience.tsx | 3 + .../auth/FarcasterAdmissionPanel.tsx | 44 +- .../auth/FarcasterMiniAppEntryGate.tsx | 5 + src/dev/AccessRequestQaStage.tsx | 3 +- src/dev/FullstackLocalQaApp.tsx | 6 + src/farcaster/FarcasterAuthProvider.tsx | 5 + src/farcaster/FarcasterAuthProviderCore.tsx | 92 +++ src/farcaster/accessRequestStateMachine.ts | 7 + src/farcaster/farcasterAuthTypes.ts | 23 + src/farcaster/farcasterOidcBridgeClient.ts | 49 ++ src/farcaster/miniapp/MiniAppHostProvider.tsx | 60 +- src/farcaster/miniapp/miniAppRuntime.ts | 73 ++- src/farcaster/useAccessRequest.ts | 30 +- .../useAdmissionGrantAcknowledgement.ts | 201 +++++++ ..._founder_for_access_request_v_2_reducer.ts | 23 + ...llow_fid_for_access_request_v_1_reducer.ts | 18 + ..._request_admission_status_v_1_procedure.ts | 20 + src/spacetime/module_bindings/index.ts | 6 + src/spacetime/module_bindings/types.ts | 9 + .../module_bindings/types/procedures.ts | 3 + .../module_bindings/types/reducers.ts | 4 + tests/WarpkeepExperience.test.tsx | 4 + tests/WarpkeepExperienceRealm.test.tsx | 4 + tests/accessRequestStateMachine.test.ts | 11 + tests/activationToolingSecurity.test.ts | 5 +- .../farcasterAccessRequestLifecycle.test.tsx | 49 +- ...sterAdmissionGrantAcknowledgement.test.tsx | 277 +++++++++ tests/farcasterOidcBridgeClient.test.ts | 28 + tests/farcasterQuickAuthLifecycle.test.tsx | 4 + tests/farcasterSessionLifecycle.test.tsx | 97 +++ tests/hermesAdminSecurity.test.ts | 326 ++++++++++- tests/miniAppHostProvider.test.tsx | 79 +++ tests/miniAppRuntime.test.ts | 72 +++ 52 files changed, 3397 insertions(+), 190 deletions(-) create mode 100644 spacetimedb/tests/accessRequestAdmissionReducers.test.ts create mode 100644 src/farcaster/useAdmissionGrantAcknowledgement.ts create mode 100644 src/spacetime/module_bindings/admin_admit_founder_for_access_request_v_2_reducer.ts create mode 100644 src/spacetime/module_bindings/admin_allow_fid_for_access_request_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/admin_get_access_request_admission_status_v_1_procedure.ts create mode 100644 tests/farcasterAdmissionGrantAcknowledgement.test.tsx diff --git a/docs/farcaster-integration.md b/docs/farcaster-integration.md index 75f4a22d..91bec03f 100644 --- a/docs/farcaster-integration.md +++ b/docs/farcaster-integration.md @@ -339,14 +339,23 @@ 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. -Before Hermes requests administrator authority or mutates admission, it calls a -separate-secret operator endpoint for the exact pending access-request -timestamp. The Durable Object proves that request is still pending and that -admission is not enabled immediately before sending. For an opted-in player, -Hermes proceeds only after Farcaster reports the matching token in -`successfulTokens`; without notification consent, it records the explicit -`not-subscribed` result and may proceed. Provider acceptance proves handoff to -Farcaster, not device display or that the player opened the alert. +Hermes first inspects the exact pending access-request tuple, then disconnects +its short-lived administrator session before calling the separate-secret +notification endpoint. For an opted-in player, Farcaster provider acceptance +creates an `awaiting-client` intent; it does not admit the player. The alert +opens an unguessable fragment capability, which is scrubbed from the URL before +rendering and acknowledged only after fresh same-FID Quick Auth. Hermes waits +for `client-acknowledged`, mints a new five-minute administrator session, and +re-reads the unchanged request tuple before invoking a request-CAS reducer. +`queued`, `not-subscribed`, `delivery-exhausted`, a legacy receipt, expiry, +identity mismatch, or a changed request all fail closed without admission. +Provider acceptance alone proves only handoff to Farcaster, not display or an +authenticated player open. + +The request-CAS reducers enforce admission kind and the exact request +cycle/timestamp. The reviewed Hermes workflow supplies the notification-open +gate; the general administrator role remains intentionally capable of other +privileged maintenance and is not cryptographically restricted by this flow. Queue-before-webhook races are retained without a token for at most 24 hours, signed opt-outs erase token material immediately, invalid tokens are purged, @@ -360,9 +369,10 @@ The reviewed payloads are: ```txt normal admission: -notificationId: warpkeep-access-approved-v2-r +notificationId: warpkeep-access-grant-v3-i title: Admission approved -body: The Hegemony is finalizing your Realm access. Your keep will open shortly. +body: Tap to finalize your Realm access. Your keep awaits in Genesis 001. +targetUrl: https://warpkeep.com/?miniApp=true#warpkeep-grant-v1= already-live reconciliation: notificationId: warpkeep-access-approved-v1-e @@ -375,14 +385,17 @@ The titles and bodies are within Farcaster's bounds, contain no identity or private state, and accurately describe their generation. Copy changes require a reviewed Worker rollout. -For a notification launch, the browser retains only -`location.type === "notification"` and a notification ID matching either -`warpkeep-access-approved-v2-r` or the rollback-compatible -`warpkeep-access-approved-v1-e` within the 128-character -limit. Host title and body are discarded. Warpkeep then shows a short -confirmation state and runs normal Quick Auth, current admission, Terms, and -canonical-keep checks. A pending or changed account stays pending; the -notification itself never grants access or creates another keep. +For a normal admission launch, the browser captures only the exact +`warpkeep-grant-v1` fragment ticket, removes it from visible history immediately, +and keeps it in one module-local call stack. It never enters React state, +storage, analytics, logs, or a network request other than the authenticated +grant endpoint. The bridge re-verifies the signed FID and current request; the +Durable Object accepts an exact one-use ticket only after provider acceptance. +The frontend then polls admission at a bounded cadence while Hermes performs +the fresh request-CAS mutation. A stale or changed account remains pending, and +the notification itself cannot create a keep, bypass Terms, or grant gameplay +authority. Legacy admitted-epoch notifications retain their existing entry +behavior but cannot authorize the new pending-request flow. **CHECK ADMISSION** is a typed, read-only presentation around credentialed `/v2/session/refresh`, not a new Farcaster channel or access-request mutation. diff --git a/docs/operations/alpha-activation.md b/docs/operations/alpha-activation.md index 442a7828..c5963ccb 100644 --- a/docs/operations/alpha-activation.md +++ b/docs/operations/alpha-activation.md @@ -261,12 +261,14 @@ a SpacetimeDB schema change. Roll them out in this order: literal value `true` in a reviewed frontend release; it does not enable the Worker or grant admission. 7. Give Hermes both isolated secrets through its private environment. For - `allow-fid` and confirmed `admit-founder`, Hermes must queue the exact pending - request generation before requesting an administrator token. If the player - opted in, require Farcaster provider acceptance before mutating admission; - `queued` or `delivery-exhausted` aborts unchanged. `not-subscribed` is an - explicit audited fallback for a player without consent. Keep - `notify-admitted` only for idempotent already-live reconciliation. + `allow-fid` and confirmed `admit-founder`, inspect the exact pending request + using a short administrator session, disconnect it, then queue the matching + notification. Require both Farcaster provider acceptance and an authenticated + same-FID client acknowledgement. Only then mint a fresh administrator token, + re-run the request and aggregate preconditions, and call the matching + request-CAS reducer. `queued`, `not-subscribed`, `delivery-exhausted`, legacy + receipts, expiry, or a changed request abort unchanged. Keep `notify-admitted` + only for idempotent already-live reconciliation. ### Owner canary and end-to-end acceptance @@ -295,23 +297,23 @@ it. setup-requested state. The access request timestamp and state must not change. 5. Confirm one new signed subscription pair through the same fixed events, then - admit the account through the existing reviewed Hermes dry-run, mutation, - and postflight sequence. Require the operator receipt to show provider - acceptance for the exact pending-request generation before the SpacetimeDB - mutation is submitted. Provider acceptance proves Farcaster handoff, not - device presentation or that the player opened the alert. + begin the reviewed Hermes admission. Require the operator to stop at + `awaiting-client`; provider acceptance proves only Farcaster handoff. Open the + exact alert in the same account and require `client-acknowledged` before a new + administrator token is minted or the request-CAS mutation is submitted. 6. Require one approval notification for that request generation. Its target - must be exactly `https://warpkeep.com/?miniApp=true`. Tap it and verify the - calm confirmation state, fresh Quick Auth, current admission, current Terms - when required, and entry through the existing canonical keep. No - notification context may create a second keep or bypass Terms. + must be the exact production URL plus a `#warpkeep-grant-v1=` one-use + capability. Tap it and verify immediate fragment scrubbing, fresh Quick Auth, + the bounded finalization state, current Terms when required, and entry through + the existing canonical keep. No notification context may create a second keep + or bypass Terms. 7. Disable notifications or remove Warpkeep again, require the fixed unsubscribe events, and confirm Realm access remains unchanged. Repeat the complete acceptance on current Farcaster iOS and Android before declaring the client rollout complete. The normal pending-request notification is `Admission approved` with -`The Hegemony is finalizing your Realm access. Your keep will open shortly.` The older +`Tap to finalize your Realm access. Your keep awaits in Genesis 001.` 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 ea7e3719..41d34b90 100644 --- a/scripts/hermes-admin.ts +++ b/scripts/hermes-admin.ts @@ -117,6 +117,8 @@ 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 ADMISSION_GRANT_ACK_POLL_MILLISECONDS = 5_000; +const ADMISSION_GRANT_ACK_MAX_POLLS = 120; const ADMIN_TOKEN_CLOCK_SAFETY_MILLISECONDS = 20_000; const MAX_RESOURCE_BACKFILL_FOUNDERS = 100n; const GENESIS_GENERATION_V2_WORLD_CELLS = 1_261n; @@ -217,7 +219,8 @@ export const FOUNDER_ADMISSION_TARGET_CONFIGURATION_DIGEST = createHash('sha256' databaseName: LEGACY_DATABASE_ALIAS, databaseIdentity: DEFAULT_DATABASE_IDENTITY, bridgeUrl: DEFAULT_BRIDGE, - reducer: 'admin_admit_founder_v1', + statusProcedure: 'admin_get_access_request_admission_status_v1', + reducer: 'admin_admit_founder_for_access_request_v2', }), 'utf8') .digest('hex'); export const ACCESS_REQUEST_RESET_TARGET_CONFIGURATION_DIGEST = createHash('sha256') @@ -726,6 +729,24 @@ type AccessRequestResetStatus = Readonly<{ requestedAtMicros: bigint | undefined; }>; +type AccessRequestAdmissionStatus = Readonly<{ + admissionState: 'missing' | 'enabled' | 'disabled'; + authEpoch: number; + requestState: 'not_requested' | 'pending' | 'resolved'; + requestCycle: bigint | undefined; + requestedAtMicros: bigint | undefined; +}>; + +type PendingAccessRequestAdmissionStatus< + State extends 'missing' | 'disabled' = 'missing' | 'disabled', +> = Readonly<{ + admissionState: State; + authEpoch: number; + requestState: 'pending'; + requestCycle: bigint; + requestedAtMicros: bigint; +}>; + function exactObjectKeys( value: Record, expected: readonly string[], @@ -991,6 +1012,121 @@ export function projectAccessRequestResetStatus(value: unknown): AccessRequestRe }); } +/** Strict private request-CAS projection used by notification-gated Hermes. */ +export function projectAccessRequestAdmissionStatus( + value: unknown, +): AccessRequestAdmissionStatus { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail('Access request admission status was invalid.'); + } + const status = value as Record; + exactObjectKeys( + status, + ACCESS_REQUEST_RESET_STATUS_KEYS, + 'Access request admission status returned unexpected fields.', + ); + if ( + status.admissionState !== 'missing' + && status.admissionState !== 'enabled' + && status.admissionState !== 'disabled' + ) { + fail('Access request admission status returned an invalid admission state.'); + } + if ( + typeof status.authEpoch !== 'number' + || !Number.isInteger(status.authEpoch) + || (status.admissionState === 'missing' + ? status.authEpoch !== 0 + : status.authEpoch < 1 || status.authEpoch > 0xffff_ffff) + ) { + fail('Access request admission status returned an invalid auth epoch.'); + } + const requestCycle = status.requestCycle === undefined + ? undefined + : requireU64( + status.requestCycle, + true, + 'Access request admission status returned an invalid request cycle.', + ); + const requestedAtMicros = status.requestedAtMicros === undefined + ? undefined + : requireU64( + status.requestedAtMicros, + false, + 'Access request admission status returned an invalid timestamp.', + ); + if ((requestCycle === undefined) !== (requestedAtMicros === undefined)) { + fail('Access request admission status returned an incomplete request tuple.'); + } + const maximumStoredRequestCycle = status.admissionState === 'disabled' + ? BigInt(status.authEpoch) + 1n + : BigInt(status.authEpoch); + if (requestCycle !== undefined && requestCycle > maximumStoredRequestCycle) { + fail('Access request admission status returned an impossible future request cycle.'); + } + if ( + status.requestState !== 'not_requested' + && status.requestState !== 'pending' + && status.requestState !== 'resolved' + ) { + fail('Access request admission status returned an invalid request state.'); + } + const currentCycle = status.admissionState === 'missing' + ? 0n + : status.admissionState === 'disabled' + ? BigInt(status.authEpoch) + 1n + : undefined; + const expectedState = requestCycle === undefined + ? 'not_requested' + : currentCycle !== undefined && requestCycle === currentCycle + ? 'pending' + : 'resolved'; + if (status.requestState !== expectedState) { + fail('Access request admission status returned an inconsistent request state.'); + } + return Object.freeze({ + admissionState: status.admissionState, + authEpoch: status.authEpoch, + requestState: status.requestState, + requestCycle, + requestedAtMicros, + }); +} + +export function requirePendingAdmissionRequest( + status: AccessRequestAdmissionStatus, + expectedAdmissionState: State, +): PendingAccessRequestAdmissionStatus { + if ( + status.admissionState !== expectedAdmissionState + || status.requestState !== 'pending' + || status.requestCycle === undefined + || status.requestedAtMicros === undefined + ) { + fail('Admission requires one exact pending access request of the expected kind.'); + } + return Object.freeze({ ...status }) as PendingAccessRequestAdmissionStatus; +} + +export function requireUnchangedPendingAdmissionRequest< + State extends 'missing' | 'disabled', +>( + before: PendingAccessRequestAdmissionStatus | AccessRequestAdmissionStatus, + after: AccessRequestAdmissionStatus, + expectedAdmissionState: State, +): PendingAccessRequestAdmissionStatus { + const exactBefore = requirePendingAdmissionRequest(before, expectedAdmissionState); + const exactAfter = requirePendingAdmissionRequest(after, expectedAdmissionState); + if ( + exactAfter.requestCycle !== exactBefore.requestCycle + || exactAfter.requestedAtMicros !== exactBefore.requestedAtMicros + || exactAfter.authEpoch !== exactBefore.authEpoch + ) { + fail('The exact pending access request changed before admission.'); + } + return exactAfter; +} + function accessRequestTimestamp(micros: bigint): string { if (micros > MAX_JAVASCRIPT_DATE_MICROS) { fail('Access request procedure returned an invalid timestamp.'); @@ -1358,14 +1494,17 @@ export function verifyFounderAdmissionResourcePostconditionV4( export function verifyFounderReenablePrecondition( world: GenesisExpansionStatusV3, resources: ResourceAggregateV4, - target: AccessRequestResetStatus, + target: AccessRequestAdmissionStatus, ): Readonly<{ world: GenesisExpansionStatusV3; resources: ResourceAggregateV4; - target: AccessRequestResetStatus; + target: AccessRequestAdmissionStatus; }> { verifyFounderAdmissionCheckpointV3(world, false); verifyExpectedResourceAggregateV4(resources, world.allowedFids); + if (target.admissionState === 'disabled' && target.authEpoch >= 0xffff_ffff) { + fail('Existing founder re-enable cannot rotate an exhausted auth epoch.'); + } if ( target.admissionState !== 'disabled' || target.requestState !== 'pending' @@ -1384,7 +1523,7 @@ export function verifyFounderReenablePrecondition( export function verifyFounderReenablePostcondition( world: GenesisExpansionStatusV3, resources: ResourceAggregateV4, - target: AccessRequestResetStatus, + target: AccessRequestAdmissionStatus, before: ReturnType, ): void { verifyFounderAdmissionCheckpointV3(world, false); @@ -1427,6 +1566,29 @@ export function verifyFounderReenablePostcondition( } } +export function verifyFounderAdmissionRequestPostcondition( + target: AccessRequestAdmissionStatus, + before: AccessRequestAdmissionStatus, +): void { + if ( + before.admissionState !== 'missing' + || before.authEpoch !== 0 + || before.requestState !== 'pending' + || before.requestCycle !== 0n + || before.requestedAtMicros === undefined + || target.admissionState !== 'enabled' + || target.authEpoch !== 1 + || target.requestState !== 'resolved' + || target.requestCycle !== before.requestCycle + || target.requestedAtMicros !== before.requestedAtMicros + ) { + fail( + 'Founder admission request postcondition failed. The mutation outcome may be ' + + 'indeterminate; perform a fresh bounded read-only inspection before any retry.', + ); + } +} + export function verifyGenesisExpansionPreconditionV3( status: GenesisExpansionStatusV3, ): GenesisExpansionStatusV3 { @@ -1638,6 +1800,8 @@ export async function requestAdminToken( export type AdmissionNotificationStatus = | 'queued' | 'already-sent' + | 'awaiting-client' + | 'client-acknowledged' | 'delivery-exhausted' | 'not-subscribed'; @@ -1696,6 +1860,8 @@ export async function requestAdmissionNotification( if ( status !== 'queued' && status !== 'already-sent' + && status !== 'awaiting-client' + && status !== 'client-acknowledged' && status !== 'delivery-exhausted' && status !== 'not-subscribed' ) { @@ -1737,6 +1903,8 @@ export async function inspectAdmissionNotification( if ( status !== 'queued' && status !== 'already-sent' + && status !== 'awaiting-client' + && status !== 'client-acknowledged' && status !== 'delivery-exhausted' && status !== 'not-subscribed' ) { @@ -1761,6 +1929,17 @@ export async function requireNotificationBeforeAdmission( // exact pending request is still current after the wait. status = await requestAdmissionNotification(bridgeUrl, fid, secret, fetchImpl); } + for ( + let poll = 0; + status === 'awaiting-client' && poll < ADMISSION_GRANT_ACK_MAX_POLLS; + poll += 1 + ) { + await sleep(ADMISSION_GRANT_ACK_POLL_MILLISECONDS); + // The operator queue route re-resolves both admission and the exact + // pending request before returning the acknowledgement state. A generic + // Durable Object snapshot is never enough to unlock authority. + status = await requestAdmissionNotification(bridgeUrl, fid, secret, fetchImpl); + } if (status === 'queued') { fail( 'Farcaster has not accepted the pending admission notification. ' @@ -1773,10 +1952,31 @@ export async function requireNotificationBeforeAdmission( + 'Admission remains unchanged; reconcile notification consent before retrying.', ); } + if (status === 'not-subscribed') { + fail( + 'Farcaster notifications are not enabled for this identity. ' + + 'Admission remains unchanged until the player enables notifications.', + ); + } + if (status === 'already-sent') { + fail( + 'Only a legacy notification receipt is available. Admission remains unchanged; ' + + 'a fresh notification grant is required.', + ); + } + if (status === 'awaiting-client') { + fail( + 'The notification was accepted but has not been opened by the verified player. ' + + 'Admission remains unchanged.', + ); + } + if (status !== 'client-acknowledged') { + fail('The notification grant did not reach a safe admission state.'); + } console.log(JSON.stringify({ admissionNotification: status, - providerAcceptanceRequired: status !== 'not-subscribed', - providerAcceptedBeforeAdmission: status === 'already-sent', + providerAcceptedBeforeAdmission: true, + authenticatedNotificationOpenBeforeAdmission: true, })); return status; } @@ -1921,6 +2121,54 @@ export function connect( }); } +type NotificationGatedReconnectDependencies = Readonly<{ + waitForNotification?: typeof requireNotificationBeforeAdmission; + requestToken?: typeof requestAdminToken; + connectToDatabase?: typeof connect; +}>; + +/** + * Never retain a privileged Spacetime connection or expiring admin JWT while + * waiting for a player to open a Farcaster notification. The click gate uses + * only its separate notification operator credential; fresh database authority + * is minted after acknowledgement and immediately before the exact CAS read. + */ +export async function reconnectAfterAdmissionNotification( + input: Readonly<{ + connection: DbConnection; + bridgeUrl: string; + fid: bigint; + notificationOperatorSecret: string | undefined; + adminSecret: string; + uri: string; + database: string; + }>, + dependencies: NotificationGatedReconnectDependencies = {}, +): Promise { + disconnectSilently(input.connection); + await ( + dependencies.waitForNotification + ?? requireNotificationBeforeAdmission + )( + input.bridgeUrl, + input.fid, + input.notificationOperatorSecret, + ); + let freshToken = ''; + try { + freshToken = await ( + dependencies.requestToken + ?? requestAdminToken + )(input.bridgeUrl, input.adminSecret); + return await ( + dependencies.connectToDatabase + ?? connect + )(input.uri, input.database, freshToken); + } finally { + freshToken = ''; + } +} + export async function readStatus( connection: DbConnection, version: AlphaStatusVersion = 'v1', @@ -2298,8 +2546,15 @@ async function main() { ? '1' : process.env.WARPKEEP_ADMIN_TOKEN_SECRET_STDIN, ); - const token = await requestAdminToken(bridgeUrl, secret); - const connection = await connect(uri, database, token); + let token = await requestAdminToken(bridgeUrl, secret); + let connection: DbConnection; + try { + connection = await connect(uri, database, token); + } finally { + // The SDK connection owns its authenticated transport after connect. Do + // not retain a second immutable admin JWT in the long-running operator. + token = ''; + } let founderAdmissionClaimed = false; let accessRequestResetClaimed = false; try { @@ -2546,41 +2801,73 @@ async function main() { && admissionPlan !== undefined && admissionPlanReference !== undefined ) { - const before = verifyFounderAdmissionPreconditionV3( + const initialTarget = requirePendingAdmissionRequest( + projectAccessRequestAdmissionStatus( + await withOperationTimeout( + connection.procedures.adminGetAccessRequestAdmissionStatusV1({ fid }), + ), + ), + 'missing', + ); + const initialWorld = verifyFounderAdmissionPreconditionV3( await readStatus(connection, 'v3') as GenesisExpansionStatusV3, ); - const beforeResources = verifyFounderAdmissionResourcePreconditionV4( + verifyFounderAdmissionResourcePreconditionV4( await readStatus(connection, 'v4') as ResourceAggregateV4, - before.allowedFids, + initialWorld.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( + normalizeAdmissionReadyTrustedProfile(admissionProfile); + + connection = await reconnectAfterAdmissionNotification({ + connection, bridgeUrl, fid, notificationOperatorSecret, + adminSecret: secret, + uri, + database, + }); + const freshTarget = requireUnchangedPendingAdmissionRequest( + initialTarget, + projectAccessRequestAdmissionStatus( + await withOperationTimeout( + connection.procedures.adminGetAccessRequestAdmissionStatusV1({ fid }), + ), + ), + 'missing', + ); + const before = verifyFounderAdmissionPreconditionV3( + await readStatus(connection, 'v3') as GenesisExpansionStatusV3, + ); + const beforeResources = verifyFounderAdmissionResourcePreconditionV4( + await readStatus(connection, 'v4') as ResourceAggregateV4, + before.allowedFids, ); + const freshAdmissionProfile = normalizeAdmissionReadyTrustedProfile(admissionProfile); claimReviewedFounderAdmissionPlan({ plan: admissionPlan, sha256: admissionPlanReference.sha256, }); founderAdmissionClaimed = true; - await withOperationTimeout(connection.reducers.adminAdmitFounderV1({ + await withOperationTimeout(connection.reducers.adminAdmitFounderForAccessRequestV2({ fid, note, - canonicalUsername: admissionProfile.canonicalUsername, - displayName: admissionProfile.displayName, - pfpUrl: admissionProfile.pfpUrl, - publicBio: admissionProfile.publicBio, + expectedRequestCycle: freshTarget.requestCycle, + expectedRequestedAtMicros: freshTarget.requestedAtMicros, + canonicalUsername: freshAdmissionProfile.canonicalUsername, + displayName: freshAdmissionProfile.displayName, + pfpUrl: freshAdmissionProfile.pfpUrl, + publicBio: freshAdmissionProfile.publicBio, profilePolicyVersion: FARCASTER_PROFILE_POLICY_VERSION, })); + verifyFounderAdmissionRequestPostcondition( + projectAccessRequestAdmissionStatus( + await withOperationTimeout( + connection.procedures.adminGetAccessRequestAdmissionStatusV1({ fid }), + ), + ), + freshTarget, + ); verifyFounderAdmissionPostconditionV3( await readStatus(connection, 'v3') as GenesisExpansionStatusV3, before, @@ -2592,28 +2879,51 @@ async function main() { founderAdmissionClaimed = false; mutationStatusHandled = true; } else if (command === 'allow-fid' && fid !== undefined && note !== undefined) { - const beforeTarget = projectAccessRequestResetStatus( + const initialTarget = projectAccessRequestAdmissionStatus( await withOperationTimeout( - connection.procedures.adminGetAccessRequestResetStatusV1({ fid }), + connection.procedures.adminGetAccessRequestAdmissionStatusV1({ fid }), ), ); - const before = verifyFounderReenablePrecondition( + verifyFounderReenablePrecondition( await readStatus(connection, 'v3', false, undefined, false) as GenesisExpansionStatusV3, await readStatus(connection, 'v4', false, undefined, false) as ResourceAggregateV4, - beforeTarget, + initialTarget, ); - await requireNotificationBeforeAdmission( + connection = await reconnectAfterAdmissionNotification({ + connection, bridgeUrl, fid, notificationOperatorSecret, + adminSecret: secret, + uri, + database, + }); + const freshTarget = requireUnchangedPendingAdmissionRequest( + initialTarget, + projectAccessRequestAdmissionStatus( + await withOperationTimeout( + connection.procedures.adminGetAccessRequestAdmissionStatusV1({ fid }), + ), + ), + 'disabled', ); - await withOperationTimeout(connection.reducers.adminAllowFid({ fid, note })); + const before = verifyFounderReenablePrecondition( + await readStatus(connection, 'v3', false, undefined, false) as GenesisExpansionStatusV3, + await readStatus(connection, 'v4', false, undefined, false) as ResourceAggregateV4, + freshTarget, + ); + await withOperationTimeout(connection.reducers.adminAllowFidForAccessRequestV1({ + fid, + note, + expectedRequestCycle: freshTarget.requestCycle, + expectedRequestedAtMicros: freshTarget.requestedAtMicros, + })); verifyFounderReenablePostcondition( await readStatus(connection, 'v3', false, undefined, false) as GenesisExpansionStatusV3, await readStatus(connection, 'v4', false, undefined, false) as ResourceAggregateV4, - projectAccessRequestResetStatus( + projectAccessRequestAdmissionStatus( await withOperationTimeout( - connection.procedures.adminGetAccessRequestResetStatusV1({ fid }), + connection.procedures.adminGetAccessRequestAdmissionStatusV1({ fid }), ), ), before, diff --git a/scripts/verify-alpha-production.mjs b/scripts/verify-alpha-production.mjs index 274c6211..4d1906a6 100644 --- a/scripts/verify-alpha-production.mjs +++ b/scripts/verify-alpha-production.mjs @@ -63,6 +63,7 @@ const AUTH_V2_QUICK_AUTH_PATH = '/v2/farcaster/quick-auth/exchange'; const AUTH_V2_ACCESS_REQUEST_PATHS = Object.freeze([ '/v2/access/status', '/v2/access/request', + '/v2/access/admission-grant', ]); const AUTH_V2_PAUSED_PATHS = new Set([ ...AUTH_V2_CREDENTIAL_PATHS.slice(0, 3), diff --git a/services/auth-bridge/README.md b/services/auth-bridge/README.md index 9529d23e..a433f6ef 100644 --- a/services/auth-bridge/README.md +++ b/services/auth-bridge/README.md @@ -33,13 +33,16 @@ future rollout step requires exact-head verification and recorded authority. | `POST` | `/v2/farcaster/quick-auth/exchange` | Verifies an exact-domain Mini App bearer and returns tokenless-pending or short-lived authorized access without a cookie. | | `POST` | `/v2/session/refresh` | Rotates the session reference and returns a fresh access token only for an authorized family. | | `POST` | `/v2/session/logout` | Revokes the server-side family and expires the cookie; fails closed if durable revocation cannot be confirmed. | +| `POST` | `/v2/access/status` | Caller-private, authenticated status for the verified FID's current access-request cycle. | +| `POST` | `/v2/access/request` | Submits one idempotent private access request for the verified FID. | +| `POST` | `/v2/access/admission-grant` | Acknowledges one provider-accepted, fragment-carried admission intent after same-FID Quick Auth; it never mutates admission. | | `POST` | `/v1/qa/challenge` | Server-only, zero-body 60-second challenge for the one registered read-only QA device. Disabled by default. | | `POST` | `/v1/qa/realm-snapshot` | Server-only proof exchange returning one bounded aggregate Realm attestation; the v1 path is a compatibility name. | | `POST` | `/v1/admin/token` | Server-only five-minute Hermes/admin JWT. | | `POST` | `/v1/admin/auth-epoch-probe` | Server-only, input-free structured resolver check. | | `POST` | `/v1/admin/config-attestation` | Server-only digest of security-relevant runtime configuration. | | `POST` | `/v1/farcaster/miniapp/webhook` | Verifies signed add/remove and notification enable/disable events; returns exact `200`. | -| `POST` | `/v1/admin/admission-notification` | Separate-secret Hermes hook; queues one alert for the exact pending request, or reconciles an already-live admission epoch. | +| `POST` | `/v1/admin/admission-notification` | Separate-secret Hermes hook; queues one alert for the exact pending request, reports client acknowledgement, or reconciles an already-live admission epoch. | | `POST` | `/v1/admin/admission-notification-status` | Separate-secret, token-free delivery diagnostics for one exact FID. | The legacy public `/v1/farcaster/challenge` and `/v1/farcaster/exchange` routes @@ -55,6 +58,17 @@ accept only a completed zero-byte stream, validate every present secrets such as `channelToken`, custody fields, verification lists, and relay metadata. +Pending-request delivery is a two-party gate. Provider acceptance stores an +`awaiting-client` intent and sends an unguessable one-use capability in the URL +fragment. The browser scrubs the fragment before rendering and submits it only +with a verified same-FID session or Quick Auth bearer. Successful acknowledgement +stores a domain-separated digest instead of the raw ticket and yields +`client-acknowledged`; no bridge route grants admission. The reviewed Hermes +workflow then reconnects with fresh administrator authority, rechecks the exact +request tuple, and calls a request-CAS reducer. General administrator authority +is intentionally broader than this operator workflow, so operational review and +least-privilege credential handling remain part of the boundary. + The QA routes are a separate service boundary, not browser/player authentication. They reject every request carrying an `Origin`, emit no CORS headers, reject query parameters and unknown JSON fields, and remain unavailable diff --git a/services/auth-bridge/src/admissionNotifications.ts b/services/auth-bridge/src/admissionNotifications.ts index dfbcd754..3225d992 100644 --- a/services/auth-bridge/src/admissionNotifications.ts +++ b/services/auth-bridge/src/admissionNotifications.ts @@ -1,5 +1,5 @@ import { readBridgeConfig, type BridgeConfig } from './config' -import { signEs256Jwt } from './jwt' +import { randomId, signEs256Jwt } from './jwt' import { AUTH_EPOCH_RESOLVER_TIMEOUT_MILLISECONDS, SpacetimeHttpAuthEpochResolver, @@ -10,6 +10,7 @@ import { } from './spacetimeAccessRequestResolver' import type { AccessRequestResolver, + AdmissionNotificationAcknowledgementStatus, AdmissionNotificationGeneration, AdmissionNotificationQueueInput, AdmissionNotificationQueueStatus, @@ -26,6 +27,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 PENDING_GRANT_RECORD = 'admission-notification-grant-v3' const DIAGNOSTICS_RECORD = 'admission-notification-diagnostics-v1' const STATE_VERSION = 1 const MAX_SUBSCRIPTIONS = 8 @@ -39,11 +41,12 @@ 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 GRANT_TARGET_FRAGMENT = 'warpkeep-grant-v1' 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.' + 'Tap to finalize your Realm access. Your keep awaits in Genesis 001.' const RETRY_DELAYS_MILLISECONDS = Object.freeze([ 30_000, 2 * 60_000, @@ -133,6 +136,30 @@ type PersistedPendingNotificationState = Readonly<{ }> }> +type PendingGrantIntentBase = Readonly<{ + version: 1 + fid: string + requestedAtMicros: number + intentId: string + createdAt: number + expiresAt: number +}> + +type PersistedPendingGrantIntent = PendingGrantIntentBase & ( + | Readonly<{ + ticket: string + ticketHash?: never + providerAcceptedAt?: number + acknowledgedAt?: never + }> + | Readonly<{ + ticket?: never + ticketHash: string + providerAcceptedAt: number + acknowledgedAt: number + }> +) + type NotificationDependencies = Readonly<{ fetchImpl?: typeof fetch now?: () => number @@ -231,6 +258,18 @@ function isTokenId(value: unknown): value is string { return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value) } +function isGrantIntentId(value: unknown): value is string { + return typeof value === 'string' && /^[A-Za-z0-9_-]{22}$/.test(value) +} + +function isGrantTicket(value: unknown): value is string { + return typeof value === 'string' && /^[A-Za-z0-9_-]{43}$/.test(value) +} + +function isGrantTicketHash(value: unknown): value is string { + return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value) +} + function isStoredDeliveryUrl(value: unknown): value is string { if (typeof value !== 'string' || value.length < 1 || value.length > 2_048) return false try { @@ -547,6 +586,121 @@ function readPendingState(value: unknown): PersistedPendingNotificationState | n }) } +function readPendingGrantIntent(value: unknown): PersistedPendingGrantIntent | null { + if (value === undefined) return null + const hasRawTicket = isRecord(value) && Object.prototype.hasOwnProperty.call(value, 'ticket') + if ( + !isRecord(value) + || !exactKeys( + value, + [ + 'version', + 'fid', + 'requestedAtMicros', + 'intentId', + 'createdAt', + 'expiresAt', + ...(hasRawTicket ? ['ticket'] : ['ticketHash', 'providerAcceptedAt', 'acknowledgedAt']), + ], + hasRawTicket ? ['providerAcceptedAt'] : [], + ) + || value.version !== 1 + || !isSafeFid(value.fid) + || !isRequestedAtMicros(value.requestedAtMicros) + || !isGrantIntentId(value.intentId) + || (hasRawTicket ? !isGrantTicket(value.ticket) : !isGrantTicketHash(value.ticketHash)) + || !isTimestamp(value.createdAt) + || !isTimestamp(value.expiresAt) + || value.expiresAt <= value.createdAt + || value.expiresAt - value.createdAt !== DELIVERY_LIFETIME_MILLISECONDS + || (value.providerAcceptedAt !== undefined && ( + !isTimestamp(value.providerAcceptedAt) + || value.providerAcceptedAt < value.createdAt + || value.providerAcceptedAt >= value.expiresAt + )) + || (!hasRawTicket && ( + !isTimestamp(value.providerAcceptedAt) + || !isTimestamp(value.acknowledgedAt) + || value.acknowledgedAt < value.providerAcceptedAt + || value.acknowledgedAt >= value.expiresAt + )) + ) throw new Error('Invalid pending admission grant intent.') + return Object.freeze({ + version: 1, + fid: value.fid, + requestedAtMicros: value.requestedAtMicros, + intentId: value.intentId, + createdAt: value.createdAt, + expiresAt: value.expiresAt, + ...(hasRawTicket + ? { + ticket: value.ticket as string, + ...(value.providerAcceptedAt === undefined + ? {} + : { providerAcceptedAt: value.providerAcceptedAt as number }), + } + : { + ticketHash: value.ticketHash as string, + providerAcceptedAt: value.providerAcceptedAt as number, + acknowledgedAt: value.acknowledgedAt as number, + }), + }) +} + +function grantMatchesGeneration( + grant: PersistedPendingGrantIntent | null, + generation: AdmissionNotificationGeneration, + fid?: string, +): grant is PersistedPendingGrantIntent { + return grant !== null + && generation.kind === 'pending-request' + && grant.requestedAtMicros === generation.requestedAtMicros + && (fid === undefined || grant.fid === fid) +} + +function createPendingGrantIntent( + fid: string, + requestedAtMicros: number, + now: number, +): PersistedPendingGrantIntent { + return Object.freeze({ + version: 1, + fid, + requestedAtMicros, + intentId: randomId(16), + ticket: randomId(32), + createdAt: now, + expiresAt: now + DELIVERY_LIFETIME_MILLISECONDS, + }) +} + +async function grantTicketHash(ticket: string): Promise { + const bytes = new TextEncoder().encode(`warpkeep-grant-ticket-v1\0${ticket}`) + try { + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)) + return Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('') + } finally { + bytes.fill(0) + } +} + +async function timingSafeGrantTicketMatch( + candidate: string, + grantIntent: PersistedPendingGrantIntent, +): Promise { + const [actual, target] = await Promise.all([ + grantTicketHash(candidate), + typeof grantIntent.ticket === 'string' + ? grantTicketHash(grantIntent.ticket) + : Promise.resolve(grantIntent.ticketHash), + ]) + let difference = 0 + for (let index = 0; index < actual.length; index += 1) { + difference |= actual.charCodeAt(index) ^ target.charCodeAt(index) + } + return difference === 0 +} + async function readCombinedState( storage: DurableObjectState['storage'], ): Promise { @@ -624,7 +778,7 @@ async function objectName(fid: string): Promise { } } -function internalUrl(path: 'event' | 'queue' | 'status'): string { +function internalUrl(path: 'event' | 'queue' | 'status' | 'ack'): string { return `${INTERNAL_ORIGIN}/${path}` } @@ -637,6 +791,8 @@ async function readQueueStatus(response: Response): Promise { + if (!response.ok) throw new Error('Admission notification store unavailable.') + const value: unknown = await response.json() + if ( + !isRecord(value) + || !exactKeys(value, ['status']) + || ( + value.status !== 'accepted' + && value.status !== 'not-ready' + && value.status !== 'stale' + ) + ) throw new Error('Admission notification store returned invalid state.') + return value.status +} + export class DurableObjectAdmissionNotificationStore implements AdmissionNotificationStore { constructor(private readonly namespace: DurableObjectNamespace) {} @@ -730,6 +905,18 @@ export class DurableObjectAdmissionNotificationStore implements AdmissionNotific return readQueueStatus(response) } + async acknowledge( + fid: string, + ticket: string, + ): Promise { + const response = await (await this.stub(fid)).fetch(internalUrl('ack'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fid, ticket }), + }) + return readAcknowledgementStatus(response) + } + async inspect(fid: string): Promise { const response = await (await this.stub(fid)).fetch(internalUrl('status'), { method: 'POST', @@ -973,6 +1160,7 @@ async function persistAndSchedule( storage: DurableObjectState['storage'], state: PersistedNotificationState, now: number, + grantIntent?: PersistedPendingGrantIntent | null, ): Promise { const legacyState = legacyStateForPersistence(state) const pendingState = pendingStateForPersistence(state) @@ -980,8 +1168,18 @@ async function persistAndSchedule( await transaction.put(STATE_KEY, legacyState) if (pendingState) await transaction.put(PENDING_STATE_RECORD, pendingState) else await transaction.delete(PENDING_STATE_RECORD) + if (grantIntent === null) await transaction.delete(PENDING_GRANT_RECORD) + else if (grantIntent !== undefined) await transaction.put(PENDING_GRANT_RECORD, grantIntent) }) - const alarmAt = nextAlarmAt(state, now) + const retainedGrant = grantIntent === undefined + ? readPendingGrantIntent(await storage.get(PENDING_GRANT_RECORD)) + : grantIntent + const stateAlarmAt = nextAlarmAt(state, now) + const alarmAt = retainedGrant && retainedGrant.expiresAt > now + ? stateAlarmAt === null + ? retainedGrant.expiresAt + : Math.min(stateAlarmAt, retainedGrant.expiresAt) + : stateAlarmAt if (alarmAt === null) await storage.deleteAlarm?.() else await storage.setAlarm(alarmAt) } @@ -1102,10 +1300,29 @@ async function recordDiagnostics( })) } -function notificationId(delivery: AdmissionDelivery): string { +function notificationId( + delivery: AdmissionDelivery, + grantIntent: PersistedPendingGrantIntent | null, +): string { return delivery.kind === 'admitted' ? `warpkeep-access-approved-v1-e${delivery.authEpoch}` - : `warpkeep-access-approved-v2-r${delivery.requestedAtMicros}` + : grantMatchesGeneration(grantIntent, delivery) + ? `warpkeep-access-grant-v3-i${grantIntent.intentId}` + : responseFailure('invalid-response') +} + +function targetUrl( + delivery: AdmissionDelivery, + grantIntent: PersistedPendingGrantIntent | null, +): string { + if (delivery.kind === 'admitted') return TARGET_URL + if ( + !grantMatchesGeneration(grantIntent, delivery) + || typeof grantIntent.ticket !== 'string' + ) { + return responseFailure('invalid-response') + } + return `${TARGET_URL}#${GRANT_TARGET_FRAGMENT}=${grantIntent.ticket}` } class NotificationResponseError extends Error { @@ -1258,6 +1475,7 @@ function deliveryResult(value: unknown, requestedToken: string): DeliveryOutcome async function sendOne( subscription: Subscription, delivery: AdmissionDelivery, + grantIntent: PersistedPendingGrantIntent | null, fetchImpl: typeof fetch, ): Promise { let response: Response @@ -1270,14 +1488,14 @@ async function sendOne( 'content-type': 'application/json', }, body: JSON.stringify({ - notificationId: notificationId(delivery), + notificationId: notificationId(delivery, grantIntent), title: delivery.kind === 'admitted' ? ADMITTED_NOTIFICATION_TITLE : PENDING_NOTIFICATION_TITLE, body: delivery.kind === 'admitted' ? ADMITTED_NOTIFICATION_BODY : PENDING_NOTIFICATION_BODY, - targetUrl: TARGET_URL, + targetUrl: targetUrl(delivery, grantIntent), tokens: [subscription.token], }), cache: 'no-store', @@ -1409,16 +1627,30 @@ function exhaustedForGeneration( : state.lastExhaustedRequestAtMicros === generation.requestedAtMicros } -function queueStatus(state: PersistedNotificationState): AdmissionNotificationQueueStatus { - if (state.delivery && sentForGeneration(state, deliveryGeneration(state.delivery))) { +function queueStatus( + state: PersistedNotificationState, + grantIntent: PersistedPendingGrantIntent | null = null, +): AdmissionNotificationQueueStatus { + if (!state.delivery || state.delivery.kind === 'pending-request') { + if (grantIntent?.acknowledgedAt !== undefined) return 'client-acknowledged' + if (grantIntent?.providerAcceptedAt !== undefined) return 'awaiting-client' + } + if ( + state.delivery?.kind === 'admitted' + && sentForGeneration(state, deliveryGeneration(state.delivery)) + ) { return 'already-sent' } - if (state.delivery && exhaustedForGeneration(state, deliveryGeneration(state.delivery))) { + if ( + state.delivery?.kind === 'admitted' + && exhaustedForGeneration(state, deliveryGeneration(state.delivery)) + ) { return 'delivery-exhausted' } if (!state.delivery || state.subscriptions.length === 0) return 'not-subscribed' if ( - state.delivery.attempts.length > 0 + state.delivery?.kind === 'admitted' + && state.delivery.attempts.length > 0 && state.delivery.attempts.every(attempt => attempt.status === 'sent') ) return 'already-sent' if ( @@ -1433,6 +1665,7 @@ function queueStatus(state: PersistedNotificationState): AdmissionNotificationQu function diagnosticsForState( state: PersistedNotificationState | null, persistedDiagnostics: PersistedNotificationDiagnostics | null, + grantIntent: PersistedPendingGrantIntent | null, ): AdmissionNotificationDiagnostics { if (!state) { return Object.freeze({ @@ -1446,9 +1679,18 @@ function diagnosticsForState( const attempts = delivery?.attempts ?? [] const generation = delivery ? deliveryGeneration(delivery) - : persistedDiagnostics?.generation + : grantIntent + ? Object.freeze({ + kind: 'pending-request' as const, + requestedAtMicros: grantIntent.requestedAtMicros, + }) + : persistedDiagnostics?.generation const status = delivery - ? queueStatus(state) + ? queueStatus(state, grantIntent) + : grantIntent?.acknowledgedAt !== undefined + ? 'client-acknowledged' + : grantIntent?.providerAcceptedAt !== undefined + ? 'awaiting-client' : generation === undefined ? 'not-subscribed' : sentForGeneration(state, generation) @@ -1568,6 +1810,17 @@ export class AdmissionNotification { await persistAndSchedule(this.state.storage, next, now) return next } + let grantIntent = readPendingGrantIntent( + await this.state.storage.get(PENDING_GRANT_RECORD), + ) + if ( + delivery.kind === 'pending-request' + && !grantMatchesGeneration(grantIntent, delivery, state.fid) + ) { + const next = withNextRevision(Object.freeze({ ...pruned, delivery: undefined })) + await persistAndSchedule(this.state.storage, next, now, null) + return next + } if (now >= delivery.expiresAt) { const exhausted = delivery.attempts.some(attempt => attempt.status === 'exhausted') const next = withNextRevision(Object.freeze({ @@ -1589,7 +1842,12 @@ export class AdmissionNotification { } : {}), })) - await persistAndSchedule(this.state.storage, next, now) + await persistAndSchedule( + this.state.storage, + next, + now, + delivery.kind === 'pending-request' ? null : undefined, + ) return next } @@ -1598,7 +1856,12 @@ export class AdmissionNotification { // remains revocable through signed disable webhooks, but no stale queue // can spring back to life when delivery is enabled again. const next = withNextRevision(Object.freeze({ ...pruned, delivery: undefined })) - await persistAndSchedule(this.state.storage, next, now) + await persistAndSchedule( + this.state.storage, + next, + now, + delivery.kind === 'pending-request' ? null : undefined, + ) return next } @@ -1666,12 +1929,24 @@ export class AdmissionNotification { ...afterAdmissionCheck, delivery: undefined, })) - await persistAndSchedule(this.state.storage, cancelled, now) + await persistAndSchedule( + this.state.storage, + cancelled, + now, + delivery.kind === 'pending-request' ? null : undefined, + ) return cancelled } - const outcome = await sendOne(subscription, delivery, this.fetchImpl) + const outcome = await sendOne(subscription, delivery, grantIntent, this.fetchImpl) latestAttemptAt = now if (outcome.result === 'successful') { + if ( + delivery.kind === 'pending-request' + && grantMatchesGeneration(grantIntent, delivery, state.fid) + && grantIntent.providerAcceptedAt === undefined + ) { + grantIntent = Object.freeze({ ...grantIntent, providerAcceptedAt: now }) + } attempts.push(Object.freeze({ appFid: attempt.appFid, tokenId: attempt.tokenId, @@ -1711,20 +1986,15 @@ export class AdmissionNotification { ...nextBase, subscriptions: Object.freeze(subscriptions), delivery: Object.freeze({ ...delivery, attempts: Object.freeze(attempts) }), - ...(attempts.length > 0 && attempts.every(attempt => attempt.status === 'sent') - ? delivery.kind === 'admitted' - ? { - lastSentAuthEpoch: Math.max( - nextBase.lastSentAuthEpoch ?? 0, - delivery.authEpoch, - ), - } - : { - lastSentRequestAtMicros: Math.max( - nextBase.lastSentRequestAtMicros ?? 0, - delivery.requestedAtMicros, - ), - } + ...(delivery.kind === 'admitted' + && attempts.length > 0 + && attempts.every(attempt => attempt.status === 'sent') + ? { + lastSentAuthEpoch: Math.max( + nextBase.lastSentAuthEpoch ?? 0, + delivery.authEpoch, + ), + } : {}), ...(invalidatedGeneration || ( attempts.length > 0 @@ -1748,12 +2018,16 @@ export class AdmissionNotification { } : {}), })) - // Keep a token-free queued admission until its bounded expiry. This closes - // the legitimate race where Hermes commits admission just before the host's - // notification-enabled webhook reaches us; a later enable event can still - // attach its one delivery attempt. Explicit disable/remove events erase raw - // token material immediately in the event path below. - await persistAndSchedule(this.state.storage, next, now) + // Keep the bounded delivery while the client grant remains unacknowledged. + // Provider acceptance is only transport evidence; it never writes the + // legacy pending-request success receipt that older operator code treated + // as admission authorization. + await persistAndSchedule( + this.state.storage, + next, + now, + delivery.kind === 'pending-request' ? grantIntent : undefined, + ) try { await recordDiagnostics( this.state.storage, @@ -1811,7 +2085,12 @@ export class AdmissionNotification { const diagnostics = readPersistedDiagnostics( await this.state.storage.get(DIAGNOSTICS_RECORD), ) - return new Response(JSON.stringify(diagnosticsForState(existing, diagnostics)), { + const grantIntent = readPendingGrantIntent( + await this.state.storage.get(PENDING_GRANT_RECORD), + ) + return new Response(JSON.stringify( + diagnosticsForState(existing, diagnostics, grantIntent), + ), { status: 200, headers: { 'content-type': 'application/json; charset=utf-8', @@ -1820,6 +2099,111 @@ export class AdmissionNotification { }) } + if (url.pathname === '/ack') { + if ( + !isRecord(value) + || !exactKeys(value, ['fid', 'ticket']) + || !isSafeFid(value.fid) + || !isGrantTicket(value.ticket) + ) return new Response(null, { status: 400 }) + const neutral = (status: AdmissionNotificationAcknowledgementStatus) => ( + new Response(JSON.stringify({ status }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }) + ) + const now = this.currentTime() + const existing = await readCombinedState(this.state.storage) + const grantIntent = readPendingGrantIntent( + await this.state.storage.get(PENDING_GRANT_RECORD), + ) + if ( + !existing + || existing.fid !== value.fid + || !grantIntent + || grantIntent.fid !== value.fid + || !(await timingSafeGrantTicketMatch(value.ticket, grantIntent)) + ) return neutral('stale') + if (!config.approvalNotificationsEnabled) { + const delivery = existing.delivery?.kind === 'pending-request' + && existing.delivery.requestedAtMicros === grantIntent.requestedAtMicros + ? undefined + : existing.delivery + const next = withNextRevision(Object.freeze({ ...existing, delivery })) + await persistAndSchedule(this.state.storage, next, now, null) + return neutral('stale') + } + if (grantIntent.expiresAt <= now) { + const delivery = existing.delivery?.kind === 'pending-request' + && existing.delivery.requestedAtMicros === grantIntent.requestedAtMicros + ? undefined + : existing.delivery + const next = withNextRevision(Object.freeze({ ...existing, delivery })) + await persistAndSchedule(this.state.storage, next, now, null) + return neutral('stale') + } + if (grantIntent.providerAcceptedAt === undefined) return neutral('not-ready') + + const invalidateGrant = async (): Promise => { + const delivery = existing.delivery?.kind === 'pending-request' + && existing.delivery.requestedAtMicros === grantIntent.requestedAtMicros + ? undefined + : existing.delivery + const next = withNextRevision(Object.freeze({ ...existing, delivery })) + await persistAndSchedule(this.state.storage, next, now, null) + return neutral('stale') + } + + const resolver = this.configuredAdmissionResolver ?? defaultAdmissionResolver(config) + const requestResolver = this.configuredAccessRequestResolver + ?? defaultAccessRequestResolver(config) + try { + const admission = await resolver.resolve(existing.fid) + if (admission.state === 'enabled') return invalidateGrant() + const request = await requestResolver.getStatus(existing.fid) + if ( + request.status !== 'requested' + || request.requestedAtMicros !== grantIntent.requestedAtMicros + ) return invalidateGrant() + } catch { + return neutral('not-ready') + } + + const latest = await readCombinedState(this.state.storage) + const latestGrant = readPendingGrantIntent( + await this.state.storage.get(PENDING_GRANT_RECORD), + ) + if ( + !latest + || latest.revision !== existing.revision + || !latestGrant + || latestGrant.intentId !== grantIntent.intentId + || latestGrant.providerAcceptedAt === undefined + || !(await timingSafeGrantTicketMatch(value.ticket, latestGrant)) + ) return neutral('stale') + if (latestGrant.acknowledgedAt !== undefined) return neutral('accepted') + const delivery = latest.delivery?.kind === 'pending-request' + && latest.delivery.requestedAtMicros === latestGrant.requestedAtMicros + ? undefined + : latest.delivery + const next = withNextRevision(Object.freeze({ ...latest, delivery })) + await persistAndSchedule(this.state.storage, next, now, Object.freeze({ + version: 1 as const, + fid: latestGrant.fid, + requestedAtMicros: latestGrant.requestedAtMicros, + intentId: latestGrant.intentId, + createdAt: latestGrant.createdAt, + expiresAt: latestGrant.expiresAt, + providerAcceptedAt: latestGrant.providerAcceptedAt, + acknowledgedAt: now, + ticketHash: await grantTicketHash(value.ticket), + })) + return neutral('accepted') + } + if (url.pathname === '/event') { if (!validVerifiedEvent(value, config)) return new Response(null, { status: 400 }) // An add event without notification details records no consent and must @@ -1913,7 +2297,14 @@ export class AdmissionNotification { } next = withNextRevision(next) - await persistAndSchedule(this.state.storage, next, now) + await persistAndSchedule( + this.state.storage, + next, + now, + value.event.type === 'disabled' && next.subscriptions.length === 0 + ? null + : undefined, + ) if (value.event.type === 'enabled' && next.delivery) { await this.attemptDelivery(next) } @@ -1934,13 +2325,35 @@ export class AdmissionNotification { requestedAtMicros: value.requestedAtMicros, }) : Object.freeze({ kind: 'admitted', authEpoch: value.authEpoch }) - if (sentForGeneration(next, generation)) { + let grantIntent = readPendingGrantIntent( + await this.state.storage.get(PENDING_GRANT_RECORD), + ) + if ( + generation.kind === 'pending-request' + && grantIntent + && grantIntent.requestedAtMicros > generation.requestedAtMicros + ) return new Response(null, { status: 409 }) + if ( + generation.kind === 'pending-request' + && grantMatchesGeneration(grantIntent, generation, value.fid) + && grantIntent.expiresAt <= now + ) { + grantIntent = null + if ( + next.delivery?.kind === 'pending-request' + && next.delivery.requestedAtMicros === generation.requestedAtMicros + ) next = Object.freeze({ ...next, delivery: undefined }) + } + if (generation.kind === 'admitted' && sentForGeneration(next, generation)) { return new Response(JSON.stringify({ status: 'already-sent' }), { status: 200, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, }) } - if (exhaustedForGeneration(next, generation)) { + if ( + generation.kind === 'admitted' + && exhaustedForGeneration(next, generation) + ) { return new Response(JSON.stringify({ status: 'delivery-exhausted' }), { status: 200, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, @@ -1960,12 +2373,35 @@ export class AdmissionNotification { ) { return new Response(null, { status: 409 }) } + if ( + generation.kind === 'pending-request' + && grantMatchesGeneration(grantIntent, generation, value.fid) + && grantIntent.providerAcceptedAt !== undefined + ) { + return new Response(JSON.stringify({ status: queueStatus(next, grantIntent) }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }) + } const diagnostics = readPersistedDiagnostics( await this.state.storage.get(DIAGNOSTICS_RECORD), ) next = recoverLegacyTransportBackoff(next, diagnostics, generation, now) + const newGrant = generation.kind === 'pending-request' + && !grantMatchesGeneration(grantIntent, generation, value.fid) + if (newGrant) { + grantIntent = createPendingGrantIntent( + value.fid, + generation.requestedAtMicros, + now, + ) + } if ( - !next.delivery + newGrant + || !next.delivery || !generationEquals(deliveryGeneration(next.delivery), generation) ) { const delivery: AdmissionDelivery = Object.freeze({ @@ -1991,9 +2427,17 @@ export class AdmissionNotification { }) } next = withNextRevision(next) - await persistAndSchedule(this.state.storage, next, now) + await persistAndSchedule( + this.state.storage, + next, + now, + generation.kind === 'pending-request' ? grantIntent : undefined, + ) next = await this.attemptDelivery(next) - return new Response(JSON.stringify({ status: queueStatus(next) }), { + grantIntent = readPendingGrantIntent( + await this.state.storage.get(PENDING_GRANT_RECORD), + ) + return new Response(JSON.stringify({ status: queueStatus(next, grantIntent) }), { status: 200, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, }) @@ -2007,7 +2451,7 @@ export class AdmissionNotification { } private async handleAlarm(): Promise { - const state = await readCombinedState(this.state.storage) + let state = await readCombinedState(this.state.storage) if (!state) { await purgePersistedState(this.state.storage) return @@ -2017,6 +2461,18 @@ export class AdmissionNotification { await purgePersistedState(this.state.storage) return } + let grantIntent = readPendingGrantIntent( + await this.state.storage.get(PENDING_GRANT_RECORD), + ) + if (grantIntent && grantIntent.expiresAt <= now) { + const delivery = state.delivery?.kind === 'pending-request' + && state.delivery.requestedAtMicros === grantIntent.requestedAtMicros + ? undefined + : state.delivery + state = withNextRevision(Object.freeze({ ...state, delivery })) + await persistAndSchedule(this.state.storage, state, now, null) + grantIntent = null + } try { this.config() } catch { @@ -2026,10 +2482,14 @@ export class AdmissionNotification { await this.state.storage.setAlarm(state.delivery ? Math.min( state.delivery.expiresAt, + grantIntent?.expiresAt ?? state.retentionExpiresAt, state.retentionExpiresAt, now + RETRY_DELAYS_MILLISECONDS[0], ) - : state.retentionExpiresAt) + : Math.min( + grantIntent?.expiresAt ?? state.retentionExpiresAt, + state.retentionExpiresAt, + )) return } const next = await this.attemptDelivery(state) diff --git a/services/auth-bridge/src/app.ts b/services/auth-bridge/src/app.ts index 48cc36aa..b0d52c48 100644 --- a/services/auth-bridge/src/app.ts +++ b/services/auth-bridge/src/app.ts @@ -131,6 +131,7 @@ const V2_REFRESH_PATH = '/v2/session/refresh' const V2_LOGOUT_PATH = '/v2/session/logout' const V2_ACCESS_STATUS_PATH = '/v2/access/status' const V2_ACCESS_REQUEST_PATH = '/v2/access/request' +const V2_ADMISSION_GRANT_PATH = '/v2/access/admission-grant' export const MINIAPP_WEBHOOK_PATH = '/v1/farcaster/miniapp/webhook' export const ADMISSION_NOTIFICATION_PATH = '/v1/admin/admission-notification' export const ADMISSION_NOTIFICATION_STATUS_PATH = '/v1/admin/admission-notification-status' @@ -299,8 +300,10 @@ function isCredentialedPath(pathname: string): boolean { || pathname === V2_LOGOUT_PATH } -function isAccessRequestPath(pathname: string): boolean { - return pathname === V2_ACCESS_STATUS_PATH || pathname === V2_ACCESS_REQUEST_PATH +function isAccessCredentialPath(pathname: string): boolean { + return pathname === V2_ACCESS_STATUS_PATH + || pathname === V2_ACCESS_REQUEST_PATH + || pathname === V2_ADMISSION_GRANT_PATH } function publicCorsHeaders(request: Request, config: BridgeConfig, pathname = new URL(request.url).pathname): HeadersInit { @@ -330,7 +333,7 @@ function routeCorsHeaders(request: Request, config: BridgeConfig, pathname = new const origin = request.headers.get('origin') return origin === QUICK_AUTH_BROWSER_ORIGIN ? quickAuthCorsHeaders(origin) : {} } - if (isAccessRequestPath(pathname)) { + if (isAccessCredentialPath(pathname)) { const origin = request.headers.get('origin') if (accessRequestUsesBearer(request)) { return origin === QUICK_AUTH_BROWSER_ORIGIN @@ -401,7 +404,7 @@ function isPublicAuthPath(pathname: string): boolean { || pathname === V2_EXCHANGE_PATH || pathname === V2_QUICK_AUTH_EXCHANGE_PATH || pathname === V2_REFRESH_PATH - || isAccessRequestPath(pathname) + || isAccessCredentialPath(pathname) } function isLegacyAuthPath(pathname: string): boolean { @@ -1096,6 +1099,7 @@ async function configurationAttestation( authEpochResolverTimeoutMilliseconds: AUTH_EPOCH_RESOLVER_TIMEOUT_MILLISECONDS, accessRequestStatusPath: V2_ACCESS_STATUS_PATH, accessRequestSubmitPath: V2_ACCESS_REQUEST_PATH, + admissionGrantPath: V2_ADMISSION_GRANT_PATH, accessRequestResolverTokenTtlSeconds: INTERNAL_ACCESS_REQUEST_RESOLVER_TOKEN_TTL_SECONDS, accessRequestResolverTimeoutMilliseconds: ACCESS_REQUEST_RESOLVER_TIMEOUT_MILLISECONDS, accessRequestStatusProcedure: SPACETIMEDB_ACCESS_REQUEST_STATUS_PROCEDURE, @@ -1560,7 +1564,7 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri 'The read-only QA observer is disabled.', ) } - if (request.method === 'OPTIONS' && isAccessRequestPath(url.pathname)) { + if (request.method === 'OPTIONS' && isAccessCredentialPath(url.pathname)) { return allowedAccessRequestPreflight(request, config) } if (request.method === 'OPTIONS' && url.pathname === V2_QUICK_AUTH_EXCHANGE_PATH) { @@ -1924,7 +1928,7 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri return json(responseBody, 200, quickAuthCorsHeaders(origin)) } - if (request.method === 'POST' && isAccessRequestPath(url.pathname)) { + if (request.method === 'POST' && isAccessCredentialPath(url.pathname)) { const credentialMode = accessCredentialMode(request) const origin = credentialMode === 'quick-auth' ? requireQuickAuthBrowserOrigin(request) @@ -1943,7 +1947,24 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri dependencies.rateLimiter, logger, ) - requireExactKeys(await parseObjectBody(request), []) + const accessBody = await parseObjectBody(request) + let admissionGrantTicket: string | undefined + if (url.pathname === V2_ADMISSION_GRANT_PATH) { + requireExactKeys(accessBody, ['ticket']) + if ( + typeof accessBody.ticket !== 'string' + || !/^[A-Za-z0-9_-]{43}$/.test(accessBody.ticket) + ) { + throw new HttpError( + 400, + 'admission_grant_invalid', + 'This admission grant is invalid or expired.', + ) + } + admissionGrantTicket = accessBody.ticket + } else { + requireExactKeys(accessBody, []) + } const expectedFid = requireExpectedAccessFid( request, config.accessExpectedFidRequired, @@ -2089,6 +2110,66 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri ) } + if (url.pathname === V2_ADMISSION_GRANT_PATH) { + const notificationStore = dependencies.admissionNotificationStore + ?? defaultAdmissionNotificationStore(env) + if (admission.state === 'enabled') { + // Best-effort capability cleanup for an admission committed by a + // concurrent operator. Entry authority is already established, + // so notification storage availability cannot revoke it. + try { + await notificationStore.acknowledge(verifiedFid, admissionGrantTicket!) + } catch { + // The bounded grant expires independently in Durable Object storage. + } + return json( + { version: 1, status: 'already-admitted' }, + 200, + accessRequestCorsHeaders(origin, credentialMode), + ) + } + if (sessionRecord?.state === 'bound') throw invalidSessionError() + if (credentialMode === 'quick-auth') { + const checkedAt = now() + if (!Number.isSafeInteger(checkedAt) || checkedAt < 0) { + throw invalidAccessCredential() + } + try { + verifiedQuickAuthClaims( + verifiedQuickAuthPayload, + Math.floor(checkedAt / 1_000), + ) + } catch { + throw invalidAccessCredential() + } + } + let acknowledgement: 'accepted' | 'not-ready' | 'stale' + try { + acknowledgement = await notificationStore.acknowledge( + verifiedFid, + admissionGrantTicket!, + ) + } catch { + throw new HttpError( + 503, + 'admission_grant_unavailable', + 'Admission finalization is temporarily unavailable.', + ) + } + if (acknowledgement === 'accepted') { + logger.event('admission_grant_acknowledged') + } else if (acknowledgement === 'not-ready') { + logger.event('admission_grant_ack_not_ready') + } else { + logger.event('admission_grant_ack_stale') + } + return json( + { version: 1, status: acknowledgement }, + 200, + accessRequestCorsHeaders(origin, credentialMode), + ) + } + let result: AccessRequestResolution if (admission.state === 'enabled') { result = Object.freeze({ status: 'already-admitted' }) @@ -2537,9 +2618,15 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri ) } if (status === 'already-sent') logger.event('admission_notification_succeeded') - else if (status === 'delivery-exhausted') logger.event('admission_notification_exhausted') - else if (status === 'not-subscribed') logger.event('admission_notification_not_subscribed') - else logger.event('admission_notification_queued') + else if (status === 'awaiting-client') { + logger.event('admission_notification_provider_accepted') + } else if (status === 'client-acknowledged') { + logger.event('admission_notification_client_acknowledged') + } else if (status === 'delivery-exhausted') { + logger.event('admission_notification_exhausted') + } else if (status === 'not-subscribed') { + logger.event('admission_notification_not_subscribed') + } else logger.event('admission_notification_queued') return json({ status }, status === 'queued' ? 202 : 200) } @@ -2669,6 +2756,7 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri quickAuthMaxIssuerLifetimeSeconds: QUICK_AUTH_MAX_ISSUER_LIFETIME_SECONDS, accessRequestStatusPath: V2_ACCESS_STATUS_PATH, accessRequestSubmitPath: V2_ACCESS_REQUEST_PATH, + admissionGrantPath: V2_ADMISSION_GRANT_PATH, accessRequestResolverTokenTtlSeconds: INTERNAL_ACCESS_REQUEST_RESOLVER_TOKEN_TTL_SECONDS, accessRequestResolverTimeoutMilliseconds: ACCESS_REQUEST_RESOLVER_TIMEOUT_MILLISECONDS, accessRequestStatusProcedure: SPACETIMEDB_ACCESS_REQUEST_STATUS_PROCEDURE, @@ -2699,7 +2787,11 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri throw new HttpError(404, 'not_found', 'Route not found.') } catch (error) { if (url.pathname === V2_QUICK_AUTH_EXCHANGE_PATH) logger.event('quick_auth_rejected') - if (isAccessRequestPath(url.pathname)) logger.event('access_request_rejected') + if (url.pathname === V2_ADMISSION_GRANT_PATH) { + logger.event('admission_grant_ack_rejected') + } else if (isAccessCredentialPath(url.pathname)) { + logger.event('access_request_rejected') + } if (error instanceof HttpError) { if (url.pathname === QA_OBSERVER_CHALLENGE_PATH) logger.event('qa_challenge_rejected') if (url.pathname === QA_OBSERVER_SNAPSHOT_PATH) logger.event('qa_snapshot_rejected') diff --git a/services/auth-bridge/src/rateLimit.ts b/services/auth-bridge/src/rateLimit.ts index b53eaf69..5159e7b8 100644 --- a/services/auth-bridge/src/rateLimit.ts +++ b/services/auth-bridge/src/rateLimit.ts @@ -21,7 +21,10 @@ const POLICIES: Readonly> = Object.freeze({ // 120-user launch bottleneck; signature, app-key and per-FID replay checks // still run before any consent state is accepted. 'miniapp-webhook': 2_000, - 'admission-notification': 12, + // Protected operator polling waits for a verified notification open before + // committing admission. Five-second polling for one bounded ten-minute + // grant needs room inside the shared five-minute limiter window. + 'admission-notification': 72, 'admin-token': 6, 'qa-challenge': 12, 'qa-snapshot': 60, diff --git a/services/auth-bridge/src/types.ts b/services/auth-bridge/src/types.ts index a2053fb5..426d2e07 100644 --- a/services/auth-bridge/src/types.ts +++ b/services/auth-bridge/src/types.ts @@ -172,6 +172,12 @@ export type SafeLogEvent = | 'admission_notification_not_subscribed' | 'admission_notification_rejected' | 'admission_notification_inspected' + | 'admission_notification_provider_accepted' + | 'admission_notification_client_acknowledged' + | 'admission_grant_acknowledged' + | 'admission_grant_ack_rejected' + | 'admission_grant_ack_not_ready' + | 'admission_grant_ack_stale' | 'rate_limited' | 'rate_limit_failed' | 'configuration_error' @@ -312,9 +318,16 @@ export interface MiniAppWebhookVerifier { export type AdmissionNotificationQueueStatus = | 'queued' | 'already-sent' + | 'awaiting-client' + | 'client-acknowledged' | 'delivery-exhausted' | 'not-subscribed' +export type AdmissionNotificationAcknowledgementStatus = + | 'accepted' + | 'not-ready' + | 'stale' + export type AdmissionNotificationGeneration = | Readonly<{ kind: 'admitted' @@ -369,6 +382,7 @@ export type AdmissionNotificationDiagnostics = Readonly<{ export interface AdmissionNotificationStore { applyEvent(event: VerifiedMiniAppWebhookEvent): Promise queueAdmission(input: AdmissionNotificationQueueInput): Promise + acknowledge(fid: string, ticket: string): Promise /** Operator-only, token-free delivery state used for bounded diagnosis. */ inspect?(fid: string): Promise } diff --git a/services/auth-bridge/test-workerd/authBridge.workerd.test.ts b/services/auth-bridge/test-workerd/authBridge.workerd.test.ts index 17f3a729..2b2273b0 100644 --- a/services/auth-bridge/test-workerd/authBridge.workerd.test.ts +++ b/services/auth-bridge/test-workerd/authBridge.workerd.test.ts @@ -489,6 +489,7 @@ describe('auth bridge production bindings in workerd', () => { admissionNotificationStore: { applyEvent, queueAdmission: vi.fn(async () => 'queued' as const), + acknowledge: vi.fn(async () => 'stale' as const), }, rateLimiter: { check: async () => ({ allowed: true }) }, logger: { event: event => events.push(event) }, diff --git a/services/auth-bridge/test/admissionNotifications.test.ts b/services/auth-bridge/test/admissionNotifications.test.ts index 81ad8ed4..52ea142c 100644 --- a/services/auth-bridge/test/admissionNotifications.test.ts +++ b/services/auth-bridge/test/admissionNotifications.test.ts @@ -19,6 +19,7 @@ const TOKEN = 'test-notification-token-with-enough-entropy' const INTERNAL_ORIGIN = 'https://admission-notification.internal' const STATE_KEY = 'admission-notification-v1' const PENDING_STATE_RECORD = 'admission-notification-pending-v2' +const PENDING_GRANT_RECORD = 'admission-notification-grant-v3' const DIAGNOSTICS_RECORD = 'admission-notification-diagnostics-v1' class FakeStorage implements DurableObjectStorage { @@ -118,7 +119,7 @@ function disabledEvent(eventId = 'b'.repeat(64)): VerifiedMiniAppWebhookEvent { return { eventId, fid: FID, appFid: APP_FID, event: { type: 'disabled' } } } -function internalRequest(path: 'event' | 'queue' | 'status', body: unknown): Request { +function internalRequest(path: 'event' | 'queue' | 'status' | 'ack', body: unknown): Request { return new Request(`${INTERNAL_ORIGIN}/${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -209,6 +210,29 @@ async function inspect(notification: AdmissionNotification): Promise { return notification.fetch(internalRequest('status', { fid: FID })) } +async function acknowledge( + notification: AdmissionNotification, + ticket: string, +): Promise { + return notification.fetch(internalRequest('ack', { fid: FID, ticket })) +} + +function pendingGrant(storage: FakeStorage): { + intentId: string + ticket: string + requestedAtMicros: number + providerAcceptedAt?: number + acknowledgedAt?: number +} { + return storage.values.get(PENDING_GRANT_RECORD) as { + intentId: string + ticket: string + requestedAtMicros: number + providerAcceptedAt?: number + acknowledgedAt?: number + } +} + describe('admission notification consent and delivery lifecycle', () => { it('closes the queue-before-consent race and keeps a retained auth-epoch receipt', async () => { const fetchImpl = vi.fn(async () => successfulDelivery()) @@ -243,7 +267,7 @@ describe('admission notification consent and delivery lifecycle', () => { expect(stored(h.storage)).not.toContain('"lastFailureReason"') }) - it('gets provider acceptance for the exact pending request before admission exists', async () => { + it('stages a unique click grant after provider acceptance for the exact pending request', async () => { const requestedAtMicros = 1_799_999_999_000_000 const fetchImpl = vi.fn(async () => successfulDelivery()) const accessRequestResolver = { @@ -260,18 +284,20 @@ describe('admission notification consent and delivery lifecycle', () => { await applyEvent(h.notification, enabledEvent()) const response = await queuePending(h.notification, requestedAtMicros) - await expect(response.json()).resolves.toEqual({ status: 'already-sent' }) + await expect(response.json()).resolves.toEqual({ status: 'awaiting-client' }) expect(accessRequestResolver.getStatus).toHaveBeenCalledWith(FID) expect(fetchImpl).toHaveBeenCalledOnce() const payload = JSON.parse(String(fetchImpl.mock.calls[0][1]?.body)) + const grant = pendingGrant(h.storage) + expect(grant.intentId).toMatch(/^[A-Za-z0-9_-]{22}$/) + expect(grant.ticket).toMatch(/^[A-Za-z0-9_-]{43}$/) expect(payload).toMatchObject({ - notificationId: `warpkeep-access-approved-v2-r${requestedAtMicros}`, + notificationId: `warpkeep-access-grant-v3-i${grant.intentId}`, title: 'Admission approved', - body: 'The Hegemony is finalizing your Realm access. Your keep will open shortly.', + body: 'Tap to finalize your Realm access. Your keep awaits in Genesis 001.', + targetUrl: `https://warpkeep.com/?miniApp=true#warpkeep-grant-v1=${grant.ticket}`, }) - expect(pendingStored(h.storage)).toContain( - `"lastSentRequestAtMicros":${requestedAtMicros}`, - ) + expect(pendingStored(h.storage)).not.toContain('lastSentRequestAtMicros') expect(pendingStored(h.storage)).not.toContain(TOKEN) const legacy = h.storage.values.get(STATE_KEY) as Record expect(Object.keys(legacy).sort()).toEqual([ @@ -286,9 +312,10 @@ describe('admission notification consent and delivery lifecycle', () => { expect(stored(h.storage)).not.toContain('pending-request') expect(stored(h.storage)).not.toContain('lastSentRequestAtMicros') await expect((await inspect(h.notification)).json()).resolves.toMatchObject({ - status: 'already-sent', + status: 'awaiting-client', generation: 'pending-request', }) + expect(await (await inspect(h.notification)).text()).not.toContain(grant.ticket) }) it('does not reuse a pending-request receipt for a later application', async () => { @@ -309,7 +336,7 @@ describe('admission notification consent and delivery lifecycle', () => { requestedAtMicros += 1_000 const second = await queuePending(h.notification, requestedAtMicros, NOW + 1) - await expect(second.json()).resolves.toEqual({ status: 'already-sent' }) + await expect(second.json()).resolves.toEqual({ status: 'awaiting-client' }) expect(fetchImpl).toHaveBeenCalledTimes(2) const notificationIds = fetchImpl.mock.calls.map(call => ( JSON.parse(String(call[1]?.body)) as { notificationId: string } @@ -317,6 +344,185 @@ describe('admission notification consent and delivery lifecycle', () => { expect(new Set(notificationIds).size).toBe(2) }) + it('acknowledges a provider-accepted grant once and accepts an exact replay', async () => { + const requestedAtMicros = 1_799_999_999_000_000 + const h = createHarness({ + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + }, + }) + await applyEvent(h.notification, enabledEvent()) + await queuePending(h.notification, requestedAtMicros) + const grant = pendingGrant(h.storage) + + await expect((await acknowledge(h.notification, 'Z'.repeat(43))).json()).resolves.toEqual({ + status: 'stale', + }) + await expect((await acknowledge(h.notification, grant.ticket)).json()).resolves.toEqual({ + status: 'accepted', + }) + await expect((await acknowledge(h.notification, grant.ticket)).json()).resolves.toEqual({ + status: 'accepted', + }) + expect(pendingGrant(h.storage).acknowledgedAt).toBe(NOW) + expect(JSON.stringify(h.storage.values.get(PENDING_GRANT_RECORD))).not.toContain(grant.ticket) + expect(JSON.stringify(h.storage.values.get(PENDING_GRANT_RECORD))).toContain('ticketHash') + expect(h.storage.values.has(PENDING_STATE_RECORD)).toBe(false) + const diagnostics = await (await inspect(h.notification)).text() + expect(diagnostics).not.toContain(grant.ticket) + expect(JSON.parse(diagnostics)).toMatchObject({ + status: 'client-acknowledged', + generation: 'pending-request', + }) + }) + + it('invalidates an acknowledged replay after the exact request is reset', async () => { + let requestedAtMicros = 1_799_999_999_000_000 + const h = createHarness({ + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + }, + }) + await applyEvent(h.notification, enabledEvent()) + await queuePending(h.notification, requestedAtMicros) + const ticket = pendingGrant(h.storage).ticket + + await expect((await acknowledge(h.notification, ticket)).json()).resolves.toEqual({ + status: 'accepted', + }) + requestedAtMicros += 1_000 + await expect((await acknowledge(h.notification, ticket)).json()).resolves.toEqual({ + status: 'stale', + }) + expect(h.storage.values.has(PENDING_GRANT_RECORD)).toBe(false) + }) + + it('does not acknowledge a grant before the provider accepts it', async () => { + const requestedAtMicros = 1_799_999_999_000_000 + const h = createHarness({ + fetchImpl: vi.fn(async () => Response.json({ + result: { + successfulTokens: [], + invalidTokens: [], + rateLimitedTokens: [TOKEN], + }, + })), + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + }, + }) + await applyEvent(h.notification, enabledEvent()) + await queuePending(h.notification, requestedAtMicros) + + await expect((await acknowledge( + h.notification, + pendingGrant(h.storage).ticket, + )).json()).resolves.toEqual({ status: 'not-ready' }) + await expect((await inspect(h.notification)).json()).resolves.toMatchObject({ + status: 'queued', + }) + }) + + it('erases the raw grant capability on notification opt-out and expiry', async () => { + const requestedAtMicros = 1_799_999_999_000_000 + const createPendingHarness = () => createHarness({ + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + }, + }) + + const optedOut = createPendingHarness() + await applyEvent(optedOut.notification, enabledEvent()) + await queuePending(optedOut.notification, requestedAtMicros) + expect(pendingGrant(optedOut.storage).ticket).toMatch(/^[A-Za-z0-9_-]{43}$/) + await applyEvent(optedOut.notification, disabledEvent()) + expect(optedOut.storage.values.has(PENDING_GRANT_RECORD)).toBe(false) + + const expired = createPendingHarness() + await applyEvent(expired.notification, enabledEvent()) + await queuePending(expired.notification, requestedAtMicros) + expired.setNow(NOW + 24 * 60 * 60 * 1_000) + await expired.notification.alarm() + expect(expired.storage.values.has(PENDING_GRANT_RECORD)).toBe(false) + }) + + it('rejects the prior ticket after the exact access request is reset', async () => { + let requestedAtMicros = 1_799_999_999_000_000 + const h = createHarness({ + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + }, + }) + await applyEvent(h.notification, enabledEvent()) + await queuePending(h.notification, requestedAtMicros) + const oldGrant = pendingGrant(h.storage) + + requestedAtMicros += 1_000 + await expect((await acknowledge(h.notification, oldGrant.ticket)).json()).resolves.toEqual({ + status: 'stale', + }) + expect(h.storage.values.has(PENDING_GRANT_RECORD)).toBe(false) + await queuePending(h.notification, requestedAtMicros, NOW + 1) + const nextGrant = pendingGrant(h.storage) + expect(nextGrant.intentId).not.toBe(oldGrant.intentId) + expect(nextGrant.ticket).not.toBe(oldGrant.ticket) + await expect((await acknowledge(h.notification, oldGrant.ticket)).json()).resolves.toEqual({ + status: 'stale', + }) + }) + + it('does not let a rollback-era pending success receipt authorize a v3 grant', async () => { + const requestedAtMicros = 1_799_999_999_000_000 + const fetchImpl = vi.fn(async () => successfulDelivery()) + const h = createHarness({ + fetchImpl, + resolver: { + resolve: vi.fn(async () => ({ state: 'disabled', authEpoch: 0 } as const)), + }, + accessRequestResolver: { + getStatus: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + submit: vi.fn(async () => ({ status: 'requested', requestedAtMicros } as const)), + }, + }) + await applyEvent(h.notification, enabledEvent()) + h.storage.values.set(PENDING_STATE_RECORD, { + version: 1, + fid: FID, + lastSentRequestAtMicros: requestedAtMicros, + }) + + await expect((await queuePending( + h.notification, + requestedAtMicros, + )).json()).resolves.toEqual({ status: 'awaiting-client' }) + expect(fetchImpl).toHaveBeenCalledOnce() + const payload = JSON.parse(String(fetchImpl.mock.calls[0][1]?.body)) as { + notificationId: string + } + expect(payload.notificationId).toMatch(/^warpkeep-access-grant-v3-i[A-Za-z0-9_-]{22}$/) + expect(pendingGrant(h.storage).ticket).toMatch(/^[A-Za-z0-9_-]{43}$/) + }) + it('cancels a staged delivery when the exact pending request no longer matches', async () => { const requestedAtMicros = 1_799_999_999_000_000 const fetchImpl = vi.fn(async () => successfulDelivery()) diff --git a/services/auth-bridge/test/app.test.ts b/services/auth-bridge/test/app.test.ts index 4c2d16c2..799ca804 100644 --- a/services/auth-bridge/test/app.test.ts +++ b/services/auth-bridge/test/app.test.ts @@ -49,6 +49,7 @@ const QUICK_AUTH_ISSUER = 'https://auth.farcaster.xyz' const QUICK_AUTH_PATH = '/v2/farcaster/quick-auth/exchange' const ACCESS_STATUS_PATH = '/v2/access/status' const ACCESS_REQUEST_PATH = '/v2/access/request' +const ADMISSION_GRANT_PATH = '/v2/access/admission-grant' const syntheticQuickAuthSegment = (value: object) => btoa(JSON.stringify(value)) .replaceAll('+', '-') .replaceAll('/', '_') @@ -235,7 +236,7 @@ function quickAuthRequest( } function accessBearerRequest( - path: typeof ACCESS_STATUS_PATH | typeof ACCESS_REQUEST_PATH, + path: typeof ACCESS_STATUS_PATH | typeof ACCESS_REQUEST_PATH | typeof ADMISSION_GRANT_PATH, body: unknown = {}, init: RequestInit = {}, ): Request { @@ -1373,6 +1374,7 @@ describe('Warpkeep auth bridge', () => { quickAuthMaxIssuerLifetimeSeconds: 60 * 60, accessRequestStatusPath: '/v2/access/status', accessRequestSubmitPath: '/v2/access/request', + admissionGrantPath: '/v2/access/admission-grant', accessRequestResolverTokenTtlSeconds: 15, accessRequestResolverTimeoutMilliseconds: 5_000, accessRequestStatusProcedure: 'access_request_get_status_v1', @@ -1433,6 +1435,7 @@ describe('Warpkeep auth bridge', () => { authEpochResolverTimeoutMilliseconds: 5_000, accessRequestStatusPath: '/v2/access/status', accessRequestSubmitPath: '/v2/access/request', + admissionGrantPath: '/v2/access/admission-grant', accessRequestResolverTokenTtlSeconds: 15, accessRequestResolverTimeoutMilliseconds: 5_000, accessRequestStatusProcedure: 'access_request_get_status_v1', @@ -2419,6 +2422,7 @@ describe('Warpkeep auth bridge', () => { }) describe('Farcaster admission notifications', () => { + const rejectGrant = vi.fn(async () => 'stale' as const) const verifiedEnableEvent = Object.freeze({ eventId: 'a'.repeat(64), fid: FID, @@ -2438,7 +2442,7 @@ describe('Warpkeep auth bridge', () => { const queueAdmission = vi.fn(async () => 'queued' as const) const h = harness({ miniAppWebhookVerifier: { verify }, - admissionNotificationStore: { applyEvent, queueAdmission }, + admissionNotificationStore: { applyEvent, queueAdmission, acknowledge: rejectGrant }, }) for (const candidate of [ @@ -2472,6 +2476,7 @@ describe('Warpkeep auth bridge', () => { admissionNotificationStore: { applyEvent, queueAdmission: vi.fn(async () => 'queued' as const), + acknowledge: rejectGrant, }, }) const signedEnvelope = { header: 'header', payload: 'payload', signature: 'signature' } @@ -2512,6 +2517,7 @@ describe('Warpkeep auth bridge', () => { admissionNotificationStore: { applyEvent, queueAdmission: vi.fn(async () => 'not-subscribed' as const), + acknowledge: rejectGrant, }, }) const response = await h.app.fetch(request( @@ -2552,6 +2558,7 @@ describe('Warpkeep auth bridge', () => { admissionNotificationStore: { applyEvent, queueAdmission: vi.fn(async () => 'queued' as const), + acknowledge: rejectGrant, }, }) const response = await h.app.fetch(request( @@ -2570,6 +2577,7 @@ describe('Warpkeep auth bridge', () => { const store: AdmissionNotificationStore = { applyEvent: vi.fn(async () => undefined), queueAdmission, + acknowledge: rejectGrant, } const h = harness({ admissionNotificationStore: store }) @@ -2615,6 +2623,7 @@ describe('Warpkeep auth bridge', () => { admissionNotificationStore: { applyEvent: vi.fn(async () => undefined), queueAdmission, + acknowledge: rejectGrant, }, }) const response = await h.app.fetch(request( @@ -2645,6 +2654,7 @@ describe('Warpkeep auth bridge', () => { admissionNotificationStore: { applyEvent: vi.fn(async () => undefined), queueAdmission, + acknowledge: rejectGrant, }, }) @@ -2679,6 +2689,7 @@ describe('Warpkeep auth bridge', () => { admissionNotificationStore: { applyEvent: vi.fn(async () => undefined), queueAdmission: vi.fn(async () => 'queued' as const), + acknowledge: rejectGrant, inspect, }, }) @@ -2722,6 +2733,91 @@ describe('Warpkeep auth bridge', () => { }) describe('neutral access requests', () => { + it('acknowledges only an exact memory-only grant for the verified pending identity', async () => { + const ticket = 'A'.repeat(43) + const acknowledge = vi.fn(async () => 'accepted' as const) + const h = harness({ + epoch: 0, + admissionNotificationStore: { + applyEvent: vi.fn(async () => undefined), + queueAdmission: vi.fn(async () => 'queued' as const), + acknowledge, + }, + }) + + const response = await h.app.fetch(accessBearerRequest( + ADMISSION_GRANT_PATH, + { ticket }, + ), env()) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + version: 1, + status: 'accepted', + }) + expect(acknowledge).toHaveBeenCalledOnce() + expect(acknowledge).toHaveBeenCalledWith(FID, ticket) + expect(h.quickAuthVerifier.verifyJwt).toHaveBeenCalledOnce() + expect(h.resolver.resolve).toHaveBeenCalledWith(FID) + expect(h.events).toContain('admission_grant_acknowledged') + expect(JSON.stringify(h.events)).not.toContain(ticket) + expect(response.headers.get('access-control-allow-origin')).toBe(QUICK_AUTH_ORIGIN) + expect(response.headers.has('access-control-allow-credentials')).toBe(false) + }) + + it('returns existing authority while best-effort erasing its exact notification capability', async () => { + const ticket = 'C'.repeat(43) + const acknowledge = vi.fn(async () => 'stale' as const) + const h = harness({ + epoch: 7, + admissionNotificationStore: { + applyEvent: vi.fn(async () => undefined), + queueAdmission: vi.fn(async () => 'already-sent' as const), + acknowledge, + }, + }) + + const response = await h.app.fetch(accessBearerRequest( + ADMISSION_GRANT_PATH, + { ticket }, + ), env()) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + version: 1, + status: 'already-admitted', + }) + expect(acknowledge).toHaveBeenCalledWith(FID, ticket) + expect(JSON.stringify(h.events)).not.toContain(ticket) + }) + + it('rejects malformed or identity-drifted grant capabilities before the store', async () => { + const acknowledge = vi.fn(async () => 'accepted' as const) + const h = harness({ + epoch: 0, + admissionNotificationStore: { + applyEvent: vi.fn(async () => undefined), + queueAdmission: vi.fn(async () => 'queued' as const), + acknowledge, + }, + }) + + const malformed = await h.app.fetch(accessBearerRequest( + ADMISSION_GRANT_PATH, + { ticket: 'short' }, + ), env()) + const drifted = await h.app.fetch(accessBearerRequest( + ADMISSION_GRANT_PATH, + { ticket: 'B'.repeat(43) }, + { headers: { 'x-warpkeep-expected-fid': '54321' } }, + ), env()) + + expect(malformed.status).toBe(400) + expect(drifted.status).toBe(409) + expect(acknowledge).not.toHaveBeenCalled() + expect(JSON.stringify(h.events)).not.toContain('B'.repeat(43)) + }) + it('reuses exact Quick Auth and returns only neutral status projections', async () => { const getStatus = vi.fn(async () => ({ status: 'not-requested' } as const)) const submit = vi.fn(async () => ({ diff --git a/services/auth-bridge/test/rateLimit.test.ts b/services/auth-bridge/test/rateLimit.test.ts index 24d6fbe0..5204f74f 100644 --- a/services/auth-bridge/test/rateLimit.test.ts +++ b/services/auth-bridge/test/rateLimit.test.ts @@ -19,7 +19,7 @@ const LIMITS: Readonly> = { 'session-refresh': 30, 'access-request': 12, 'miniapp-webhook': 2_000, - 'admission-notification': 12, + 'admission-notification': 72, 'admin-token': 6, 'qa-challenge': 12, 'qa-snapshot': 60, diff --git a/spacetimedb/src/index.ts b/spacetimedb/src/index.ts index 2d658f21..10ff3cf7 100644 --- a/spacetimedb/src/index.ts +++ b/spacetimedb/src/index.ts @@ -15,7 +15,9 @@ export { adminSeedWorld, adminExpandGenesisWorldV3, adminAllowFid, + adminAllowFidForAccessRequestV1, adminAdmitFounderV1, + adminAdmitFounderForAccessRequestV2, adminDisableFid, adminBumpAuthEpoch, adminGetAlphaStatus, @@ -34,6 +36,7 @@ export { accessRequestGetStatusV1, accessRequestSubmitV1, adminListAccessRequestsV1, + adminGetAccessRequestAdmissionStatusV1, adminGetAccessRequestResetStatusV1, adminResetAccessRequestV1, } from './reducers/accessRequests'; diff --git a/spacetimedb/src/reducers/accessRequests.ts b/spacetimedb/src/reducers/accessRequests.ts index 55574aac..1a8c841b 100644 --- a/spacetimedb/src/reducers/accessRequests.ts +++ b/spacetimedb/src/reducers/accessRequests.ts @@ -52,6 +52,17 @@ const adminAccessRequestResetStatusV1 = t.object('AdminAccessRequestResetStatusV requestedAtMicros: t.option(t.u64()), }); +const adminAccessRequestAdmissionStatusV1 = t.object( + 'AdminAccessRequestAdmissionStatusV1', + { + admissionState: t.string(), + authEpoch: t.u32(), + requestState: t.string(), + requestCycle: t.option(t.u64()), + requestedAtMicros: t.option(t.u64()), + }, +); + type AdmissionState = AuthResolverAdmission['state']; function resolveAdmissionState( @@ -162,6 +173,64 @@ function adminResetStatus( }; } +/** + * Exact read-only admission/request view used by Hermes to prepare a request- + * CAS transition. The module does not observe notification delivery or opens. + * Missing identities use epoch and cycle zero; existing identities retain their + * private auth epoch. Impossible future cycles fail closed. + */ +function adminAdmissionStatus( + tx: Parameters[0], + fid: bigint, +) { + const allowed = tx.db.allowedFid.fid.find(fid); + const admissionState = resolveAdmissionState(allowed); + let authEpoch = 0; + if (allowed !== null) { + if ( + !Number.isInteger(allowed.authEpoch) + || allowed.authEpoch < 1 + || allowed.authEpoch > MAX_AUTH_EPOCH + ) { + throw new SenderError('ACCESS_REQUEST_STATE_INTEGRITY'); + } + authEpoch = allowed.authEpoch; + assertGenesisFounderForFid(tx, fid); + assertGenesisResourceForFid(tx, fid); + } + + const request = tx.db.accessRequestV1.fid.find(fid); + const currentRequestCycle = requestCycleForAdmission(allowed, admissionState); + const maximumStoredRequestCycle = admissionState === 'disabled' + ? BigInt(authEpoch) + 1n + : BigInt(authEpoch); + if ( + request !== null + && ( + (allowed === null && request.requestCycle !== 0n) + || ( + allowed !== null + && request.requestCycle > maximumStoredRequestCycle + ) + ) + ) { + throw new SenderError('ACCESS_REQUEST_STATE_INTEGRITY'); + } + const requestState = request === null + ? 'not_requested' + : currentRequestCycle !== undefined + && request.requestCycle === currentRequestCycle + ? 'pending' + : 'resolved'; + return { + admissionState, + authEpoch, + requestState, + requestCycle: request?.requestCycle, + requestedAtMicros: request === null ? undefined : requestedAtMicros(request), + }; +} + /** * Caller-private status. The sole FID comes from the bridge-issued resolver * token; there is deliberately no browser-controlled FID argument. @@ -349,6 +418,24 @@ export const adminListAccessRequestsV1 = warpkeep.procedure( }), ); +/** + * Admin-only exact state for one request-CAS admission decision used by the + * notification-gated Hermes operator. The module itself does not observe the + * notification. The product contains no profile, note, token, or external + * identity. + */ +export const adminGetAccessRequestAdmissionStatusV1 = warpkeep.procedure( + { name: 'admin_get_access_request_admission_status_v1' }, + { fid: t.u64() }, + adminAccessRequestAdmissionStatusV1, + (ctx, { fid }) => + ctx.withTx(tx => { + requireAdmin(tx); + requireSupportedFid(fid); + return adminAdmissionStatus(tx, fid); + }), +); + /** * Exact admin-private pre/post view for the bounded founder reset operator. * It returns only authority state and the request tuple needed for CAS; no diff --git a/spacetimedb/src/reducers/admin.ts b/spacetimedb/src/reducers/admin.ts index 1023b929..a03596aa 100644 --- a/spacetimedb/src/reducers/admin.ts +++ b/spacetimedb/src/reducers/admin.ts @@ -110,7 +110,9 @@ function applyAllowedFidTransition( fid: bigint; note: string; adminSubject: string; - auditAction: 'allow_fid' | 'admit_founder_v1'; + auditAction: 'allow_fid' | 'admit_founder_v1' + | 'allow_fid_for_access_request_v1' + | 'admit_founder_for_access_request_v2'; }>, ): void { const existing = ctx.db.allowedFid.fid.find(input.fid); @@ -161,6 +163,33 @@ function applyAllowedFidTransition( } } +/** + * Exact access-request compare-and-swap guard shared by the request-CAS + * admission reducers used by notification-gated Hermes. The module does not + * observe a notification; it enforces only the authoritative admission kind + * and database-derived request tuple. This helper performs no writes. + */ +function requireExactAccessRequest( + ctx: Parameters[0], + fid: bigint, + expectedRequestCycle: bigint, + expectedRequestedAtMicros: bigint, + requiredRequestCycle: bigint, +): void { + const request = ctx.db.accessRequestV1.fid.find(fid); + const storedRequestedAtMicros = request?.requestedAt.microsSinceUnixEpoch; + if ( + expectedRequestCycle !== requiredRequestCycle + || request === null + || request.requestCycle !== expectedRequestCycle + || storedRequestedAtMicros === undefined + || storedRequestedAtMicros <= 0n + || storedRequestedAtMicros !== expectedRequestedAtMicros + ) { + throw new SenderError('ACCESS_REQUEST_ADMISSION_CAS_MISMATCH'); + } +} + function assertExactGenesisDynamicGraph(ctx: Parameters[0]) { assertGenesisFoundingGraph(ctx); const resource = inspectGenesisResourceGraph(ctx); @@ -878,6 +907,121 @@ export const adminUpsertRealmProfileV1 = warpkeep.reducer( }, ); +/** + * Owner-only request-CAS re-admission used by notification-gated Hermes. This + * reducer does not observe notification delivery or acknowledgement; it + * enforces only the exact disabled admission kind and request tuple. Founder + * and resource state remain permanent. + */ +export const adminAllowFidForAccessRequestV1 = warpkeep.reducer( + { name: 'admin_allow_fid_for_access_request_v1' }, + { + fid: t.u64(), + note: t.string(), + expectedRequestCycle: t.u64(), + expectedRequestedAtMicros: t.u64(), + }, + (ctx, { + fid, + note, + expectedRequestCycle, + expectedRequestedAtMicros, + }) => { + const admin = requireAdmin(ctx); + requireSupportedFid(fid); + const cleanNote = cleanAdminNote(note); + const existing = ctx.db.allowedFid.fid.find(fid); + if (existing === null || existing.enabled) { + throw new SenderError('ACCESS_REQUEST_ADMISSION_CAS_MISMATCH'); + } + requireExactAccessRequest( + ctx, + fid, + expectedRequestCycle, + expectedRequestedAtMicros, + BigInt(existing.authEpoch) + 1n, + ); + + assertGenesisFounderForFid(ctx, fid); + assertGenesisResourceForFid(ctx, fid); + applyAllowedFidTransition(ctx, { + fid, + note: cleanNote, + adminSubject: admin.subject, + auditAction: 'allow_fid_for_access_request_v1', + }); + assertGenesisFounderForFid(ctx, fid); + assertGenesisResourceForFid(ctx, fid); + grantDailyMarkIfActive(ctx, fid); + }, +); + +/** + * Owner-only first-founding request CAS used by notification-gated Hermes. This + * reducer does not observe a notification; the absent admission row and exact + * cycle-zero request tuple are its authority preconditions. Trusted profile + * validation, atomic founding, graph assertions, and daily Marks behavior match + * admin_admit_founder_v1. + */ +export const adminAdmitFounderForAccessRequestV2 = warpkeep.reducer( + { name: 'admin_admit_founder_for_access_request_v2' }, + { + fid: t.u64(), + note: t.string(), + expectedRequestCycle: t.u64(), + expectedRequestedAtMicros: t.u64(), + canonicalUsername: t.string(), + displayName: t.option(t.string()), + pfpUrl: t.string(), + publicBio: t.option(t.string()), + profilePolicyVersion: t.string(), + }, + (ctx, input) => { + const admin = requireAdmin(ctx); + requireSupportedFid(input.fid); + const cleanNote = cleanAdminNote(input.note); + if (input.profilePolicyVersion !== FARCASTER_PROFILE_POLICY_VERSION) { + throw new SenderError('PROFILE_POLICY_MISMATCH'); + } + + let normalized; + try { + normalized = normalizeAdmissionReadyTrustedProfile(input); + } catch (error) { + if (error instanceof ProfileAuthorityPolicyError) throw new SenderError(error.code); + throw error; + } + + if (ctx.db.allowedFid.fid.find(input.fid) !== null) { + throw new SenderError('ACCESS_REQUEST_ADMISSION_CAS_MISMATCH'); + } + requireExactAccessRequest( + ctx, + input.fid, + input.expectedRequestCycle, + input.expectedRequestedAtMicros, + 0n, + ); + + applyAllowedFidTransition(ctx, { + fid: input.fid, + note: cleanNote, + adminSubject: admin.subject, + auditAction: 'admit_founder_for_access_request_v2', + }); + ensureGenesisFounder(ctx, input.fid, normalized); + const verifiedProfile = ctx.db.realmProfileV1.fid.find(input.fid); + if ( + verifiedProfile === null + || !admissionProfileIsComplete(verifiedProfile) + || !trustedProfilesEqual(verifiedProfile, normalized) + ) throw new SenderError('FOUNDER_PROFILE_INCOMPLETE'); + assertGenesisFounderForFid(ctx, input.fid); + assertGenesisResourceForFid(ctx, input.fid); + grantDailyMarkIfActive(ctx, input.fid); + }, +); + /** Burn and wallet-attribution mutation wires were retired in Alpha 0.3.33. */ export const adminDisableFid = warpkeep.reducer( { name: 'admin_disable_fid' }, diff --git a/spacetimedb/src/schema.ts b/spacetimedb/src/schema.ts index dc82a505..e489e541 100644 --- a/spacetimedb/src/schema.ts +++ b/spacetimedb/src/schema.ts @@ -1396,6 +1396,8 @@ for (const name of [ 'admin_get_alpha_status_v2', 'admin_get_alpha_status_v3', 'admin_admit_founder_v1', + 'admin_admit_founder_for_access_request_v2', + 'admin_allow_fid_for_access_request_v1', 'admin_upsert_realm_profile_v1', 'accept_alpha_terms_v1', 'get_my_resource_state_v1', @@ -1440,6 +1442,7 @@ for (const name of [ 'access_request_get_status_v1', 'access_request_submit_v1', 'admin_list_access_requests_v1', + 'admin_get_access_request_admission_status_v1', 'admin_get_access_request_reset_status_v1', 'admin_reset_access_request_v1', 'admin_get_daily_marks_status_v1', diff --git a/spacetimedb/tests/accessRequestAdmissionReducers.test.ts b/spacetimedb/tests/accessRequestAdmissionReducers.test.ts new file mode 100644 index 00000000..bdef96e1 --- /dev/null +++ b/spacetimedb/tests/accessRequestAdmissionReducers.test.ts @@ -0,0 +1,212 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import test from 'node:test'; + +function source(path: string): string { + return readFileSync(new URL(path, import.meta.url), 'utf8'); +} + +function section(text: string, startMarker: string, endMarker: string): string { + const start = text.indexOf(startMarker); + const end = text.indexOf(endMarker, start + startMarker.length); + assert.ok(start >= 0 && end > start, `missing source section ${startMarker}`); + return text.slice(start, end); +} + +test('request-CAS admission uses one exact read-only request tuple guard', () => { + const admin = source('../src/reducers/admin.ts'); + const guard = section( + admin, + 'function requireExactAccessRequest(', + '\n}\n\nfunction assertExactGenesisDynamicGraph', + ); + + assert.match(guard, /accessRequestV1\.fid\.find\(fid\)/); + assert.match(guard, /request\?\.requestedAt\.microsSinceUnixEpoch/); + assert.match(guard, /expectedRequestCycle !== requiredRequestCycle/); + assert.match(guard, /request\.requestCycle !== expectedRequestCycle/); + assert.match(guard, /storedRequestedAtMicros <= 0n/); + assert.match(guard, /storedRequestedAtMicros !== expectedRequestedAtMicros/); + assert.match(guard, /ACCESS_REQUEST_ADMISSION_CAS_MISMATCH/); + assert.doesNotMatch(guard, /\.(?:insert|update|delete)\s*\(/); +}); + +test('Hermes exact admission status covers missing and existing identities without mutation', () => { + const requests = source('../src/reducers/accessRequests.ts'); + const product = section( + requests, + "const adminAccessRequestAdmissionStatusV1 = t.object(", + '\n);\n\n', + ); + assert.deepEqual( + [...product.matchAll(/^\s{4}([A-Za-z][A-Za-z0-9]*):/gm)].map(match => match[1]), + [ + 'admissionState', + 'authEpoch', + 'requestState', + 'requestCycle', + 'requestedAtMicros', + ], + ); + assert.match(product, /authEpoch: t\.u32\(\)/); + assert.match(product, /requestCycle: t\.option\(t\.u64\(\)\)/); + assert.match(product, /requestedAtMicros: t\.option\(t\.u64\(\)\)/); + + const status = section( + requests, + 'function adminAdmissionStatus(', + '/**\n * Caller-private status.', + ); + assert.match(status, /const admissionState = resolveAdmissionState\(allowed\)/); + assert.match(status, /let authEpoch = 0/); + assert.match(status, /allowed\.authEpoch < 1/); + assert.match(status, /allowed\.authEpoch > MAX_AUTH_EPOCH/); + assert.match(status, /assertGenesisFounderForFid\(tx, fid\)/); + assert.match(status, /assertGenesisResourceForFid\(tx, fid\)/); + assert.match(status, /requestCycleForAdmission\(allowed, admissionState\)/); + assert.match(status, /admissionState === 'disabled'/); + assert.match(status, /BigInt\(authEpoch\) \+ 1n/); + assert.match(status, /allowed === null && request\.requestCycle !== 0n/); + assert.match(status, /request\.requestCycle > maximumStoredRequestCycle/); + assert.match(status, /requestedAtMicros\(request\)/); + assert.match(status, /'not_requested'/); + assert.match(status, /'pending'/); + assert.match(status, /'resolved'/); + assert.doesNotMatch(status, /\.(?:insert|update|delete)\s*\(/); + + const procedure = section( + requests, + 'export const adminGetAccessRequestAdmissionStatusV1', + '/**\n * Exact admin-private pre/post view', + ); + assert.match(procedure, /name: 'admin_get_access_request_admission_status_v1'/); + assert.match(procedure, /\{ fid: t\.u64\(\) \}/); + assert.match(procedure, /requireAdmin\(tx\)/); + assert.match(procedure, /requireSupportedFid\(fid\)/); + assert.match(procedure, /return adminAdmissionStatus\(tx, fid\)/); + assert.doesNotMatch(procedure, /\.(?:insert|update|delete)\s*\(/); + assert.doesNotMatch(procedure, /realmProfileV1|adminAudit|allowedFid\.iter|accessRequestV1\.iter/); +}); + +test('existing-founder request CAS requires disabled state and preserves the founder graph', () => { + const admin = source('../src/reducers/admin.ts'); + const reducer = section( + admin, + 'export const adminAllowFidForAccessRequestV1', + '/**\n * Owner-only first-founding request CAS', + ); + + assert.match(reducer, /name: 'admin_allow_fid_for_access_request_v1'/); + assert.match(reducer, /expectedRequestCycle: t\.u64\(\)/); + assert.match(reducer, /expectedRequestedAtMicros: t\.u64\(\)/); + assert.match(reducer, /const admin = requireAdmin\(ctx\)/); + assert.match(reducer, /requireSupportedFid\(fid\)/); + assert.match(reducer, /existing === null \|\| existing\.enabled/); + assert.match(reducer, /BigInt\(existing\.authEpoch\) \+ 1n/); + assert.match(reducer, /requireExactAccessRequest\(/); + assert.ok( + reducer.indexOf('requireExactAccessRequest(') + < reducer.indexOf('applyAllowedFidTransition(ctx'), + ); + assert.match(reducer, /auditAction: 'allow_fid_for_access_request_v1'/); + assert.equal( + reducer.match(/assertGenesisFounderForFid\(ctx, fid\)/g)?.length, + 2, + ); + assert.equal( + reducer.match(/assertGenesisResourceForFid\(ctx, fid\)/g)?.length, + 2, + ); + assert.match(reducer, /grantDailyMarkIfActive\(ctx, fid\)/); + assert.ok( + reducer.indexOf('applyAllowedFidTransition(ctx') + < reducer.indexOf('grantDailyMarkIfActive(ctx, fid)'), + ); + assert.doesNotMatch(reducer, /ensureGenesisFounder/); +}); + +test('first-time profiled request CAS requires missing state and cycle zero', () => { + const admin = source('../src/reducers/admin.ts'); + const reducer = section( + admin, + 'export const adminAdmitFounderForAccessRequestV2', + '/** Burn and wallet-attribution mutation wires', + ); + + assert.match(reducer, /name: 'admin_admit_founder_for_access_request_v2'/); + assert.match(reducer, /expectedRequestCycle: t\.u64\(\)/); + assert.match(reducer, /expectedRequestedAtMicros: t\.u64\(\)/); + assert.match(reducer, /canonicalUsername: t\.string\(\)/); + assert.match(reducer, /pfpUrl: t\.string\(\)/); + assert.match(reducer, /const admin = requireAdmin\(ctx\)/); + assert.match(reducer, /requireSupportedFid\(input\.fid\)/); + assert.match(reducer, /normalizeAdmissionReadyTrustedProfile\(input\)/); + assert.match(reducer, /allowedFid\.fid\.find\(input\.fid\) !== null/); + assert.match( + reducer, + /requireExactAccessRequest\([\s\S]*input\.expectedRequestCycle,[\s\S]*input\.expectedRequestedAtMicros,[\s\S]*0n/, + ); + assert.ok( + reducer.indexOf('requireExactAccessRequest(') + < reducer.indexOf('applyAllowedFidTransition(ctx'), + ); + assert.match(reducer, /auditAction: 'admit_founder_for_access_request_v2'/); + assert.match(reducer, /ensureGenesisFounder\(ctx, input\.fid, normalized\)/); + assert.match(reducer, /admissionProfileIsComplete\(verifiedProfile\)/); + assert.match(reducer, /trustedProfilesEqual\(verifiedProfile, normalized\)/); + assert.match(reducer, /assertGenesisFounderForFid\(ctx, input\.fid\)/); + assert.match(reducer, /assertGenesisResourceForFid\(ctx, input\.fid\)/); + assert.match(reducer, /grantDailyMarkIfActive\(ctx, input\.fid\)/); + assert.ok( + reducer.indexOf('ensureGenesisFounder(ctx, input.fid, normalized)') + < reducer.indexOf('grantDailyMarkIfActive(ctx, input.fid)'), + ); +}); + +test('legacy admission reducers remain separate compatibility wires', () => { + const admin = source('../src/reducers/admin.ts'); + const allow = section( + admin, + 'export const adminAllowFid =', + '/**\n * Atomic owner-only founding path.', + ); + const admit = section( + admin, + 'export const adminAdmitFounderV1 =', + '/** Trusted local-operator profile projection', + ); + + assert.match(allow, /name: 'admin_allow_fid'/); + assert.match(admit, /name: 'admin_admit_founder_v1'/); + assert.doesNotMatch(allow, /expectedRequestCycle|expectedRequestedAtMicros/); + assert.doesNotMatch(admit, /expectedRequestCycle|expectedRequestedAtMicros/); +}); + +test('CAS reducers are exported, explicitly pinned, and represented in generated bindings', () => { + const schema = source('../src/schema.ts'); + const moduleIndex = source('../src/index.ts'); + for (const [wire, sourceName] of [ + ['admin_allow_fid_for_access_request_v1', 'adminAllowFidForAccessRequestV1'], + ['admin_admit_founder_for_access_request_v2', 'adminAdmitFounderForAccessRequestV2'], + ] as const) { + assert.equal(schema.match(new RegExp(`'${wire}'`, 'g'))?.length, 1); + assert.match(moduleIndex, new RegExp(`\\b${sourceName}\\b`)); + } + + assert.equal( + existsSync(new URL('../../src/spacetime/module_bindings/admin_allow_fid_for_access_request_v_1_reducer.ts', import.meta.url)), + true, + ); + assert.equal( + existsSync(new URL('../../src/spacetime/module_bindings/admin_admit_founder_for_access_request_v_2_reducer.ts', import.meta.url)), + true, + ); + + const procedureWire = 'admin_get_access_request_admission_status_v1'; + assert.equal(schema.match(new RegExp(`'${procedureWire}'`, 'g'))?.length, 1); + assert.match(moduleIndex, /\badminGetAccessRequestAdmissionStatusV1\b/); + assert.equal( + existsSync(new URL('../../src/spacetime/module_bindings/admin_get_access_request_admission_status_v_1_procedure.ts', import.meta.url)), + true, + ); +}); diff --git a/spacetimedb/tests/foundingReducers.test.ts b/spacetimedb/tests/foundingReducers.test.ts index 47e50855..bdc1a36f 100644 --- a/spacetimedb/tests/foundingReducers.test.ts +++ b/spacetimedb/tests/foundingReducers.test.ts @@ -92,7 +92,7 @@ test('profiled admission validates before writes and atomically creates the comp test('profile maintenance accepts canonical clears without touching ownership or castle state', () => { const admin = source('../src/reducers/admin.ts'); const start = admin.indexOf('export const adminUpsertRealmProfileV1'); - const end = admin.indexOf('/** Burn and wallet-attribution mutation wires', start); + const end = admin.indexOf('/**\n * Owner-only request-CAS re-admission', start); const reducer = admin.slice(start, end); assert.match(reducer, /normalizeTrustedPublicProfile\(input\)/); diff --git a/src/components/WarpkeepExperience.tsx b/src/components/WarpkeepExperience.tsx index 7bf35cab..6e10199c 100644 --- a/src/components/WarpkeepExperience.tsx +++ b/src/components/WarpkeepExperience.tsx @@ -264,6 +264,7 @@ export function WarpkeepExperience() { state: farcasterAuthState, accessRequest, admissionCheck, + admissionGrantAcknowledgement, restoreSession: restoreFarcasterSession, beginSignIn: beginFarcasterSignIn, cancelSignIn: cancelFarcasterSignIn, @@ -1366,6 +1367,7 @@ export function WarpkeepExperience() { }: AuthRailRenderControls) => ( void; /** Presentation hint only; admission still comes from the auth/backend state. */ approvalNotificationLaunch?: boolean; + admissionGrantAcknowledgement?: AdmissionGrantAcknowledgementViewState; }>; type AdmissionPresentation = Readonly<{ @@ -109,13 +111,24 @@ export function FarcasterAdmissionPanel({ onRequestAccess, onRetryAccessRequestStatus, onSignOut, - approvalNotificationLaunch = false + approvalNotificationLaunch = false, + admissionGrantAcknowledgement = Object.freeze({ phase: 'idle' }) }: FarcasterAdmissionPanelProps) { const headingId = `farcaster-admission-heading-${useId().replace(/:/g, '')}`; const accessRequestDescriptionId = `${headingId}-access-request-description`; const localHeadingRef = useRef(null); const defaultPresentation = presentationByPhase[phase]; - const presentation = approvalNotificationLaunch + const admissionGrantActive = admissionGrantAcknowledgement.phase === 'acknowledging' + || admissionGrantAcknowledgement.phase === 'finalizing'; + const presentation = admissionGrantActive + ? Object.freeze({ + eyebrow: 'HEGEMONY FRONTIER ACCESS', + title: 'FINALIZING HEGEMONY ADMISSION', + liveMessage: admissionGrantAcknowledgement.phase === 'acknowledging' + ? 'Confirming this notification with your verified Farcaster identity…' + : 'Your notification is confirmed. Finalizing Realm access…' + }) + : approvalNotificationLaunch && phase !== 'denied' && phase !== 'error' ? phase === 'connecting' || phase === 'reconnecting' || phase === 'checking-admission' @@ -139,7 +152,8 @@ export function FarcasterAdmissionPanel({ || phase === 'checking-admission' || phase === 'bootstrapping' || phase === 'accepting-terms' - || phase === 'opening-realm'; + || phase === 'opening-realm' + || admissionGrantActive; const denied = phase === 'denied'; const awaitingTerms = phase === 'awaiting-terms'; const unavailable = phase === 'error'; @@ -201,14 +215,24 @@ export function FarcasterAdmissionPanel({ {denied ? ( <>

- {approvalNotificationLaunch + {admissionGrantActive + ? 'Keep Warpkeep open for a moment while the Hegemony completes your admission.' + : admissionGrantAcknowledgement.phase === 'confirmed-pending' + ? 'Your notification was confirmed. Realm access is still being completed; check again shortly.' + : admissionGrantAcknowledgement.phase === 'stale' + ? 'This approval invitation is no longer current. Your latest access request remains unchanged.' + : admissionGrantAcknowledgement.phase === 'temporary-error' + ? 'Warpkeep could not confirm this approval invitation. Reopen the Farcaster notification to try again safely.' + : approvalNotificationLaunch ? 'Warpkeep has not yet confirmed active admission. Check again in a moment.' : 'This Farcaster identity is not yet admitted to the Hegemony frontier.'}

- + {!admissionGrantActive ? ( + + ) : null} ) : null} @@ -242,7 +266,7 @@ export function FarcasterAdmissionPanel({ REVIEW TERMS ) : null} - {denied && onRequestAccess ? ( + {denied && !admissionGrantActive && onRequestAccess ? ( ) : null} - {denied && !accessRequestOwnsPrimaryAction(accessRequest) ? ( + {denied && !admissionGrantActive && !accessRequestOwnsPrimaryAction(accessRequest) ? ( void; onBackToMenu: () => void; onCancelTermsAttempt: () => void; @@ -359,6 +361,7 @@ export function FarcasterMiniAppEntryGate({ backendState, accessRequest = IDLE_ACCESS_REQUEST, admissionCheck = IDLE_ADMISSION_CHECK, + admissionGrantAcknowledgement, recoveryReason, onAcceptTerms, onBackToMenu, @@ -455,6 +458,7 @@ export function FarcasterMiniAppEntryGate({ unavailable() }), [mode]); const loadBridgeClient = useCallback(async () => bridgeClient, [bridgeClient]); diff --git a/src/dev/FullstackLocalQaApp.tsx b/src/dev/FullstackLocalQaApp.tsx index 014b7900..d780bbc2 100644 --- a/src/dev/FullstackLocalQaApp.tsx +++ b/src/dev/FullstackLocalQaApp.tsx @@ -782,6 +782,12 @@ function createLocalBridge( status: 'already-admitted' as const }); }, + async acknowledgeAdmissionGrant() { + return Object.freeze({ + version: 1 as const, + status: 'already-admitted' as const + }); + }, async logoutSession() { authorized = false; } diff --git a/src/farcaster/FarcasterAuthProvider.tsx b/src/farcaster/FarcasterAuthProvider.tsx index 4f01926a..ee3f445c 100644 --- a/src/farcaster/FarcasterAuthProvider.tsx +++ b/src/farcaster/FarcasterAuthProvider.tsx @@ -32,6 +32,7 @@ import type { FarcasterBrowserBindingFactory } from './farcasterAuthTypes'; import { useMiniAppHost } from './miniapp'; +import { useMiniAppAdmissionGrant } from './miniapp/MiniAppHostProvider'; export type FarcasterAuthProviderProps = Readonly<{ children: ReactNode; @@ -67,6 +68,7 @@ export function FarcasterAuthProvider({ deviceSessionEnvironment }: FarcasterAuthProviderProps) { const miniAppHost = useMiniAppHost(); + const admissionGrant = useMiniAppAdmissionGrant(); const loadQuickAuthToken: FarcasterQuickAuthTokenLoader | undefined = miniAppHost.isMiniApp ? miniAppHost.quickAuth.getToken @@ -81,6 +83,9 @@ export function FarcasterAuthProvider({ loadAuthority={loadAuthority} loadBridgeClient={loadBridgeClient} loadQuickAuthToken={loadQuickAuthToken} + admissionGrantAvailable={admissionGrant !== undefined} + readAdmissionGrantTicket={admissionGrant?.read} + onAdmissionGrantCapabilityConsumed={admissionGrant?.clear} quickAuthPresentationIdentity={ miniAppHost.isMiniApp ? miniAppHost.context?.user diff --git a/src/farcaster/FarcasterAuthProviderCore.tsx b/src/farcaster/FarcasterAuthProviderCore.tsx index 78e4f46e..c845fa10 100644 --- a/src/farcaster/FarcasterAuthProviderCore.tsx +++ b/src/farcaster/FarcasterAuthProviderCore.tsx @@ -36,6 +36,7 @@ import { } from './farcasterBrowserBinding'; import { parseFarcasterOidcJwt } from './farcasterOidcSession'; import { useAccessRequest } from './useAccessRequest'; +import { useAdmissionGrantAcknowledgement } from './useAdmissionGrantAcknowledgement'; import { clearFarcasterPresentationSession, persistFarcasterPresentationSession, @@ -44,6 +45,7 @@ import { import type { FarcasterAuthError, FarcasterAuthEntryStage, + AdmissionGrantAcknowledgementViewState, AccessRequestViewState, FarcasterAdmissionCheckViewState, FarcasterAuthContext, @@ -81,6 +83,14 @@ export type FarcasterBridgeFailureClassifier = ( const NO_BRIDGE_FAILURE_CLASSIFICATION: FarcasterBridgeFailureClassifier = () => null; const MANUAL_ADMISSION_CHECK_MINIMUM_MS = 300; +const ADMISSION_FINALIZATION_MAXIMUM_MS = 10 * 60 * 1_000; +const ADMISSION_FINALIZATION_POLL_DELAYS_MS = Object.freeze([ + 0, + 2_000, + 5_000, + 10_000 +] as const); +const ADMISSION_FINALIZATION_STEADY_POLL_MS = 30_000; type AuthorityRefreshOutcome = | Readonly<{ status: 'authorized'; fid: number }> @@ -146,6 +156,11 @@ export type FarcasterAuthProviderCoreProps = Readonly<{ loadQuickAuthToken?: FarcasterQuickAuthTokenLoader; /** Untrusted host presentation fields; retained only for a bridge-verified same FID. */ quickAuthPresentationIdentity?: FarcasterRelayDisplayIdentity; + /** Capability presence only; raw material remains outside React state/props. */ + admissionGrantAvailable?: boolean; + /** Reads raw capability material only into the acknowledgement effect stack. */ + readAdmissionGrantTicket?: () => string | undefined; + onAdmissionGrantCapabilityConsumed?: (expectedTicket: string) => void; normalizeAuthError: FarcasterAuthErrorNormalizer; /** Concrete transport details stay outside the full-stack auth core. */ classifyBridgeFailure?: FarcasterBridgeFailureClassifier; @@ -164,6 +179,7 @@ export type FarcasterAuthControllerValue = Readonly<{ state: FarcasterAuthViewState; accessRequest: AccessRequestViewState; admissionCheck: FarcasterAdmissionCheckViewState; + admissionGrantAcknowledgement: AdmissionGrantAcknowledgementViewState; /** Bearer material is intentionally separate from presentation state. */ oidcSession: FarcasterOidcSession | undefined; /** @@ -1233,6 +1249,9 @@ export function FarcasterAuthProviderCore({ loadBridgeClient, loadQuickAuthToken, quickAuthPresentationIdentity, + admissionGrantAvailable = false, + readAdmissionGrantTicket, + onAdmissionGrantCapabilityConsumed, normalizeAuthError, classifyBridgeFailure = NO_BRIDGE_FAILURE_CLASSIFICATION, resolveAuthContext = getBrowserFarcasterAuthContext, @@ -1263,6 +1282,7 @@ export function FarcasterAuthProviderCore({ const admissionCheckLockedRef = useRef(false); const admissionCheckGenerationRef = useRef(0); const admissionCheckFidRef = useRef(undefined); + const accessRequestReconciliationKeyRef = useRef(undefined); const admissionCheckMinimumRef = useRef void; settle: () => void; @@ -2133,6 +2153,76 @@ export function FarcasterAuthProviderCore({ } }); + useEffect(() => { + const view = machineRef.current.view; + const reconciliationKey = view.phase === 'pending-admission' + && admissionCheck.phase === 'still-pending' + ? `${view.identity.fid}:${admissionCheck.checkedAt}` + : undefined; + if ( + reconciliationKey === undefined + || accessRequestReconciliationKeyRef.current === reconciliationKey + ) return; + accessRequestReconciliationKeyRef.current = reconciliationKey; + // CHECK ADMISSION is also the deliberate server-authoritative boundary + // that reconciles an application removed by an owner reset. The request + // controller itself decides whether its current phase is eligible. + accessRequest.retryStatus(); + }, [accessRequest, admissionCheck]); + + const admissionGrantAcknowledgement = useAdmissionGrantAcknowledgement({ + available: admissionGrantAvailable, + readTicket: readAdmissionGrantTicket, + authState: machine.view, + authGeneration: machine.generation, + loadBridgeClient, + loadQuickAuthToken, + onCapabilityConsumed: onAdmissionGrantCapabilityConsumed + }); + + useEffect(() => { + if ( + admissionGrantAcknowledgement.phase !== 'finalizing' + || machine.view.phase !== 'pending-admission' + ) return undefined; + let cancelled = false; + let timer: ReturnType | undefined; + const startedAt = Date.now(); + let completedPolls = 0; + const nextDelay = () => ( + ADMISSION_FINALIZATION_POLL_DELAYS_MS[completedPolls] + ?? ADMISSION_FINALIZATION_STEADY_POLL_MS + ); + const poll = async () => { + if ( + cancelled + || machineRef.current.view.phase !== 'pending-admission' + || Date.now() - startedAt >= ADMISSION_FINALIZATION_MAXIMUM_MS + ) return; + await refreshAuthoritySession(false, false, false); + completedPolls += 1; + if ( + cancelled + || machineRef.current.view.phase !== 'pending-admission' + || Date.now() - startedAt >= ADMISSION_FINALIZATION_MAXIMUM_MS + ) return; + const remaining = ADMISSION_FINALIZATION_MAXIMUM_MS - (Date.now() - startedAt); + timer = globalThis.setTimeout( + () => { void poll(); }, + Math.min(nextDelay(), remaining) + ); + }; + timer = globalThis.setTimeout(() => { void poll(); }, nextDelay()); + return () => { + cancelled = true; + if (timer !== undefined) globalThis.clearTimeout(timer); + }; + }, [ + admissionGrantAcknowledgement.phase, + machine.view.phase, + refreshAuthoritySession + ]); + useEffect(() => { purgeBearerStorage(); const terminationStatus = readFarcasterSessionTerminationIntent({ @@ -2313,6 +2403,7 @@ export function FarcasterAuthProviderCore({ state: machine.view, accessRequest: accessRequest.state, admissionCheck, + admissionGrantAcknowledgement, oidcSession, restoreSession, beginSignIn: beginConsentGatedSignIn, @@ -2332,6 +2423,7 @@ export function FarcasterAuthProviderCore({ controller, accessRequest, admissionCheck, + admissionGrantAcknowledgement, checkAdmission, machine.view, oidcSession, diff --git a/src/farcaster/accessRequestStateMachine.ts b/src/farcaster/accessRequestStateMachine.ts index 8e541dd9..d90ef5a7 100644 --- a/src/farcaster/accessRequestStateMachine.ts +++ b/src/farcaster/accessRequestStateMachine.ts @@ -9,6 +9,7 @@ export type AccessRequestStateEvent = type: 'status-load-started'; context: AccessRequestStatusContext; }> + | Readonly<{ type: 'status-recheck-started' }> | Readonly<{ type: 'status-available'; context: AccessRequestStatusContext; @@ -125,6 +126,12 @@ export function transitionAccessRequestState( : current; case 'request-received': case 'already-requested': + // A user-triggered admission check may discover that an owner reset the + // current application cycle. Only this explicit authoritative recheck + // can leave a confirmed presentation; stale status/error events cannot. + return event.type === 'status-recheck-started' + ? Object.freeze({ phase: 'loading-status', context: 'initial' }) + : current; case 'already-admitted': return current; } diff --git a/src/farcaster/farcasterAuthTypes.ts b/src/farcaster/farcasterAuthTypes.ts index 208c23da..4a74a118 100644 --- a/src/farcaster/farcasterAuthTypes.ts +++ b/src/farcaster/farcasterAuthTypes.ts @@ -389,6 +389,11 @@ export type FarcasterAccessRequestOptions = FarcasterBridgeRequestOptions & Read expectedFid: number; }>; +export type FarcasterAdmissionGrantOptions = FarcasterAccessRequestOptions & Readonly<{ + /** Opaque one-use capability captured from the notification target fragment. */ + ticket: string; +}>; + /** * Private per-request authentication for the access-request bridge routes. * Quick Auth material exists only in the controller call stack; presentation @@ -405,6 +410,20 @@ export type AccessRequestStatus = export type AccessRequestStatusContext = 'initial' | 'post-submission'; +export type AdmissionGrantAcknowledgementStatus = + | Readonly<{ version: 1; status: 'accepted' }> + | Readonly<{ version: 1; status: 'not-ready' }> + | Readonly<{ version: 1; status: 'stale' }> + | Readonly<{ version: 1; status: 'already-admitted' }>; + +export type AdmissionGrantAcknowledgementViewState = + | Readonly<{ phase: 'idle' }> + | Readonly<{ phase: 'acknowledging' }> + | Readonly<{ phase: 'finalizing' }> + | Readonly<{ phase: 'confirmed-pending' }> + | Readonly<{ phase: 'stale' }> + | Readonly<{ phase: 'temporary-error' }>; + /** * Bounded, monotonic state safe for React presentation. It cannot represent * credentials, FIDs, private request rows, or server application-cycle data. @@ -469,6 +488,10 @@ export interface FarcasterOidcBridgeClient { authentication: AccessRequestAuthentication, options: FarcasterAccessRequestOptions ): Promise; + acknowledgeAdmissionGrant( + authentication: AccessRequestAuthentication, + options: FarcasterAdmissionGrantOptions + ): Promise; logoutSession(options?: FarcasterBridgeRequestOptions): Promise; } diff --git a/src/farcaster/farcasterOidcBridgeClient.ts b/src/farcaster/farcasterOidcBridgeClient.ts index bacb2c33..a08bfa31 100644 --- a/src/farcaster/farcasterOidcBridgeClient.ts +++ b/src/farcaster/farcasterOidcBridgeClient.ts @@ -1,6 +1,8 @@ import { + type AdmissionGrantAcknowledgementStatus, type AccessRequestAuthentication, type AccessRequestStatus, + type FarcasterAdmissionGrantOptions, type FarcasterAccessRequestOptions, isBoundedFarcasterSignature, type FarcasterBridgeChallenge, @@ -39,6 +41,7 @@ const NONCE_PATTERN = /^[A-Za-z0-9]{8,128}$/; const REQUEST_ID_PATTERN = /^[A-Za-z0-9._~-]{8,256}$/; const COMPACT_JWT_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; +const ADMISSION_GRANT_TICKET_PATTERN = /^[A-Za-z0-9_-]{43}$/; const RETRYABLE_EXCHANGE_ERROR_CODES = new Set([ 'challenge_unavailable', 'binding_verification_unavailable', @@ -448,6 +451,23 @@ function readAccessRequestStatus(value: unknown): AccessRequestStatus | undefine }); } +function readAdmissionGrantAcknowledgement( + value: unknown +): AdmissionGrantAcknowledgementStatus | undefined { + if ( + !isRecord(value) + || value.version !== 1 + || !hasOnlyAllowedKeys(value, ['version', 'status']) + || ( + value.status !== 'accepted' + && value.status !== 'not-ready' + && value.status !== 'stale' + && value.status !== 'already-admitted' + ) + ) return undefined; + return Object.freeze({ version: 1, status: value.status }); +} + function readSafeQuickAuthSessionResponse( value: unknown, issuer: string, @@ -856,6 +876,7 @@ export function createFarcasterOidcBridgeClient( const logoutUrl = new URL('v2/session/logout', bridgeUrl); const accessStatusUrl = new URL('v2/access/status', bridgeUrl); const accessRequestUrl = new URL('v2/access/request', bridgeUrl); + const admissionGrantUrl = new URL('v2/access/admission-grant', bridgeUrl); return Object.freeze({ issuer, @@ -1065,6 +1086,34 @@ export function createFarcasterOidcBridgeClient( return status; }, + async acknowledgeAdmissionGrant( + authentication: AccessRequestAuthentication, + requestOptions: FarcasterAdmissionGrantOptions + ) { + const requestSecurity = readAccessRequestSecurity( + authentication, + requestOptions?.expectedFid + ); + if ( + !requestSecurity + || typeof requestOptions?.ticket !== 'string' + || !ADMISSION_GRANT_TICKET_PATTERN.test(requestOptions.ticket) + ) throw new FarcasterOidcBridgeClientError(); + const result = await postJson( + fetchImplementation, + admissionGrantUrl, + { ticket: requestOptions.ticket }, + requestOptions.signal, + BRIDGE_REQUEST_TIMEOUT_MS, + undefined, + requestSecurity, + ACCESS_STATUS_IDENTITY_CHANGED_RESPONSES + ); + const status = readAdmissionGrantAcknowledgement(result); + if (!status) throw new FarcasterOidcBridgeClientError(); + return status; + }, + async logoutSession(requestOptions?: FarcasterBridgeRequestOptions) { await postNoContent(fetchImplementation, logoutUrl, {}, requestOptions?.signal); } diff --git a/src/farcaster/miniapp/MiniAppHostProvider.tsx b/src/farcaster/miniapp/MiniAppHostProvider.tsx index b4b138b6..1df75250 100644 --- a/src/farcaster/miniapp/MiniAppHostProvider.tsx +++ b/src/farcaster/miniapp/MiniAppHostProvider.tsx @@ -10,6 +10,8 @@ import { } from 'react'; import { + captureMiniAppAdmissionGrantTicket, + clearMiniAppAdmissionGrantTicket, DEFAULT_MINI_APP_BROWSER_RUNTIME, defaultMiniAppSdkLoader, hasExactMiniAppHint, @@ -108,6 +110,14 @@ export type MiniAppHostQuickAuth = Readonly<{ ) => Promise; }>; +/** Private, memory-only capability delivered through a Farcaster notification. */ +export type MiniAppAdmissionGrant = Readonly<{ + /** Reads the capability into one private effect call stack, never React state. */ + read: () => string | undefined; + /** Clears only the exact capability that the effect actually exchanged. */ + clear: (expectedTicket: string) => void; +}>; + export type MiniAppHostValue = Readonly<{ state: MiniAppHostState; isMiniApp: boolean; @@ -556,6 +566,9 @@ const MISSING_PROVIDER_VALUE: MiniAppHostValue = Object.freeze({ const MiniAppHostContext = createContext( MISSING_PROVIDER_VALUE ); +const MiniAppAdmissionGrantContext = createContext< + MiniAppAdmissionGrant | undefined +>(undefined); export function MiniAppHostProvider({ children, @@ -573,6 +586,9 @@ export function MiniAppHostProvider({ quickAuthDeadlineMilliseconds ); const miniAppHinted = isMiniAppHinted(runtime); + const [hasAdmissionGrant, setHasAdmissionGrant] = useState( + () => miniAppHinted && captureMiniAppAdmissionGrantTicket(runtime) !== undefined + ); let isFramed = false; try { isFramed = runtime.isFramed?.() === true; @@ -583,6 +599,8 @@ export function MiniAppHostProvider({ const [snapshot, setSnapshot] = useState( miniAppHinted ? DETECTING_SNAPSHOT : REGULAR_WEB_SNAPSHOT ); + const [admissionGrantConsumed, setAdmissionGrantConsumed] = useState(false); + const [admissionGrantRevision, setAdmissionGrantRevision] = useState(0); const snapshotRef = useRef(snapshot); snapshotRef.current = snapshot; const [attemptGeneration, setAttemptGeneration] = useState(0); @@ -1399,6 +1417,35 @@ export function MiniAppHostProvider({ return Object.freeze({ getToken }); }, [quickAuthDeadline]); + useEffect(() => { + if (!miniAppHinted || !runtime.subscribeNavigationChange) return undefined; + const reconcileNavigation = () => { + if (captureMiniAppAdmissionGrantTicket(runtime, true) === undefined) return; + setHasAdmissionGrant(true); + setAdmissionGrantConsumed(false); + setAdmissionGrantRevision(revision => revision + 1); + }; + const unsubscribe = runtime.subscribeNavigationChange(reconcileNavigation); + // A same-document launch may update the fragment between render-time + // capture and passive-effect subscription. Reconcile once after the + // listener is live so that narrow window cannot lose the capability. + reconcileNavigation(); + return unsubscribe; + }, [miniAppHinted, runtime]); + + const admissionGrant = useMemo(() => { + void admissionGrantRevision; + if (!hasAdmissionGrant || admissionGrantConsumed) return undefined; + return Object.freeze({ + read: () => captureMiniAppAdmissionGrantTicket(runtime), + clear: (expectedTicket: string) => { + if (clearMiniAppAdmissionGrantTicket(runtime.document, expectedTicket)) { + setAdmissionGrantConsumed(true); + } + } + }); + }, [admissionGrantConsumed, admissionGrantRevision, hasAdmissionGrant, runtime]); + const value = useMemo(() => Object.freeze({ state: snapshot.state, isMiniApp: snapshot.state === 'miniapp', @@ -1425,9 +1472,11 @@ export function MiniAppHostProvider({ ]); return ( - - {children} - + + + {children} + + ); } @@ -1435,6 +1484,11 @@ export function useMiniAppHost(): MiniAppHostValue { return useContext(MiniAppHostContext); } +/** Auth-only capability channel; intentionally absent from the broad host API. */ +export function useMiniAppAdmissionGrant(): MiniAppAdmissionGrant | undefined { + return useContext(MiniAppAdmissionGrantContext); +} + export function useMiniAppBackNavigation( depth: number, onBack: () => unknown diff --git a/src/farcaster/miniapp/miniAppRuntime.ts b/src/farcaster/miniapp/miniAppRuntime.ts index abae0320..12b91da6 100644 --- a/src/farcaster/miniapp/miniAppRuntime.ts +++ b/src/farcaster/miniapp/miniAppRuntime.ts @@ -129,6 +129,9 @@ export type MiniAppSdkLoader = () => Promise; export type MiniAppBrowserRuntime = Readonly<{ search: () => string; + hash?: () => string; + replaceHash?: (hash: string) => void; + subscribeNavigationChange?: (listener: () => void) => () => void; isFramed?: () => boolean; viewport: () => Readonly<{ width: number; height: number }>; subscribeViewportChange?: (listener: () => void) => () => void; @@ -159,7 +162,10 @@ 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|v2-r)[1-9]\d*$/; + /^(?:warpkeep-access-approved-(?:v1-e|v2-r)[1-9]\d*|warpkeep-access-grant-v3-i[A-Za-z0-9_-]{22})$/; +const ADMISSION_GRANT_FRAGMENT_PATTERN = + /^#warpkeep-grant-v1=([A-Za-z0-9_-]{43})$/; +const ADMISSION_GRANT_TICKETS = new WeakMap(); function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); @@ -259,6 +265,51 @@ function sanitizedApprovalNotificationId( } } +/** + * Capture the one-use admission grant capability before hash routing can + * normalize the Mini App entry URL. The value remains process-memory-only; + * it is never copied into history state or browser storage. + */ +export function captureMiniAppAdmissionGrantTicket( + runtime: MiniAppBrowserRuntime, + currentLocationOnly = false +): string | undefined { + let hash: string; + try { + hash = runtime.hash?.() ?? ''; + } catch { + return currentLocationOnly + ? undefined + : ADMISSION_GRANT_TICKETS.get(runtime.document); + } + const match = ADMISSION_GRANT_FRAGMENT_PATTERN.exec(hash); + if (match) { + const ticket = match[1]; + // A reused mobile WebView can receive a later notification without a new + // Document. A valid current fragment atomically supersedes retained state. + ADMISSION_GRANT_TICKETS.set(runtime.document, ticket); + try { + runtime.replaceHash?.('#menu'); + } catch { + // The capability is already retained in private memory. Failure to tidy a + // presentation-only hash must not duplicate or discard it. + } + return ticket; + } + return currentLocationOnly + ? undefined + : ADMISSION_GRANT_TICKETS.get(runtime.document); +} + +export function clearMiniAppAdmissionGrantTicket( + documentValue: Document, + expectedTicket: string +): boolean { + if (ADMISSION_GRANT_TICKETS.get(documentValue) !== expectedTicket) return false; + ADMISSION_GRANT_TICKETS.delete(documentValue); + return true; +} + function finiteAxis(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value @@ -582,9 +633,29 @@ function subscribeDefaultViewportChange(listener: () => void): () => void { }; } +function subscribeDefaultNavigationChange(listener: () => void): () => void { + window.addEventListener('hashchange', listener); + window.addEventListener('popstate', listener); + window.addEventListener('pageshow', listener); + return () => { + window.removeEventListener('hashchange', listener); + window.removeEventListener('popstate', listener); + window.removeEventListener('pageshow', listener); + }; +} + export const DEFAULT_MINI_APP_BROWSER_RUNTIME: MiniAppBrowserRuntime = Object.freeze({ search: () => window.location.search, + hash: () => window.location.hash, + replaceHash: (hash: string) => { + window.history.replaceState( + window.history.state, + '', + `${window.location.pathname}${window.location.search}${hash}` + ); + }, + subscribeNavigationChange: subscribeDefaultNavigationChange, isFramed: () => { try { return window.self !== window.top; diff --git a/src/farcaster/useAccessRequest.ts b/src/farcaster/useAccessRequest.ts index 0312f21f..a2c61de4 100644 --- a/src/farcaster/useAccessRequest.ts +++ b/src/farcaster/useAccessRequest.ts @@ -121,7 +121,7 @@ async function waitForMinimumPresentation( * Run one bridge operation with an authentication value that exists only in * this call stack. Quick Auth material never enters React state or context. */ -async function withAccessAuthentication( +export async function withAccessAuthentication( loadQuickAuthToken: (( options?: FarcasterQuickAuthTokenOptions ) => Promise) | undefined, @@ -375,6 +375,13 @@ export function useAccessRequest({ diagnose('request_status_unavailable'); return; } + if (operation.kind === 'manual-status') { + // A successful user-triggered read proves that no request is currently + // on record. Release the old lifecycle lock only at this authority + // boundary so an intentional owner reset can be submitted once again. + submissionLockRef.current = undefined; + duplicateDiagnosticKeyRef.current = undefined; + } applyEvent(operation, { type: 'status-available', context }); }, [applyAuthoritativeRequestConfirmation, applyEvent, diagnose]); @@ -386,7 +393,17 @@ export function useAccessRequest({ ): AccessRequestOperation | undefined => { if (currentLifecycleKeyRef.current !== exactLifecycleKey) return undefined; const operation = beginOperation(exactLifecycleKey, kind); - const next = applyEvent(operation, { type: 'status-load-started', context }); + const current = stateLifecycleKeyRef.current === exactLifecycleKey + ? stateRef.current + : IDLE_ACCESS_REQUEST_STATE; + const next = applyEvent(operation, + kind === 'manual-status' + && ( + current.phase === 'request-received' + || current.phase === 'already-requested' + ) + ? { type: 'status-recheck-started' } + : { type: 'status-load-started', context }); if (next?.phase !== 'loading-status') { finishOperation(operation); operation.controller.abort(); @@ -470,17 +487,22 @@ export function useAccessRequest({ const retryStatus = useCallback(() => { const exactLifecycleKey = lifecycleKey; + const current = stateRef.current; if ( !exactLifecycleKey || currentLifecycleKeyRef.current !== exactLifecycleKey || stateLifecycleKeyRef.current !== exactLifecycleKey || pendingFid === undefined - || stateRef.current.phase !== 'status-unavailable' + || ( + current.phase !== 'status-unavailable' + && current.phase !== 'request-received' + && current.phase !== 'already-requested' + ) ) return; startStatusRead( exactLifecycleKey, pendingFid, - stateRef.current.context, + current.phase === 'status-unavailable' ? current.context : 'initial', 'manual-status' ); }, [lifecycleKey, pendingFid, startStatusRead]); diff --git a/src/farcaster/useAdmissionGrantAcknowledgement.ts b/src/farcaster/useAdmissionGrantAcknowledgement.ts new file mode 100644 index 00000000..eda1bf36 --- /dev/null +++ b/src/farcaster/useAdmissionGrantAcknowledgement.ts @@ -0,0 +1,201 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { + AdmissionGrantAcknowledgementViewState, + FarcasterAuthViewState, + FarcasterOidcBridgeClient, + FarcasterQuickAuthTokenOptions, + FarcasterQuickAuthTokenResult +} from './farcasterAuthTypes'; +import { withAccessAuthentication } from './useAccessRequest'; + +// Farcaster delivery permits a 15-second provider response. Keep one final +// acknowledgement attempt beyond that settlement window without approaching +// the shared 12-per-five-minute access-request envelope. +const RETRY_DELAYS_MILLISECONDS = Object.freeze([0, 1_000, 3_000, 7_000, 9_000]); +const FINALIZATION_WAIT_MILLISECONDS = 10 * 60 * 1_000; + +type AdmissionGrantAcknowledgementOptions = Readonly<{ + available: boolean; + readTicket?: () => string | undefined; + authState: FarcasterAuthViewState; + authGeneration: number; + loadBridgeClient: () => Promise; + loadQuickAuthToken?: ( + options?: FarcasterQuickAuthTokenOptions + ) => Promise; + onCapabilityConsumed?: (expectedTicket: string) => void; +}>; + +const IDLE_STATE: AdmissionGrantAcknowledgementViewState = Object.freeze({ + phase: 'idle' +}); + +function wait(milliseconds: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(false); + if (milliseconds <= 0) return Promise.resolve(true); + return new Promise((resolve) => { + let settled = false; + const finish = (value: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal.removeEventListener('abort', abort); + resolve(value); + }; + const abort = () => finish(false); + const timer = setTimeout(() => finish(true), milliseconds); + signal.addEventListener('abort', abort, { once: true }); + }); +} + +/** + * Exchanges a memory-only notification capability only after the Mini App has + * a bridge-verified pending identity. The capability never enters context, + * storage, history state, diagnostics, or presentation. + */ +export function useAdmissionGrantAcknowledgement({ + available, + readTicket, + authState, + authGeneration, + loadBridgeClient, + loadQuickAuthToken, + onCapabilityConsumed +}: AdmissionGrantAcknowledgementOptions): AdmissionGrantAcknowledgementViewState { + const [state, setState] = useState( + IDLE_STATE + ); + const stateRef = useRef(IDLE_STATE); + const operationGenerationRef = useRef(0); + const finalizationDeadlineRef = useRef(undefined); + const finalizationFidRef = useRef(undefined); + const publish = (next: AdmissionGrantAcknowledgementViewState) => { + stateRef.current = next; + setState(next); + }; + + useEffect(() => { + operationGenerationRef.current += 1; + const operationGeneration = operationGenerationRef.current; + if (!available || authState.phase !== 'pending-admission') { + if ( + authState.phase === 'pending-admission' + && stateRef.current.phase === 'finalizing' + ) { + const deadline = finalizationDeadlineRef.current; + if ( + deadline === undefined + || finalizationFidRef.current !== authState.identity.fid + ) { + finalizationDeadlineRef.current = undefined; + finalizationFidRef.current = undefined; + publish(Object.freeze({ phase: 'stale' })); + return undefined; + } + const remaining = Math.max(0, deadline - Date.now()); + const timer = globalThis.setTimeout(() => { + if ( + operationGenerationRef.current === operationGeneration + && stateRef.current.phase === 'finalizing' + ) { + finalizationDeadlineRef.current = undefined; + finalizationFidRef.current = undefined; + publish(Object.freeze({ phase: 'confirmed-pending' })); + } + }, remaining); + return () => globalThis.clearTimeout(timer); + } + finalizationDeadlineRef.current = undefined; + finalizationFidRef.current = undefined; + publish(IDLE_STATE); + return undefined; + } + + const controller = new AbortController(); + const expectedFid = authState.identity.fid; + finalizationDeadlineRef.current = undefined; + finalizationFidRef.current = undefined; + const ticket = readTicket?.(); + if (!ticket || !/^[A-Za-z0-9_-]{43}$/.test(ticket)) { + try { + onCapabilityConsumed?.(ticket ?? ''); + } catch { + // Invalid local capability material is still safe to forget. + } + publish(Object.freeze({ phase: 'stale' })); + return () => controller.abort(); + } + const isCurrent = () => ( + !controller.signal.aborted + && operationGenerationRef.current === operationGeneration + ); + publish(Object.freeze({ phase: 'acknowledging' })); + + void (async () => { + for (const delay of RETRY_DELAYS_MILLISECONDS) { + if (!await wait(delay, controller.signal) || !isCurrent()) return; + try { + const client = await loadBridgeClient(); + if (!isCurrent()) return; + const acknowledgeAdmissionGrant = client.acknowledgeAdmissionGrant; + const result = await withAccessAuthentication( + loadQuickAuthToken, + isCurrent, + authentication => acknowledgeAdmissionGrant(authentication, { + ticket, + expectedFid, + signal: controller.signal + }) + ); + if (!isCurrent()) return; + if (result.status === 'accepted' || result.status === 'already-admitted') { + finalizationFidRef.current = expectedFid; + finalizationDeadlineRef.current = Date.now() + FINALIZATION_WAIT_MILLISECONDS; + try { + onCapabilityConsumed?.(ticket); + } catch { + // Capability cleanup cannot change the server acknowledgement. + } + publish(Object.freeze({ phase: 'finalizing' })); + return; + } + if (result.status === 'stale') { + finalizationDeadlineRef.current = undefined; + finalizationFidRef.current = undefined; + try { + onCapabilityConsumed?.(ticket); + } catch { + // A stale capability is still safe to forget locally. + } + publish(Object.freeze({ phase: 'stale' })); + return; + } + // A provider receipt may still be settling into the Durable Object + // immediately after a fast notification launch. Retry only within + // this small bounded schedule; never resubmit the access request. + } catch { + // Quick Auth, network, and bridge availability share the same bounded + // retry envelope. No mutation is repeated after an accepted reply. + } + } + if (isCurrent()) { + finalizationDeadlineRef.current = undefined; + finalizationFidRef.current = undefined; + publish(Object.freeze({ phase: 'temporary-error' })); + } + })(); + + return () => controller.abort(); + }, [ + authGeneration, + authState, + loadBridgeClient, + loadQuickAuthToken, + onCapabilityConsumed, + available, + readTicket + ]); + + return state; +} diff --git a/src/spacetime/module_bindings/admin_admit_founder_for_access_request_v_2_reducer.ts b/src/spacetime/module_bindings/admin_admit_founder_for_access_request_v_2_reducer.ts new file mode 100644 index 00000000..60e76359 --- /dev/null +++ b/src/spacetime/module_bindings/admin_admit_founder_for_access_request_v_2_reducer.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + fid: __t.u64(), + note: __t.string(), + expectedRequestCycle: __t.u64(), + expectedRequestedAtMicros: __t.u64(), + canonicalUsername: __t.string(), + displayName: __t.option(__t.string()), + pfpUrl: __t.string(), + publicBio: __t.option(__t.string()), + profilePolicyVersion: __t.string(), +}; diff --git a/src/spacetime/module_bindings/admin_allow_fid_for_access_request_v_1_reducer.ts b/src/spacetime/module_bindings/admin_allow_fid_for_access_request_v_1_reducer.ts new file mode 100644 index 00000000..099cfc42 --- /dev/null +++ b/src/spacetime/module_bindings/admin_allow_fid_for_access_request_v_1_reducer.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + fid: __t.u64(), + note: __t.string(), + expectedRequestCycle: __t.u64(), + expectedRequestedAtMicros: __t.u64(), +}; diff --git a/src/spacetime/module_bindings/admin_get_access_request_admission_status_v_1_procedure.ts b/src/spacetime/module_bindings/admin_get_access_request_admission_status_v_1_procedure.ts new file mode 100644 index 00000000..3ffa3dfe --- /dev/null +++ b/src/spacetime/module_bindings/admin_get_access_request_admission_status_v_1_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AdminAccessRequestAdmissionStatusV1, +} from "./types"; + +export const params = { + fid: __t.u64(), +}; +export const returnType = AdminAccessRequestAdmissionStatusV1 \ No newline at end of file diff --git a/src/spacetime/module_bindings/index.ts b/src/spacetime/module_bindings/index.ts index 3d7f3b7a..b1120e02 100644 --- a/src/spacetime/module_bindings/index.ts +++ b/src/spacetime/module_bindings/index.ts @@ -39,8 +39,10 @@ import AdminActivateDailyMarksV1Reducer from "./admin_activate_daily_marks_v_1_r import AdminActivateGenesisWaterLayoutV1Reducer from "./admin_activate_genesis_water_layout_v_1_reducer"; import AdminActivateGenesisWaterRevisionV1Reducer from "./admin_activate_genesis_water_revision_v_1_reducer"; import AdminActivateWorkerSystemV1Reducer from "./admin_activate_worker_system_v_1_reducer"; +import AdminAdmitFounderForAccessRequestV2Reducer from "./admin_admit_founder_for_access_request_v_2_reducer"; import AdminAdmitFounderV1Reducer from "./admin_admit_founder_v_1_reducer"; import AdminAllowFidReducer from "./admin_allow_fid_reducer"; +import AdminAllowFidForAccessRequestV1Reducer from "./admin_allow_fid_for_access_request_v_1_reducer"; import AdminBackfillDailyMarkAccountsV1Reducer from "./admin_backfill_daily_mark_accounts_v_1_reducer"; import AdminBackfillResourceAccountsV1Reducer from "./admin_backfill_resource_accounts_v_1_reducer"; import AdminBackfillWorkerRosterV1Reducer from "./admin_backfill_worker_roster_v_1_reducer"; @@ -80,6 +82,7 @@ import ReturnLegacyExpeditionV1Reducer from "./return_legacy_expedition_v_1_redu // Import all procedure arg schemas import * as AccessRequestGetStatusV1Procedure from "./access_request_get_status_v_1_procedure"; import * as AccessRequestSubmitV1Procedure from "./access_request_submit_v_1_procedure"; +import * as AdminGetAccessRequestAdmissionStatusV1Procedure from "./admin_get_access_request_admission_status_v_1_procedure"; import * as AdminGetAccessRequestResetStatusV1Procedure from "./admin_get_access_request_reset_status_v_1_procedure"; import * as AdminGetAlphaStatusProcedure from "./admin_get_alpha_status_procedure"; import * as AdminGetAlphaStatusV2Procedure from "./admin_get_alpha_status_v_2_procedure"; @@ -577,8 +580,10 @@ const reducersSchema = __reducers( __reducerSchema("admin_activate_genesis_water_layout_v1", AdminActivateGenesisWaterLayoutV1Reducer), __reducerSchema("admin_activate_genesis_water_revision_v1", AdminActivateGenesisWaterRevisionV1Reducer), __reducerSchema("admin_activate_worker_system_v1", AdminActivateWorkerSystemV1Reducer), + __reducerSchema("admin_admit_founder_for_access_request_v2", AdminAdmitFounderForAccessRequestV2Reducer), __reducerSchema("admin_admit_founder_v1", AdminAdmitFounderV1Reducer), __reducerSchema("admin_allow_fid", AdminAllowFidReducer), + __reducerSchema("admin_allow_fid_for_access_request_v1", AdminAllowFidForAccessRequestV1Reducer), __reducerSchema("admin_backfill_daily_mark_accounts_v1", AdminBackfillDailyMarkAccountsV1Reducer), __reducerSchema("admin_backfill_resource_accounts_v1", AdminBackfillResourceAccountsV1Reducer), __reducerSchema("admin_backfill_worker_roster_v1", AdminBackfillWorkerRosterV1Reducer), @@ -620,6 +625,7 @@ const reducersSchema = __reducers( const proceduresSchema = __procedures( __procedureSchema("access_request_get_status_v1", AccessRequestGetStatusV1Procedure.params, AccessRequestGetStatusV1Procedure.returnType), __procedureSchema("access_request_submit_v1", AccessRequestSubmitV1Procedure.params, AccessRequestSubmitV1Procedure.returnType), + __procedureSchema("admin_get_access_request_admission_status_v1", AdminGetAccessRequestAdmissionStatusV1Procedure.params, AdminGetAccessRequestAdmissionStatusV1Procedure.returnType), __procedureSchema("admin_get_access_request_reset_status_v1", AdminGetAccessRequestResetStatusV1Procedure.params, AdminGetAccessRequestResetStatusV1Procedure.returnType), __procedureSchema("admin_get_alpha_status", AdminGetAlphaStatusProcedure.params, AdminGetAlphaStatusProcedure.returnType), __procedureSchema("admin_get_alpha_status_v2", AdminGetAlphaStatusV2Procedure.params, AdminGetAlphaStatusV2Procedure.returnType), diff --git a/src/spacetime/module_bindings/types.ts b/src/spacetime/module_bindings/types.ts index 5d9f79dc..99446666 100644 --- a/src/spacetime/module_bindings/types.ts +++ b/src/spacetime/module_bindings/types.ts @@ -23,6 +23,15 @@ export const AccessRequestV1 = __t.object("AccessRequestV1", { }); export type AccessRequestV1 = __Infer; +export const AdminAccessRequestAdmissionStatusV1 = __t.object("AdminAccessRequestAdmissionStatusV1", { + admissionState: __t.string(), + authEpoch: __t.u32(), + requestState: __t.string(), + requestCycle: __t.option(__t.u64()), + requestedAtMicros: __t.option(__t.u64()), +}); +export type AdminAccessRequestAdmissionStatusV1 = __Infer; + export const AdminAccessRequestEntryV1 = __t.object("AdminAccessRequestEntryV1", { fid: __t.u64(), requestedAtMicros: __t.u64(), diff --git a/src/spacetime/module_bindings/types/procedures.ts b/src/spacetime/module_bindings/types/procedures.ts index 3f815303..7597db0a 100644 --- a/src/spacetime/module_bindings/types/procedures.ts +++ b/src/spacetime/module_bindings/types/procedures.ts @@ -8,6 +8,7 @@ import { type Infer as __Infer } from "spacetimedb"; // Import all procedure arg schemas import * as AccessRequestGetStatusV1Procedure from "../access_request_get_status_v_1_procedure"; import * as AccessRequestSubmitV1Procedure from "../access_request_submit_v_1_procedure"; +import * as AdminGetAccessRequestAdmissionStatusV1Procedure from "../admin_get_access_request_admission_status_v_1_procedure"; import * as AdminGetAccessRequestResetStatusV1Procedure from "../admin_get_access_request_reset_status_v_1_procedure"; import * as AdminGetAlphaStatusProcedure from "../admin_get_alpha_status_procedure"; import * as AdminGetAlphaStatusV2Procedure from "../admin_get_alpha_status_v_2_procedure"; @@ -43,6 +44,8 @@ export type AccessRequestGetStatusV1Args = __Infer; export type AccessRequestSubmitV1Args = __Infer; export type AccessRequestSubmitV1Result = __Infer; +export type AdminGetAccessRequestAdmissionStatusV1Args = __Infer; +export type AdminGetAccessRequestAdmissionStatusV1Result = __Infer; export type AdminGetAccessRequestResetStatusV1Args = __Infer; export type AdminGetAccessRequestResetStatusV1Result = __Infer; export type AdminGetAlphaStatusArgs = __Infer; diff --git a/src/spacetime/module_bindings/types/reducers.ts b/src/spacetime/module_bindings/types/reducers.ts index a89e204e..f82f4582 100644 --- a/src/spacetime/module_bindings/types/reducers.ts +++ b/src/spacetime/module_bindings/types/reducers.ts @@ -11,8 +11,10 @@ import AdminActivateDailyMarksV1Reducer from "../admin_activate_daily_marks_v_1_ import AdminActivateGenesisWaterLayoutV1Reducer from "../admin_activate_genesis_water_layout_v_1_reducer"; import AdminActivateGenesisWaterRevisionV1Reducer from "../admin_activate_genesis_water_revision_v_1_reducer"; import AdminActivateWorkerSystemV1Reducer from "../admin_activate_worker_system_v_1_reducer"; +import AdminAdmitFounderForAccessRequestV2Reducer from "../admin_admit_founder_for_access_request_v_2_reducer"; import AdminAdmitFounderV1Reducer from "../admin_admit_founder_v_1_reducer"; import AdminAllowFidReducer from "../admin_allow_fid_reducer"; +import AdminAllowFidForAccessRequestV1Reducer from "../admin_allow_fid_for_access_request_v_1_reducer"; import AdminBackfillDailyMarkAccountsV1Reducer from "../admin_backfill_daily_mark_accounts_v_1_reducer"; import AdminBackfillResourceAccountsV1Reducer from "../admin_backfill_resource_accounts_v_1_reducer"; import AdminBackfillWorkerRosterV1Reducer from "../admin_backfill_worker_roster_v_1_reducer"; @@ -54,8 +56,10 @@ export type AdminActivateDailyMarksV1Params = __Infer; export type AdminActivateGenesisWaterRevisionV1Params = __Infer; export type AdminActivateWorkerSystemV1Params = __Infer; +export type AdminAdmitFounderForAccessRequestV2Params = __Infer; export type AdminAdmitFounderV1Params = __Infer; export type AdminAllowFidParams = __Infer; +export type AdminAllowFidForAccessRequestV1Params = __Infer; export type AdminBackfillDailyMarkAccountsV1Params = __Infer; export type AdminBackfillResourceAccountsV1Params = __Infer; export type AdminBackfillWorkerRosterV1Params = __Infer; diff --git a/tests/WarpkeepExperience.test.tsx b/tests/WarpkeepExperience.test.tsx index 92f6396f..2e4fc35a 100644 --- a/tests/WarpkeepExperience.test.tsx +++ b/tests/WarpkeepExperience.test.tsx @@ -89,6 +89,10 @@ function renderVerifiedMiniAppTitle(ui: ReactElement) { status: 'requested' as const, requestedAt: Date.now() })), + acknowledgeAdmissionGrant: vi.fn(async () => ({ + version: 1 as const, + status: 'stale' as const + })), logoutSession: vi.fn(async () => undefined) }; diff --git a/tests/WarpkeepExperienceRealm.test.tsx b/tests/WarpkeepExperienceRealm.test.tsx index 91d1fa0a..4ddef8f7 100644 --- a/tests/WarpkeepExperienceRealm.test.tsx +++ b/tests/WarpkeepExperienceRealm.test.tsx @@ -337,6 +337,10 @@ function createBridge( status: 'requested' as const, requestedAt: now() })), + acknowledgeAdmissionGrant: vi.fn(async () => ({ + version: 1 as const, + status: 'stale' as const + })), logoutSession: vi.fn(async () => undefined) } satisfies FarcasterOidcBridgeClient; } diff --git a/tests/accessRequestStateMachine.test.ts b/tests/accessRequestStateMachine.test.ts index 7a1a4651..574af890 100644 --- a/tests/accessRequestStateMachine.test.ts +++ b/tests/accessRequestStateMachine.test.ts @@ -52,6 +52,17 @@ describe('access-request monotonic state machine', () => { } }); + it('allows only an explicit authoritative recheck to leave a requested presentation', () => { + for (const terminal of [ + { phase: 'request-received' as const, requestedAt: 1_785_414_896_000 }, + { phase: 'already-requested' as const, requestedAt: 1_785_414_896_000 } + ]) { + expect(transitionAccessRequestState(terminal, { + type: 'status-recheck-started' + })).toEqual({ phase: 'loading-status', context: 'initial' }); + } + }); + it('keeps a missing ambiguous reconciliation unavailable rather than reopening', () => { const verifying = { phase: 'verifying-ambiguous-result' as const }; const unavailable = transitionAccessRequestState(verifying, { diff --git a/tests/activationToolingSecurity.test.ts b/tests/activationToolingSecurity.test.ts index 48f4f1da..74fd92b3 100644 --- a/tests/activationToolingSecurity.test.ts +++ b/tests/activationToolingSecurity.test.ts @@ -159,6 +159,7 @@ const AUTH_V2_QUICK_AUTH_PATH = '/v2/farcaster/quick-auth/exchange'; const AUTH_V2_ACCESS_REQUEST_PATHS = new Set([ '/v2/access/status', '/v2/access/request', + '/v2/access/admission-grant', ]); const AUTH_V2_PAUSED_PATHS = new Set([ '/v2/farcaster/challenge', @@ -4556,7 +4557,7 @@ describe('bounded auth-v2 production readiness verification', () => { fetchImpl, })).resolves.toBeUndefined(); - expect(fetchImpl).toHaveBeenCalledTimes(35); + expect(fetchImpl).toHaveBeenCalledTimes(39); for (const [input, init] of fetchImpl.mock.calls) { const url = new URL(String(input)); const headers = new Headers(init?.headers); @@ -4648,7 +4649,7 @@ describe('bounded auth-v2 production readiness verification', () => { fetchImpl, })).resolves.toBeUndefined(); - expect(fetchImpl).toHaveBeenCalledTimes(35); + expect(fetchImpl).toHaveBeenCalledTimes(39); for (const [input, init] of fetchImpl.mock.calls) { const url = new URL(String(input)); const headers = new Headers(init?.headers); diff --git a/tests/farcasterAccessRequestLifecycle.test.tsx b/tests/farcasterAccessRequestLifecycle.test.tsx index 08b1bb67..5f481c58 100644 --- a/tests/farcasterAccessRequestLifecycle.test.tsx +++ b/tests/farcasterAccessRequestLifecycle.test.tsx @@ -85,7 +85,11 @@ function bridge(overrides: Partial = {}): FarcasterOi status: 'requested' as const, requestedAt: REQUESTED_AT })), - ...overrides + ...overrides, + acknowledgeAdmissionGrant: overrides.acknowledgeAdmissionGrant ?? vi.fn(async () => ({ + version: 1 as const, + status: 'stale' as const + })) }; } @@ -102,6 +106,7 @@ type HarnessProps = Readonly<{ onAuthenticationIdentityChanged?: () => void; bridgeLoaderVersion?: number; captureRequestAccess?: (callback: () => boolean) => void; + captureRetryStatus?: (callback: () => void) => void; extra?: ReactNode; }>; @@ -116,6 +121,7 @@ function Harness({ onAuthenticationIdentityChanged, bridgeLoaderVersion = 0, captureRequestAccess, + captureRetryStatus, extra }: HarnessProps) { // The version is intentionally captured so tests can churn loader identity. @@ -135,6 +141,7 @@ function Harness({ onAuthenticationIdentityChanged }); captureRequestAccess?.(access.requestAccess); + captureRetryStatus?.(access.retryStatus); return (
{access.state.phase} @@ -356,6 +363,46 @@ describe('professional access-request lifecycle', () => { unsubscribe(); }); + it('reopens exactly once after a deliberate status recheck proves an owner reset', async () => { + const getAccessRequestStatus = vi.fn() + .mockResolvedValueOnce({ version: 1, status: 'not-requested' }) + .mockResolvedValueOnce({ version: 1, status: 'not-requested' }); + const requestAccess = vi.fn() + .mockResolvedValueOnce({ + version: 1 as const, + status: 'requested' as const, + requestedAt: REQUESTED_AT + }) + .mockResolvedValueOnce({ + version: 1 as const, + status: 'requested' as const, + requestedAt: REQUESTED_AT + 1_000 + }); + let recheckStatus = () => {}; + render( + { recheckStatus = callback; }} + client={bridge({ getAccessRequestStatus, requestAccess })} + generation={1} + /> + ); + + fireEvent.click(await screen.findByRole('button', { name: 'REQUEST ACCESS' })); + await waitFor(() => expect(screen.getByTestId('access-phase').textContent) + .toBe('request-received')); + expect(requestAccess).toHaveBeenCalledTimes(1); + + act(() => recheckStatus()); + await waitFor(() => expect(screen.getByTestId('access-phase').textContent) + .toBe('request-available')); + fireEvent.click(screen.getByRole('button', { name: 'REQUEST ACCESS' })); + await waitFor(() => expect(screen.getByTestId('access-timestamp').textContent) + .toBe(String(REQUESTED_AT + 1_000))); + expect(getAccessRequestStatus).toHaveBeenCalledTimes(2); + expect(requestAccess).toHaveBeenCalledTimes(2); + }); + it('reconciles one lost mutation response with one authoritative confirmation cue', async () => { const observedKinds: string[] = []; const unsubscribe = subscribeWarpkeepSfx(events => { diff --git a/tests/farcasterAdmissionGrantAcknowledgement.test.tsx b/tests/farcasterAdmissionGrantAcknowledgement.test.tsx new file mode 100644 index 00000000..26e57832 --- /dev/null +++ b/tests/farcasterAdmissionGrantAcknowledgement.test.tsx @@ -0,0 +1,277 @@ +import { StrictMode, useCallback } from 'react'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useAdmissionGrantAcknowledgement } from '../src/farcaster/useAdmissionGrantAcknowledgement'; +import type { + FarcasterAuthViewState, + FarcasterOidcBridgeClient, + FarcasterQuickAuthTokenResult +} from '../src/farcaster/farcasterAuthTypes'; + +const TICKET = 'A'.repeat(43); +const VERIFIED_AT = 1_785_414_896_000; + +const pending = (fid: number): FarcasterAuthViewState => Object.freeze({ + phase: 'pending-admission', + identity: Object.freeze({ + fid, + verifications: [] as const, + verifiedAt: VERIFIED_AT + }), + sessionExpiresAt: VERIFIED_AT + 60_000 +}); + +const anonymous: FarcasterAuthViewState = Object.freeze({ phase: 'anonymous' }); + +function bridge( + acknowledgeAdmissionGrant: NonNullable +): FarcasterOidcBridgeClient { + return { + issuer: 'https://auth.warpkeep.example', + audience: 'warpkeep-spacetimedb', + createChallenge: vi.fn(async () => { throw new Error('unused'); }), + exchangeCompletedSignIn: vi.fn(async () => { throw new Error('unused'); }), + refreshSession: vi.fn(async () => { throw new Error('unused'); }), + logoutSession: vi.fn(async () => undefined), + getAccessRequestStatus: vi.fn(async () => ({ + version: 1 as const, + status: 'not-requested' as const + })), + requestAccess: vi.fn(async () => ({ + version: 1 as const, + status: 'requested' as const, + requestedAt: VERIFIED_AT + })), + acknowledgeAdmissionGrant + }; +} + +type HarnessProps = Readonly<{ + ticket?: string; + authState: FarcasterAuthViewState; + generation: number; + client: FarcasterOidcBridgeClient; + loadQuickAuthToken?: () => Promise; + onCapabilityConsumed?: () => void; +}>; + +function Harness({ + ticket, + authState, + generation, + client, + loadQuickAuthToken, + onCapabilityConsumed +}: HarnessProps) { + const loadBridgeClient = useCallback(async () => client, [client]); + const readTicket = useCallback(() => ticket, [ticket]); + const state = useAdmissionGrantAcknowledgement({ + available: ticket !== undefined, + readTicket, + authState, + authGeneration: generation, + loadBridgeClient, + loadQuickAuthToken, + onCapabilityConsumed + }); + return {state.phase}; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { resolve = resolvePromise; }); + return Object.freeze({ promise, resolve }); +} + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('Farcaster admission notification grant acknowledgement', () => { + it('never exchanges a capability before a bridge-verified pending identity exists', async () => { + const acknowledge = vi.fn(async () => ({ version: 1 as const, status: 'accepted' as const })); + render( + + ); + + expect(screen.getByTestId('phase').textContent).toBe('idle'); + await Promise.resolve(); + expect(acknowledge).not.toHaveBeenCalled(); + }); + + it('uses Quick Auth, binds the verified FID, and consumes one accepted capability once', async () => { + const acknowledge = vi.fn(async () => ({ version: 1 as const, status: 'accepted' as const })); + const loadQuickAuthToken = vi.fn(async () => ({ + status: 'token' as const, + token: 'header.payload.signature' + })); + const consumed = vi.fn(); + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('phase').textContent).toBe('finalizing'); + }); + expect(loadQuickAuthToken).toHaveBeenCalledTimes(1); + expect(acknowledge).toHaveBeenCalledTimes(1); + expect(acknowledge).toHaveBeenCalledWith( + { mode: 'quick-auth', token: 'header.payload.signature' }, + expect.objectContaining({ ticket: TICKET, expectedFid: 539_854 }) + ); + expect(consumed).toHaveBeenCalledTimes(1); + }); + + it('forgets a stale capability without entering finalization', async () => { + const acknowledge = vi.fn(async () => ({ version: 1 as const, status: 'stale' as const })); + const consumed = vi.fn(); + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId('phase').textContent).toBe('stale'); + }); + expect(acknowledge).toHaveBeenCalledTimes(1); + expect(consumed).toHaveBeenCalledTimes(1); + }); + + it('retries a not-ready provider settlement inside one bounded operation', async () => { + vi.useFakeTimers(); + const acknowledge = vi.fn() + .mockResolvedValueOnce({ version: 1 as const, status: 'not-ready' as const }) + .mockResolvedValueOnce({ version: 1 as const, status: 'accepted' as const }); + const consumed = vi.fn(); + render( + + ); + + await act(async () => { await vi.advanceTimersByTimeAsync(1_100); }); + expect(screen.getByTestId('phase').textContent).toBe('finalizing'); + expect(acknowledge).toHaveBeenCalledTimes(2); + expect(consumed).toHaveBeenCalledTimes(1); + }); + + it('keeps one bounded retry beyond the provider fifteen-second settlement window', async () => { + vi.useFakeTimers(); + const acknowledge = vi.fn() + .mockResolvedValueOnce({ version: 1 as const, status: 'not-ready' as const }) + .mockResolvedValueOnce({ version: 1 as const, status: 'not-ready' as const }) + .mockResolvedValueOnce({ version: 1 as const, status: 'not-ready' as const }) + .mockResolvedValueOnce({ version: 1 as const, status: 'not-ready' as const }) + .mockResolvedValueOnce({ version: 1 as const, status: 'accepted' as const }); + render( + + ); + + await act(async () => { await vi.advanceTimersByTimeAsync(11_000); }); + expect(acknowledge).toHaveBeenCalledTimes(4); + expect(screen.getByTestId('phase').textContent).toBe('acknowledging'); + await act(async () => { await vi.advanceTimersByTimeAsync(9_000); }); + expect(acknowledge).toHaveBeenCalledTimes(5); + expect(screen.getByTestId('phase').textContent).toBe('finalizing'); + }); + + it('leaves a bounded finalizing state when admission authority never arrives', async () => { + vi.useFakeTimers(); + const acknowledge = vi.fn(async () => ({ version: 1 as const, status: 'accepted' as const })); + const client = bridge(acknowledge); + const view = render( + + ); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + expect(screen.getByTestId('phase').textContent).toBe('finalizing'); + + view.rerender( + + ); + for (let minute = 0; minute < 9; minute += 1) { + await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); + view.rerender( + + ); + expect(screen.getByTestId('phase').textContent).toBe('finalizing'); + } + await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); + expect(screen.getByTestId('phase').textContent).toBe('confirmed-pending'); + }); + + it('abandons an in-flight capability when the verified identity is cleared', async () => { + const token = deferred(); + const acknowledge = vi.fn(async () => ({ version: 1 as const, status: 'accepted' as const })); + const client = bridge(acknowledge); + const view = render( + token.promise} + ticket={TICKET} + /> + ); + await waitFor(() => { + expect(screen.getByTestId('phase').textContent).toBe('acknowledging'); + }); + + view.rerender( + token.promise} + ticket={TICKET} + /> + ); + token.resolve({ status: 'token', token: 'header.payload.signature' }); + await act(async () => { await Promise.resolve(); }); + + expect(acknowledge).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/farcasterOidcBridgeClient.test.ts b/tests/farcasterOidcBridgeClient.test.ts index 19d06dac..8c6532d4 100644 --- a/tests/farcasterOidcBridgeClient.test.ts +++ b/tests/farcasterOidcBridgeClient.test.ts @@ -819,6 +819,34 @@ describe('Farcaster OIDC bridge v2 client', () => { expect(String(requestInit?.body)).not.toContain('fid'); }); + it('sends the one-use admission capability only in the authenticated POST body', async () => { + const ticket = 'A'.repeat(43); + const fetch = createFetch({ version: 1, status: 'accepted' }); + const bridge = createBridge(fetch); + + await expect(bridge.acknowledgeAdmissionGrant!( + { mode: 'quick-auth', token: 'header.payload.signature' }, + { expectedFid: Number(FID), ticket } + )).resolves.toEqual({ version: 1, status: 'accepted' }); + + const [url, init] = vi.mocked(fetch).mock.calls[0]!; + expect(String(url)).toBe('https://auth.warpkeep.example/v2/access/admission-grant'); + expect(String(url)).not.toContain(ticket); + expect(init?.credentials).toBe('omit'); + expect(init?.headers).toMatchObject({ + authorization: 'Bearer header.payload.signature', + 'x-warpkeep-expected-fid': String(FID) + }); + expect(JSON.parse(String(init?.body))).toEqual({ ticket }); + expect(String(init?.body)).not.toContain('fid'); + + await expect(bridge.acknowledgeAdmissionGrant!( + { mode: 'pending-session' }, + { expectedFid: Number(FID), ticket: 'short' } + )).rejects.toBeInstanceOf(FarcasterOidcBridgeClientError); + expect(fetch).toHaveBeenCalledOnce(); + }); + it('rejects malformed access authentication and response envelopes', async () => { const fetch = createFetch( { version: 1, status: 'requested', requestedAt: NOW, fid: FID }, diff --git a/tests/farcasterQuickAuthLifecycle.test.tsx b/tests/farcasterQuickAuthLifecycle.test.tsx index 71ba5889..57f0f34f 100644 --- a/tests/farcasterQuickAuthLifecycle.test.tsx +++ b/tests/farcasterQuickAuthLifecycle.test.tsx @@ -139,6 +139,10 @@ function bridge( status: 'requested' as const, requestedAt: Date.now() })), + acknowledgeAdmissionGrant: vi.fn(async () => ({ + version: 1 as const, + status: 'stale' as const + })), logoutSession: vi.fn(async () => {}) }; } diff --git a/tests/farcasterSessionLifecycle.test.tsx b/tests/farcasterSessionLifecycle.test.tsx index 9708ec7f..8054392f 100644 --- a/tests/farcasterSessionLifecycle.test.tsx +++ b/tests/farcasterSessionLifecycle.test.tsx @@ -7,6 +7,10 @@ import { useFarcasterAuth, type FarcasterAuthProviderProps } from '../src/farcaster/FarcasterAuthProvider'; +import { + FarcasterAuthProviderCore, + type FarcasterQuickAuthTokenLoader +} from '../src/farcaster/FarcasterAuthProviderCore'; import { FARCASTER_AUTH_REQUEST_TTL_MS } from '../src/farcaster/farcasterAuthContext'; import { FarcasterOidcBridgeClientError } from '../src/farcaster/farcasterOidcBridgeClient'; import { @@ -211,6 +215,10 @@ function createBridge( })), logoutSession: vi.fn(async () => undefined), ...overrides, + acknowledgeAdmissionGrant: overrides.acknowledgeAdmissionGrant ?? vi.fn(async () => ({ + version: 1 as const, + status: 'stale' as const + })), issuer: overrides.issuer ?? 'https://auth.warpkeep.example', audience: overrides.audience ?? 'warpkeep-spacetimedb' }; @@ -282,6 +290,9 @@ function AuthHarness({ duplicateBegin = false }: { duplicateBegin?: boolean }) { {JSON.stringify(auth.admissionCheck)} + + {JSON.stringify(auth.admissionGrantAcknowledgement)} +