1import { Database } from "bun:sqlite";2import { createCommerceClient } from "./commerce-client.mjs";3import { createErasureLedger } from "../erasure.mjs";4 5// resolveSession is the host app's authentication boundary, supplied by the caller.6export function startAppBackend({7 path,8 providers,9 receiver,10 resolveSession,11 resolveStoreUser,12}) {13 const db = new Database(path, { create: true });14 db.exec(15 "CREATE TABLE IF NOT EXISTS erasure_requests (user_id TEXT, provider TEXT, PRIMARY KEY(user_id, provider))",16 );17 const erased = createErasureLedger(db);18 const clients = Object.fromEntries(19 Object.entries(providers).map(([name, config]) => [20 name,21 createCommerceClient(config),22 ]),23 );24 const inFlight = new Map();25 let selected = Object.keys(clients)[0];26 async function drainErasure() {27 for (const row of db.query("SELECT * FROM erasure_requests").all()) {28 try {29 receiver.eraseUser(row.user_id);30 await Promise.allSettled([...(inFlight.get(row.user_id) ?? [])]);31 const result = await clients[row.provider].call("eraseUser", {32 userId: row.user_id,33 });34 if (result.accepted && result.status === "completed")35 db.query(36 "DELETE FROM erasure_requests WHERE user_id = ? AND provider = ?",37 ).run(row.user_id, row.provider);38 } catch {39 /* The durable request is retried by the app's worker. */40 }41 }42 return db.query("SELECT count(*) AS count FROM erasure_requests").get()43 .count;44 }45 const server = Bun.serve({46 hostname: "127.0.0.1",47 port: 0,48 maxRequestBodySize: 32768,49 async fetch(request) {50 const userId = await resolveSession(request);51 if (!userId || erased.has(userId))52 return new Response("Unauthenticated", { status: 401 });53 const url = new URL(request.url);54 try {55 if (url.pathname === "/purchase" && request.method === "POST") {56 const input = await request.json();57 if (erased.has(userId))58 return new Response("Unauthenticated", { status: 401 });59 if (input.store === "amazon" || input.store === "horizon") {60 const storeUser = await resolveStoreUser?.(request, input.store);61 if (!storeUser || input[input.store]?.userId !== storeUser)62 return new Response(63 "Store account is not linked to this session",64 { status: 403 },65 );66 }67 if (erased.has(userId))68 return new Response("Unauthenticated", { status: 401 });69 const work = clients[selected].fulfill(input, {70 userId,71 productId: "premium.monthly",72 });73 const pending = inFlight.get(userId) ?? new Set();74 pending.add(work);75 inFlight.set(userId, pending);76 let result;77 try {78 result = await work;79 } finally {80 pending.delete(work);81 if (!pending.size) inFlight.delete(userId);82 }83 // A deletion can race the upstream calls; never return access afterwards.84 return Response.json(erased.has(userId) ? { access: false } : result);85 }86 if (url.pathname === "/access" && request.method === "GET") {87 const result = await clients[selected].call("entitlements", {88 userId,89 });90 return erased.has(userId)91 ? new Response("Unauthenticated", { status: 401 })92 : Response.json(result);93 }94 if (url.pathname === "/account" && request.method === "DELETE") {95 db.transaction(() => {96 erased.remember(userId);97 for (const name of Object.keys(clients))98 db.query(99 "INSERT OR IGNORE INTO erasure_requests VALUES (?,?)",100 ).run(userId, name);101 })();102 receiver.eraseUser(userId);103 const pending = await drainErasure();104 return Response.json({105 accepted: true,106 status: pending ? "queued" : "completed",107 });108 }109 return new Response("Not found", { status: 404 });110 } catch {111 return Response.json(112 { error: "Commerce provider unavailable; retry the request." },113 { status: 503 },114 );115 }116 },117 });118 return {119 url: `http://127.0.0.1:${server.port}`,120 select(name) {121 if (!clients[name]) throw new Error("Unknown provider");122 selected = name;123 },124 drainErasure,125 async close() {126 await server.stop(true);127 db.close();128 },129 };130}131