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.
540 lines
18 KiB
TypeScript
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)
|
|
}
|