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.
This commit is contained in:
parent
5aa268970a
commit
bb0e54dcee
13 changed files with 1412 additions and 41 deletions
|
|
@ -1,9 +1,17 @@
|
|||
// server/cloudflare-worker/src/index.ts
|
||||
// D3RO Voice — Global Cloudflare Edge Gateway & Proxy Worker
|
||||
|
||||
import { drainPushDispatches } from './push-drain.ts'
|
||||
|
||||
export interface Env {
|
||||
BACKEND_ORIGIN: string
|
||||
SERVICE_NAME?: string
|
||||
/** Supabase project base URL, e.g. https://<ref>.supabase.co */
|
||||
SUPABASE_URL?: string
|
||||
/** Supabase service-role key (secret). Used only for the scheduled drain. */
|
||||
SUPABASE_SERVICE_ROLE_KEY?: string
|
||||
/** Optional drain batch size, 1-100 (default 20). */
|
||||
PUSH_DRAIN_BATCH_LIMIT?: string
|
||||
}
|
||||
|
||||
const CORS_HEADERS: Record<string, string> = {
|
||||
|
|
@ -93,4 +101,26 @@ export default {
|
|||
)
|
||||
}
|
||||
},
|
||||
|
||||
// Cron trigger: drain the Supabase push outbox. Nothing else in the
|
||||
// repository scheduled this, so transactionally enqueued notifications
|
||||
// (team invites, transcription completion, billing) never left the queue.
|
||||
async scheduled(
|
||||
_event: unknown,
|
||||
env: Env,
|
||||
ctx: { waitUntil(promise: Promise<unknown>): void },
|
||||
): Promise<void> {
|
||||
ctx.waitUntil(drainAndLog(env))
|
||||
},
|
||||
}
|
||||
|
||||
async function drainAndLog(env: Env): Promise<void> {
|
||||
const result = await drainPushDispatches(env)
|
||||
if (result.ok) {
|
||||
console.log(
|
||||
`[push-drain] claimed=${result.claimed ?? 0} completed=${result.completed ?? 0} pending=${result.pending ?? 0}`,
|
||||
)
|
||||
} else {
|
||||
console.error(`[push-drain] failed status=${result.status} error=${result.error ?? 'unknown'}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
69
server/cloudflare-worker/src/push-drain.test.ts
Normal file
69
server/cloudflare-worker/src/push-drain.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// server/cloudflare-worker/src/push-drain.test.ts
|
||||
import { drainPushDispatches, resolveBatchLimit } from './push-drain.ts'
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
const env = { SUPABASE_URL: 'https://example.supabase.co', SUPABASE_SERVICE_ROLE_KEY: 'x'.repeat(40) }
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
Deno.test('batch limit is clamped to the edge function contract', () => {
|
||||
assert(resolveBatchLimit(undefined) === 20, 'default is 20')
|
||||
assert(resolveBatchLimit('5') === 5, 'explicit value is used')
|
||||
assert(resolveBatchLimit('9999') === 100, 'upper bound is 100')
|
||||
assert(resolveBatchLimit('0') === 20, 'zero falls back to the default')
|
||||
assert(resolveBatchLimit('abc') === 20, 'garbage falls back to the default')
|
||||
})
|
||||
|
||||
Deno.test('drain fails closed without configuration', async () => {
|
||||
const result = await drainPushDispatches({}, () => {
|
||||
throw new Error('must not be called')
|
||||
})
|
||||
assert(result.ok === false, 'not ok')
|
||||
assert(result.error === 'push_drain_not_configured', 'clear configuration error')
|
||||
assert(result.status === 503, 'service unavailable')
|
||||
})
|
||||
|
||||
Deno.test('drain posts to the drain endpoint with the service role bearer', async () => {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = []
|
||||
const fetchImpl = ((url: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init: init ?? {} })
|
||||
return Promise.resolve(jsonResponse(200, { claimed: 3, completed: 2, pending: 1 }))
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const result = await drainPushDispatches(env, fetchImpl)
|
||||
assert(result.ok === true, 'ok')
|
||||
assert(result.claimed === 3 && result.completed === 2 && result.pending === 1, 'summary parsed')
|
||||
assert(calls[0].url === 'https://example.supabase.co/functions/v1/send-push?mode=drain&limit=20', 'drain url')
|
||||
const headers = calls[0].init.headers as Record<string, string>
|
||||
assert(headers.Authorization === `Bearer ${env.SUPABASE_SERVICE_ROLE_KEY}`, 'service role bearer')
|
||||
assert(headers['Content-Type'] === 'application/json', 'json content type')
|
||||
assert(calls[0].init.body === '{}', 'empty json body')
|
||||
assert(calls[0].init.method === 'POST', 'post method')
|
||||
})
|
||||
|
||||
Deno.test('drain reports upstream failures without throwing', async () => {
|
||||
const httpFail = ((() => Promise.resolve(new Response('nope', { status: 500 }))) as unknown as typeof fetch)
|
||||
const result = await drainPushDispatches(env, httpFail)
|
||||
assert(result.ok === false, 'not ok')
|
||||
assert(result.error === 'push_drain_http_500', 'http error surfaced')
|
||||
assert(result.status === 500, 'status surfaced')
|
||||
|
||||
const unreachable = (() => Promise.reject(new Error('network'))) as unknown as typeof fetch
|
||||
const unreachableResult = await drainPushDispatches(env, unreachable)
|
||||
assert(unreachableResult.error === 'push_drain_unreachable', 'network error surfaced')
|
||||
})
|
||||
|
||||
Deno.test('drain tolerates a malformed success body', async () => {
|
||||
const fetchImpl = ((() => Promise.resolve(new Response('not-json', { status: 200 }))) as unknown as typeof fetch)
|
||||
const result = await drainPushDispatches(env, fetchImpl)
|
||||
assert(result.ok === true, 'ok')
|
||||
assert(result.claimed === undefined, 'summary left undefined')
|
||||
})
|
||||
84
server/cloudflare-worker/src/push-drain.ts
Normal file
84
server/cloudflare-worker/src/push-drain.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// server/cloudflare-worker/src/push-drain.ts
|
||||
// Cloudflare Cron-triggered drain for the Supabase push outbox.
|
||||
//
|
||||
// Supabase owns the durable outbox and the send-push Edge Function; nothing in
|
||||
// the repository scheduled the drain, so transactionally enqueued notifications
|
||||
// (team invites, transcription completion, billing) never left the queue. This
|
||||
// worker drains them on a cron schedule.
|
||||
|
||||
export interface PushDrainEnv {
|
||||
SUPABASE_URL?: string
|
||||
SUPABASE_SERVICE_ROLE_KEY?: string
|
||||
PUSH_DRAIN_BATCH_LIMIT?: string
|
||||
}
|
||||
|
||||
export interface PushDrainResult {
|
||||
ok: boolean
|
||||
status: number
|
||||
claimed?: number
|
||||
completed?: number
|
||||
pending?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
const DEFAULT_BATCH_LIMIT = 20
|
||||
const MAX_BATCH_LIMIT = 100
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
|
||||
export function resolveBatchLimit(raw: string | undefined): number {
|
||||
const parsed = Number(raw ?? DEFAULT_BATCH_LIMIT)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) return DEFAULT_BATCH_LIMIT
|
||||
return Math.min(parsed, MAX_BATCH_LIMIT)
|
||||
}
|
||||
|
||||
export async function drainPushDispatches(
|
||||
env: PushDrainEnv,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<PushDrainResult> {
|
||||
const baseUrl = env.SUPABASE_URL?.trim().replace(/\/+$/, '') ?? ''
|
||||
const serviceRoleKey = env.SUPABASE_SERVICE_ROLE_KEY?.trim() ?? ''
|
||||
if (!baseUrl || serviceRoleKey.length < 20) {
|
||||
return { ok: false, status: 503, error: 'push_drain_not_configured' }
|
||||
}
|
||||
|
||||
const limit = resolveBatchLimit(env.PUSH_DRAIN_BATCH_LIMIT)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetchImpl(
|
||||
`${baseUrl}/functions/v1/send-push?mode=drain&limit=${limit}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${serviceRoleKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: '{}',
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
return { ok: false, status: 502, error: 'push_drain_unreachable' }
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { ok: false, status: response.status, error: `push_drain_http_${response.status}` }
|
||||
}
|
||||
|
||||
let body: Record<string, unknown> = {}
|
||||
try {
|
||||
const parsed = await response.json()
|
||||
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
body = parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
body = {}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: response.status,
|
||||
claimed: typeof body.claimed === 'number' ? body.claimed : undefined,
|
||||
completed: typeof body.completed === 'number' ? body.completed : undefined,
|
||||
pending: typeof body.pending === 'number' ? body.pending : undefined,
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,14 @@ compatibility_date = "2024-04-01"
|
|||
[vars]
|
||||
BACKEND_ORIGIN = "http://192.168.0.39:5050"
|
||||
SERVICE_NAME = "D3RO Voice Cloud Gateway"
|
||||
# Supabase project base URL for the scheduled push-outbox drain.
|
||||
# SUPABASE_URL = "https://<project-ref>.supabase.co"
|
||||
# PUSH_DRAIN_BATCH_LIMIT = "20"
|
||||
|
||||
# Scheduled drain of the Supabase push outbox (every minute).
|
||||
# Requires the secret: wrangler secret put SUPABASE_SERVICE_ROLE_KEY
|
||||
[triggers]
|
||||
crons = ["* * * * *"]
|
||||
|
||||
# Cloudflare Custom Domain / Route Binding (선택 시 활성화)
|
||||
# routes = [
|
||||
|
|
|
|||
189
server/supabase/functions/_shared/apns.test.ts
Normal file
189
server/supabase/functions/_shared/apns.test.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
// server/supabase/functions/_shared/apns.test.ts
|
||||
// Apple Push Notification service (token/.p8) transport tests. Firebase-free.
|
||||
|
||||
import {
|
||||
createApnsToken,
|
||||
readApnsConfig,
|
||||
sendApnsMessage,
|
||||
type ApnsConfig,
|
||||
} from './apns.ts'
|
||||
import { PushContractError, type PushNotification } from './push-contract.ts'
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
async function assertPushError(
|
||||
action: () => unknown | Promise<unknown>,
|
||||
code: string,
|
||||
status: number,
|
||||
stale?: boolean,
|
||||
): Promise<void> {
|
||||
let actual: unknown
|
||||
try {
|
||||
await action()
|
||||
} catch (error) {
|
||||
actual = error
|
||||
}
|
||||
assert(actual instanceof PushContractError, `expected PushContractError for ${code}`)
|
||||
assert(actual.code === code, `expected ${code}, received ${actual.code}`)
|
||||
assert(actual.status === status, `expected status ${status}, received ${actual.status}`)
|
||||
if (stale !== undefined) {
|
||||
assert(actual.staleRegistration === stale, `expected stale=${stale}`)
|
||||
}
|
||||
}
|
||||
|
||||
function toPem(bytes: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
const b64 = btoa(binary)
|
||||
const lines = b64.match(/.{1,64}/g)?.join('\n') ?? b64
|
||||
return `-----BEGIN PRIVATE KEY-----\n${lines}\n-----END PRIVATE KEY-----\n`
|
||||
}
|
||||
|
||||
async function makeP8(): Promise<string> {
|
||||
const keys = await crypto.subtle.generateKey(
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
true,
|
||||
['sign'],
|
||||
)
|
||||
return toPem(new Uint8Array(await crypto.subtle.exportKey('pkcs8', keys.privateKey)))
|
||||
}
|
||||
|
||||
function b64urlDecode(value: string): string {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
|
||||
return new TextDecoder().decode(Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)))
|
||||
}
|
||||
|
||||
const DEVICE_TOKEN = 'a'.repeat(64)
|
||||
const notification: PushNotification = {
|
||||
title: 'Transcription complete',
|
||||
body: 'Open D3RO Voice',
|
||||
data: { schema_version: '1', event_type: 'transcription.completed', resource_id: '11111111-2222-4333-8444-555555555555', route: 'HistoryDetail', history_id: '11111111-2222-4333-8444-555555555555' },
|
||||
}
|
||||
|
||||
Deno.test('apns config requires valid credentials', () => {
|
||||
const empty = () => undefined
|
||||
assertPushError(() => readApnsConfig(empty), 'apns_not_configured', 503)
|
||||
|
||||
const malformed = (name: string) => ({
|
||||
APNS_KEY_ID: 'too-short',
|
||||
APNS_TEAM_ID: 'ABCDEFGHIJ',
|
||||
APNS_PRIVATE_KEY: '-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----',
|
||||
APNS_TOPIC: 'com.d3ro.voice',
|
||||
})[name]
|
||||
assertPushError(() => readApnsConfig(malformed), 'apns_credentials_invalid', 503)
|
||||
})
|
||||
|
||||
Deno.test('apns provider token carries kid and ES256', async () => {
|
||||
const pem = await makeP8()
|
||||
const config: ApnsConfig = {
|
||||
keyId: 'ABCDEFGHIJ',
|
||||
teamId: 'KLMNOPQRST',
|
||||
privateKey: pem,
|
||||
topic: 'com.d3ro.voice',
|
||||
environment: 'production',
|
||||
}
|
||||
const token = await createApnsToken(config, 1_700_000_000)
|
||||
const [header, claims] = token.split('.')
|
||||
const decodedHeader = JSON.parse(b64urlDecode(header)) as Record<string, unknown>
|
||||
const decodedClaims = JSON.parse(b64urlDecode(claims)) as Record<string, unknown>
|
||||
assert(decodedHeader.alg === 'ES256', 'alg must be ES256')
|
||||
assert(decodedHeader.kid === 'ABCDEFGHIJ', 'kid must be the key id')
|
||||
assert(decodedClaims.iss === 'KLMNOPQRST', 'iss must be the team id')
|
||||
assert(typeof decodedClaims.iat === 'number', 'iat must be set')
|
||||
assert(token.split('.').length === 3, 'token is a JWT')
|
||||
})
|
||||
|
||||
Deno.test('apns send targets the right host with topic headers', async () => {
|
||||
const config: ApnsConfig = {
|
||||
keyId: 'ABCDEFGHIJ',
|
||||
teamId: 'KLMNOPQRST',
|
||||
privateKey: 'unused',
|
||||
topic: 'com.d3ro.voice',
|
||||
environment: 'production',
|
||||
}
|
||||
const calls: Array<{ url: string; init: RequestInit }> = []
|
||||
const fetchImpl = ((url: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init: init ?? {} })
|
||||
return Promise.resolve(new Response('{}', { status: 200 }))
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const result = await sendApnsMessage(DEVICE_TOKEN, notification, {
|
||||
config,
|
||||
fetchImpl,
|
||||
getAccessToken: () => Promise.resolve('test-token'),
|
||||
})
|
||||
assert(result.status === 200, 'ok status returned')
|
||||
assert(calls.length === 1, 'one request issued')
|
||||
assert(calls[0].url === `https://api.push.apple.com/3/device/${DEVICE_TOKEN}`, 'production host used')
|
||||
const headers = calls[0].init.headers as Record<string, string>
|
||||
assert(headers['apns-topic'] === 'com.d3ro.voice', 'topic header present')
|
||||
assert(headers['apns-push-type'] === 'alert', 'push type header present')
|
||||
assert(headers.authorization === 'bearer test-token', 'bearer token present')
|
||||
})
|
||||
|
||||
Deno.test('apns send maps stale and auth responses', async () => {
|
||||
const config: ApnsConfig = {
|
||||
keyId: 'ABCDEFGHIJ',
|
||||
teamId: 'KLMNOPQRST',
|
||||
privateKey: 'unused',
|
||||
topic: 'com.d3ro.voice',
|
||||
environment: 'sandbox',
|
||||
}
|
||||
const respond = (status: number, body: unknown) => (
|
||||
(() => Promise.resolve(new Response(JSON.stringify(body), { status }))) as unknown as typeof fetch
|
||||
)
|
||||
const options = { config, getAccessToken: () => Promise.resolve('t') }
|
||||
|
||||
await assertPushError(
|
||||
() => sendApnsMessage(DEVICE_TOKEN, notification, { ...options, fetchImpl: respond(410, { reason: 'Unregistered' }) }),
|
||||
'apns_registration_stale',
|
||||
410,
|
||||
true,
|
||||
)
|
||||
await assertPushError(
|
||||
() => sendApnsMessage(DEVICE_TOKEN, notification, { ...options, fetchImpl: respond(400, { reason: 'BadDeviceToken' }) }),
|
||||
'apns_registration_stale',
|
||||
410,
|
||||
true,
|
||||
)
|
||||
await assertPushError(
|
||||
() => sendApnsMessage(DEVICE_TOKEN, notification, { ...options, fetchImpl: respond(403, { reason: 'InvalidProviderToken' }) }),
|
||||
'apns_credentials_invalid',
|
||||
503,
|
||||
)
|
||||
await assertPushError(
|
||||
() => sendApnsMessage(DEVICE_TOKEN, notification, { ...options, fetchImpl: respond(503, { reason: 'ServiceUnavailable' }) }),
|
||||
'apns_send_failed',
|
||||
502,
|
||||
)
|
||||
await assertPushError(
|
||||
() => sendApnsMessage('not-a-token', notification, { ...options, fetchImpl: respond(200, {}) }),
|
||||
'apns_registration_invalid',
|
||||
400,
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
Deno.test('apns send uses the sandbox host when configured', async () => {
|
||||
const config: ApnsConfig = {
|
||||
keyId: 'ABCDEFGHIJ',
|
||||
teamId: 'KLMNOPQRST',
|
||||
privateKey: 'unused',
|
||||
topic: 'com.d3ro.voice',
|
||||
environment: 'sandbox',
|
||||
}
|
||||
const calls: string[] = []
|
||||
const fetchImpl = ((url: string | URL | Request) => {
|
||||
calls.push(String(url))
|
||||
return Promise.resolve(new Response('{}', { status: 200 }))
|
||||
}) as unknown as typeof fetch
|
||||
await sendApnsMessage(DEVICE_TOKEN, notification, {
|
||||
config,
|
||||
fetchImpl,
|
||||
getAccessToken: () => Promise.resolve('t'),
|
||||
})
|
||||
assert(calls[0].startsWith('https://api.sandbox.push.apple.com/'), 'sandbox host used')
|
||||
})
|
||||
199
server/supabase/functions/_shared/apns.ts
Normal file
199
server/supabase/functions/_shared/apns.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
// server/supabase/functions/_shared/apns.ts
|
||||
// Apple Push Notification service transport (token-based, .p8 / ES256 JWT).
|
||||
//
|
||||
// Firebase-free: APNs is Apple's own service. Only an Apple Developer key is
|
||||
// required; no Firebase project is involved.
|
||||
|
||||
import { PushContractError, type PushNotification } from './push-contract.ts'
|
||||
|
||||
export type ApnsEnvironment = 'production' | 'sandbox'
|
||||
|
||||
export interface ApnsConfig {
|
||||
keyId: string
|
||||
teamId: string
|
||||
privateKey: string
|
||||
topic: string
|
||||
environment: ApnsEnvironment
|
||||
}
|
||||
|
||||
export interface ApnsSendOptions {
|
||||
fetchImpl?: typeof fetch
|
||||
config?: ApnsConfig
|
||||
now?: number
|
||||
getAccessToken?: (config: ApnsConfig, nowSeconds: number) => Promise<string>
|
||||
}
|
||||
|
||||
export interface ApnsSendResult {
|
||||
status: number
|
||||
}
|
||||
|
||||
const KEY_ID_PATTERN = /^[A-Z0-9]{10}$/
|
||||
const TOPIC_PATTERN = /^[A-Za-z0-9.-]{3,155}$/
|
||||
const DEVICE_TOKEN_PATTERN = /^[0-9a-fA-F]{32,200}$/
|
||||
const encoder = new TextEncoder()
|
||||
const REQUEST_TIMEOUT_MS = 10_000
|
||||
const TOKEN_TTL_SECONDS = 50 * 60
|
||||
|
||||
let cachedToken: { identity: string; token: string; issuedAtSeconds: number } | null = null
|
||||
|
||||
export function readApnsConfig(
|
||||
readEnv: (name: string) => string | undefined = (name) => Deno.env.get(name),
|
||||
): ApnsConfig {
|
||||
const keyId = readEnv('APNS_KEY_ID')?.trim() ?? ''
|
||||
const teamId = readEnv('APNS_TEAM_ID')?.trim() ?? ''
|
||||
const privateKey = readEnv('APNS_PRIVATE_KEY') ?? ''
|
||||
const topic = readEnv('APNS_TOPIC')?.trim() ?? ''
|
||||
const environmentRaw = readEnv('APNS_ENVIRONMENT')?.trim().toLowerCase() ?? 'production'
|
||||
if (!keyId || !teamId || !privateKey || !topic) {
|
||||
throw new PushContractError('apns_not_configured', 503)
|
||||
}
|
||||
if (
|
||||
!KEY_ID_PATTERN.test(keyId)
|
||||
|| !KEY_ID_PATTERN.test(teamId)
|
||||
|| !TOPIC_PATTERN.test(topic)
|
||||
|| !privateKey.includes('BEGIN PRIVATE KEY')
|
||||
|| (environmentRaw !== 'production' && environmentRaw !== 'sandbox')
|
||||
) {
|
||||
throw new PushContractError('apns_credentials_invalid', 503)
|
||||
}
|
||||
return {
|
||||
keyId,
|
||||
teamId,
|
||||
privateKey,
|
||||
topic,
|
||||
environment: environmentRaw === 'sandbox' ? 'sandbox' : 'production',
|
||||
}
|
||||
}
|
||||
|
||||
function decodePemPrivateKey(pem: string): Uint8Array {
|
||||
const body = pem
|
||||
.replace(/-----BEGIN PRIVATE KEY-----/g, '')
|
||||
.replace(/-----END PRIVATE KEY-----/g, '')
|
||||
.replace(/\s/g, '')
|
||||
if (!body) throw new PushContractError('apns_credentials_invalid', 503)
|
||||
try {
|
||||
const binary = atob(body)
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
} catch {
|
||||
throw new PushContractError('apns_credentials_invalid', 503)
|
||||
}
|
||||
}
|
||||
|
||||
export async function createApnsToken(config: ApnsConfig, nowSeconds: number): Promise<string> {
|
||||
const header = base64Url(encoder.encode(JSON.stringify({ alg: 'ES256', kid: config.keyId })))
|
||||
const claims = base64Url(encoder.encode(JSON.stringify({ iss: config.teamId, iat: nowSeconds })))
|
||||
const signingInput = `${header}.${claims}`
|
||||
try {
|
||||
const decoded = decodePemPrivateKey(config.privateKey)
|
||||
const keyBytes = new ArrayBuffer(decoded.byteLength)
|
||||
new Uint8Array(keyBytes).set(decoded)
|
||||
const key = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
keyBytes,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
false,
|
||||
['sign'],
|
||||
)
|
||||
const signature = await crypto.subtle.sign(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||
key,
|
||||
encoder.encode(signingInput),
|
||||
)
|
||||
return `${signingInput}.${base64Url(new Uint8Array(signature))}`
|
||||
} catch (error) {
|
||||
if (error instanceof PushContractError) throw error
|
||||
throw new PushContractError('apns_credentials_invalid', 503)
|
||||
}
|
||||
}
|
||||
|
||||
function base64Url(bytes: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
export async function getApnsAccessToken(
|
||||
config: ApnsConfig,
|
||||
nowSeconds: number,
|
||||
): Promise<string> {
|
||||
const identity = `${config.keyId}\n${config.teamId}`
|
||||
if (
|
||||
cachedToken
|
||||
&& cachedToken.identity === identity
|
||||
&& cachedToken.issuedAtSeconds + TOKEN_TTL_SECONDS > nowSeconds
|
||||
) {
|
||||
return cachedToken.token
|
||||
}
|
||||
const token = await createApnsToken(config, nowSeconds)
|
||||
cachedToken = { identity, token, issuedAtSeconds: nowSeconds }
|
||||
return token
|
||||
}
|
||||
|
||||
function apnsHost(environment: ApnsEnvironment): string {
|
||||
return environment === 'sandbox' ? 'https://api.sandbox.push.apple.com' : 'https://api.push.apple.com'
|
||||
}
|
||||
|
||||
function isStaleReason(reason: string): boolean {
|
||||
return reason === 'BadDeviceToken'
|
||||
|| reason === 'DeviceTokenNotForTopic'
|
||||
|| reason === 'Unregistered'
|
||||
|| reason === 'ExpiredToken'
|
||||
}
|
||||
|
||||
export async function sendApnsMessage(
|
||||
registrationId: string,
|
||||
notification: PushNotification,
|
||||
options: ApnsSendOptions = {},
|
||||
): Promise<ApnsSendResult> {
|
||||
if (typeof registrationId !== 'string' || !DEVICE_TOKEN_PATTERN.test(registrationId)) {
|
||||
throw new PushContractError('apns_registration_invalid', 400, true)
|
||||
}
|
||||
const config = options.config ?? readApnsConfig()
|
||||
const fetchImpl = options.fetchImpl ?? fetch
|
||||
const nowSeconds = Math.floor((options.now ?? Date.now()) / 1000)
|
||||
const accessToken = await (options.getAccessToken ?? getApnsAccessToken)(config, nowSeconds)
|
||||
|
||||
const body = JSON.stringify({
|
||||
aps: {
|
||||
alert: { title: notification.title, body: notification.body },
|
||||
sound: 'default',
|
||||
},
|
||||
...notification.data,
|
||||
})
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetchImpl(`${apnsHost(config.environment)}/3/device/${registrationId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `bearer ${accessToken}`,
|
||||
'apns-topic': config.topic,
|
||||
'apns-push-type': 'alert',
|
||||
'apns-priority': '10',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
} catch {
|
||||
throw new PushContractError('apns_send_timeout', 504)
|
||||
}
|
||||
|
||||
if (response.ok) return { status: response.status }
|
||||
|
||||
let reason = ''
|
||||
try {
|
||||
const parsed = await response.json() as Record<string, unknown>
|
||||
reason = typeof parsed.reason === 'string' ? parsed.reason : ''
|
||||
} catch {
|
||||
reason = ''
|
||||
}
|
||||
|
||||
if (response.status === 410 || (response.status === 400 && isStaleReason(reason))) {
|
||||
throw new PushContractError('apns_registration_stale', 410, true)
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw new PushContractError('apns_credentials_invalid', 503)
|
||||
}
|
||||
throw new PushContractError('apns_send_failed', 502)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import {
|
|||
isServiceRoleAuthorization,
|
||||
isServiceRoleApiKey,
|
||||
parsePushRequest,
|
||||
providerIsSupported,
|
||||
PushContractError,
|
||||
readFcmConfig,
|
||||
sendFcmMessage,
|
||||
|
|
@ -32,6 +33,12 @@ const fcmConfig = {
|
|||
tokenUri: 'https://oauth2.googleapis.com/token',
|
||||
}
|
||||
|
||||
Deno.test('push providers fcm, webpush, and apns are supported', () => {
|
||||
assert(providerIsSupported('fcm'), 'fcm must be supported')
|
||||
assert(providerIsSupported('webpush'), 'webpush must be supported')
|
||||
assert(providerIsSupported('apns'), 'apns must be supported')
|
||||
})
|
||||
|
||||
Deno.test('push input accepts only event_type and resource_id', () => {
|
||||
const parsed = parsePushRequest({
|
||||
event_type: 'transcription.completed',
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ function androidDelivery(eventType: PushEventType, resourceId: string): {
|
|||
}
|
||||
}
|
||||
|
||||
function validateOutboundData(data: Record<string, string>): {
|
||||
export function validateOutboundData(data: Record<string, string>): {
|
||||
eventType: PushEventType
|
||||
resourceId: string
|
||||
} {
|
||||
|
|
@ -475,7 +475,7 @@ export async function sendFcmMessage(
|
|||
}
|
||||
|
||||
export function providerIsSupported(provider: PushProvider): boolean {
|
||||
return provider === 'fcm'
|
||||
return provider === 'fcm' || provider === 'webpush' || provider === 'apns'
|
||||
}
|
||||
|
||||
async function credentialsEqual(presented: string, expected: string): Promise<boolean> {
|
||||
|
|
|
|||
251
server/supabase/functions/_shared/webpush.test.ts
Normal file
251
server/supabase/functions/_shared/webpush.test.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
// server/supabase/functions/_shared/webpush.test.ts
|
||||
// RFC 8291 (aes128gcm) + VAPID transport tests. Firebase-free web push.
|
||||
|
||||
import {
|
||||
encryptWebPushPayload,
|
||||
parseWebPushSubscription,
|
||||
readWebPushConfig,
|
||||
sendWebPushMessage,
|
||||
type WebPushConfig,
|
||||
type WebPushSubscription,
|
||||
} from './webpush.ts'
|
||||
import { PushContractError, type PushNotification } from './push-contract.ts'
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
async function assertPushError(
|
||||
action: () => unknown | Promise<unknown>,
|
||||
code: string,
|
||||
status: number,
|
||||
stale?: boolean,
|
||||
): Promise<void> {
|
||||
let actual: unknown
|
||||
try {
|
||||
await action()
|
||||
} catch (error) {
|
||||
actual = error
|
||||
}
|
||||
assert(actual instanceof PushContractError, `expected PushContractError for ${code}`)
|
||||
assert(actual.code === code, `expected ${code}, received ${actual.code}`)
|
||||
assert(actual.status === status, `expected status ${status}, received ${actual.status}`)
|
||||
if (stale !== undefined) {
|
||||
assert(actual.staleRegistration === stale, `expected stale=${stale}`)
|
||||
}
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
function b64url(bytes: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function unb64url(value: string): Uint8Array {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
|
||||
const binary = atob(padded)
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
}
|
||||
|
||||
function concat(...parts: Uint8Array[]): Uint8Array {
|
||||
const output = new Uint8Array(parts.reduce((sum, part) => sum + part.byteLength, 0))
|
||||
let offset = 0
|
||||
for (const part of parts) {
|
||||
output.set(part, offset)
|
||||
offset += part.byteLength
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(bytes.byteLength)
|
||||
new Uint8Array(buffer).set(bytes)
|
||||
return buffer
|
||||
}
|
||||
|
||||
async function hmacSha256(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {
|
||||
const imported = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
toArrayBuffer(key),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign'],
|
||||
)
|
||||
return new Uint8Array(await crypto.subtle.sign('HMAC', imported, toArrayBuffer(data)))
|
||||
}
|
||||
|
||||
async function hkdfExpand(prk: Uint8Array, info: Uint8Array, length: number): Promise<Uint8Array> {
|
||||
const blocks = Math.ceil(length / 32)
|
||||
const output = new Uint8Array(blocks * 32)
|
||||
let previous = new Uint8Array(0)
|
||||
for (let index = 0; index < blocks; index += 1) {
|
||||
const block = await hmacSha256(prk, concat(previous, info, Uint8Array.of(index + 1)))
|
||||
output.set(block, index * 32)
|
||||
previous = new Uint8Array(block)
|
||||
}
|
||||
return output.slice(0, length)
|
||||
}
|
||||
|
||||
async function makeVapidConfig(): Promise<WebPushConfig> {
|
||||
const keys = await crypto.subtle.generateKey(
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
)
|
||||
const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', keys.publicKey))
|
||||
const jwk = await crypto.subtle.exportKey('jwk', keys.privateKey)
|
||||
assert(typeof jwk.d === 'string', 'private scalar must be exportable')
|
||||
return {
|
||||
publicKey: b64url(publicRaw),
|
||||
privateKey: jwk.d,
|
||||
subject: 'mailto:push@d3ro.test',
|
||||
}
|
||||
}
|
||||
|
||||
async function makeSubscription(): Promise<{ registrationId: string; subscription: WebPushSubscription; privateKey: CryptoKey; publicRaw: Uint8Array }> {
|
||||
const uaKeys = await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
true,
|
||||
['deriveBits'],
|
||||
)
|
||||
const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', uaKeys.publicKey))
|
||||
const auth = crypto.getRandomValues(new Uint8Array(16))
|
||||
const subscription: WebPushSubscription = {
|
||||
endpoint: 'https://push.example.com/subscriptions/abc123',
|
||||
p256dh: b64url(publicRaw),
|
||||
auth: b64url(auth),
|
||||
}
|
||||
return {
|
||||
registrationId: JSON.stringify({ endpoint: subscription.endpoint, keys: { p256dh: subscription.p256dh, auth: subscription.auth } }),
|
||||
subscription,
|
||||
privateKey: uaKeys.privateKey,
|
||||
publicRaw,
|
||||
}
|
||||
}
|
||||
|
||||
const notification: PushNotification = {
|
||||
title: 'Transcription complete',
|
||||
body: 'Open D3RO Voice',
|
||||
data: { schema_version: '1', event_type: 'transcription.completed', resource_id: '11111111-2222-4333-8444-555555555555', route: 'HistoryDetail', history_id: '11111111-2222-4333-8444-555555555555' },
|
||||
}
|
||||
|
||||
Deno.test('web push config requires valid VAPID credentials', () => {
|
||||
const empty = () => undefined
|
||||
assertPushError(() => readWebPushConfig(empty), 'webpush_not_configured', 503)
|
||||
const malformed = (name: string) => ({
|
||||
WEBPUSH_VAPID_PUBLIC_KEY: 'not-a-key',
|
||||
WEBPUSH_VAPID_PRIVATE_KEY: 'also-bad',
|
||||
WEBPUSH_SUBJECT: 'mailto:push@d3ro.test',
|
||||
})[name]
|
||||
assertPushError(() => readWebPushConfig(malformed), 'webpush_credentials_invalid', 503)
|
||||
})
|
||||
|
||||
Deno.test('web push subscription parsing is strict', async () => {
|
||||
const { registrationId, subscription } = await makeSubscription()
|
||||
const parsed = parseWebPushSubscription(registrationId)
|
||||
assert(parsed.endpoint === subscription.endpoint, 'endpoint preserved')
|
||||
assert(parsed.p256dh === subscription.p256dh, 'p256dh preserved')
|
||||
|
||||
assertPushError(() => parseWebPushSubscription('short'), 'webpush_registration_invalid', 400, true)
|
||||
assertPushError(
|
||||
() => parseWebPushSubscription(JSON.stringify({ endpoint: 'http://insecure.example.com', keys: { p256dh: subscription.p256dh, auth: subscription.auth } })),
|
||||
'webpush_registration_invalid',
|
||||
400,
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
Deno.test('aes128gcm encryption round-trips per RFC 8291', async () => {
|
||||
const { registrationId, subscription, privateKey, publicRaw } = await makeSubscription()
|
||||
const fixedSalt = crypto.getRandomValues(new Uint8Array(16))
|
||||
const payload = encoder.encode('hello web push')
|
||||
const body = await encryptWebPushPayload(
|
||||
parseWebPushSubscription(registrationId),
|
||||
payload,
|
||||
() => fixedSalt,
|
||||
)
|
||||
|
||||
// Header: salt(16) | rs(4) | idlen(1) | keyid(65)
|
||||
assert(body.byteLength === 16 + 4 + 1 + 65 + payload.byteLength + 1 + 16, 'body length matches aes128gcm')
|
||||
const salt = body.slice(0, 16)
|
||||
const recordSize = new DataView(body.buffer, body.byteOffset + 16, 4).getUint32(0, false)
|
||||
assert(recordSize === 4096, 'record size is 4096')
|
||||
assert(body[20] === 65, 'key id length is 65')
|
||||
const asPublic = body.slice(21, 86)
|
||||
const ciphertext = body.slice(86)
|
||||
|
||||
assert(salt.every((byte, index) => byte === fixedSalt[index]), 'salt preserved')
|
||||
|
||||
// Decrypt as the user agent would.
|
||||
const asKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
toArrayBuffer(asPublic),
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
[],
|
||||
)
|
||||
const shared = new Uint8Array(
|
||||
await crypto.subtle.deriveBits({ name: 'ECDH', public: asKey }, privateKey, 256),
|
||||
)
|
||||
const prkKey = await hmacSha256(unb64url(subscription.auth), shared)
|
||||
const keyInfo = concat(encoder.encode('WebPush: info\x00'), publicRaw, asPublic)
|
||||
const ikm = await hkdfExpand(prkKey, keyInfo, 32)
|
||||
const prk = await hmacSha256(salt, ikm)
|
||||
const cek = await hkdfExpand(prk, encoder.encode('Content-Encoding: aes128gcm\x00'), 16)
|
||||
const nonce = await hkdfExpand(prk, encoder.encode('Content-Encoding: nonce\x00'), 12)
|
||||
const aesKey = await crypto.subtle.importKey('raw', toArrayBuffer(cek), 'AES-GCM', false, ['decrypt'])
|
||||
const plaintext = new Uint8Array(
|
||||
await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: toArrayBuffer(nonce), tagLength: 128 },
|
||||
aesKey,
|
||||
toArrayBuffer(ciphertext),
|
||||
),
|
||||
)
|
||||
const decoded = plaintext.slice(0, plaintext.byteLength - 1)
|
||||
assert(plaintext[plaintext.byteLength - 1] === 0x02, 'last-record delimiter is 0x02')
|
||||
assert(new TextDecoder().decode(decoded) === 'hello web push', 'payload round-trips')
|
||||
})
|
||||
|
||||
Deno.test('web push send maps provider responses', async () => {
|
||||
const { registrationId } = await makeSubscription()
|
||||
const config = await makeVapidConfig()
|
||||
|
||||
const okFetch = (() => Promise.resolve(new Response(null, { status: 201 }))) as unknown as typeof fetch
|
||||
const result = await sendWebPushMessage(registrationId, notification, { config, fetchImpl: okFetch })
|
||||
assert(result.status === 201, 'created status returned')
|
||||
|
||||
const staleFetch = (() => Promise.resolve(new Response(null, { status: 410 }))) as unknown as typeof fetch
|
||||
await assertPushError(
|
||||
() => sendWebPushMessage(registrationId, notification, { config, fetchImpl: staleFetch }),
|
||||
'webpush_registration_stale',
|
||||
410,
|
||||
true,
|
||||
)
|
||||
|
||||
const tooLarge = (() => Promise.resolve(new Response(null, { status: 413 }))) as unknown as typeof fetch
|
||||
await assertPushError(
|
||||
() => sendWebPushMessage(registrationId, notification, { config, fetchImpl: tooLarge }),
|
||||
'webpush_payload_too_large',
|
||||
500,
|
||||
)
|
||||
|
||||
const failed = (() => Promise.resolve(new Response(null, { status: 500 }))) as unknown as typeof fetch
|
||||
await assertPushError(
|
||||
() => sendWebPushMessage(registrationId, notification, { config, fetchImpl: failed }),
|
||||
'webpush_send_failed',
|
||||
502,
|
||||
)
|
||||
})
|
||||
|
||||
Deno.test('web push send rejects a malformed registration id', async () => {
|
||||
const config = await makeVapidConfig()
|
||||
await assertPushError(
|
||||
() => sendWebPushMessage('not-json-registration-id', notification, { config }),
|
||||
'webpush_registration_invalid',
|
||||
400,
|
||||
true,
|
||||
)
|
||||
})
|
||||
308
server/supabase/functions/_shared/webpush.ts
Normal file
308
server/supabase/functions/_shared/webpush.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
// server/supabase/functions/_shared/webpush.ts
|
||||
// Standard Web Push (VAPID + RFC 8291 aes128gcm) transport.
|
||||
//
|
||||
// Firebase-free: the browser Push API delivers the message; we only sign with
|
||||
// VAPID and encrypt per RFC 8291. No Google account or Firebase project needed.
|
||||
|
||||
import { PushContractError, type PushNotification } from './push-contract.ts'
|
||||
|
||||
export interface WebPushConfig {
|
||||
/** base64url encoded uncompressed P-256 public key (65 bytes, 0x04 prefix). */
|
||||
publicKey: string
|
||||
/** base64url encoded P-256 private scalar (32 bytes). */
|
||||
privateKey: string
|
||||
/** VAPID subject: `mailto:` or `https:` URL. */
|
||||
subject: string
|
||||
}
|
||||
|
||||
export interface WebPushSubscription {
|
||||
endpoint: string
|
||||
p256dh: string
|
||||
auth: string
|
||||
}
|
||||
|
||||
export interface WebPushSendOptions {
|
||||
fetchImpl?: typeof fetch
|
||||
config?: WebPushConfig
|
||||
now?: number
|
||||
randomBytes?: (length: number) => Uint8Array
|
||||
}
|
||||
|
||||
export interface WebPushSendResult {
|
||||
status: number
|
||||
}
|
||||
|
||||
const SUBJECT_PATTERN = /^(mailto:[^@\s]+@[^@\s]+|https:\/\/[^\s]+)$/
|
||||
const RECORD_SIZE = 4096
|
||||
const encoder = new TextEncoder()
|
||||
const REQUEST_TIMEOUT_MS = 10_000
|
||||
|
||||
function decodeBase64Url(value: string): Uint8Array {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
|
||||
const binary = atob(padded)
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
}
|
||||
|
||||
function encodeBase64Url(bytes: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function concat(...parts: Uint8Array[]): Uint8Array {
|
||||
const total = parts.reduce((sum, part) => sum + part.byteLength, 0)
|
||||
const output = new Uint8Array(total)
|
||||
let offset = 0
|
||||
for (const part of parts) {
|
||||
output.set(part, offset)
|
||||
offset += part.byteLength
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(bytes.byteLength)
|
||||
new Uint8Array(buffer).set(bytes)
|
||||
return buffer
|
||||
}
|
||||
|
||||
async function hmacSha256(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {
|
||||
const imported = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
toArrayBuffer(key),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign'],
|
||||
)
|
||||
const signature = await crypto.subtle.sign('HMAC', imported, toArrayBuffer(data))
|
||||
return new Uint8Array(signature)
|
||||
}
|
||||
|
||||
async function hkdfExpand(prk: Uint8Array, info: Uint8Array, length: number): Promise<Uint8Array> {
|
||||
const blocks = Math.ceil(length / 32)
|
||||
const output = new Uint8Array(blocks * 32)
|
||||
let previous = new Uint8Array(0)
|
||||
for (let index = 0; index < blocks; index += 1) {
|
||||
const block = await hmacSha256(prk, concat(previous, info, Uint8Array.of(index + 1)))
|
||||
output.set(block, index * 32)
|
||||
previous = new Uint8Array(block)
|
||||
}
|
||||
return output.slice(0, length)
|
||||
}
|
||||
|
||||
export function readWebPushConfig(
|
||||
readEnv: (name: string) => string | undefined = (name) => Deno.env.get(name),
|
||||
): WebPushConfig {
|
||||
const publicKey = readEnv('WEBPUSH_VAPID_PUBLIC_KEY')?.trim() ?? ''
|
||||
const privateKey = readEnv('WEBPUSH_VAPID_PRIVATE_KEY')?.trim() ?? ''
|
||||
const subject = readEnv('WEBPUSH_SUBJECT')?.trim() ?? ''
|
||||
if (!publicKey || !privateKey || !subject) {
|
||||
throw new PushContractError('webpush_not_configured', 503)
|
||||
}
|
||||
try {
|
||||
const publicBytes = decodeBase64Url(publicKey)
|
||||
const privateBytes = decodeBase64Url(privateKey)
|
||||
if (
|
||||
publicBytes.byteLength !== 65
|
||||
|| publicBytes[0] !== 0x04
|
||||
|| privateBytes.byteLength !== 32
|
||||
|| !SUBJECT_PATTERN.test(subject)
|
||||
) {
|
||||
throw new Error('invalid web push credentials')
|
||||
}
|
||||
return { publicKey, privateKey, subject }
|
||||
} catch {
|
||||
throw new PushContractError('webpush_credentials_invalid', 503)
|
||||
}
|
||||
}
|
||||
|
||||
export function parseWebPushSubscription(registrationId: string): WebPushSubscription {
|
||||
if (typeof registrationId !== 'string' || registrationId.length < 20 || registrationId.length > 8192) {
|
||||
throw new PushContractError('webpush_registration_invalid', 400, true)
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(registrationId) as Record<string, unknown>
|
||||
const endpoint = typeof parsed.endpoint === 'string' ? parsed.endpoint : ''
|
||||
const keys = parsed.keys
|
||||
const p256dh = keys && typeof keys === 'object' && typeof (keys as Record<string, unknown>).p256dh === 'string'
|
||||
? (keys as Record<string, string>).p256dh
|
||||
: ''
|
||||
const auth = keys && typeof keys === 'object' && typeof (keys as Record<string, unknown>).auth === 'string'
|
||||
? (keys as Record<string, string>).auth
|
||||
: ''
|
||||
const endpointUrl = new URL(endpoint)
|
||||
const p256dhBytes = decodeBase64Url(p256dh)
|
||||
const authBytes = decodeBase64Url(auth)
|
||||
if (
|
||||
endpointUrl.protocol !== 'https:'
|
||||
|| p256dhBytes.byteLength !== 65
|
||||
|| p256dhBytes[0] !== 0x04
|
||||
|| authBytes.byteLength < 16
|
||||
|| authBytes.byteLength > 32
|
||||
) {
|
||||
throw new Error('invalid subscription')
|
||||
}
|
||||
return { endpoint: endpointUrl.toString(), p256dh, auth }
|
||||
} catch (error) {
|
||||
if (error instanceof PushContractError) throw error
|
||||
throw new PushContractError('webpush_registration_invalid', 400, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function buildVapidAuthorization(
|
||||
config: WebPushConfig,
|
||||
endpoint: string,
|
||||
nowSeconds: number,
|
||||
): Promise<string> {
|
||||
const publicBytes = decodeBase64Url(config.publicKey)
|
||||
const jwk: JsonWebKey = {
|
||||
kty: 'EC',
|
||||
crv: 'P-256',
|
||||
d: config.privateKey,
|
||||
x: encodeBase64Url(publicBytes.slice(1, 33)),
|
||||
y: encodeBase64Url(publicBytes.slice(33, 65)),
|
||||
}
|
||||
const key = await crypto.subtle.importKey(
|
||||
'jwk',
|
||||
jwk,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
false,
|
||||
['sign'],
|
||||
)
|
||||
const header = encodeBase64Url(encoder.encode(JSON.stringify({ typ: 'JWT', alg: 'ES256' })))
|
||||
const payload = encodeBase64Url(encoder.encode(JSON.stringify({
|
||||
aud: new URL(endpoint).origin,
|
||||
exp: nowSeconds + 12 * 3600,
|
||||
sub: config.subject,
|
||||
})))
|
||||
const signingInput = `${header}.${payload}`
|
||||
const signature = await crypto.subtle.sign(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||
key,
|
||||
encoder.encode(signingInput),
|
||||
)
|
||||
const jwt = `${signingInput}.${encodeBase64Url(new Uint8Array(signature))}`
|
||||
return `vapid t=${jwt}, k=${config.publicKey}`
|
||||
}
|
||||
|
||||
export async function encryptWebPushPayload(
|
||||
subscription: WebPushSubscription,
|
||||
payload: Uint8Array,
|
||||
randomBytes: (length: number) => Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
const uaPublic = decodeBase64Url(subscription.p256dh)
|
||||
const authSecret = decodeBase64Url(subscription.auth)
|
||||
const salt = randomBytes(16)
|
||||
|
||||
const applicationKeys = await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
true,
|
||||
['deriveBits'],
|
||||
)
|
||||
const applicationPublic = new Uint8Array(
|
||||
await crypto.subtle.exportKey('raw', applicationKeys.publicKey),
|
||||
)
|
||||
const userAgentKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
toArrayBuffer(uaPublic),
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
[],
|
||||
)
|
||||
const sharedSecret = new Uint8Array(
|
||||
await crypto.subtle.deriveBits(
|
||||
{ name: 'ECDH', public: userAgentKey },
|
||||
applicationKeys.privateKey,
|
||||
256,
|
||||
),
|
||||
)
|
||||
|
||||
const prkKey = await hmacSha256(authSecret, sharedSecret)
|
||||
const keyInfo = concat(
|
||||
encoder.encode('WebPush: info\x00'),
|
||||
uaPublic,
|
||||
applicationPublic,
|
||||
)
|
||||
const ikm = await hkdfExpand(prkKey, keyInfo, 32)
|
||||
const prk = await hmacSha256(salt, ikm)
|
||||
const contentEncryptionKey = await hkdfExpand(
|
||||
prk,
|
||||
encoder.encode('Content-Encoding: aes128gcm\x00'),
|
||||
16,
|
||||
)
|
||||
const nonce = await hkdfExpand(prk, encoder.encode('Content-Encoding: nonce\x00'), 12)
|
||||
|
||||
const plaintext = concat(payload, Uint8Array.of(0x02))
|
||||
const aesKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
toArrayBuffer(contentEncryptionKey),
|
||||
'AES-GCM',
|
||||
false,
|
||||
['encrypt'],
|
||||
)
|
||||
const ciphertext = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: toArrayBuffer(nonce), tagLength: 128 },
|
||||
aesKey,
|
||||
toArrayBuffer(plaintext),
|
||||
),
|
||||
)
|
||||
|
||||
const header = new Uint8Array(16 + 4 + 1 + 65)
|
||||
header.set(salt, 0)
|
||||
new DataView(header.buffer).setUint32(16, RECORD_SIZE, false)
|
||||
header[20] = 65
|
||||
header.set(applicationPublic, 21)
|
||||
|
||||
return concat(header, ciphertext)
|
||||
}
|
||||
|
||||
export async function sendWebPushMessage(
|
||||
registrationId: string,
|
||||
notification: PushNotification,
|
||||
options: WebPushSendOptions = {},
|
||||
): Promise<WebPushSendResult> {
|
||||
const subscription = parseWebPushSubscription(registrationId)
|
||||
const config = options.config ?? readWebPushConfig()
|
||||
const fetchImpl = options.fetchImpl ?? fetch
|
||||
const nowSeconds = Math.floor((options.now ?? Date.now()) / 1000)
|
||||
const randomBytes = options.randomBytes
|
||||
?? ((length: number) => crypto.getRandomValues(new Uint8Array(length)))
|
||||
|
||||
const payload = encoder.encode(JSON.stringify({
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
data: notification.data,
|
||||
}))
|
||||
const body = await encryptWebPushPayload(subscription, payload, randomBytes)
|
||||
const authorization = await buildVapidAuthorization(config, subscription.endpoint, nowSeconds)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetchImpl(subscription.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
'Content-Encoding': 'aes128gcm',
|
||||
'Content-Type': 'application/octet-stream',
|
||||
TTL: '86400',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
} catch {
|
||||
throw new PushContractError('webpush_send_timeout', 504)
|
||||
}
|
||||
|
||||
if (response.status === 200 || response.status === 201 || response.status === 202) {
|
||||
return { status: response.status }
|
||||
}
|
||||
if (response.status === 404 || response.status === 410) {
|
||||
throw new PushContractError('webpush_registration_stale', 410, true)
|
||||
}
|
||||
if (response.status === 413) {
|
||||
throw new PushContractError('webpush_payload_too_large', 500)
|
||||
}
|
||||
throw new PushContractError('webpush_send_failed', 502)
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
isServiceRoleAuthorization,
|
||||
isUuid,
|
||||
parsePushRequest,
|
||||
providerIsSupported,
|
||||
PushContractError,
|
||||
readFcmConfig,
|
||||
sendFcmMessage,
|
||||
|
|
@ -15,6 +16,8 @@ import {
|
|||
type PushProvider,
|
||||
type TeamInviteNotificationContext,
|
||||
} from '../_shared/push-contract.ts'
|
||||
import { readApnsConfig, sendApnsMessage } from '../_shared/apns.ts'
|
||||
import { readWebPushConfig, sendWebPushMessage } from '../_shared/webpush.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
|
||||
const JSON_HEADERS = {
|
||||
|
|
@ -435,8 +438,8 @@ async function finalizeDispatch(
|
|||
return normalizeDispatchSummary(data, attemptId)
|
||||
}
|
||||
|
||||
function retryableFcmError(error: unknown): { code: string; status: number } {
|
||||
if (!(error instanceof PushContractError)) return { code: 'fcm_send_failed', status: 502 }
|
||||
function retryablePushError(error: unknown, fallbackCode: string): { code: string; status: number } {
|
||||
if (!(error instanceof PushContractError)) return { code: fallbackCode, status: 502 }
|
||||
const permanent = new Set(['invalid_push_payload'])
|
||||
return {
|
||||
code: permanent.has(error.code) ? 'push_payload_invalid' : error.code,
|
||||
|
|
@ -478,58 +481,116 @@ async function processAttempt(input: {
|
|||
inviteContext = { teamId: invite.team_id, inviteToken: invite.token }
|
||||
}
|
||||
|
||||
const unsupported = deliveries.filter((delivery) => delivery.provider !== 'fcm')
|
||||
const unsupported = deliveries.filter((delivery) => !providerIsSupported(delivery.provider))
|
||||
await mapWithConcurrency(unsupported, SEND_CONCURRENCY, (delivery) => (
|
||||
finalizeDelivery(delivery, 'permanent_failure', 'push_provider_not_supported')
|
||||
))
|
||||
const fcmDeliveries = deliveries.filter((delivery) => delivery.provider === 'fcm')
|
||||
|
||||
let transientError: string | null = null
|
||||
let transientStatus: number | null = null
|
||||
if (fcmDeliveries.length > 0) {
|
||||
|
||||
const recordTransient = (code: string, status: number): void => {
|
||||
transientError ??= code
|
||||
transientStatus ??= status
|
||||
}
|
||||
|
||||
const deliveriesFor = (provider: PushProvider): LeasedDelivery[] => (
|
||||
deliveries.filter((delivery) => delivery.provider === provider)
|
||||
)
|
||||
|
||||
const failProvider = async (provider: PushProvider, code: string): Promise<void> => {
|
||||
await mapWithConcurrency(deliveriesFor(provider), SEND_CONCURRENCY, (delivery) => (
|
||||
finalizeDelivery(delivery, 'retryable_failure', code)
|
||||
))
|
||||
}
|
||||
|
||||
const sendFor = async (
|
||||
provider: PushProvider,
|
||||
fallbackCode: string,
|
||||
send: (registrationId: string) => Promise<unknown>,
|
||||
): Promise<void> => {
|
||||
const scoped = deliveriesFor(provider)
|
||||
if (scoped.length === 0) return
|
||||
await mapWithConcurrency(scoped, SEND_CONCURRENCY, async (delivery) => {
|
||||
try {
|
||||
await send(delivery.registrationId)
|
||||
await finalizeDelivery(delivery, 'delivered', null)
|
||||
} catch (error) {
|
||||
if (error instanceof PushContractError && error.staleRegistration) {
|
||||
await finalizeDelivery(delivery, 'stale', 'registration_stale')
|
||||
return
|
||||
}
|
||||
const normalized = retryablePushError(error, fallbackCode)
|
||||
if (normalized.code === 'push_payload_invalid') {
|
||||
await finalizeDelivery(delivery, 'permanent_failure', normalized.code)
|
||||
} else {
|
||||
recordTransient(normalized.code, normalized.status)
|
||||
await finalizeDelivery(delivery, 'retryable_failure', normalized.code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const buildNotification = (): ReturnType<typeof buildPushNotification> => (
|
||||
buildPushNotification(input.eventType, input.resourceId, 'en', inviteContext)
|
||||
)
|
||||
|
||||
if (deliveriesFor('fcm').length > 0) {
|
||||
let config: ReturnType<typeof readFcmConfig> | null = null
|
||||
let accessToken: string | null = null
|
||||
try {
|
||||
config = readFcmConfig()
|
||||
accessToken = await getFcmAccessToken(config)
|
||||
} catch (error) {
|
||||
const normalized = retryableFcmError(error)
|
||||
transientError = normalized.code
|
||||
transientStatus = normalized.status
|
||||
await mapWithConcurrency(fcmDeliveries, SEND_CONCURRENCY, (delivery) => (
|
||||
finalizeDelivery(delivery, 'retryable_failure', normalized.code)
|
||||
const normalized = retryablePushError(error, 'fcm_send_failed')
|
||||
recordTransient(normalized.code, normalized.status)
|
||||
await failProvider('fcm', normalized.code)
|
||||
}
|
||||
if (config && accessToken) {
|
||||
await sendFor('fcm', 'fcm_send_failed', (registrationId) => sendFcmMessage(
|
||||
registrationId,
|
||||
buildNotification(),
|
||||
{
|
||||
config: config as ReturnType<typeof readFcmConfig>,
|
||||
getAccessToken: () => Promise.resolve(accessToken as string),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if (config && accessToken) {
|
||||
await mapWithConcurrency(fcmDeliveries, SEND_CONCURRENCY, async (delivery) => {
|
||||
const notification = buildPushNotification(
|
||||
input.eventType,
|
||||
input.resourceId,
|
||||
'en',
|
||||
inviteContext,
|
||||
)
|
||||
try {
|
||||
await sendFcmMessage(delivery.registrationId, notification, {
|
||||
config,
|
||||
getAccessToken: () => Promise.resolve(accessToken),
|
||||
})
|
||||
await finalizeDelivery(delivery, 'delivered', null)
|
||||
} catch (error) {
|
||||
if (error instanceof PushContractError && error.staleRegistration) {
|
||||
await finalizeDelivery(delivery, 'stale', 'registration_stale')
|
||||
return
|
||||
}
|
||||
const normalized = retryableFcmError(error)
|
||||
if (normalized.code === 'push_payload_invalid') {
|
||||
await finalizeDelivery(delivery, 'permanent_failure', normalized.code)
|
||||
} else {
|
||||
transientError ??= normalized.code
|
||||
transientStatus ??= normalized.status
|
||||
await finalizeDelivery(delivery, 'retryable_failure', normalized.code)
|
||||
}
|
||||
}
|
||||
})
|
||||
if (deliveriesFor('webpush').length > 0) {
|
||||
let webPushConfig: ReturnType<typeof readWebPushConfig> | null = null
|
||||
try {
|
||||
webPushConfig = readWebPushConfig()
|
||||
} catch (error) {
|
||||
const normalized = retryablePushError(error, 'webpush_send_failed')
|
||||
recordTransient(normalized.code, normalized.status)
|
||||
await failProvider('webpush', normalized.code)
|
||||
}
|
||||
if (webPushConfig) {
|
||||
await sendFor('webpush', 'webpush_send_failed', (registrationId) => sendWebPushMessage(
|
||||
registrationId,
|
||||
buildNotification(),
|
||||
{ config: webPushConfig as ReturnType<typeof readWebPushConfig> },
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if (deliveriesFor('apns').length > 0) {
|
||||
let apnsConfig: ReturnType<typeof readApnsConfig> | null = null
|
||||
try {
|
||||
apnsConfig = readApnsConfig()
|
||||
} catch (error) {
|
||||
const normalized = retryablePushError(error, 'apns_send_failed')
|
||||
recordTransient(normalized.code, normalized.status)
|
||||
await failProvider('apns', normalized.code)
|
||||
}
|
||||
if (apnsConfig) {
|
||||
await sendFor('apns', 'apns_send_failed', (registrationId) => sendApnsMessage(
|
||||
registrationId,
|
||||
buildNotification(),
|
||||
{ config: apnsConfig as ReturnType<typeof readApnsConfig> },
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
148
server/supabase/migrations/20260913000033_team_activities.sql
Normal file
148
server/supabase/migrations/20260913000033_team_activities.sql
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
-- ============================================================================
|
||||
-- Team activity feed
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Adds a team-scoped activity/comment stream so members can see shared notes
|
||||
-- and system events (member changes, invites, shared meetings/documents).
|
||||
--
|
||||
-- Follows the locked RPC write model established in
|
||||
-- 20260821000008_atomic_team_security.sql: team tables have no direct
|
||||
-- INSERT/UPDATE/DELETE policies, so all writes go through SECURITY DEFINER
|
||||
-- RPCs. Reads are RLS-filtered to team members via public.user_team_ids().
|
||||
--
|
||||
-- Also registers the team tables (and this one) with the realtime publication.
|
||||
-- Previously teams/team_members/team_invites were never added, so the mobile
|
||||
-- team Realtime subscriptions could not receive postgres_changes events.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.team_activities (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
team_id uuid NOT NULL REFERENCES public.teams(id) ON DELETE CASCADE,
|
||||
actor_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
kind text NOT NULL CHECK (
|
||||
kind IN (
|
||||
'note',
|
||||
'member_joined',
|
||||
'member_left',
|
||||
'invite_created',
|
||||
'meeting_shared',
|
||||
'document_shared'
|
||||
)
|
||||
),
|
||||
body text CHECK (body IS NULL OR char_length(body) <= 2000),
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_team_activities_team_created
|
||||
ON public.team_activities (team_id, created_at DESC);
|
||||
|
||||
ALTER TABLE public.team_activities ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS team_activities_read_member ON public.team_activities;
|
||||
CREATE POLICY team_activities_read_member ON public.team_activities
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (team_id IN (SELECT public.user_team_ids(auth.uid())));
|
||||
|
||||
-- Writes are RPC-only: no INSERT/UPDATE/DELETE policies exist on purpose.
|
||||
GRANT SELECT ON public.team_activities TO authenticated;
|
||||
REVOKE INSERT, UPDATE, DELETE ON public.team_activities FROM PUBLIC, anon, authenticated;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_team_activity(
|
||||
p_team_id uuid,
|
||||
p_kind text,
|
||||
p_body text,
|
||||
p_metadata jsonb DEFAULT '{}'::jsonb
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
current_user_id uuid := auth.uid();
|
||||
member_role text;
|
||||
normalized_body text;
|
||||
new_id uuid;
|
||||
BEGIN
|
||||
IF current_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
IF p_kind IS NULL OR p_kind NOT IN (
|
||||
'note',
|
||||
'member_joined',
|
||||
'member_left',
|
||||
'invite_created',
|
||||
'meeting_shared',
|
||||
'document_shared'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'invalid_activity_kind' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
normalized_body := nullif(btrim(coalesce(p_body, '')), '');
|
||||
IF normalized_body IS NOT NULL AND char_length(normalized_body) > 2000 THEN
|
||||
RAISE EXCEPTION 'activity_body_too_long' USING ERRCODE = '22001';
|
||||
END IF;
|
||||
IF normalized_body IS NULL AND p_kind = 'note' THEN
|
||||
RAISE EXCEPTION 'activity_body_required' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended(p_team_id::text, 73053));
|
||||
SELECT role INTO member_role FROM public.team_members
|
||||
WHERE team_id = p_team_id AND user_id = current_user_id;
|
||||
IF member_role IS NULL THEN
|
||||
RAISE EXCEPTION 'team_member_required' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
INSERT INTO public.team_activities (team_id, actor_id, kind, body, metadata)
|
||||
VALUES (
|
||||
p_team_id,
|
||||
current_user_id,
|
||||
p_kind,
|
||||
normalized_body,
|
||||
coalesce(p_metadata, '{}'::jsonb)
|
||||
)
|
||||
RETURNING id INTO new_id;
|
||||
RETURN jsonb_build_object(
|
||||
'id', new_id,
|
||||
'team_id', p_team_id,
|
||||
'actor_id', current_user_id,
|
||||
'kind', p_kind,
|
||||
'body', normalized_body,
|
||||
'created_at', now()
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.create_team_activity(uuid, text, text, jsonb) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.create_team_activity(uuid, text, text, jsonb) TO authenticated;
|
||||
|
||||
-- Realtime: register team tables + activities. ALTER PUBLICATION ADD TABLE has
|
||||
-- no IF NOT EXISTS, so probe pg_publication_tables first.
|
||||
DO $$
|
||||
DECLARE
|
||||
t text;
|
||||
BEGIN
|
||||
FOR t IN
|
||||
SELECT unnest(ARRAY[
|
||||
'team_activities',
|
||||
'teams',
|
||||
'team_members',
|
||||
'team_invites'
|
||||
])
|
||||
LOOP
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_publication_tables
|
||||
WHERE pubname = 'supabase_realtime'
|
||||
AND schemaname = 'public'
|
||||
AND tablename = t
|
||||
) THEN
|
||||
EXECUTE format(
|
||||
'ALTER PUBLICATION supabase_realtime ADD TABLE public.%I',
|
||||
t
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
Loading…
Add table
Add a link
Reference in a new issue