feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
540
server/supabase/functions/_shared/push-contract.ts
Normal file
540
server/supabase/functions/_shared/push-contract.ts
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
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',
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue