1import assert from "node:assert/strict";2import { operation, validate } from "./contract.mjs";3import { CREDENTIALS, FIXTURE } from "./provider.mjs";4import { deliver } from "./webhooks.mjs";5 6export const STAGES = [7 {8 title: "Start with the contract",9 built: "HTTP routes + schema validation + SQLite",10 result: "A running server, an empty purchase table, and no access.",11 },12 {13 title: "Verify a purchase",14 built: "Fixture store adapter + purchase persistence",15 result: "Valid evidence is saved. Alice still has no access.",16 },17 {18 title: "Connect it to a user",19 built: "Server authorization + atomic binding + entitlement reads",20 result: "Alice gets Premium. Another user cannot take the purchase.",21 },22 {23 title: "Handle cancellation",24 built: "Lifecycle processing + transactional event outbox",25 result: "Renewal stops. Alice keeps the time she already paid for.",26 },27 {28 title: "Deliver, retry, deduplicate",29 built: "HMAC signatures + retry worker + durable receiver inbox",30 result:31 "A 503 retries successfully. Redelivery creates no second inbox row.",32 },33 {34 title: "Expire access and restart",35 built: "Expiry-aware reads + recovery from SQLite",36 result:37 "Access closes at the deadline. Restarting preserves purchases and deliveries.",38 },39 {40 title: "Delete the account",41 built: "Idempotent erasure + receiver cleanup + durable deletion guard",42 result:43 "Alice is removed from purchases and event copies. Late deliveries cannot restore her account data.",44 },45];46 47export async function requestOperation(baseUrl, name, input, role = "server") {48 const spec = operation(name);49 const url = new URL(spec.path, baseUrl);50 if (spec.method === "GET" && input)51 for (const [key, value] of Object.entries(input))52 url.searchParams.set(key, value);53 const response = await fetch(url, {54 method: spec.method,55 headers: {56 "content-type": "application/json",57 ...(role ? { authorization: CREDENTIALS[role] } : {}),58 },59 ...(spec.method === "POST" ? { body: JSON.stringify(input) } : {}),60 });61 return {62 operation: name,63 httpStatus: response.status,64 body: await response.json(),65 };66}67 68export function createScenario(runtime) {69 const history = [];70 const checks = [];71 const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence };72 const call = (name, input, role) =>73 requestOperation(runtime.baseUrl, name, input, role);74 const check = (label, actual, expected) => {75 assert.deepEqual(actual, expected, label);76 checks.push(label);77 };78 79 async function advance() {80 const stage = history.length;81 if (stage >= STAGES.length) return history.at(-1);82 const responses = [];83 const run = async (name, input, role) => {84 const result = await call(name, input, role);85 responses.push(result);86 return result;87 };88 const start = checks.length;89 if (stage === 0) {90 check(91 "Fixture request matches the installed schema",92 validate(operation("verifyPurchase").input, evidence),93 true,94 );95 check(96 "Capabilities use the published response schema",97 (await run("providerCapabilities", null, null)).httpStatus,98 200,99 );100 check(101 "Purchase storage starts empty",102 runtime.provider.inspect().purchases,103 [],104 );105 check(106 "No profile conformance is claimed",107 (await run("providerCapabilities", null, null)).body.profiles,108 undefined,109 );110 } else if (stage === 1) {111 check(112 "Fixture evidence is accepted",113 (await run("verifyPurchase", evidence, "verification")).body.isValid,114 true,115 );116 check(117 "Verification does not grant access",118 (await run("entitlements", { userId: FIXTURE.userId })).body.productIds,119 [],120 );121 check(122 "Invalid evidence produces a negative verdict",123 (124 await run(125 "verifyPurchase",126 { store: FIXTURE.store, evidence: "invalid" },127 "verification",128 )129 ).body.isValid,130 false,131 );132 check(133 "An upstream outage is not a negative verdict",134 (135 await run(136 "verifyPurchase",137 { store: FIXTURE.store, evidence: "local-upstream-outage" },138 "verification",139 )140 ).body.error.code,141 "VERIFICATION_FAILED",142 );143 } else if (stage === 2) {144 check(145 "Verification credentials cannot bind a user",146 (147 await run(148 "bindPurchase",149 { ...evidence, userId: FIXTURE.userId },150 "verification",151 )152 ).body.error.code,153 "FORBIDDEN",154 );155 check(156 "The server binds Alice",157 (await run("bindPurchase", { ...evidence, userId: FIXTURE.userId }))158 .body.bound,159 true,160 );161 check(162 "Repeating the same binding succeeds",163 (await run("bindPurchase", { ...evidence, userId: FIXTURE.userId }))164 .body.bound,165 true,166 );167 check(168 "Bob cannot take Alice's purchase",169 (await run("bindPurchase", { ...evidence, userId: "demo_bob" })).body170 .bound,171 false,172 );173 check(174 "Alice can access Premium",175 (await run("entitlements", { userId: FIXTURE.userId })).body.productIds,176 [FIXTURE.productId],177 );178 check(179 "First binding queues one grant; repeat and conflict queue none",180 runtime.provider.inspect().deliveries.map((row) => row.eventType),181 ["entitlement.granted"],182 );183 } else if (stage === 3) {184 runtime.time = FIXTURE.startsAt + 86_400_000;185 runtime.provider.observe({186 id: "fixture-cancel",187 kind: "cancel",188 occurredAt: runtime.time,189 });190 const result = await run("subscriptionStatus", {191 userId: FIXTURE.userId,192 });193 check("Cancellation keeps paid access", result.body.active, true);194 check(195 "Cancellation stops renewal",196 result.body.subscription.willRenew,197 false,198 );199 check(200 "Cancellation queues one event",201 runtime.provider202 .inspect()203 .deliveries.filter((row) => row.eventType === "subscription.canceled")204 .length,205 1,206 );207 } else if (stage === 4) {208 const failed = await deliver(209 runtime.provider,210 runtime.secret,211 runtime.now,212 async () => new Response(null, { status: 503 }),213 );214 responses.push({215 operation: "webhook: receiver unavailable",216 body: failed,217 });218 check("A 503 leaves a durable retry", failed[0].status, "pending");219 runtime.restart();220 runtime.time += 30_000;221 const delivered = await deliver(222 runtime.provider,223 runtime.secret,224 runtime.now,225 runtime.post,226 );227 responses.push({228 operation: "webhook: retry after restart",229 body: delivered,230 });231 check("Retry survives provider restart", delivered[0].httpStatus, 200);232 check(233 "Retry keeps the delivery identity",234 delivered[0].deliveryId,235 failed[0].deliveryId,236 );237 // Simulate loss of the delivery acknowledgement after the receiver committed.238 runtime.provider.db239 .query("UPDATE outbox SET status = 'pending', next_at = 0")240 .run();241 runtime.time += 1_000;242 responses.push({243 operation: "webhook: lost-ack redelivery",244 body: await deliver(245 runtime.provider,246 runtime.secret,247 runtime.now,248 runtime.post,249 ),250 });251 check(252 "Redelivery has one inbox row per event",253 runtime.receiver.count(),254 2,255 );256 } else if (stage === 5) {257 runtime.time = FIXTURE.expiresAt;258 check(259 "Access closes at expiry before a notification arrives",260 (await run("entitlements", { userId: FIXTURE.userId })).body.productIds,261 [],262 );263 const observation = {264 id: "fixture-expire",265 kind: "expire",266 occurredAt: runtime.time,267 };268 runtime.provider.observe(observation);269 check(270 "Duplicate store notification emits no extra event",271 runtime.provider.observe(observation),272 false,273 );274 const before = runtime.provider.inspect();275 runtime.restart();276 check(277 "Reopening both databases preserves state",278 runtime.provider.inspect(),279 before,280 );281 check(282 "Receiver deduplication survives restart",283 runtime.receiver.count(),284 2,285 );286 responses.push({287 operation: "webhook: expiry + revocation",288 body: await deliver(289 runtime.provider,290 runtime.secret,291 runtime.now,292 runtime.post,293 ),294 });295 check("All four events reach the receiver", runtime.receiver.count(), 4);296 check(297 "The final status is inactive",298 (await run("subscriptionStatus", { userId: FIXTURE.userId })).body299 .active,300 false,301 );302 } else if (stage === 6) {303 check(304 "Verification credentials cannot erase users",305 (await run("eraseUser", { userId: FIXTURE.userId }, "verification"))306 .httpStatus,307 403,308 );309 // The app removes its event copies before requesting provider erasure.310 runtime.receiver.eraseUser(FIXTURE.userId);311 const erased = await run("eraseUser", { userId: FIXTURE.userId });312 check(313 "Provider erasure completes",314 [erased.body.accepted, erased.body.status],315 [true, "completed"],316 );317 runtime.restart();318 check(319 "Erasure retry after restart returns the same job",320 (await run("eraseUser", { userId: FIXTURE.userId })).body,321 erased.body,322 );323 check(324 "Purchase has no account identity",325 runtime.provider.inspect().purchases.map((row) => row.userId),326 [null],327 );328 check("App event copies are erased", runtime.receiver.count(), 0);329 check(330 "Erased account has no access",331 (await run("entitlements", { userId: FIXTURE.userId })).body.productIds,332 [],333 );334 check(335 "A stale binding retry cannot restore identity",336 (await run("bindPurchase", { ...evidence, userId: FIXTURE.userId }))337 .body.bound,338 false,339 );340 }341 const entry = {342 step: stage + 1,343 ...STAGES[stage],344 simulatedTime: new Date(runtime.time).toISOString(),345 ...runtime.provider.inspect(),346 inboxCount: runtime.receiver.count(),347 responses,348 checks: checks.slice(start),349 totalChecks: checks.length,350 };351 history.push(entry);352 return entry;353 }354 355 return { advance, history };356}357