d3ro-voice/server/supabase/functions/_shared/push-contract.ts
Yun Chan bb0e54dcee feat(server): deliver push without a Firebase project
Every notification depended on Firebase Cloud Messaging, so a missing
Firebase project, which is the current state, meant no notification could be
delivered on any platform. Web Push and token-based Apple Push are now first
class transports alongside FCM, chosen per registered device, and a scheduled
Cloudflare Worker drain retries an outbox so a provider outage delays rather
than drops a message.
2026-09-16 23:24:44 +09:00

540 lines
18 KiB
TypeScript

export const PUSH_EVENT_TYPES = [
'transcription.completed',
'team.invite.created',
'billing.status.changed',
] as const
export type PushEventType = typeof PUSH_EVENT_TYPES[number]
export type PushLocale = 'ko' | 'en'
export type PushProvider = 'fcm' | 'apns' | 'webpush'
export interface PushRequest {
eventType: PushEventType
resourceId: string
}
export interface PushNotification {
title: string
body: string
data: Record<string, string>
}
export interface TeamInviteNotificationContext {
inviteToken: string
teamId: string
}
export interface FcmConfig {
clientEmail: string
privateKey: string
projectId: string
tokenUri: string
}
interface FcmAccessToken {
token: string
expiresAtMs: number
credentialIdentity: string
}
export interface FcmSendOptions {
fetchImpl?: typeof fetch
config?: FcmConfig
getAccessToken?: (config: FcmConfig, fetchImpl: typeof fetch) => Promise<string>
}
export interface FcmSendResult {
messageId: string
}
export class PushContractError extends Error {
constructor(
public readonly code: string,
public readonly status: number,
public readonly staleRegistration = false,
) {
super(code)
this.name = 'PushContractError'
}
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const INVITE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{20,128}$/
const PROJECT_ID_PATTERN = /^[a-z][a-z0-9-]{4,61}[a-z0-9]$/
const encoder = new TextEncoder()
const REQUEST_TIMEOUT_MS = 10_000
let cachedAccessToken: FcmAccessToken | null = null
let pendingAccessToken: { credentialIdentity: string; promise: Promise<string> } | null = null
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function isPushEventType(value: unknown): value is PushEventType {
return typeof value === 'string' && (PUSH_EVENT_TYPES as readonly string[]).includes(value)
}
export function isUuid(value: unknown): value is string {
return typeof value === 'string' && UUID_PATTERN.test(value)
}
export function parsePushRequest(value: unknown): PushRequest {
if (!isRecord(value)) throw new PushContractError('invalid_push_request', 400)
const keys = Object.keys(value)
if (
keys.length !== 2
|| !keys.includes('event_type')
|| !keys.includes('resource_id')
|| !isPushEventType(value.event_type)
|| !isUuid(value.resource_id)
) {
const eventWasUnsupported = typeof value.event_type === 'string'
&& !(PUSH_EVENT_TYPES as readonly string[]).includes(value.event_type)
throw new PushContractError(
eventWasUnsupported ? 'unsupported_push_event' : 'invalid_push_request',
400,
)
}
return { eventType: value.event_type, resourceId: value.resource_id }
}
export function normalizePushLocale(value: unknown): PushLocale {
return typeof value === 'string' && value.toLowerCase().startsWith('ko') ? 'ko' : 'en'
}
function fixedCopy(eventType: PushEventType, locale: PushLocale): Pick<PushNotification, 'title' | 'body'> {
const korean: Record<PushEventType, Pick<PushNotification, 'title' | 'body'>> = {
'transcription.completed': {
title: '전사가 완료됐어',
body: 'D3RO Voice에서 결과를 확인해.',
},
'team.invite.created': {
title: '새 팀 초대가 도착했어',
body: 'D3RO Voice에서 초대를 확인해.',
},
'billing.status.changed': {
title: '구독 상태가 업데이트됐어',
body: 'D3RO Voice에서 최신 상태를 확인해.',
},
}
const english: Record<PushEventType, Pick<PushNotification, 'title' | 'body'>> = {
'transcription.completed': {
title: 'Transcription complete',
body: 'Open D3RO Voice to view the result.',
},
'team.invite.created': {
title: 'New team invitation',
body: 'Open D3RO Voice to review the invitation.',
},
'billing.status.changed': {
title: 'Subscription updated',
body: 'Open D3RO Voice to view the latest status.',
},
}
return (locale === 'ko' ? korean : english)[eventType]
}
function androidDelivery(eventType: PushEventType, resourceId: string): {
collapse_key: string
priority: 'high'
ttl: string
restricted_package_name: 'com.d3ro.voice'
} {
const eventKey: Record<PushEventType, string> = {
'transcription.completed': 'transcription',
'team.invite.created': 'invite',
'billing.status.changed': 'billing',
}
const ttl: Record<PushEventType, string> = {
'transcription.completed': '3600s',
'team.invite.created': '86400s',
'billing.status.changed': '21600s',
}
return {
collapse_key: `${eventKey[eventType]}:${resourceId}`,
priority: 'high',
ttl: ttl[eventType],
restricted_package_name: 'com.d3ro.voice',
}
}
export function validateOutboundData(data: Record<string, string>): {
eventType: PushEventType
resourceId: string
} {
const eventType = data.event_type
const resourceId = data.resource_id
if (
data.schema_version !== '1'
|| !isPushEventType(eventType)
|| !isUuid(resourceId)
) {
throw new PushContractError('invalid_push_payload', 500)
}
const allowedKeys: Record<PushEventType, readonly string[]> = {
'transcription.completed': ['schema_version', 'event_type', 'resource_id', 'route', 'history_id'],
'team.invite.created': [
'schema_version',
'event_type',
'resource_id',
'route',
'invite_token',
'team_id',
],
'billing.status.changed': [
'schema_version',
'event_type',
'resource_id',
'route',
'subscription_id',
],
}
const expectedKeys = allowedKeys[eventType]
if (
Object.keys(data).length !== expectedKeys.length
|| Object.keys(data).some((key) => !expectedKeys.includes(key))
|| (eventType === 'transcription.completed'
&& (data.route !== 'HistoryDetail' || data.history_id !== resourceId))
|| (eventType === 'billing.status.changed'
&& (data.route !== 'ProPaywall' || data.subscription_id !== resourceId))
|| (eventType === 'team.invite.created'
&& (
data.route !== 'InviteAccept'
|| !INVITE_TOKEN_PATTERN.test(data.invite_token ?? '')
|| !isUuid(data.team_id)
))
) {
throw new PushContractError('invalid_push_payload', 500)
}
return { eventType, resourceId }
}
export function buildPushNotification(
eventType: PushEventType,
resourceId: string,
locale: PushLocale,
context?: TeamInviteNotificationContext,
): PushNotification {
if (!isUuid(resourceId)) throw new PushContractError('invalid_push_resource', 500)
const copy = fixedCopy(eventType, locale)
const common = {
schema_version: '1',
event_type: eventType,
resource_id: resourceId,
}
if (eventType === 'transcription.completed') {
return {
...copy,
data: { ...common, route: 'HistoryDetail', history_id: resourceId },
}
}
if (eventType === 'billing.status.changed') {
return {
...copy,
data: { ...common, route: 'ProPaywall', subscription_id: resourceId },
}
}
if (
!context
|| !INVITE_TOKEN_PATTERN.test(context.inviteToken)
|| !isUuid(context.teamId)
) {
throw new PushContractError('invalid_team_invite_resource', 500)
}
return {
...copy,
data: {
...common,
route: 'InviteAccept',
invite_token: context.inviteToken,
team_id: context.teamId,
},
}
}
function encodeBase64Url(value: Uint8Array | string): string {
const bytes = typeof value === 'string' ? encoder.encode(value) : value
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}
function decodePrivateKey(pem: string): Uint8Array {
const body = pem
.replace(/-----BEGIN PRIVATE KEY-----/g, '')
.replace(/-----END PRIVATE KEY-----/g, '')
.replace(/\s/g, '')
if (!body) throw new PushContractError('fcm_credentials_invalid', 503)
try {
const binary = atob(body)
return Uint8Array.from(binary, (character) => character.charCodeAt(0))
} catch {
throw new PushContractError('fcm_credentials_invalid', 503)
}
}
export function readFcmConfig(
readEnv: (name: string) => string | undefined = (name) => Deno.env.get(name),
): FcmConfig {
const raw = readEnv('FCM_SERVICE_ACCOUNT_JSON')?.trim() ?? ''
if (!raw) throw new PushContractError('fcm_not_configured', 503)
try {
const parsed = JSON.parse(raw) as Record<string, unknown>
const configuredProjectId = readEnv('FCM_PROJECT_ID')?.trim()
const accountProjectId = typeof parsed.project_id === 'string' ? parsed.project_id.trim() : ''
const projectId = configuredProjectId || accountProjectId
const clientEmail = typeof parsed.client_email === 'string' ? parsed.client_email.trim() : ''
const privateKey = typeof parsed.private_key === 'string' ? parsed.private_key : ''
const tokenUri = typeof parsed.token_uri === 'string'
? parsed.token_uri.trim()
: 'https://oauth2.googleapis.com/token'
const parsedTokenUri = new URL(tokenUri)
if (
!clientEmail
|| !privateKey
|| !PROJECT_ID_PATTERN.test(projectId)
|| (configuredProjectId && accountProjectId && configuredProjectId !== accountProjectId)
|| parsedTokenUri.protocol !== 'https:'
|| parsedTokenUri.hostname !== 'oauth2.googleapis.com'
|| parsedTokenUri.pathname !== '/token'
|| parsedTokenUri.username !== ''
|| parsedTokenUri.password !== ''
|| parsedTokenUri.search !== ''
|| parsedTokenUri.hash !== ''
) {
throw new Error('invalid credentials')
}
return { clientEmail, privateKey, projectId, tokenUri: parsedTokenUri.toString() }
} catch (error) {
if (error instanceof PushContractError) throw error
throw new PushContractError('fcm_credentials_invalid', 503)
}
}
async function createServiceAccountAssertion(config: FcmConfig, nowSeconds: number): Promise<string> {
const header = encodeBase64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
const claims = encodeBase64Url(JSON.stringify({
iss: config.clientEmail,
scope: 'https://www.googleapis.com/auth/firebase.messaging',
aud: config.tokenUri,
iat: nowSeconds,
exp: nowSeconds + 3600,
}))
const signingInput = `${header}.${claims}`
try {
const decodedKey = decodePrivateKey(config.privateKey)
const keyBytes = new ArrayBuffer(decodedKey.byteLength)
new Uint8Array(keyBytes).set(decodedKey)
const key = await crypto.subtle.importKey(
'pkcs8',
keyBytes,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['sign'],
)
const signature = await crypto.subtle.sign(
'RSASSA-PKCS1-v1_5',
key,
encoder.encode(signingInput),
)
return `${signingInput}.${encodeBase64Url(new Uint8Array(signature))}`
} catch (error) {
if (error instanceof PushContractError) throw error
throw new PushContractError('fcm_credentials_invalid', 503)
}
}
export async function getFcmAccessToken(
config: FcmConfig,
fetchImpl: typeof fetch = fetch,
): Promise<string> {
const nowMs = Date.now()
const credentialIdentity = `${config.clientEmail}\n${config.projectId}\n${config.tokenUri}`
if (
cachedAccessToken
&& cachedAccessToken.credentialIdentity === credentialIdentity
&& cachedAccessToken.expiresAtMs > nowMs + 60_000
) {
return cachedAccessToken.token
}
if (pendingAccessToken?.credentialIdentity === credentialIdentity) {
return pendingAccessToken.promise
}
const promise = (async (): Promise<string> => {
const assertion = await createServiceAccountAssertion(config, Math.floor(nowMs / 1000))
let response: Response
try {
response = await fetchImpl(config.tokenUri, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion,
}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
} catch {
throw new PushContractError('fcm_auth_timeout', 504)
}
if (!response.ok) throw new PushContractError('fcm_auth_failed', 502)
const body = await response.json() as Record<string, unknown>
if (typeof body.access_token !== 'string' || body.access_token.length < 10) {
throw new PushContractError('fcm_auth_failed', 502)
}
const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 3600
cachedAccessToken = {
token: body.access_token,
expiresAtMs: nowMs + Math.max(60, expiresIn) * 1000,
credentialIdentity,
}
return body.access_token
})()
pendingAccessToken = { credentialIdentity, promise }
try {
return await promise
} finally {
if (pendingAccessToken?.promise === promise) pendingAccessToken = null
}
}
function isUnregisteredFcmError(value: unknown): boolean {
if (!isRecord(value) || !isRecord(value.error)) return false
if (value.error.status === 'UNREGISTERED') return true
const details = Array.isArray(value.error.details) ? value.error.details : []
return details.some((detail) => (
isRecord(detail)
&& detail['@type'] === 'type.googleapis.com/google.firebase.fcm.v1.FcmError'
&& detail.errorCode === 'UNREGISTERED'
))
}
export async function sendFcmMessage(
registrationId: string,
notification: PushNotification,
options: FcmSendOptions = {},
): Promise<FcmSendResult> {
if (
typeof registrationId !== 'string'
|| registrationId.length < 20
|| registrationId.length > 4096
|| /\s/.test(registrationId)
) {
throw new PushContractError('fcm_registration_invalid', 400, true)
}
const fetchImpl = options.fetchImpl ?? fetch
const config = options.config ?? readFcmConfig()
const outbound = validateOutboundData(notification.data)
const accessToken = await (options.getAccessToken ?? getFcmAccessToken)(config, fetchImpl)
const endpoint = `https://fcm.googleapis.com/v1/projects/${config.projectId}/messages:send`
let response: Response
try {
response = await fetchImpl(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({
message: {
token: registrationId,
data: notification.data,
// Data-only is deliberate. Android displays notification payloads
// before app code can validate them while backgrounded. The native
// receiver validates this exact data allowlist and renders fixed
// local copy itself.
android: androidDelivery(
outbound.eventType,
outbound.resourceId,
),
},
}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
} catch {
throw new PushContractError('fcm_send_timeout', 504)
}
let body: unknown = null
try {
body = await response.json()
} catch {
// A malformed provider response is never exposed to the caller.
}
if (!response.ok) {
const stale = response.status === 404 && isUnregisteredFcmError(body)
throw new PushContractError(stale ? 'fcm_registration_stale' : 'fcm_send_failed', 502, stale)
}
if (!isRecord(body) || typeof body.name !== 'string' || body.name.length < 3) {
throw new PushContractError('fcm_response_invalid', 502)
}
return { messageId: body.name }
}
export function providerIsSupported(provider: PushProvider): boolean {
return provider === 'fcm' || provider === 'webpush' || provider === 'apns'
}
async function credentialsEqual(presented: string, expected: string): Promise<boolean> {
const [presentedDigest, expectedDigest] = await Promise.all([
crypto.subtle.digest('SHA-256', encoder.encode(presented)),
crypto.subtle.digest('SHA-256', encoder.encode(expected)),
])
const presentedBytes = new Uint8Array(presentedDigest)
const expectedBytes = new Uint8Array(expectedDigest)
let difference = presentedBytes.length ^ expectedBytes.length
for (let index = 0; index < Math.max(presentedBytes.length, expectedBytes.length); index += 1) {
difference |= (presentedBytes[index] ?? 0) ^ (expectedBytes[index] ?? 0)
}
return difference === 0
}
export async function isServiceRoleAuthorization(
authorization: string | null,
serviceRoleKey: string | undefined,
): Promise<boolean> {
if (!authorization || !serviceRoleKey || serviceRoleKey.length < 20) return false
const match = /^Bearer ([^\s]+)$/.exec(authorization)
if (!match) return false
return await credentialsEqual(match[1], serviceRoleKey)
}
export async function isServiceRoleApiKey(
apiKey: string | null,
readEnv: (name: string) => string | undefined = (name) => Deno.env.get(name),
): Promise<boolean> {
if (!apiKey || apiKey.length < 20 || /\s/.test(apiKey)) return false
const candidates = new Set<string>()
const legacyServiceKey = readEnv('SUPABASE_SERVICE_ROLE_KEY')?.trim()
if (legacyServiceKey && legacyServiceKey.length >= 20) candidates.add(legacyServiceKey)
const modernServiceKey = readEnv('SUPABASE_SECRET_KEY')?.trim()
if (modernServiceKey?.startsWith('sb_secret_') && modernServiceKey.length >= 20) {
candidates.add(modernServiceKey)
}
const secretMap = readEnv('SUPABASE_SECRET_KEYS')?.trim()
if (secretMap) {
try {
const parsed = JSON.parse(secretMap) as unknown
if (isRecord(parsed)) {
for (const value of Object.values(parsed)) {
if (
typeof value === 'string'
&& value.startsWith('sb_secret_')
&& value.length >= 20
) {
candidates.add(value)
}
}
}
} catch {
return false
}
}
const comparisons = await Promise.all(
[...candidates].map((candidate) => credentialsEqual(apiKey, candidate)),
)
return comparisons.some(Boolean)
}