mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
* refactor(push): organize provider, delivery, and host authentication owners * style(push): brace moved fixture guards and cleanup
313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
import { PushAuthAdmission } from './http/push-auth-admission.js'
|
||
import { createAdaptorServer } from '@hono/node-server'
|
||
import { PUSH_LIMITS } from '@orca-cloud/push-contract/push-limits'
|
||
import { PushDeviceRegistrationRequestSchema } from '@orca-cloud/push-contract/device-registration-messages'
|
||
import {
|
||
PushHostChallengeRequestSchema,
|
||
PushHostSessionRequestSchema
|
||
} from '@orca-cloud/push-contract/host-auth-messages'
|
||
import { PushSendRequestSchema, type PushSendResult } from '@orca-cloud/push-contract/send-messages'
|
||
import { Hono, type MiddlewareHandler } from 'hono'
|
||
import { bodyLimit } from 'hono/body-limit'
|
||
import { ApnsClient } from './providers/apns/apns-client.js'
|
||
import {
|
||
createApnsHttp2Transport,
|
||
type ApnsTransport
|
||
} from './providers/apns/apns-http2-transport.js'
|
||
import {
|
||
clientIpRateLimit,
|
||
ClientIpRateLimiter,
|
||
readClientIp
|
||
} from './http/client-ip-rate-limit.js'
|
||
import { DurablePushStore } from './delivery/durable-push-store.js'
|
||
import { DurablePushWorker } from './delivery/durable-push-worker.js'
|
||
import type { PushConfig } from './config.js'
|
||
import { PushDeviceRegistryStore } from './devices/device-registry-store.js'
|
||
import { createFcmAccessTokenProvider } from './providers/fcm/fcm-access-token.js'
|
||
import {
|
||
createFcmFetchTransport,
|
||
FcmClient,
|
||
type FcmTransport
|
||
} from './providers/fcm/fcm-client.js'
|
||
import { PushHostChallengeStore } from './host-auth/host-challenge-store.js'
|
||
import { PushHostSessionStore } from './host-auth/host-session-store.js'
|
||
import type { CloudDatabase as PushDatabase } from '@orca-cloud/cloud-database/cloud-database'
|
||
import { PushDispatcher } from './delivery/push-dispatcher.js'
|
||
import { PushObservability } from './push-observability.js'
|
||
import { reserveRequestConnection } from './database/push-background-database.js'
|
||
import { createPushReadiness } from './http/push-readiness.js'
|
||
import { PushRequestDrain } from './http/push-request-drain.js'
|
||
|
||
export type PushServerOptions = {
|
||
now?: () => number
|
||
apnsTransport?: ApnsTransport
|
||
fcmTransport?: FcmTransport
|
||
fcmAccessToken?: () => Promise<string>
|
||
}
|
||
|
||
type PushVariables = { hostFingerprint: string }
|
||
|
||
export function readBearer(header: string | undefined): string | null {
|
||
if (!header) return null
|
||
const [scheme, ...rest] = header.split(' ')
|
||
const token = rest.join(' ').trim()
|
||
return scheme?.toLowerCase() === 'bearer' && token.length > 0 ? token : null
|
||
}
|
||
|
||
// Enforce Hono’s body limit: Content-Length misses chunked bodies that req.json would fully buffer.
|
||
const limitBody = bodyLimit({
|
||
maxSize: PUSH_LIMITS.maxHttpBodyBytes,
|
||
onError: (context) => context.json({ error: 'request_too_large' }, 413)
|
||
})
|
||
|
||
export function createPushServer(
|
||
config: PushConfig,
|
||
database: PushDatabase,
|
||
options: PushServerOptions = {}
|
||
) {
|
||
const now = options.now ?? Date.now
|
||
const observability = new PushObservability()
|
||
const challenges = new PushHostChallengeStore(database, config.publicUrl, now)
|
||
const sessions = new PushHostSessionStore(database, now)
|
||
const devices = new PushDeviceRegistryStore(database, now)
|
||
const deliveryStore = new DurablePushStore(
|
||
database,
|
||
now,
|
||
reserveRequestConnection(database, config.databasePoolMax)
|
||
)
|
||
const apnsTransport = options.apnsTransport ?? (config.apns ? createApnsHttp2Transport() : null)
|
||
const dispatcher = new PushDispatcher({
|
||
devices,
|
||
...(config.apns && apnsTransport
|
||
? {
|
||
apns: new ApnsClient({
|
||
topic: config.apnsTopic,
|
||
credentials: config.apns,
|
||
transport: apnsTransport,
|
||
now
|
||
})
|
||
}
|
||
: {}),
|
||
fcm: new FcmClient({
|
||
now,
|
||
projectId: config.fcmProjectId,
|
||
accessToken: options.fcmAccessToken ?? createFcmAccessTokenProvider(),
|
||
transport: options.fcmTransport ?? createFcmFetchTransport()
|
||
}),
|
||
onOutcome: (status) =>
|
||
observability.record(
|
||
status === 'sent' ? 'delivery_sent' : status === 'dead' ? 'delivery_dead' : 'delivery_error'
|
||
)
|
||
})
|
||
const worker = new DurablePushWorker(deliveryStore, dispatcher, {
|
||
now,
|
||
onRetry: () => observability.record('delivery_retry')
|
||
})
|
||
const ready = createPushReadiness(database, { now })
|
||
const unauthenticatedIps = new ClientIpRateLimiter({ now })
|
||
const limitUnauthenticatedIp = clientIpRateLimit(unauthenticatedIps, {
|
||
trustedProxyHops: config.trustedProxyHops,
|
||
onLimited: () => observability.record('ip_rate_limited')
|
||
})
|
||
const limitAuthenticatedIp = clientIpRateLimit(
|
||
new ClientIpRateLimiter({
|
||
now,
|
||
capacity: PUSH_LIMITS.authenticatedRequestsPerMinutePerIp
|
||
}),
|
||
{
|
||
trustedProxyHops: config.trustedProxyHops,
|
||
onLimited: () => observability.record('ip_rate_limited')
|
||
}
|
||
)
|
||
const authAdmission = new PushAuthAdmission()
|
||
const invalidBearerIps = new ClientIpRateLimiter({ now })
|
||
const authenticatedHosts = new ClientIpRateLimiter({
|
||
now,
|
||
capacity: PUSH_LIMITS.authenticatedRequestsPerMinutePerHost
|
||
})
|
||
const app = new Hono<{ Variables: PushVariables }>()
|
||
const requestDrain = new PushRequestDrain()
|
||
app.use('*', requestDrain.middleware)
|
||
// Default Hono logging exposes pg row detail; log only error names.
|
||
app.onError((error, context) => {
|
||
observability.record('request_error')
|
||
console.warn(
|
||
JSON.stringify({
|
||
event: 'orca_push_request_failed',
|
||
error: error instanceof Error ? error.name : 'unknown'
|
||
})
|
||
)
|
||
return context.json({ error: 'internal' }, 500)
|
||
})
|
||
|
||
app.get('/health', (context) =>
|
||
context.json({ ok: true, pushProtocol: 1, deliveryProtocol: 2, mode: config.mode })
|
||
)
|
||
app.get('/ready', limitUnauthenticatedIp, async (context) =>
|
||
(await ready())
|
||
? context.json({ ok: true })
|
||
: context.json({ error: 'dependency_unavailable' }, 503)
|
||
)
|
||
|
||
if (config.mode === 'validation') {
|
||
app.use('*', async (context) => context.json({ error: 'validation_only' }, 503))
|
||
}
|
||
|
||
const bearerSession: MiddlewareHandler<{ Variables: PushVariables }> = async (context, next) => {
|
||
const ip = readClientIp(context, config.trustedProxyHops)
|
||
if (!invalidBearerIps.available(ip)) return context.json({ error: 'rate_limited' }, 429)
|
||
const bearer = readBearer(context.req.header('authorization'))
|
||
if (!bearer) {
|
||
invalidBearerIps.allow(ip)
|
||
return context.json({ error: 'invalid_token' }, 401)
|
||
}
|
||
const session = await authAdmission.run(async () => {
|
||
if (!invalidBearerIps.available(ip)) return null
|
||
const result = await sessions.resolve(bearer)
|
||
if (!result.ok) invalidBearerIps.allow(ip)
|
||
return result
|
||
})
|
||
if (!session) {
|
||
context.header('Retry-After', '1')
|
||
return context.json({ error: 'busy' }, 503)
|
||
}
|
||
if (!session.ok) {
|
||
return context.json(
|
||
{ error: session.reason === 'session_expired' ? 'session_expired' : 'invalid_token' },
|
||
401
|
||
)
|
||
}
|
||
if (!authenticatedHosts.allow(session.hostFingerprint))
|
||
return context.json({ error: 'rate_limited' }, 429)
|
||
context.set('hostFingerprint', session.hostFingerprint)
|
||
await next()
|
||
return
|
||
}
|
||
// `/v1/devices/*` matches `/v1/devices` itself; a second registration for the
|
||
// bare path would run both middlewares twice on it.
|
||
app.use('/v1/devices/*', limitAuthenticatedIp, bearerSession)
|
||
app.use('/v1/send', limitAuthenticatedIp, bearerSession)
|
||
|
||
app.post('/v1/host/challenge', limitUnauthenticatedIp, limitBody, async (context) => {
|
||
const body = PushHostChallengeRequestSchema.safeParse(
|
||
await context.req.json().catch(() => null)
|
||
)
|
||
if (!body.success) return context.json({ error: 'invalid_request' }, 400)
|
||
const issued = await challenges.issue(body.data.hostPublicKeyB64)
|
||
if (!issued) {
|
||
observability.record('challenge_rejected')
|
||
return context.json({ error: 'invalid_request' }, 400)
|
||
}
|
||
observability.record('challenge_issued')
|
||
const { hostFingerprint: _bound, ...response } = issued
|
||
return context.json(response)
|
||
})
|
||
|
||
app.post('/v1/host/session', limitUnauthenticatedIp, limitBody, async (context) => {
|
||
const body = PushHostSessionRequestSchema.safeParse(await context.req.json().catch(() => null))
|
||
if (!body.success) return context.json({ error: 'invalid_request' }, 400)
|
||
const verification = await challenges.verify(body.data.challengeId, body.data.proofB64)
|
||
if (!verification.ok) {
|
||
observability.record('session_rejected')
|
||
return context.json(
|
||
{
|
||
error: verification.reason === 'unknown_challenge' ? 'invalid_challenge' : 'invalid_proof'
|
||
},
|
||
401
|
||
)
|
||
}
|
||
observability.record('session_issued')
|
||
return context.json(await sessions.create(verification.hostFingerprint))
|
||
})
|
||
|
||
app.post('/v1/devices', limitBody, async (context) => {
|
||
const body = PushDeviceRegistrationRequestSchema.safeParse(
|
||
await context.req.json().catch(() => null)
|
||
)
|
||
if (!body.success) return context.json({ error: 'invalid_request' }, 400)
|
||
const registered = await devices.upsert({
|
||
hostFingerprint: context.get('hostFingerprint'),
|
||
deviceId: body.data.deviceId,
|
||
platform: body.data.platform,
|
||
token: body.data.token,
|
||
...(body.data.apnsEnvironment === undefined
|
||
? {}
|
||
: { apnsEnvironment: body.data.apnsEnvironment })
|
||
})
|
||
if (!registered.ok) {
|
||
observability.record('device_rejected')
|
||
return context.json({ error: 'too_many_devices' }, 409)
|
||
}
|
||
observability.record('device_registered')
|
||
return context.json({ registrationId: registered.registrationId })
|
||
})
|
||
|
||
app.delete('/v1/devices/:registrationId', async (context) => {
|
||
const deleted = await devices.deleteOwned(
|
||
context.get('hostFingerprint'),
|
||
context.req.param('registrationId')
|
||
)
|
||
if (!deleted) return context.json({ error: 'not_found' }, 404)
|
||
observability.record('device_deleted')
|
||
return context.body(null, 204)
|
||
})
|
||
|
||
app.get('/v1/devices', async (context) =>
|
||
context.json({ devices: await devices.list(context.get('hostFingerprint')) })
|
||
)
|
||
|
||
app.post('/v1/send', limitBody, async (context) => {
|
||
const body = PushSendRequestSchema.safeParse(await context.req.json().catch(() => null))
|
||
if (!body.success) return context.json({ error: 'invalid_request' }, 400)
|
||
const hostFingerprint = context.get('hostFingerprint')
|
||
const owned = await devices.findOwned(hostFingerprint, body.data.registrationIds)
|
||
const results: PushSendResult[] = []
|
||
for (const registrationId of body.data.registrationIds) {
|
||
const device = owned.get(registrationId)
|
||
if (!device) {
|
||
observability.record('send_error')
|
||
results.push({ registrationId, status: 'error' })
|
||
continue
|
||
}
|
||
if (device.dead) {
|
||
observability.record('send_dead')
|
||
results.push({ registrationId, status: 'dead' })
|
||
continue
|
||
}
|
||
const reservation = await deliveryStore.accept(
|
||
hostFingerprint,
|
||
registrationId,
|
||
body.data.notification
|
||
)
|
||
if (reservation !== 'queued') {
|
||
observability.record(reservation === 'rate_limited' ? 'send_rate_limited' : 'send_error')
|
||
results.push({ registrationId, status: reservation })
|
||
continue
|
||
}
|
||
observability.record('send_queued')
|
||
results.push({ registrationId, status: 'queued' })
|
||
}
|
||
if (results.some((result) => result.status === 'queued')) worker.wake()
|
||
return context.json({ results })
|
||
})
|
||
|
||
return {
|
||
app,
|
||
requestDrain,
|
||
server: createAdaptorServer(app),
|
||
challenges,
|
||
sessions,
|
||
devices,
|
||
deliveryStore,
|
||
unauthenticatedIps,
|
||
worker,
|
||
observability,
|
||
ready,
|
||
closeTransports: (): void => {
|
||
if (apnsTransport && 'close' in apnsTransport) {
|
||
;(apnsTransport as { close: () => void }).close()
|
||
}
|
||
}
|
||
}
|
||
}
|