Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions services/auth-bridge/src/admissionNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const RETRY_DELAYS_MILLISECONDS = Object.freeze([
4 * 60 * 60_000,
12 * 60 * 60_000,
])
const LEGACY_TRANSPORT_RETRY_MINIMUM_AGE_MILLISECONDS = 30_000

type DeliveryAttemptStatus = 'pending' | 'retrying' | 'sent' | 'exhausted'

Expand Down Expand Up @@ -1010,6 +1011,61 @@ function deliveryGeneration(delivery: AdmissionDelivery): AdmissionNotificationG
})
}

/**
* Older Cloudflare deployments classified their runtime-level redirect failure
* as the broad `transport` reason. An authenticated operator replay may bring
* the exact legacy fifth-attempt generation forward after Farcaster's minimum
* retry interval. The legacy record did not store a last-attempt timestamp, so
* derive it from the persisted four-hour backoff invariant. The attempt counter
* and six-attempt ceiling never reset. Current deployments emit richer records,
* so this compatibility path cannot become a general-purpose backoff bypass.
*/
function recoverLegacyTransportBackoff(
state: PersistedNotificationState,
diagnostics: PersistedNotificationDiagnostics | null,
generation: AdmissionNotificationGeneration,
now: number,
): PersistedNotificationState {
if (
!state.delivery
|| state.delivery.kind !== 'admitted'
|| generation.kind !== 'admitted'
|| !generationEquals(deliveryGeneration(state.delivery), generation)
|| !diagnostics
|| !generationEquals(diagnostics.generation, generation)
|| diagnostics.retryReasons.length !== 1
|| diagnostics.retryReasons[0] !== 'transport'
|| diagnostics.lastFailureReason !== undefined
|| diagnostics.lastAttemptAt !== undefined
|| state.delivery.attempts.length === 0
|| state.delivery.attempts.some((attempt) => (
attempt.status !== 'retrying'
|| attempt.attempts !== MAX_DELIVERY_ATTEMPTS - 1
|| attempt.nextAttemptAt === undefined
|| attempt.nextAttemptAt <= now
|| attempt.nextAttemptAt - RETRY_DELAYS_MILLISECONDS[attempt.attempts - 1]
> now - LEGACY_TRANSPORT_RETRY_MINIMUM_AGE_MILLISECONDS
))
) return state

const attempts = state.delivery.attempts.map((attempt) => {
return Object.freeze({
appFid: attempt.appFid,
tokenId: attempt.tokenId,
status: 'pending' as const,
attempts: attempt.attempts,
verificationFailures: attempt.verificationFailures,
})
})
return Object.freeze({
...state,
delivery: Object.freeze({
...state.delivery,
attempts: Object.freeze(attempts),
}),
})
}

async function recordDiagnostics(
storage: DurableObjectState['storage'],
generation: AdmissionNotificationGeneration,
Expand Down Expand Up @@ -1904,6 +1960,10 @@ export class AdmissionNotification {
) {
return new Response(null, { status: 409 })
}
const diagnostics = readPersistedDiagnostics(
await this.state.storage.get<unknown>(DIAGNOSTICS_RECORD),
)
next = recoverLegacyTransportBackoff(next, diagnostics, generation, now)
if (
!next.delivery
|| !generationEquals(deliveryGeneration(next.delivery), generation)
Expand Down
71 changes: 71 additions & 0 deletions services/auth-bridge/test/admissionNotifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 DIAGNOSTICS_RECORD = 'admission-notification-diagnostics-v1'

class FakeStorage implements DurableObjectStorage {
readonly values = new Map<string, unknown>()
Expand Down Expand Up @@ -586,6 +587,76 @@ describe('admission notification consent and delivery lifecycle', () => {
})
})

it('lets an operator replay bring only a legacy transport backoff forward', async () => {
const fetchImpl = vi.fn<typeof fetch>(async () => successfulDelivery())
const h = createHarness({ fetchImpl })
await applyEvent(h.notification, enabledEvent())
const state = h.storage.values.get(STATE_KEY) as Record<string, unknown>
const subscriptions = state.subscriptions as Array<Record<string, unknown>>
h.storage.values.set(STATE_KEY, {
...state,
delivery: {
authEpoch: 7,
queuedAt: NOW - 60_000,
expiresAt: NOW - 60_000 + 24 * 60 * 60 * 1_000,
attempts: [{
appFid: APP_FID,
tokenId: subscriptions[0].tokenId,
status: 'retrying',
attempts: 5,
verificationFailures: 0,
nextAttemptAt: NOW - 30_000 + 4 * 60 * 60_000,
}],
},
})
h.storage.values.set(DIAGNOSTICS_RECORD, {
authEpoch: 7,
retryReasons: ['transport'],
})

await expect((await queue(h.notification)).json()).resolves.toEqual({
status: 'already-sent',
})
expect(fetchImpl).toHaveBeenCalledOnce()
expect(stored(h.storage)).toContain('"attempts":6')
expect(stored(h.storage)).toContain('"lastSentAuthEpoch":7')
})

it('does not accelerate a current transport retry classification', async () => {
const fetchImpl = vi.fn<typeof fetch>(async () => successfulDelivery())
const h = createHarness({ fetchImpl })
await applyEvent(h.notification, enabledEvent())
const state = h.storage.values.get(STATE_KEY) as Record<string, unknown>
const subscriptions = state.subscriptions as Array<Record<string, unknown>>
h.storage.values.set(STATE_KEY, {
...state,
delivery: {
authEpoch: 7,
queuedAt: NOW - 60_000,
expiresAt: NOW - 60_000 + 24 * 60 * 60 * 1_000,
attempts: [{
appFid: APP_FID,
tokenId: subscriptions[0].tokenId,
status: 'retrying',
attempts: 1,
verificationFailures: 0,
nextAttemptAt: NOW + 30_000,
}],
},
})
h.storage.values.set(DIAGNOSTICS_RECORD, {
generation: 'admitted',
authEpoch: 7,
retryReasons: ['transport-fetch-rejected'],
lastAttemptAt: NOW - 30_000,
lastFailureReason: 'transport-fetch-rejected',
})

await expect((await queue(h.notification)).json()).resolves.toEqual({ status: 'queued' })
expect(fetchImpl).not.toHaveBeenCalled()
expect(stored(h.storage)).toContain('"attempts":1')
})

it('rejects redirects without following or retrying them', async () => {
const h = createHarness({
fetchImpl: vi.fn<typeof fetch>(async (_input, init) => {
Expand Down