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 } 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 } 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 } | null = null function isRecord(value: unknown): value is Record { 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 { const korean: Record> = { 'transcription.completed': { title: '전사가 완료됐어', body: 'D3RO Voice에서 결과를 확인해.', }, 'team.invite.created': { title: '새 팀 초대가 도착했어', body: 'D3RO Voice에서 초대를 확인해.', }, 'billing.status.changed': { title: '구독 상태가 업데이트됐어', body: 'D3RO Voice에서 최신 상태를 확인해.', }, } const english: Record> = { '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 = { 'transcription.completed': 'transcription', 'team.invite.created': 'invite', 'billing.status.changed': 'billing', } const ttl: Record = { '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', } } function validateOutboundData(data: Record): { 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 = { '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 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 { 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 { 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 => { 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 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 { 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' } async function credentialsEqual(presented: string, expected: string): Promise { 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 { 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 { if (!apiKey || apiKey.length < 20 || /\s/.test(apiKey)) return false const candidates = new Set() 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) }