From dee674c04212c2a6f51af27538815f9a41d3734e Mon Sep 17 00:00:00 2001 From: ael-dev3 Date: Mon, 3 Aug 2026 22:19:08 +0200 Subject: [PATCH] Repair Farcaster alerts and smooth Mini App zoom --- package-lock.json | 12 +- services/auth-bridge/README.md | 14 +- services/auth-bridge/pnpm-lock.yaml | 9 +- services/auth-bridge/pnpm-workspace.yaml | 2 + .../auth-bridge/src/admissionNotifications.ts | 294 ++++++++++++++++-- services/auth-bridge/src/app.ts | 62 ++++ services/auth-bridge/src/types.ts | 23 ++ .../test/admissionNotifications.test.ts | 166 +++++++++- services/auth-bridge/test/app.test.ts | 59 ++++ src/components/realm/createRealmScene.ts | 13 +- src/components/realm/realmPinchZoom.ts | 31 ++ tests/realmPinchZoom.test.ts | 42 +++ tests/realmSceneCleanup.test.ts | 3 +- 13 files changed, 687 insertions(+), 43 deletions(-) create mode 100644 src/components/realm/realmPinchZoom.ts create mode 100644 tests/realmPinchZoom.test.ts diff --git a/package-lock.json b/package-lock.json index 1aba81f1..ebef4c7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3339,9 +3339,9 @@ } }, "node_modules/jsdom/node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { @@ -4603,9 +4603,9 @@ } }, "node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "license": "MIT", "optional": true, "peer": true, diff --git a/services/auth-bridge/README.md b/services/auth-bridge/README.md index 554b6ed7..d1bef630 100644 --- a/services/auth-bridge/README.md +++ b/services/auth-bridge/README.md @@ -39,6 +39,7 @@ future rollout step requires exact-head verification and recorded authority. | `POST` | `/v1/admin/config-attestation` | Server-only digest of security-relevant runtime configuration. | | `POST` | `/v1/farcaster/miniapp/webhook` | Verifies signed add/remove and notification enable/disable events; returns exact `200`. | | `POST` | `/v1/admin/admission-notification` | Separate-secret Hermes hook; rechecks live admission and queues one exact-epoch alert. | +| `POST` | `/v1/admin/admission-notification-status` | Separate-secret, token-free delivery diagnostics for one exact FID. | The legacy public `/v1/farcaster/challenge` and `/v1/farcaster/exchange` routes are retired in the local contract and return `410 legacy_auth_retired`; they do @@ -388,6 +389,12 @@ within 366 days. Signed opt-outs remain accepted while delivery is paused and erase raw token material immediately. Each send rechecks the exact current admission epoch; stable notification IDs, retry ceilings, replay tombstones, and bounded epoch receipts make retries idempotent. +The operator-only status projection contains only queue state, the admission +epoch, aggregate attempt counts, static retry categories, and the next retry +time. It never returns a notification token, delivery URL, webhook payload, or +provider response. Delivery parsing accepts Farcaster's optional additive +`failedTokens` field, ignores harmless provider metadata, and still rejects +invalid reasons, contradictory known outcome categories, and token mismatches. The production browser and Pages activation gate separately pin the exact bridge and issuer `https://auth.warpkeep.com`, audience `warpkeep-spacetimedb`, and the @@ -467,9 +474,10 @@ request/response, or symmetric secret. `/v1/admin/token`, `Authorization: Bearer `, reject browser `Origin` headers, emit no admin CORS headers, and are only for a server-side operator process. Never expose their credential or response to frontend code. -`/v1/admin/admission-notification` has the same no-Origin/no-CORS boundary but -uses only `NOTIFICATION_OPERATOR_SECRET`; it never accepts the general admin -secret. The public webhook accepts no browser Origin and trusts only a valid +`/v1/admin/admission-notification` and its token-free `-status` companion have +the same no-Origin/no-CORS boundary but use only +`NOTIFICATION_OPERATOR_SECRET`; neither accepts the general admin secret. The +public webhook accepts no browser Origin and trusts only a valid Farcaster JFS envelope whose app key agrees across both configured Hubs and is active on-chain. Both configured Optimism RPCs are queried with bounded retries: matching answers are required when both respond, one healthy view may serve as diff --git a/services/auth-bridge/pnpm-lock.yaml b/services/auth-bridge/pnpm-lock.yaml index 01df0ea4..88f18c79 100644 --- a/services/auth-bridge/pnpm-lock.yaml +++ b/services/auth-bridge/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: overrides: miniflare@4.20260708.1>sharp: 0.35.3 + miniflare@4.20260708.1>undici: 7.29.0 postcss: 8.5.25 importers: @@ -1026,8 +1027,8 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} unenv@2.0.0-rc.24: @@ -1795,7 +1796,7 @@ snapshots: dependencies: '@cspotcode/source-map-support': 0.8.1 sharp: 0.35.3(@types/node@26.1.1) - undici: 7.28.0 + undici: 7.29.0 workerd: 1.20260708.1 ws: 8.21.0 youch: 4.1.0-beta.10 @@ -1944,7 +1945,7 @@ snapshots: undici-types@8.3.0: {} - undici@7.28.0: {} + undici@7.29.0: {} unenv@2.0.0-rc.24: dependencies: diff --git a/services/auth-bridge/pnpm-workspace.yaml b/services/auth-bridge/pnpm-workspace.yaml index d9d4068a..d2f77506 100644 --- a/services/auth-bridge/pnpm-workspace.yaml +++ b/services/auth-bridge/pnpm-workspace.yaml @@ -5,6 +5,8 @@ allowBuilds: # Remove after Miniflare stops pinning vulnerable sharp versions below 0.35.0. overrides: "miniflare@4.20260708.1>sharp": 0.35.3 + # Miniflare's pinned Undici release is raised to the patched 7.x line. + "miniflare@4.20260708.1>undici": 7.29.0 # Vite's compatible range otherwise resolves to a PostCSS release affected by # GHSA-566m-qj78-rww5. Keep this exact until the toolchain raises its floor. postcss: 8.5.25 diff --git a/services/auth-bridge/src/admissionNotifications.ts b/services/auth-bridge/src/admissionNotifications.ts index 8d60ca4e..0951482d 100644 --- a/services/auth-bridge/src/admissionNotifications.ts +++ b/services/auth-bridge/src/admissionNotifications.ts @@ -6,6 +6,8 @@ import { } from './spacetimeAuthEpochResolver' import type { AdmissionNotificationQueueStatus, + AdmissionNotificationDiagnostics, + AdmissionNotificationRetryReason, AdmissionNotificationStore, AuthEpochResolver, DurableObjectNamespace, @@ -16,6 +18,7 @@ import type { const INTERNAL_ORIGIN = 'https://admission-notification.internal' const STATE_KEY = 'admission-notification-v1' +const DIAGNOSTICS_RECORD = 'admission-notification-diagnostics-v1' const STATE_VERSION = 1 const MAX_SUBSCRIPTIONS = 8 const MAX_SEEN_EVENTS = 32 @@ -59,6 +62,11 @@ type DeliveryAttempt = Readonly<{ nextAttemptAt?: number }> +type PersistedNotificationDiagnostics = Readonly<{ + authEpoch: number + retryReasons: readonly AdmissionNotificationRetryReason[] +}> + type AdmissionDelivery = Readonly<{ authEpoch: number queuedAt: number @@ -91,6 +99,18 @@ type DeliveryResult = | 'invalid' | 'retryable' +type DeliveryOutcome = Readonly<{ + result: DeliveryResult + retryReason?: Exclude +}> + +type FailedTokenReason = + | 'domain_mismatch' + | 'target_url_mismatch' + | 'no_webhook_url' + | 'invalid_token' + | 'unknown' + function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } @@ -178,6 +198,26 @@ function isDeliveryStatus(value: unknown): value is DeliveryAttemptStatus { || value === 'exhausted' } +function isRetryReason(value: unknown): value is AdmissionNotificationRetryReason { + return value === 'admission-verification' + || value === 'transport' + || value === 'upstream-status' + || value === 'invalid-response' + || value === 'rate-limited' + || value === 'provider-domain-mismatch' + || value === 'provider-target-url-mismatch' + || value === 'provider-no-webhook-url' + || value === 'provider-unknown' +} + +function isFailedTokenReason(value: unknown): value is FailedTokenReason { + return value === 'domain_mismatch' + || value === 'target_url_mismatch' + || value === 'no_webhook_url' + || value === 'invalid_token' + || value === 'unknown' +} + function readSubscription(value: unknown): Subscription | null { if ( !isRecord(value) @@ -239,6 +279,21 @@ function readAttempt(value: unknown): DeliveryAttempt | null { }) } +function readPersistedDiagnostics(value: unknown): PersistedNotificationDiagnostics | null { + if ( + !isRecord(value) + || !exactKeys(value, ['authEpoch', 'retryReasons']) + || !isAuthEpoch(value.authEpoch) + || !Array.isArray(value.retryReasons) + || value.retryReasons.some(reason => !isRetryReason(reason)) + || new Set(value.retryReasons).size !== value.retryReasons.length + ) return null + return Object.freeze({ + authEpoch: value.authEpoch, + retryReasons: Object.freeze([...value.retryReasons] as AdmissionNotificationRetryReason[]), + }) +} + function readDelivery(value: unknown): AdmissionDelivery | null { if ( !isRecord(value) @@ -369,7 +424,7 @@ async function objectName(fid: string): Promise { } } -function internalUrl(path: 'event' | 'queue'): string { +function internalUrl(path: 'event' | 'queue' | 'status'): string { return `${INTERNAL_ORIGIN}/${path}` } @@ -391,6 +446,46 @@ 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', 'deliveryAttemptCount', 'verificationFailureCount', 'retryReasons'], + ['authEpoch', 'nextAttemptAt'], + ) + || ( + value.status !== 'queued' + && value.status !== 'already-sent' + && value.status !== 'delivery-exhausted' + && value.status !== 'not-subscribed' + ) + || (value.authEpoch !== undefined && !isAuthEpoch(value.authEpoch)) + || typeof value.deliveryAttemptCount !== 'number' + || !Number.isSafeInteger(value.deliveryAttemptCount) + || value.deliveryAttemptCount < 0 + || typeof value.verificationFailureCount !== 'number' + || !Number.isSafeInteger(value.verificationFailureCount) + || value.verificationFailureCount < 0 + || !Array.isArray(value.retryReasons) + || value.retryReasons.some(reason => !isRetryReason(reason)) + || new Set(value.retryReasons).size !== value.retryReasons.length + || (value.nextAttemptAt !== undefined && !isTimestamp(value.nextAttemptAt)) + ) { + throw new Error('Admission notification store returned invalid diagnostics.') + } + return Object.freeze({ + status: value.status, + ...(value.authEpoch === undefined ? {} : { authEpoch: value.authEpoch }), + deliveryAttemptCount: value.deliveryAttemptCount as number, + verificationFailureCount: value.verificationFailureCount as number, + retryReasons: Object.freeze([...value.retryReasons] as AdmissionNotificationRetryReason[]), + ...(value.nextAttemptAt === undefined ? {} : { nextAttemptAt: value.nextAttemptAt }), + }) +} + export class DurableObjectAdmissionNotificationStore implements AdmissionNotificationStore { constructor(private readonly namespace: DurableObjectNamespace) {} @@ -422,6 +517,15 @@ export class DurableObjectAdmissionNotificationStore implements AdmissionNotific }) return readQueueStatus(response) } + + async inspect(fid: string): Promise { + const response = await (await this.stub(fid)).fetch(internalUrl('status'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fid }), + }) + return readDiagnostics(response) + } } function configuredClient(config: BridgeConfig, appFid: number, url: string): boolean { @@ -596,6 +700,23 @@ async function purgePersistedState(storage: DurableObjectState['storage']): Prom await storage.deleteAll() } +async function recordRetryReasons( + storage: DurableObjectState['storage'], + authEpoch: number, + retryReasons: readonly AdmissionNotificationRetryReason[], +): Promise { + if (retryReasons.length === 0) return + const existing = readPersistedDiagnostics(await storage.get(DIAGNOSTICS_RECORD)) + const combined = new Set( + existing?.authEpoch === authEpoch ? existing.retryReasons : [], + ) + retryReasons.forEach(reason => combined.add(reason)) + await storage.put(DIAGNOSTICS_RECORD, Object.freeze({ + authEpoch, + retryReasons: Object.freeze(Array.from(combined).sort()), + })) +} + function notificationId(authEpoch: number): string { return `warpkeep-access-approved-v1-e${authEpoch}` } @@ -642,35 +763,80 @@ function tokenArray(value: unknown, requestedToken: string): boolean { && value.every(token => token === requestedToken) } -function deliveryResult(value: unknown, requestedToken: string): DeliveryResult | null { - if (!isRecord(value) || !exactKeys(value, ['result']) || !isRecord(value.result)) { +function failedTokenReason( + value: unknown, + requestedToken: string, +): FailedTokenReason | null | undefined { + if (value === undefined) return undefined + if (!Array.isArray(value) || value.length > 1) return null + if (value.length === 0) return undefined + const failed = value[0] + if ( + !isRecord(failed) + || failed.token !== requestedToken + || !isFailedTokenReason(failed.reason) + || ( + failed.fid !== undefined + && ( + typeof failed.fid !== 'number' + || !Number.isSafeInteger(failed.fid) + || failed.fid < 1 + ) + ) + ) return null + return failed.reason +} + +function providerRetryReason( + reason: Exclude, +): Exclude { + if (reason === 'domain_mismatch') return 'provider-domain-mismatch' + if (reason === 'target_url_mismatch') return 'provider-target-url-mismatch' + if (reason === 'no_webhook_url') return 'provider-no-webhook-url' + return 'provider-unknown' +} + +function deliveryResult(value: unknown, requestedToken: string): DeliveryOutcome | null { + if (!isRecord(value) || !isRecord(value.result)) { return null } const result = value.result - if (!exactKeys( - result, - ['successfulTokens', 'invalidTokens', 'rateLimitedTokens'], - )) return null if ( !tokenArray(result.successfulTokens, requestedToken) || !tokenArray(result.invalidTokens, requestedToken) || !tokenArray(result.rateLimitedTokens, requestedToken) ) return null + const failedReason = failedTokenReason(result.failedTokens, requestedToken) + if (failedReason === null) return null const successful = (result.successfulTokens as unknown[]).length const invalid = (result.invalidTokens as unknown[]).length const rateLimited = (result.rateLimitedTokens as unknown[]).length const categories = successful + invalid + rateLimited - if (categories !== 1) return null - if (successful === 1) return 'successful' - if (invalid === 1) return 'invalid' - return 'retryable' + if (categories > 1) return null + if (successful === 1) { + return failedReason === undefined ? Object.freeze({ result: 'successful' }) : null + } + if (invalid === 1) { + if (failedReason !== undefined && failedReason !== 'invalid_token') return null + return Object.freeze({ result: 'invalid' }) + } + if (rateLimited === 1) { + return failedReason === undefined + ? Object.freeze({ result: 'retryable', retryReason: 'rate-limited' }) + : null + } + if (failedReason === undefined || failedReason === 'invalid_token') return null + return Object.freeze({ + result: 'retryable', + retryReason: providerRetryReason(failedReason), + }) } async function sendOne( subscription: Subscription, delivery: AdmissionDelivery, fetchImpl: typeof fetch, -): Promise { +): Promise { let response: Response try { response = await fetchImpl(subscription.url, { @@ -691,16 +857,21 @@ async function sendOne( signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MILLISECONDS), }) } catch { - return 'retryable' + return Object.freeze({ result: 'retryable', retryReason: 'transport' }) + } + if (!response.ok) { + return Object.freeze({ + result: 'retryable', + retryReason: response.status === 429 ? 'rate-limited' : 'upstream-status', + }) } - if (!response.ok) return 'retryable' try { return deliveryResult( await boundedDeliveryJson(response), subscription.token, - ) ?? 'retryable' + ) ?? Object.freeze({ result: 'retryable', retryReason: 'invalid-response' }) } catch { - return 'retryable' + return Object.freeze({ result: 'retryable', retryReason: 'invalid-response' }) } } @@ -779,6 +950,54 @@ function queueStatus(state: PersistedNotificationState): AdmissionNotificationQu return 'queued' } +function diagnosticsForState( + state: PersistedNotificationState | null, + persistedDiagnostics: PersistedNotificationDiagnostics | null, +): AdmissionNotificationDiagnostics { + if (!state) { + return Object.freeze({ + status: 'not-subscribed', + deliveryAttemptCount: 0, + verificationFailureCount: 0, + retryReasons: Object.freeze([]), + }) + } + const delivery = state.delivery + const attempts = delivery?.attempts ?? [] + const receiptAuthEpoch = state.lastSentAuthEpoch === undefined + ? state.lastExhaustedAuthEpoch + : state.lastExhaustedAuthEpoch === undefined + ? state.lastSentAuthEpoch + : Math.max(state.lastSentAuthEpoch, state.lastExhaustedAuthEpoch) + const status = delivery + ? queueStatus(state) + : receiptAuthEpoch === undefined + ? 'not-subscribed' + : state.lastSentAuthEpoch === receiptAuthEpoch + ? 'already-sent' + : 'delivery-exhausted' + const nextAttemptAt = attempts.reduce((earliest, attempt) => { + if (attempt.nextAttemptAt === undefined) return earliest + return earliest === undefined ? attempt.nextAttemptAt : Math.min(earliest, attempt.nextAttemptAt) + }, undefined) + const authEpoch = delivery?.authEpoch + ?? receiptAuthEpoch + const retryReasons = authEpoch !== undefined && persistedDiagnostics?.authEpoch === authEpoch + ? persistedDiagnostics.retryReasons + : Object.freeze([]) + return Object.freeze({ + status, + ...(authEpoch === undefined ? {} : { authEpoch }), + deliveryAttemptCount: attempts.reduce((sum, attempt) => sum + attempt.attempts, 0), + verificationFailureCount: attempts.reduce( + (sum, attempt) => sum + attempt.verificationFailures, + 0, + ), + retryReasons, + ...(nextAttemptAt === undefined ? {} : { nextAttemptAt }), + }) +} + function defaultAdmissionResolver(config: BridgeConfig): AuthEpochResolver { return new SpacetimeHttpAuthEpochResolver({ uri: config.spacetimeDbUri, @@ -880,6 +1099,7 @@ export class AdmissionNotification { let subscriptions = [...pruned.subscriptions] let nextBase = pruned const attempts: DeliveryAttempt[] = [] + const retryReasons: AdmissionNotificationRetryReason[] = [] const resolver = this.configuredAdmissionResolver ?? defaultAdmissionResolver(config) for (const attempt of delivery.attempts) { const subscription = subscriptions.find(candidate => ( @@ -910,6 +1130,7 @@ export class AdmissionNotification { // Resolver availability is not a Farcaster delivery attempt. Back it // off separately so an upstream outage cannot permanently exhaust the // admission epoch before any notification request is made. + retryReasons.push('admission-verification') attempts.push(deferForAdmissionVerification(attempt, now, delivery.expiresAt)) continue } @@ -925,20 +1146,25 @@ export class AdmissionNotification { await persistAndSchedule(this.state.storage, cancelled, now) return cancelled } - const result = await sendOne(subscription, delivery, this.fetchImpl) - if (result === 'successful') { + const outcome = await sendOne(subscription, delivery, this.fetchImpl) + if (outcome.result === 'successful') { attempts.push(Object.freeze({ - ...attempt, + appFid: attempt.appFid, + tokenId: attempt.tokenId, status: 'sent', attempts: attempt.attempts + 1, verificationFailures: 0, - nextAttemptAt: undefined, })) - } else if (result === 'invalid') { + } else if (outcome.result === 'invalid') { subscriptions = subscriptions.filter(candidate => candidate.appFid !== attempt.appFid) nextBase = withRevokedTokenIds(nextBase, [attempt.tokenId]) } else { - attempts.push(retryAttempt(attempt, now, delivery.expiresAt)) + retryReasons.push(outcome.retryReason ?? 'invalid-response') + attempts.push(retryAttempt( + attempt, + now, + delivery.expiresAt, + )) } } const current = readState(await this.state.storage.get(STATE_KEY)) @@ -978,6 +1204,12 @@ export class AdmissionNotification { // 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) + try { + await recordRetryReasons(this.state.storage, delivery.authEpoch, retryReasons) + } catch { + // Diagnostics are subordinate to delivery state. Losing a static reason + // must not turn an idempotently queued send into an apparent failure. + } return next } @@ -1014,6 +1246,24 @@ export class AdmissionNotification { return new Response(null, { status: 503 }) } + if (url.pathname === '/status') { + if (!isRecord(value) || !exactKeys(value, ['fid']) || !isSafeFid(value.fid)) { + return new Response(null, { status: 400 }) + } + const existing = readState(await this.state.storage.get(STATE_KEY)) + if (existing && existing.fid !== value.fid) return new Response(null, { status: 409 }) + const diagnostics = readPersistedDiagnostics( + await this.state.storage.get(DIAGNOSTICS_RECORD), + ) + return new Response(JSON.stringify(diagnosticsForState(existing, diagnostics)), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }) + } + 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 diff --git a/services/auth-bridge/src/app.ts b/services/auth-bridge/src/app.ts index aa478d5e..f5df1f1b 100644 --- a/services/auth-bridge/src/app.ts +++ b/services/auth-bridge/src/app.ts @@ -133,6 +133,7 @@ const V2_ACCESS_STATUS_PATH = '/v2/access/status' const V2_ACCESS_REQUEST_PATH = '/v2/access/request' 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' const LEGACY_CHALLENGE_PATH = '/v1/farcaster/challenge' const LEGACY_EXCHANGE_PATH = '/v1/farcaster/exchange' const QUICK_AUTH_DOMAIN = 'warpkeep.com' @@ -382,6 +383,7 @@ function isServerOnlyAdminPath(pathname: string): boolean { || pathname === AUTH_EPOCH_PROBE_PATH || pathname === CONFIG_ATTESTATION_PATH || pathname === ADMISSION_NOTIFICATION_PATH + || pathname === ADMISSION_NOTIFICATION_STATUS_PATH } function isQaObserverPath(pathname: string): boolean { @@ -2515,6 +2517,65 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri return json({ status }, status === 'queued' ? 202 : 200) } + if (request.method === 'POST' && url.pathname === ADMISSION_NOTIFICATION_STATUS_PATH) { + requireAdminNoOrigin(request) + if (!config.approvalNotificationsEnabled) { + throw new HttpError( + 503, + 'approval_notifications_paused', + 'Admission notifications are temporarily unavailable.', + ) + } + if (url.search) { + throw new HttpError(400, 'notification_query_not_allowed', 'This endpoint does not accept query parameters.') + } + await enforceRateLimit( + request, + 'admission-notification', + env, + dependencies.rateLimiter, + logger, + ) + const notificationConfig = config.miniAppNotifications + if (!notificationConfig) throw new ConfigurationError() + const credential = adminCredential(request) + if (!credential || !(await timingSafeSecretMatch( + credential, + notificationConfig.operatorSecret, + ))) { + logger.event('admission_notification_rejected') + throw new HttpError( + 401, + 'invalid_notification_credentials', + 'Notification operator credentials are invalid.', + ) + } + const body = await parseObjectBody(request) + requireExactKeys(body, ['fid']) + const fid = canonicalNotificationFid(body.fid) + const store = dependencies.admissionNotificationStore + ?? defaultAdmissionNotificationStore(env) + if (!store.inspect) { + throw new HttpError( + 503, + 'admission_notification_unavailable', + 'Admission notification delivery is temporarily unavailable.', + ) + } + let diagnostics + try { + diagnostics = await store.inspect(fid) + } catch { + throw new HttpError( + 503, + 'admission_notification_unavailable', + 'Admission notification delivery is temporarily unavailable.', + ) + } + logger.event('admission_notification_inspected') + return json(diagnostics) + } + if (request.method === 'POST' && url.pathname === AUTH_EPOCH_PROBE_PATH) { requireAdminNoOrigin(request) // Reusing the existing persisted action preserves rollback compatibility. @@ -2591,6 +2652,7 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri config.miniAppNotifications?.clients.map(client => client.appFid) ?? [], miniAppWebhookPath: MINIAPP_WEBHOOK_PATH, admissionNotificationPath: ADMISSION_NOTIFICATION_PATH, + admissionNotificationStatusPath: ADMISSION_NOTIFICATION_STATUS_PATH, publicAuthEnabled: config.publicAuthEnabled, accessExpectedFidRequired: config.accessExpectedFidRequired, qaObserverEnabled: config.qaObserverEnabled, diff --git a/services/auth-bridge/src/types.ts b/services/auth-bridge/src/types.ts index 43483db3..1dafa1bc 100644 --- a/services/auth-bridge/src/types.ts +++ b/services/auth-bridge/src/types.ts @@ -171,6 +171,7 @@ export type SafeLogEvent = | 'admission_notification_retrying' | 'admission_notification_not_subscribed' | 'admission_notification_rejected' + | 'admission_notification_inspected' | 'rate_limited' | 'rate_limit_failed' | 'configuration_error' @@ -314,6 +315,26 @@ export type AdmissionNotificationQueueStatus = | 'delivery-exhausted' | 'not-subscribed' +export type AdmissionNotificationRetryReason = + | 'admission-verification' + | 'transport' + | 'upstream-status' + | 'invalid-response' + | 'rate-limited' + | 'provider-domain-mismatch' + | 'provider-target-url-mismatch' + | 'provider-no-webhook-url' + | 'provider-unknown' + +export type AdmissionNotificationDiagnostics = Readonly<{ + status: AdmissionNotificationQueueStatus + authEpoch?: number + deliveryAttemptCount: number + verificationFailureCount: number + retryReasons: readonly AdmissionNotificationRetryReason[] + nextAttemptAt?: number +}> + /** Raw notification tokens remain behind this server-only interface. */ export interface AdmissionNotificationStore { applyEvent(event: VerifiedMiniAppWebhookEvent): Promise @@ -322,6 +343,8 @@ export interface AdmissionNotificationStore { authEpoch: number queuedAt: number }>): Promise + /** Operator-only, token-free delivery state used for bounded diagnosis. */ + inspect?(fid: string): Promise } export interface PublicIdentity { diff --git a/services/auth-bridge/test/admissionNotifications.test.ts b/services/auth-bridge/test/admissionNotifications.test.ts index 0f2c4340..2a037ac9 100644 --- a/services/auth-bridge/test/admissionNotifications.test.ts +++ b/services/auth-bridge/test/admissionNotifications.test.ts @@ -115,7 +115,7 @@ function disabledEvent(eventId = 'b'.repeat(64)): VerifiedMiniAppWebhookEvent { return { eventId, fid: FID, appFid: APP_FID, event: { type: 'disabled' } } } -function internalRequest(path: 'event' | 'queue', body: unknown): Request { +function internalRequest(path: 'event' | 'queue' | 'status', body: unknown): Request { return new Request(`${INTERNAL_ORIGIN}/${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -180,6 +180,10 @@ async function queue( return notification.fetch(internalRequest('queue', { fid: FID, authEpoch, queuedAt })) } +async function inspect(notification: AdmissionNotification): Promise { + return notification.fetch(internalRequest('status', { fid: FID })) +} + 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()) @@ -337,13 +341,50 @@ describe('admission notification consent and delivery lifecycle', () => { expect(stored(h.storage)).toContain('revokedTokenIds') }) - it('fails closed on response fields outside the installed Mini App schema', async () => { + it('accepts the current additive Farcaster response on a successful delivery', async () => { + const fetchImpl = vi.fn(async () => Response.json({ + result: { + successfulTokens: [TOKEN], + invalidTokens: [], + rateLimitedTokens: [], + failedTokens: [], + }, + })) + const h = createHarness({ fetchImpl }) + await applyEvent(h.notification, enabledEvent()) + + const response = await queue(h.notification) + await expect(response.json()).resolves.toEqual({ status: 'already-sent' }) + expect(fetchImpl).toHaveBeenCalledOnce() + expect(stored(h.storage)).toContain('"lastSentAuthEpoch":7') + }) + + it('deduplicates the current mirrored invalid-token result before purging consent', async () => { + const fetchImpl = vi.fn(async () => Response.json({ + result: { + successfulTokens: [], + invalidTokens: [TOKEN], + rateLimitedTokens: [], + failedTokens: [{ token: TOKEN, fid: 12_345, reason: 'invalid_token' }], + }, + })) + const h = createHarness({ fetchImpl }) + await applyEvent(h.notification, enabledEvent()) + + const response = await queue(h.notification) + await expect(response.json()).resolves.toEqual({ status: 'not-subscribed' }) + expect(fetchImpl).toHaveBeenCalledOnce() + expect(stored(h.storage)).not.toContain(TOKEN) + expect(stored(h.storage)).toContain('revokedTokenIds') + }) + + it('retains consent and records a bounded retry for a structured provider failure', async () => { const fetchImpl = vi.fn(async () => Response.json({ result: { successfulTokens: [], invalidTokens: [], rateLimitedTokens: [], - failedTokens: [{ token: TOKEN, reason: 'invalid_token' }], + failedTokens: [{ token: TOKEN, reason: 'no_webhook_url' }], }, })) const h = createHarness({ fetchImpl }) @@ -351,9 +392,92 @@ describe('admission notification consent and delivery lifecycle', () => { const response = await queue(h.notification) await expect(response.json()).resolves.toEqual({ status: 'queued' }) - expect(fetchImpl).toHaveBeenCalledOnce() expect(stored(h.storage)).toContain(TOKEN) expect(stored(h.storage)).toContain('"status":"retrying"') + expect(stored(h.storage)).not.toContain('retryReason') + expect(stored(h.storage)).not.toContain('provider-no-webhook-url') + await expect((await inspect(h.notification)).json()).resolves.toMatchObject({ + retryReasons: ['provider-no-webhook-url'], + }) + }) + + it('ignores additive provider metadata after validating known outcome fields', async () => { + const fetchImpl = vi.fn(async () => Response.json({ + providerRequestId: 'opaque-provider-metadata', + result: { + successfulTokens: [TOKEN], + invalidTokens: [], + rateLimitedTokens: [], + failedTokens: [], + unsupportedTokens: [], + }, + })) + const h = createHarness({ fetchImpl }) + await applyEvent(h.notification, enabledEvent()) + + const response = await queue(h.notification) + await expect(response.json()).resolves.toEqual({ status: 'already-sent' }) + }) + + it('fails closed on unknown structured reasons or contradictory known outcomes', async () => { + const responses = [ + { + result: { + successfulTokens: [], + invalidTokens: [], + rateLimitedTokens: [], + failedTokens: [{ token: TOKEN, reason: 'future_reason' }], + }, + }, + { + result: { + successfulTokens: [TOKEN], + invalidTokens: [], + rateLimitedTokens: [], + failedTokens: [{ token: TOKEN, reason: 'no_webhook_url' }], + }, + }, + ] + for (const providerResponse of responses) { + const h = createHarness({ + fetchImpl: vi.fn(async () => Response.json(providerResponse)), + }) + await applyEvent(h.notification, enabledEvent()) + const response = await queue(h.notification) + await expect(response.json()).resolves.toEqual({ status: 'queued' }) + await expect((await inspect(h.notification)).json()).resolves.toMatchObject({ + retryReasons: ['invalid-response'], + }) + } + }) + + it('returns only token-free delivery diagnostics', async () => { + const resolver = { + resolve: vi.fn(async () => { throw new Error('private resolver detail') }), + } + const h = createHarness({ resolver }) + + await expect((await inspect(h.notification)).json()).resolves.toEqual({ + status: 'not-subscribed', + deliveryAttemptCount: 0, + verificationFailureCount: 0, + retryReasons: [], + }) + await applyEvent(h.notification, enabledEvent()) + await queue(h.notification) + + const response = await inspect(h.notification) + expect(response.status).toBe(200) + const text = await response.text() + expect(text).not.toContain(TOKEN) + expect(JSON.parse(text)).toEqual({ + status: 'queued', + authEpoch: 7, + deliveryAttemptCount: 0, + verificationFailureCount: 1, + retryReasons: ['admission-verification'], + nextAttemptAt: NOW + 30_000, + }) }) it('never resets the six-attempt ceiling for the same admission epoch', async () => { @@ -378,6 +502,40 @@ describe('admission notification consent and delivery lifecycle', () => { expect(fetchImpl).toHaveBeenCalledTimes(6) }) + it('reports the newest terminal receipt after an older successful epoch', async () => { + let failDelivery = false + let resolverEpoch = 7 + const fetchImpl = vi.fn(async () => ( + failDelivery ? new Response(null, { status: 503 }) : successfulDelivery() + )) + const h = createHarness({ + fetchImpl, + resolver: { + resolve: vi.fn(async () => ({ state: 'enabled', authEpoch: resolverEpoch } as const)), + }, + }) + await applyEvent(h.notification, enabledEvent()) + await queue(h.notification, 7) + + failDelivery = true + resolverEpoch = 8 + await queue(h.notification, 8) + for (let attempt = 1; attempt < 6; attempt += 1) { + const alarm = Number(h.storage.alarm) + h.setNow(alarm) + await h.notification.alarm() + } + h.setNow(NOW + 7 * 24 * 60 * 60 * 1_000) + await h.notification.alarm() + + await expect((await inspect(h.notification)).json()).resolves.toMatchObject({ + status: 'delivery-exhausted', + authEpoch: 8, + deliveryAttemptCount: 0, + verificationFailureCount: 0, + }) + }) + it('bounds raw subscription-token retention even without an admission queue', async () => { const h = createHarness() await applyEvent(h.notification, enabledEvent()) diff --git a/services/auth-bridge/test/app.test.ts b/services/auth-bridge/test/app.test.ts index 6919f7e9..77156bc3 100644 --- a/services/auth-bridge/test/app.test.ts +++ b/services/auth-bridge/test/app.test.ts @@ -2,6 +2,7 @@ import { createSiweMessage } from 'viem/siwe' import { Errors as QuickAuthErrors } from '@farcaster/quick-auth' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { + ADMISSION_NOTIFICATION_STATUS_PATH, FARCASTER_VERIFICATION_TIMEOUT_MILLISECONDS, QUICK_AUTH_MAX_ISSUER_LIFETIME_SECONDS, REQUEST_BODY_TIMEOUT_MILLISECONDS, @@ -67,6 +68,7 @@ const SERVER_ONLY_ADMIN_PATHS = [ '/v1/admin/auth-epoch-probe', '/v1/admin/config-attestation', ADMISSION_NOTIFICATION_PATH, + ADMISSION_NOTIFICATION_STATUS_PATH, ] as const const BINDING_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' const BINDING_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' @@ -2444,6 +2446,9 @@ describe('Warpkeep auth bridge', () => { request(ADMISSION_NOTIFICATION_PATH, { fid: FID }, { headers: { authorization: `Bearer ${NOTIFICATION_OPERATOR_SECRET}` }, }), + request(ADMISSION_NOTIFICATION_STATUS_PATH, { fid: FID }, { + headers: { authorization: `Bearer ${NOTIFICATION_OPERATOR_SECRET}` }, + }), ]) { const response = await h.app.fetch(candidate, env()) expect(response.status).toBe(503) @@ -2622,6 +2627,60 @@ describe('Warpkeep auth bridge', () => { }) expect(queueAdmission).not.toHaveBeenCalled() }) + + it('exposes only token-free diagnostics to the separate operator credential', async () => { + const inspect = vi.fn(async () => Object.freeze({ + status: 'queued' as const, + authEpoch: 7, + deliveryAttemptCount: 1, + verificationFailureCount: 0, + retryReasons: Object.freeze(['invalid-response'] as const), + nextAttemptAt: 1_800_000_030_000, + })) + const h = harness({ + admissionNotificationStore: { + applyEvent: vi.fn(async () => undefined), + queueAdmission: vi.fn(async () => 'queued' as const), + inspect, + }, + }) + + for (const headers of [ + new Headers({ authorization: `Bearer ${ADMIN_SECRET}` }), + new Headers({ + authorization: `Bearer ${NOTIFICATION_OPERATOR_SECRET}`, + origin: ORIGIN, + }), + ]) { + const rejected = await h.app.fetch(request( + ADMISSION_NOTIFICATION_STATUS_PATH, + { fid: FID }, + { headers }, + ), notificationEnv()) + expect(rejected.status).toBe(headers.has('origin') ? 403 : 401) + } + expect(inspect).not.toHaveBeenCalled() + + const accepted = await h.app.fetch(request( + ADMISSION_NOTIFICATION_STATUS_PATH, + { fid: FID }, + { headers: { authorization: `Bearer ${NOTIFICATION_OPERATOR_SECRET}` } }, + ), notificationEnv()) + expect(accepted.status).toBe(200) + const body = await accepted.json() + expect(body).toEqual({ + status: 'queued', + authEpoch: 7, + deliveryAttemptCount: 1, + verificationFailureCount: 0, + retryReasons: ['invalid-response'], + nextAttemptAt: 1_800_000_030_000, + }) + expect(inspect).toHaveBeenCalledWith(FID) + expect(h.events).toContain('admission_notification_inspected') + expect(JSON.stringify(body)).not.toContain(NOTIFICATION_OPERATOR_SECRET) + expect(JSON.stringify(body)).not.toContain(verifiedEnableEvent.event.details.token) + }) }) describe('neutral access requests', () => { diff --git a/src/components/realm/createRealmScene.ts b/src/components/realm/createRealmScene.ts index 1517214f..dac2505c 100644 --- a/src/components/realm/createRealmScene.ts +++ b/src/components/realm/createRealmScene.ts @@ -185,6 +185,10 @@ import { type RealmPointerGestureResult, type RealmPointerStartLane } from './realmPointerGestureCoordinator'; +import { + realmPinchZoomAmount, + realmPinchZoomProfileForChromeMode +} from './realmPinchZoom'; import { arbitrateRealmPick, type RealmInteractionTarget, @@ -4047,9 +4051,12 @@ function initializeRealmScene( return; } const current = localPoint(result.pinch.centroid.x, result.pinch.centroid.y); - const zoomAmount = result.pinch.scaleRatio > 0 - ? Math.log(result.pinch.scaleRatio) * 0.78 - : 0; + const zoomAmount = realmPinchZoomAmount( + result.pinch.scaleRatio, + realmPinchZoomProfileForChromeMode( + interactionRoot.dataset.realmChromeMode + ) + ); const hasTranslation = Math.abs(result.pinch.centroidDelta.x) >= 0.01 || Math.abs(result.pinch.centroidDelta.y) >= 0.01; if (!hasTranslation && Math.abs(zoomAmount) < 0.000001) return; diff --git a/src/components/realm/realmPinchZoom.ts b/src/components/realm/realmPinchZoom.ts new file mode 100644 index 00000000..f0384ad7 --- /dev/null +++ b/src/components/realm/realmPinchZoom.ts @@ -0,0 +1,31 @@ +export type RealmPinchZoomProfile = 'standard' | 'miniapp'; + +const STANDARD_PINCH_ZOOM_SENSITIVITY = 0.78; +const MINI_APP_PINCH_ZOOM_SENSITIVITY = 0.46; +const MINI_APP_PINCH_ZOOM_SOFT_LIMIT = 0.12; + +export function realmPinchZoomProfileForChromeMode( + chromeMode: string | undefined +): RealmPinchZoomProfile { + return chromeMode === 'miniapp' ? 'miniapp' : 'standard'; +} + +/** + * Converts one incremental pinch scale into the camera's normalized zoom. + * Standard browser input retains the established response exactly. Mini App + * WebViews receive a gentler curve and smoothly compress unusually large + * pointer batches instead of turning them into abrupt camera jumps. + */ +export function realmPinchZoomAmount( + scaleRatio: number, + profile: RealmPinchZoomProfile +) { + if (!Number.isFinite(scaleRatio) || scaleRatio <= 0) return 0; + const logarithmicDelta = Math.log(scaleRatio); + if (profile === 'standard') { + return logarithmicDelta * STANDARD_PINCH_ZOOM_SENSITIVITY; + } + const scaledDelta = logarithmicDelta * MINI_APP_PINCH_ZOOM_SENSITIVITY; + return Math.tanh(scaledDelta / MINI_APP_PINCH_ZOOM_SOFT_LIMIT) + * MINI_APP_PINCH_ZOOM_SOFT_LIMIT; +} diff --git a/tests/realmPinchZoom.test.ts b/tests/realmPinchZoom.test.ts new file mode 100644 index 00000000..abb53b24 --- /dev/null +++ b/tests/realmPinchZoom.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { + realmPinchZoomAmount, + realmPinchZoomProfileForChromeMode +} from '../src/components/realm/realmPinchZoom'; + +describe('realm pinch zoom', () => { + it('selects the gentler profile only for the verified Mini App chrome mode', () => { + expect(realmPinchZoomProfileForChromeMode('miniapp')).toBe('miniapp'); + expect(realmPinchZoomProfileForChromeMode('compact-web')).toBe('standard'); + expect(realmPinchZoomProfileForChromeMode('desktop-web')).toBe('standard'); + expect(realmPinchZoomProfileForChromeMode(undefined)).toBe('standard'); + }); + + it('preserves the established standard browser pinch response', () => { + expect(realmPinchZoomAmount(1.1, 'standard')) + .toBeCloseTo(Math.log(1.1) * 0.78, 12); + expect(realmPinchZoomAmount(0.9, 'standard')) + .toBeCloseTo(Math.log(0.9) * 0.78, 12); + }); + + it('slows ordinary Mini App pinches and smoothly bounds delayed WebView batches', () => { + const ordinaryStandard = realmPinchZoomAmount(1.1, 'standard'); + const ordinaryMiniApp = realmPinchZoomAmount(1.1, 'miniapp'); + const delayedMiniApp = realmPinchZoomAmount(2, 'miniapp'); + + expect(ordinaryMiniApp).toBeGreaterThan(0); + expect(ordinaryMiniApp).toBeLessThan(ordinaryStandard * 0.65); + expect(delayedMiniApp).toBeGreaterThan(ordinaryMiniApp); + expect(delayedMiniApp).toBeLessThan(0.12); + expect(realmPinchZoomAmount(0.5, 'miniapp')) + .toBeCloseTo(-delayedMiniApp, 12); + }); + + it('rejects malformed scale samples without moving the camera', () => { + expect(realmPinchZoomAmount(0, 'miniapp')).toBe(0); + expect(realmPinchZoomAmount(-1, 'miniapp')).toBe(0); + expect(realmPinchZoomAmount(Number.NaN, 'miniapp')).toBe(0); + expect(realmPinchZoomAmount(Number.POSITIVE_INFINITY, 'miniapp')).toBe(0); + }); +}); diff --git a/tests/realmSceneCleanup.test.ts b/tests/realmSceneCleanup.test.ts index c2712fa9..046272f8 100644 --- a/tests/realmSceneCleanup.test.ts +++ b/tests/realmSceneCleanup.test.ts @@ -3074,9 +3074,10 @@ describe('realm scene setup cleanup', () => { scene.dispose(); }); - it('pans by the moving pinch centroid without changing the final pinch scale', () => { + it('pans a Mini App pinch centroid without changing the final pinch scale', () => { const root = document.createElement('main'); root.className = 'realm-map-screen'; + root.dataset.realmChromeMode = 'miniapp'; const canvas = document.createElement('canvas'); canvas.className = 'realm-map-screen__canvas'; const label = document.createElement('button');