fix(push): stop web push from posting to arbitrary endpoints
This commit is contained in:
parent
1afaea7214
commit
043ef579a8
7 changed files with 418 additions and 34 deletions
|
|
@ -0,0 +1,84 @@
|
|||
// server/supabase/functions/send-push/dispatch-response.test.ts
|
||||
// The dispatch presenter must not leak provider transport codes to end users.
|
||||
|
||||
import { presentDispatchOutcome, type DispatchOutcome } from './dispatch-response.ts'
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
const EVENT = 'transcription.completed'
|
||||
const RESOURCE = '11111111-2222-4333-8444-555555555555'
|
||||
|
||||
function outcome(overrides: Partial<DispatchOutcome> = {}): DispatchOutcome {
|
||||
return {
|
||||
status: 'succeeded',
|
||||
complete: true,
|
||||
attempted: 1,
|
||||
delivered: 1,
|
||||
stale: 0,
|
||||
retryableFailed: 0,
|
||||
permanentFailed: 0,
|
||||
nextRetryAt: null,
|
||||
transientError: null,
|
||||
transientStatus: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const TRANSIENT_CASES: ReadonlyArray<[string, number]> = [
|
||||
['webpush_send_timeout', 504],
|
||||
['webpush_send_failed', 502],
|
||||
['webpush_payload_too_large', 500],
|
||||
['webpush_not_configured', 503],
|
||||
['fcm_send_failed', 502],
|
||||
]
|
||||
|
||||
Deno.test('user callers see one generic failure regardless of the transport outcome', () => {
|
||||
const seen = new Set<string>()
|
||||
for (const [code, status] of TRANSIENT_CASES) {
|
||||
const presented = presentDispatchOutcome('user', EVENT, RESOURCE, outcome({
|
||||
status: 'pending',
|
||||
complete: false,
|
||||
delivered: 0,
|
||||
retryableFailed: 1,
|
||||
transientError: code,
|
||||
transientStatus: status,
|
||||
}))
|
||||
assert(presented.body.error === 'push_delivery_failed', `${code} leaked as ${String(presented.body.error)}`)
|
||||
assert(!JSON.stringify(presented.body).includes(code), `${code} must not appear in the body`)
|
||||
seen.add(`${presented.status}:${String(presented.body.error)}`)
|
||||
}
|
||||
assert(seen.size === 1, `user responses must be indistinguishable, got ${[...seen].join(', ')}`)
|
||||
})
|
||||
|
||||
Deno.test('system callers keep the detailed transport code and status', () => {
|
||||
for (const [code, status] of TRANSIENT_CASES) {
|
||||
const presented = presentDispatchOutcome('system', EVENT, RESOURCE, outcome({
|
||||
status: 'pending',
|
||||
complete: false,
|
||||
delivered: 0,
|
||||
retryableFailed: 1,
|
||||
transientError: code,
|
||||
transientStatus: status,
|
||||
}))
|
||||
assert(presented.body.error === code, `system caller expects ${code}`)
|
||||
assert(presented.status === status, `system caller expects status ${status}`)
|
||||
}
|
||||
})
|
||||
|
||||
Deno.test('success, pending and partial responses keep their existing shape', () => {
|
||||
const ok = presentDispatchOutcome('user', EVENT, RESOURCE, outcome())
|
||||
assert(ok.status === 200, 'complete dispatch is 200')
|
||||
assert(!('error' in ok.body), 'success carries no error')
|
||||
assert(ok.body.sent === 1 && ok.body.event_type === EVENT && ok.body.resource_id === RESOURCE, 'body fields preserved')
|
||||
assert(ok.body.duplicate === false, 'duplicate flag preserved')
|
||||
|
||||
const pending = presentDispatchOutcome('system', EVENT, RESOURCE, outcome({ status: 'processing', complete: false }))
|
||||
assert(pending.status === 202, 'incomplete dispatch is 202')
|
||||
|
||||
for (const audience of ['user', 'system'] as const) {
|
||||
const partial = presentDispatchOutcome(audience, EVENT, RESOURCE, outcome({ status: 'partial', permanentFailed: 1 }))
|
||||
assert(partial.status === 502 && partial.body.error === 'push_delivery_failed', `${audience} partial is 502`)
|
||||
}
|
||||
})
|
||||
74
server/supabase/functions/send-push/dispatch-response.ts
Normal file
74
server/supabase/functions/send-push/dispatch-response.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// server/supabase/functions/send-push/dispatch-response.ts
|
||||
// Pure presenter: turns a processed push dispatch into the HTTP body/status.
|
||||
//
|
||||
// Provider transport codes (webpush_send_timeout, fcm_send_failed, …) and
|
||||
// their distinct HTTP statuses describe what happened on the function's own
|
||||
// network. Echoing them to an end-user caller turns the dispatch endpoint into
|
||||
// a reachability oracle, so user callers only ever see one generic failure.
|
||||
// Trusted system callers (service role, drain worker) keep the detailed code
|
||||
// for operations.
|
||||
|
||||
export type DispatchAudience = 'user' | 'system'
|
||||
|
||||
export interface DispatchOutcome {
|
||||
status: 'pending' | 'processing' | 'succeeded' | 'partial' | 'failed'
|
||||
complete: boolean
|
||||
attempted: number
|
||||
delivered: number
|
||||
stale: number
|
||||
retryableFailed: number
|
||||
permanentFailed: number
|
||||
nextRetryAt: string | null
|
||||
transientError: string | null
|
||||
transientStatus: number | null
|
||||
}
|
||||
|
||||
export interface DispatchResponse {
|
||||
body: Record<string, unknown>
|
||||
status: number
|
||||
}
|
||||
|
||||
export const DELIVERY_FAILURE_CODE = 'push_delivery_failed'
|
||||
const DELIVERY_FAILURE_STATUS = 502
|
||||
|
||||
export function presentDispatchOutcome(
|
||||
audience: DispatchAudience,
|
||||
eventType: string,
|
||||
resourceId: string,
|
||||
outcome: DispatchOutcome,
|
||||
): DispatchResponse {
|
||||
const body: Record<string, unknown> = {
|
||||
event_type: eventType,
|
||||
resource_id: resourceId,
|
||||
duplicate: false,
|
||||
status: outcome.status,
|
||||
complete: outcome.complete,
|
||||
attempted: outcome.attempted,
|
||||
sent: outcome.delivered,
|
||||
stale_removed: outcome.stale,
|
||||
retryable_failed: outcome.retryableFailed,
|
||||
permanent_failed: outcome.permanentFailed,
|
||||
next_retry_at: outcome.nextRetryAt,
|
||||
}
|
||||
const failed = outcome.status === 'partial' || outcome.status === 'failed'
|
||||
|
||||
if (outcome.transientError) {
|
||||
if (audience === 'user') {
|
||||
return {
|
||||
body: { error: DELIVERY_FAILURE_CODE, ...body },
|
||||
status: DELIVERY_FAILURE_STATUS,
|
||||
}
|
||||
}
|
||||
return {
|
||||
body: { error: outcome.transientError, ...body },
|
||||
status: outcome.transientStatus ?? DELIVERY_FAILURE_STATUS,
|
||||
}
|
||||
}
|
||||
if (failed) {
|
||||
return {
|
||||
body: { error: DELIVERY_FAILURE_CODE, ...body },
|
||||
status: DELIVERY_FAILURE_STATUS,
|
||||
}
|
||||
}
|
||||
return { body, status: outcome.complete ? 200 : 202 }
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
import { readApnsConfig, sendApnsMessage } from '../_shared/apns.ts'
|
||||
import { readWebPushConfig, sendWebPushMessage } from '../_shared/webpush.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
import { presentDispatchOutcome } from './dispatch-response.ts'
|
||||
|
||||
const JSON_HEADERS = {
|
||||
...corsHeaders,
|
||||
|
|
@ -739,29 +740,8 @@ Deno.serve(async (req: Request) => {
|
|||
dispatchLeaseToken: reservation.dispatchLeaseToken,
|
||||
inviteContext: resolved.inviteContext,
|
||||
})
|
||||
const responseBody = {
|
||||
event_type: input.eventType,
|
||||
resource_id: input.resourceId,
|
||||
duplicate: false,
|
||||
status: result.status,
|
||||
complete: result.complete,
|
||||
attempted: result.attempted,
|
||||
sent: result.delivered,
|
||||
stale_removed: result.stale,
|
||||
retryable_failed: result.retryableFailed,
|
||||
permanent_failed: result.permanentFailed,
|
||||
next_retry_at: result.nextRetryAt,
|
||||
}
|
||||
if (result.transientError) {
|
||||
return jsonResponse(
|
||||
{ error: result.transientError, ...responseBody },
|
||||
result.transientStatus ?? 502,
|
||||
)
|
||||
}
|
||||
if (result.status === 'partial' || result.status === 'failed') {
|
||||
return jsonResponse({ error: 'push_delivery_failed', ...responseBody }, 502)
|
||||
}
|
||||
return jsonResponse(responseBody, result.complete ? 200 : 202)
|
||||
const presented = presentDispatchOutcome(actor.kind, input.eventType, input.resourceId, result)
|
||||
return jsonResponse(presented.body, presented.status)
|
||||
} catch (error) {
|
||||
if (error instanceof PushContractError) {
|
||||
return jsonResponse({ error: error.code, message: error.code }, error.status)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue