/** * HTTP routes for the realtime app's server component. * * Two endpoints, both required by the offline-first sync engine running in the * UI: * * GET /sync/token — hands the UI a short-lived signed JWT it uses to * open a WebSocket to the sync service. * POST /sync — receives batched client mutations (PUT/PATCH/DELETE) * and writes them straight into Postgres. The sync * service then streams them back out to every other * connected client. * * Everything that talks to the database goes through `postgres.js`. We use the * tagged-template form (sql`...`) deliberately — see the upload handler below * for why. */ import { importJWK, SignJWT } from 'jose'; import postgres from 'postgres'; // One module-level pool. The default settings are fine for a single Fly // machine; postgres.js auto-commits each query at the end of execution, which // is the behaviour we want for an autocommit CRUD endpoint. const sql = postgres(process.env.DATABASE_URL!); // The signing key is loaded lazily so a misconfigured env var doesn't crash // the server before it can even serve /health. The kid travels with the JWT // header so the sync service can pick the right verifier key from its JWKS. let privateKey: CryptoKey; let kid: string; async function getSigningKey() { if (privateKey) return { privateKey, kid }; const jwk = JSON.parse(process.env.APP_SYNC_SIGNING_KEY!); kid = jwk.kid; privateKey = await importJWK(jwk, 'EdDSA') as CryptoKey; return { privateKey, kid }; } export async function routes(req: Request): Promise { const url = new URL(req.url); // Liveness probe used by the platform supervisor. Must stay cheap and never // touch the database — if the DB is briefly unreachable we still want the // process to be considered "up" so the supervisor doesn't kill it. if (url.pathname === '/health') { return Response.json({ status: 'ok' }); } // ─── Credentials endpoint ──────────────────────────────────────────────── // // The UI's sync client calls this on boot and again whenever its token is // about to expire. We mint a fresh JWT and tell the client which sync // service URL to talk to. // // Important claims: // - alg: EdDSA matches the signing key. The sync service's JWKS entry // declares the same alg. // - sub: every JWT verified by the sync service MUST carry a `sub` // claim. The service rejects tokens without one (PSYNC_S2101). // Use a stable identifier here — even a constant is fine for // single-tenant apps; multi-tenant apps should use a real user id. // - aud: the audience is the sync service URL. The service is // configured with the same value; a mismatch means the JWT is // accepted by us and rejected by it, which manifests as an endless // reconnect loop in the browser. // - exp: keep this short. The client refreshes well before expiry, so // 1h is plenty. if (url.pathname === '/sync/token') { const { privateKey: key, kid: k } = await getSigningKey(); const token = await new SignJWT({}) .setProtectedHeader({ alg: 'EdDSA', kid: k }) .setSubject('app-user') .setIssuedAt() .setExpirationTime('1h') .setAudience(process.env.APP_SYNC_URL!) .sign(key); return Response.json({ token, endpoint: process.env.APP_SYNC_URL }); } // ─── Upload endpoint ───────────────────────────────────────────────────── // // The UI's sync client batches every local write into a transaction and // POSTs the batch here. Each entry has: // - table: the target table name // - op: PUT | PATCH | DELETE // - id: the row's primary key // - data: for PUT/PATCH, the column values being set // // We MUST use postgres.js's tagged-template form (`sql\`...\``) for the // actual SQL. The older `sql.unsafe(query, params)` API silently runs in // an extended-query mode whose writes are not visible to other connections // until the connection is reused — the route happily returns 200 but the // row never shows up to anyone else. The tagged-template form auto-commits // every query, which is what we want. // // The `sql(value, ...keys)` helper is context-aware: inside `INSERT INTO // ... ${...}` it expands to `(col1, col2, ...) VALUES ('a', 'b', ...)`, // inside `SET ${...}` it expands to `col1 = 'a', col2 = 'b'`. Same call, // two meanings, both safe (values are sent as bind parameters). if (req.method === 'POST' && url.pathname === '/sync') { const { ops } = await req.json() as { ops: Array<{ table: string; op: string; id: string; data: Record }>; }; for (const op of ops) { if (op.op === 'PUT') { // Build a single row object so the id participates in the INSERT // column list. ON CONFLICT then needs to update everything *except* // the primary key. const row: Record = { id: op.id, ...op.data }; const cols = Object.keys(row); const updateCols = cols.filter(c => c !== 'id'); await (sql as unknown as (s: TemplateStringsArray, ...args: unknown[]) => Promise)` INSERT INTO ${sql(op.table)} ${sql(row, ...cols)} ON CONFLICT (id) DO UPDATE SET ${sql(row, ...updateCols)} `; } else if (op.op === 'PATCH') { const cols = Object.keys(op.data); await (sql as unknown as (s: TemplateStringsArray, ...args: unknown[]) => Promise)` UPDATE ${sql(op.table)} SET ${sql(op.data, ...cols)} WHERE id = ${op.id} `; } else if (op.op === 'DELETE') { await sql`DELETE FROM ${sql(op.table)} WHERE id = ${op.id}`; } } // The sync client treats 200 as "this batch is durable, drop it from // my local upload queue". If you ever need to push back (e.g. validation // failed) return a non-2xx so the client retries. return Response.json({ ok: true }); } return new Response('Not found', { status: 404 }); }