diff --git a/app/api/push/jmap/[id]/route.ts b/app/api/push/jmap/[id]/route.ts deleted file mode 100644 index 1d7850df..00000000 --- a/app/api/push/jmap/[id]/route.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { logger } from '@/lib/logger'; -import { subscriptionStore } from '@/lib/push/store'; -import { sendFcmPush } from '@/lib/push/fcm'; -import { isValidSubscriptionId } from '@/lib/push/validation'; -import type { JmapPushBody } from '@/lib/push/types'; - -/** - * POST /api/push/jmap/:id - * - * Destination configured on JMAP PushSubscription. Spec (RFC 8620 §7.2) - * allows two body shapes: - * - * { "@type": "PushVerification", pushSubscriptionId, verificationCode } - * { "@type": "StateChange", changed: { [accountId]: { [type]: state } } } - * - * PushVerification is terminated here — cached against the subscription so - * the client can poll it. StateChange is fanned out via FCM so the mobile - * app wakes and re-fetches with its own credentials. - */ - -function getAllowedOrigin(): string | null { - const raw = process.env.PUSH_ALLOWED_JMAP_ORIGIN?.trim(); - return raw ? raw : null; -} - -function getSharedSecret(): string | null { - const raw = process.env.PUSH_SHARED_SECRET?.trim(); - return raw ? raw : null; -} - -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ id: string }> }, -) { - try { - const { id } = await params; - if (!isValidSubscriptionId(id)) { - return NextResponse.json({ error: 'Invalid subscriptionId' }, { status: 400 }); - } - - const allowedOrigin = getAllowedOrigin(); - if (allowedOrigin) { - const origin = request.headers.get('origin') ?? ''; - if (origin && origin !== allowedOrigin) { - return NextResponse.json({ error: 'Origin not allowed' }, { status: 403 }); - } - } - - const sharedSecret = getSharedSecret(); - if (sharedSecret) { - // Stalwart supports a custom `Authorization` header on the push URL via - // PushSubscription.keys/headers — a shared secret is the strongest - // authentication a stateless relay can enforce. - const presented = request.headers.get('x-push-secret') ?? ''; - if (presented !== sharedSecret) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } - } - - const record = await subscriptionStore.get(id); - if (!record) { - return NextResponse.json({ error: 'Unknown subscription' }, { status: 404 }); - } - - const body = (await request.json().catch(() => null)) as JmapPushBody | null; - if (!body || typeof body['@type'] !== 'string') { - return NextResponse.json({ error: 'Invalid JMAP push body' }, { status: 400 }); - } - - if (body['@type'] === 'PushVerification') { - record.verificationCode = body.verificationCode; - await subscriptionStore.put(id, record); - return NextResponse.json({ ok: true }); - } - - if (body['@type'] === 'StateChange') { - const result = await sendFcmPush(record, body); - record.lastPushAt = Date.now(); - await subscriptionStore.put(id, record); - if (result.unregistered) { - // FCM told us the token is no longer valid — drop the subscription - // so the device re-registers on next launch. - await subscriptionStore.delete(id); - } - return NextResponse.json({ ok: result.ok }); - } - - return NextResponse.json({ error: 'Unsupported JMAP push type' }, { status: 400 }); - } catch (error) { - logger.error('push: jmap handler failed', { - error: error instanceof Error ? error.message : 'Unknown error', - }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} diff --git a/app/api/push/register/[id]/route.ts b/app/api/push/register/[id]/route.ts deleted file mode 100644 index f9ad8028..00000000 --- a/app/api/push/register/[id]/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { logger } from '@/lib/logger'; -import { subscriptionStore } from '@/lib/push/store'; -import { isValidSubscriptionId } from '@/lib/push/validation'; - -/** - * DELETE /api/push/register/:id - * - * Client tears down its relay mapping on logout / uninstall. Idempotent — - * deleting a non-existent id still returns ok. - */ -export async function DELETE( - _request: NextRequest, - { params }: { params: Promise<{ id: string }> }, -) { - try { - const { id } = await params; - if (!isValidSubscriptionId(id)) { - return NextResponse.json({ error: 'Invalid subscriptionId' }, { status: 400 }); - } - await subscriptionStore.delete(id); - return NextResponse.json({ ok: true }); - } catch (error) { - logger.error('push: unregister failed', { - error: error instanceof Error ? error.message : 'Unknown error', - }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} diff --git a/app/api/push/register/route.ts b/app/api/push/register/route.ts deleted file mode 100644 index 1f586f9e..00000000 --- a/app/api/push/register/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { logger } from '@/lib/logger'; -import { subscriptionStore } from '@/lib/push/store'; -import { isValidFcmToken, isValidSubscriptionId } from '@/lib/push/validation'; -import type { SubscriptionRecord } from '@/lib/push/types'; - -/** - * POST /api/push/register - * Body: { subscriptionId, fcmToken, accountLabel? } - * - * Called by the mobile app once Firebase has issued an FCM registration - * token. The relay stores only the opaque token — no user credentials. - */ -export async function POST(request: NextRequest) { - try { - const body = (await request.json().catch(() => null)) as { - subscriptionId?: unknown; - fcmToken?: unknown; - accountLabel?: unknown; - } | null; - if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - - const { subscriptionId, fcmToken, accountLabel } = body; - if (!isValidSubscriptionId(subscriptionId)) { - return NextResponse.json({ error: 'Invalid subscriptionId' }, { status: 400 }); - } - if (!isValidFcmToken(fcmToken)) { - return NextResponse.json({ error: 'Invalid fcmToken' }, { status: 400 }); - } - - // Preserve verificationCode if a previous record exists — the JMAP server - // may have already POSTed PushVerification before the app re-registers. - const existing = await subscriptionStore.get(subscriptionId); - - const record: SubscriptionRecord = { - fcmToken, - verificationCode: existing?.verificationCode ?? null, - createdAt: existing?.createdAt ?? Date.now(), - lastPushAt: existing?.lastPushAt ?? null, - accountLabel: - typeof accountLabel === 'string' ? accountLabel.slice(0, 120) : undefined, - }; - await subscriptionStore.put(subscriptionId, record); - - return NextResponse.json({ ok: true }); - } catch (error) { - logger.error('push: register failed', { - error: error instanceof Error ? error.message : 'Unknown error', - }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} diff --git a/app/api/push/verify/[id]/route.ts b/app/api/push/verify/[id]/route.ts deleted file mode 100644 index 762bd8c6..00000000 --- a/app/api/push/verify/[id]/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { logger } from '@/lib/logger'; -import { subscriptionStore } from '@/lib/push/store'; -import { isValidSubscriptionId } from '@/lib/push/validation'; - -/** - * GET /api/push/verify/:id - * - * Mobile app polls this after creating a JMAP PushSubscription. When the - * JMAP server POSTs a PushVerification to /api/push/jmap/:id we stash the - * code on the record; the client then PATCHes the subscription with it and - * activation completes (RFC 8620 §7.2.2). - */ -export async function GET( - _request: NextRequest, - { params }: { params: Promise<{ id: string }> }, -) { - try { - const { id } = await params; - if (!isValidSubscriptionId(id)) { - return NextResponse.json({ error: 'Invalid subscriptionId' }, { status: 400 }); - } - const record = await subscriptionStore.get(id); - if (!record) { - return NextResponse.json({ error: 'Unknown subscription' }, { status: 404 }); - } - return NextResponse.json({ verificationCode: record.verificationCode ?? null }); - } catch (error) { - logger.error('push: verify poll failed', { - error: error instanceof Error ? error.message : 'Unknown error', - }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} diff --git a/lib/push/fcm.ts b/lib/push/fcm.ts deleted file mode 100644 index 310837b3..00000000 --- a/lib/push/fcm.ts +++ /dev/null @@ -1,158 +0,0 @@ -import crypto from 'node:crypto'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import type { StateChange } from './types'; -import type { SubscriptionRecord } from './types'; - -interface ServiceAccount { - type: string; - project_id: string; - private_key_id: string; - private_key: string; - client_email: string; - token_uri: string; -} - -let cachedAccount: ServiceAccount | null = null; -let cachedAccessToken: { token: string; expiresAt: number } | null = null; - -async function loadServiceAccount(): Promise { - if (cachedAccount) return cachedAccount; - - const inline = process.env.FCM_SERVICE_ACCOUNT_JSON; - if (inline && inline.trim().startsWith('{')) { - cachedAccount = JSON.parse(inline) as ServiceAccount; - return cachedAccount; - } - - const filePath = inline && inline.trim().length > 0 - ? inline - : path.join(process.env.PUSH_DATA_DIR ?? './data/push', 'fcm-service-account.json'); - const raw = await fs.readFile(filePath, 'utf8'); - cachedAccount = JSON.parse(raw) as ServiceAccount; - return cachedAccount; -} - -function base64url(buf: Buffer | string): string { - const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf); - return b.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -async function mintAccessToken(): Promise { - const now = Date.now(); - if (cachedAccessToken && cachedAccessToken.expiresAt - 60_000 > now) { - return cachedAccessToken.token; - } - - const account = await loadServiceAccount(); - const iat = Math.floor(now / 1000); - const exp = iat + 3600; - const header = { alg: 'RS256', typ: 'JWT', kid: account.private_key_id }; - const claim = { - iss: account.client_email, - scope: 'https://www.googleapis.com/auth/firebase.messaging', - aud: account.token_uri ?? 'https://oauth2.googleapis.com/token', - iat, - exp, - }; - const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(claim))}`; - const signature = crypto - .createSign('RSA-SHA256') - .update(signingInput) - .sign(account.private_key); - const jwt = `${signingInput}.${base64url(signature)}`; - - const res = await fetch(account.token_uri ?? 'https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', - assertion: jwt, - }), - }); - if (!res.ok) { - throw new Error(`FCM oauth2 token failed: ${res.status} ${await res.text()}`); - } - const body = (await res.json()) as { access_token: string; expires_in: number }; - cachedAccessToken = { - token: body.access_token, - expiresAt: now + body.expires_in * 1000, - }; - return body.access_token; -} - -export interface FcmSendResult { - ok: boolean; - status: number; - unregistered: boolean; - body?: unknown; -} - -/** - * Send a data message via FCM HTTP v1. Returns `unregistered: true` if the - * token was rejected with UNREGISTERED / NOT_FOUND — callers should delete - * the subscription in that case. - */ -export async function sendFcmPush( - record: SubscriptionRecord, - change: StateChange, -): Promise { - const account = await loadServiceAccount(); - const accessToken = await mintAccessToken(); - const hasEmail = Boolean(change.changed && Object.values(change.changed).some((types) => 'Email' in types)); - - const title = hasEmail ? 'New mail' : 'Mailbox updated'; - const body = record.accountLabel ?? 'Tap to open Bulwark'; - - const message = { - message: { - token: record.fcmToken, - android: { - priority: 'HIGH', - notification: { - title, - body, - channel_id: 'bulwark_mail', - default_sound: true, - }, - }, - data: { - kind: 'jmap-state-change', - changed: JSON.stringify(change.changed ?? {}), - }, - }, - }; - - const res = await fetch( - `https://fcm.googleapis.com/v1/projects/${encodeURIComponent(account.project_id)}/messages:send`, - { - method: 'POST', - headers: { - authorization: `Bearer ${accessToken}`, - 'content-type': 'application/json', - }, - body: JSON.stringify(message), - }, - ); - - const rawBody = await res.text(); - let parsed: unknown = rawBody; - try { - parsed = rawBody.length > 0 ? JSON.parse(rawBody) : null; - } catch { - // keep raw text - } - - const errStatus = - typeof parsed === 'object' && parsed && 'error' in parsed - ? ((parsed as { error?: { status?: string } }).error?.status ?? '') - : ''; - const unregistered = res.status === 404 || errStatus === 'UNREGISTERED' || errStatus === 'NOT_FOUND'; - - return { - ok: res.ok, - status: res.status, - unregistered, - body: parsed, - }; -} diff --git a/lib/push/store.ts b/lib/push/store.ts deleted file mode 100644 index c143eed2..00000000 --- a/lib/push/store.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; -import { logger } from '@/lib/logger'; -import type { SubscriptionRecord } from './types'; - -// JMAP subscriptions default to "no expiry" but servers may expire at will. -// We evict mappings 30d past last activity; active devices re-register on -// every app launch so real users never age out. -const TTL_MS = 30 * 24 * 60 * 60 * 1000; -// How long a verification code sits waiting for the client to poll it. -const VERIFICATION_TTL_MS = 10 * 60 * 1000; - -function getPushDir(): string { - return process.env.PUSH_DATA_DIR || path.join(process.cwd(), 'data', 'push'); -} - -function subscriptionsPath(): string { - return path.join(getPushDir(), 'subscriptions.json'); -} - -interface SubscriptionsFile { - records: Record; -} - -class SubscriptionStore { - private cache: Record = {}; - private loaded = false; - private writeQueue: Promise = Promise.resolve(); - - async load(): Promise { - try { - const raw = await readFile(subscriptionsPath(), 'utf-8'); - const parsed = JSON.parse(raw) as SubscriptionsFile; - this.cache = parsed.records ?? {}; - this.evictExpired(); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - this.cache = {}; - } else { - logger.warn('push: failed to read subscriptions', { - error: error instanceof Error ? error.message : 'Unknown error', - }); - this.cache = {}; - } - } - this.loaded = true; - } - - async ensureLoaded(): Promise { - if (!this.loaded) await this.load(); - } - - async get(id: string): Promise { - await this.ensureLoaded(); - const record = this.cache[id]; - if (!record) return null; - if (this.isExpired(record)) { - delete this.cache[id]; - await this.persist(); - return null; - } - return record; - } - - async put(id: string, record: SubscriptionRecord): Promise { - await this.ensureLoaded(); - this.cache[id] = record; - await this.persist(); - } - - async delete(id: string): Promise { - await this.ensureLoaded(); - if (id in this.cache) { - delete this.cache[id]; - await this.persist(); - } - } - - private isExpired(record: SubscriptionRecord): boolean { - const age = Date.now() - (record.lastPushAt ?? record.createdAt); - return age > TTL_MS; - } - - private evictExpired(): void { - for (const [id, record] of Object.entries(this.cache)) { - if (this.isExpired(record)) delete this.cache[id]; - } - } - - private persist(): Promise { - // Serialize writes so concurrent requests don't clobber each other. - const next = this.writeQueue.then(() => this.flush()); - this.writeQueue = next.catch(() => undefined); - return next; - } - - private async flush(): Promise { - const dir = getPushDir(); - if (!existsSync(dir)) { - await mkdir(dir, { recursive: true }); - } - const target = subscriptionsPath(); - const tmp = target + '.tmp'; - const payload: SubscriptionsFile = { records: this.cache }; - await writeFile(tmp, JSON.stringify(payload, null, 2), 'utf-8'); - await rename(tmp, target); - } -} - -export const subscriptionStore = new SubscriptionStore(); - -export { VERIFICATION_TTL_MS }; diff --git a/lib/push/types.ts b/lib/push/types.ts deleted file mode 100644 index 531942af..00000000 --- a/lib/push/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Types shared between the relay routes and the store. Kept in a leaf module -// so both server code and admin UIs can import without pulling `node:fs`. - -export interface SubscriptionRecord { - fcmToken: string; - verificationCode: string | null; - createdAt: number; - lastPushAt: number | null; - accountLabel?: string; -} - -export interface PushVerification { - '@type': 'PushVerification'; - pushSubscriptionId: string; - verificationCode: string; -} - -export interface StateChange { - '@type': 'StateChange'; - changed: Record>; -} - -export type JmapPushBody = PushVerification | StateChange; diff --git a/lib/push/validation.ts b/lib/push/validation.ts deleted file mode 100644 index 10fad1e7..00000000 --- a/lib/push/validation.ts +++ /dev/null @@ -1,10 +0,0 @@ -export function isValidSubscriptionId(id: unknown): id is string { - return typeof id === 'string' && /^[A-Za-z0-9_-]{8,128}$/.test(id); -} - -export function isValidFcmToken(value: unknown): value is string { - // FCM registration tokens are opaque. In practice they're ~140–250 chars of - // [A-Za-z0-9:_-]. Be permissive on length; strict on charset to block - // obvious garbage without rejecting future token formats. - return typeof value === 'string' && value.length >= 64 && value.length <= 4096 && /^[A-Za-z0-9:_-]+$/.test(value); -}