akeso-check 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,147 @@
1
+ import { makeEvent, scenarios, signPayload } from "./stripe-events.mjs";
2
+
3
+ /* Delivers the lifecycle scenarios to a running app and grades what its own
4
+ * billing entitlement says after each one.
5
+ *
6
+ * Discipline carried from the product-care engine: a delivery failure is OUR
7
+ * problem (server down, connection refused) and produces "could not test",
8
+ * never a failing grade for the app. Only the app's own answer to "is this
9
+ * person entitled?" can pass or fail a scenario.
10
+ */
11
+
12
+ async function deliver(webhookUrl, event, webhookSecret) {
13
+ const rawBody = JSON.stringify(event);
14
+ const response = await fetch(webhookUrl, {
15
+ method: "POST",
16
+ headers: {
17
+ "content-type": "application/json",
18
+ "stripe-signature": signPayload(rawBody, webhookSecret),
19
+ },
20
+ body: rawBody,
21
+ });
22
+ return { status: response.status, ok: response.ok };
23
+ }
24
+
25
+ async function probe(probeUrl, account) {
26
+ /* A probe deployed on a public app carries a guard token in its URL, so the
27
+ account parameter joins with & when a query already exists. */
28
+ const joiner = probeUrl.includes("?") ? "&" : "?";
29
+ const response = await fetch(`${probeUrl}${joiner}account=${encodeURIComponent(account)}`);
30
+ if (!response.ok) throw new Error(`probe returned ${response.status}`);
31
+ const body = await response.json();
32
+ if (typeof body.billingEntitled !== "boolean") throw new Error("probe did not return billingEntitled");
33
+ return body.billingEntitled;
34
+ }
35
+
36
+ export async function runLifecycle({ webhookUrl, probeUrl, webhookSecret, accountFor, settleMs = 60, resetBeforeEach = false }) {
37
+ const results = [];
38
+
39
+ for (const scenario of scenarios({ accountFor })) {
40
+ /* On a shared real account, return it to "not entitled" before each
41
+ scenario by delivering a cancellation the app itself understands. On a
42
+ healthy app this makes every grant provable with one account; on an app
43
+ that ignores cancellations the reset is a no-op and the vacuous-pass
44
+ guard below reports not_provable — never a fake pass either way. */
45
+ if (resetBeforeEach) {
46
+ const reset = makeEvent({
47
+ type: "customer.subscription.deleted",
48
+ account: scenario.account,
49
+ created: scenario.resetCreated,
50
+ object: { status: "canceled" },
51
+ });
52
+ try {
53
+ await deliver(webhookUrl, reset, webhookSecret);
54
+ await new Promise((resolve) => setTimeout(resolve, settleMs));
55
+ } catch { /* the scenario's own delivery reports a dead server */ }
56
+ }
57
+
58
+ /* When scenarios are mapped onto a real shared account (a deployed app
59
+ where rows for made-up accounts cannot exist), a grant scenario that
60
+ starts with access already granted would pass vacuously. Same doctrine
61
+ as the sandbox driver: not provable is not a pass. */
62
+ if (accountFor && scenario.expect === true) {
63
+ let before = null;
64
+ try { before = await probe(probeUrl, scenario.account); } catch { /* main probe below reports it */ }
65
+ if (before === true) {
66
+ results.push({
67
+ id: scenario.id, name: scenario.name, expected: scenario.expect,
68
+ observed: null, deliveries: [], outcome: "not_provable",
69
+ critical: Boolean(scenario.critical), harnessError: null,
70
+ note: "not provable: access was already on when this scenario started",
71
+ });
72
+ continue;
73
+ }
74
+ }
75
+
76
+ const deliveries = [];
77
+ let harnessError = null;
78
+ try {
79
+ for (const event of scenario.events) {
80
+ deliveries.push({ type: event.type, ...(await deliver(webhookUrl, event, webhookSecret)) });
81
+ /* Small gap so file-backed fixtures and debounced handlers settle;
82
+ deployed serverless apps need more room than local fixtures. */
83
+ await new Promise((resolve) => setTimeout(resolve, settleMs));
84
+ }
85
+ } catch (error) {
86
+ harnessError = error?.message || String(error);
87
+ }
88
+
89
+ /* If the app rejected every event we sent, its state was never exercised
90
+ and the probe can only echo whatever was already true. That is not a
91
+ lifecycle result — for anyone. (Today's cause was our own signing bug;
92
+ an app-side verification bug shows up in the static findings instead.) */
93
+ if (!harnessError && deliveries.length && deliveries.every((d) => !d.ok)) {
94
+ harnessError = `every delivery was rejected (${deliveries.map((d) => d.status).join(", ")}); the lifecycle was never exercised`;
95
+ }
96
+
97
+ let observed = null;
98
+ let probeError = null;
99
+ if (!harnessError) {
100
+ try { observed = await probe(probeUrl, scenario.account); } catch (error) { probeError = error?.message || String(error); }
101
+ }
102
+
103
+ const graded = scenario.expect !== null && !harnessError && !probeError;
104
+ results.push({
105
+ id: scenario.id,
106
+ name: scenario.name,
107
+ expected: scenario.expect,
108
+ observed,
109
+ deliveries,
110
+ outcome: harnessError || probeError
111
+ ? "could_not_test" /* our fault, never the app's */
112
+ : scenario.expect === null
113
+ ? "reported" /* refund policy: shown, not graded */
114
+ : observed === scenario.expect ? "pass" : "fail",
115
+ critical: Boolean(scenario.critical),
116
+ harnessError: harnessError || probeError || null,
117
+ });
118
+ }
119
+
120
+ return { results, grade: gradeOf(results), scenarioCount: results.length };
121
+ }
122
+
123
+ /* One letter a founder understands. F is reserved for the failure that costs
124
+ money every single day: canceled customers keeping access. */
125
+ export function gradeOf(results) {
126
+ const graded = results.filter((r) => r.outcome === "pass" || r.outcome === "fail");
127
+ const untestable = results.filter((r) => r.outcome === "could_not_test" || r.outcome === "not_provable");
128
+ if (graded.length === 0) return { letter: "?", reason: "Nothing could be tested. See the errors below. This is a problem with the run, not proof about the app." };
129
+
130
+ const failures = graded.filter((r) => r.outcome === "fail");
131
+ const criticalFailure = failures.find((r) => r.critical);
132
+
133
+ const letter = criticalFailure ? "F"
134
+ : failures.length === 0 ? (untestable.length ? "B" : "A")
135
+ : failures.length === 1 ? "C"
136
+ : "D";
137
+
138
+ const reason = criticalFailure
139
+ ? "Customers who cancel keep their paid access. This leaks money every day until it is fixed."
140
+ : failures.length === 0
141
+ ? untestable.length
142
+ ? `Every tested scenario passed, but ${untestable.length} could not be tested.`
143
+ : "Every lifecycle scenario passed."
144
+ : `${failures.length} lifecycle scenario${failures.length === 1 ? "" : "s"} failed.`;
145
+
146
+ return { letter, reason, failures: failures.map((r) => r.id), untested: untestable.map((r) => r.id) };
147
+ }
package/src/probe.mjs ADDED
@@ -0,0 +1,167 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ /* The access probe: a temporary route the Check adds to the founder's app so
5
+ * the lifecycle pass can ask THE APP'S OWN CODE "is this account entitled?"
6
+ * after each transition. Added for the run, removed afterwards — and removal
7
+ * refuses to touch any file the Check did not write.
8
+ *
9
+ * The judgement problem lives here: which function IS the app's access
10
+ * decision? Detection ranks candidate files; this module only wires a probe
11
+ * when it finds an exported function whose name and shape it recognises. When
12
+ * it is not sure, it says so and emits a two-line stub for the founder (or
13
+ * their coding agent) to complete — an honest gap beats a wrong wire.
14
+ */
15
+
16
+ const MARKER = "AKESO PROBE — added by Akeso Check for a test run. Safe to delete.";
17
+
18
+ /* Function names that mean "the billing/access decision" when exported.
19
+ Ordered: an explicit entitlement function beats a generic isPro. */
20
+ const KNOWN_NAMES = [
21
+ "getBillingEntitlement",
22
+ "billingEntitled",
23
+ "isSubscribed",
24
+ "hasActiveSubscription",
25
+ "isPro",
26
+ "isPremium",
27
+ "isPaid",
28
+ "hasPaidAccess",
29
+ "hasAccess",
30
+ ];
31
+
32
+ /* Find recognisable exported access functions in one file. Regex over source is
33
+ deliberate — no TS compiler dependency — and anything it cannot classify is
34
+ simply not offered, never guessed at. */
35
+ export function findAccessExports(source) {
36
+ const found = [];
37
+ for (const name of KNOWN_NAMES) {
38
+ const patterns = [
39
+ new RegExp(`export\\s+(async\\s+)?function\\s+${name}\\s*\\(([^)]*)\\)`),
40
+ new RegExp(`export\\s+const\\s+${name}\\s*=\\s*(async\\s*)?\\(([^)]*)\\)\\s*=>`),
41
+ ];
42
+ for (const pattern of patterns) {
43
+ const match = source.match(pattern);
44
+ if (match) {
45
+ const params = (match[2] ?? "").split(",").map((p) => p.trim()).filter(Boolean);
46
+ found.push({ name, paramCount: params.length, firstParam: params[0] || null });
47
+ break;
48
+ }
49
+ }
50
+ }
51
+ return found;
52
+ }
53
+
54
+ /* Choose the probe target across the ranked candidate files. One-argument
55
+ functions only: isPro(userId) is wireable; isPro(userId, featureFlag, ctx)
56
+ is a guess we refuse to make. */
57
+ export async function chooseProbeTarget(root, accessDecisionSites) {
58
+ const considered = [];
59
+ for (const site of accessDecisionSites.slice(0, 6)) {
60
+ if (site.clientSideOnly) continue; /* a browser-side gate cannot answer for the server */
61
+ const source = await readFile(path.join(root, site.file), "utf8").catch(() => null);
62
+ if (!source) continue;
63
+ for (const fn of findAccessExports(source)) {
64
+ considered.push({ file: site.file, ...fn, siteScore: site.score });
65
+ }
66
+ }
67
+ considered.sort((a, b) =>
68
+ (KNOWN_NAMES.indexOf(a.name) - KNOWN_NAMES.indexOf(b.name)) || (b.siteScore - a.siteScore));
69
+
70
+ const wireable = considered.filter((c) => c.paramCount === 1);
71
+ return {
72
+ chosen: wireable[0] || null,
73
+ considered,
74
+ reason: wireable[0]
75
+ ? `Found ${wireable[0].name}(${wireable[0].firstParam}) in ${wireable[0].file}.`
76
+ : considered.length
77
+ ? "Access functions exist but none takes exactly one argument — wiring one would be a guess."
78
+ : "No recognisable exported access function was found.",
79
+ };
80
+ }
81
+
82
+ function probeRoutePath(root, framework) {
83
+ if (framework === "next-pages") return path.join(root, "pages", "api", "__akeso_probe.ts");
84
+ return path.join(root, "app", "api", "__akeso_probe", "route.ts");
85
+ }
86
+
87
+ /* Relative import from the generated route file to the access module, with the
88
+ extension handled the way the app's own imports are. */
89
+ function importSpecifier(fromFile, toFile) {
90
+ let relative = path.relative(path.dirname(fromFile), toFile).replaceAll(path.sep, "/");
91
+ if (!relative.startsWith(".")) relative = `./${relative}`;
92
+ return relative.replace(/\.(ts|tsx)$/, "").replace(/\.(js)$/, "");
93
+ }
94
+
95
+ export function renderProbe({ framework, specifier, exportName }) {
96
+ const body = `// ${MARKER}
97
+ // It exists so the lifecycle test can ask this app's own code whether an
98
+ // account is billing-entitled. It reads; it never writes.
99
+ import { ${exportName} } from "${specifier}";
100
+
101
+ ${framework === "next-pages"
102
+ ? `export default async function handler(req, res) {
103
+ const account = String(req.query.account || "");
104
+ res.status(200).json({ billingEntitled: Boolean(await ${exportName}(account)) });
105
+ }`
106
+ : `export async function GET(request${framework.startsWith("node") ? "" : ": Request"}) {
107
+ const account = new URL(request.url).searchParams.get("account") || "";
108
+ return Response.json({ billingEntitled: Boolean(await ${exportName}(account)) });
109
+ }`}
110
+ `;
111
+ return body;
112
+ }
113
+
114
+ export function renderProbeStub(framework) {
115
+ return `// ${MARKER}
116
+ // Akeso could not identify this app's access function on its own.
117
+ // ONE LINE TO COMPLETE: import your access check and return its answer.
118
+ // Example: import { isPro } from "../../lib/access";
119
+ ${framework === "next-pages"
120
+ ? `export default async function handler(req, res) {
121
+ const account = String(req.query.account || "");
122
+ res.status(200).json({ billingEntitled: /* await isPro(account) */ null });
123
+ }`
124
+ : `export async function GET(request: Request) {
125
+ const account = new URL(request.url).searchParams.get("account") || "";
126
+ return Response.json({ billingEntitled: /* await isPro(account) */ null });
127
+ }`}
128
+ `;
129
+ }
130
+
131
+ export async function installProbe(root, detection) {
132
+ const framework = detection.framework?.framework || "next-app-router";
133
+ const routeFile = probeRoutePath(root, framework)
134
+ .replace(/route\.ts$/, framework === "node-other" ? "route.mjs" : "route.ts");
135
+
136
+ const target = await chooseProbeTarget(root, detection.accessDecisionSites || []);
137
+ const content = target.chosen
138
+ ? renderProbe({
139
+ framework,
140
+ specifier: importSpecifier(routeFile, path.join(root, target.chosen.file)),
141
+ exportName: target.chosen.name,
142
+ })
143
+ : renderProbeStub(framework);
144
+
145
+ await mkdir(path.dirname(routeFile), { recursive: true });
146
+ await writeFile(routeFile, content);
147
+ return {
148
+ routeFile,
149
+ urlPath: "/api/__akeso_probe",
150
+ wired: Boolean(target.chosen),
151
+ reason: target.reason,
152
+ target: target.chosen || null,
153
+ };
154
+ }
155
+
156
+ /* Removal refuses to delete anything without the marker: if a founder edited
157
+ the stub into something real and kept it, that file is now theirs. */
158
+ export async function removeProbe(routeFile) {
159
+ const content = await readFile(routeFile, "utf8").catch(() => null);
160
+ if (content === null) return { removed: false, reason: "already gone" };
161
+ if (!content.includes(MARKER)) return { removed: false, reason: "file no longer carries the Akeso marker — leaving it alone" };
162
+ await rm(routeFile);
163
+ /* tidy the wrapper dir Next requires, only if we created it and it is now empty */
164
+ const dir = path.dirname(routeFile);
165
+ if (path.basename(dir) === "__akeso_probe") await rm(dir, { recursive: false }).catch(() => {});
166
+ return { removed: true };
167
+ }
package/src/report.mjs ADDED
@@ -0,0 +1,127 @@
1
+ /* The report a founder actually sees: a local HTML file that opens in their
2
+ * browser. It looks like a website; it is a file on their machine, and saying
3
+ * so prominently is part of the product — the whole trust story is "nothing
4
+ * left your computer."
5
+ *
6
+ * Design rules, inherited from the product-care dashboard: plain English
7
+ * first, colour only where it carries state, nothing on the page that was not
8
+ * measured, and the limits of the run stated as prominently as the findings.
9
+ */
10
+
11
+ const escapeHtml = (value) => String(value ?? "")
12
+ .replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
13
+
14
+ const GRADE_COPY = {
15
+ A: "Your billing lifecycle holds up.",
16
+ B: "Nothing failed, but some scenarios could not be tested.",
17
+ C: "One lifecycle scenario fails.",
18
+ D: "Several lifecycle scenarios fail.",
19
+ F: "Canceled customers keep paid access.",
20
+ "?": "The run itself had problems. This is not a verdict on your app.",
21
+ };
22
+
23
+ export function renderReport({ detection, lifecycle, generatedAt = new Date() }) {
24
+ const grade = lifecycle?.grade ?? { letter: "?", reason: "The lifecycle test did not run." };
25
+ const handler = detection.webhookHandlers?.[0] || null;
26
+
27
+ const staticFindings = [];
28
+ if (!handler) {
29
+ staticFindings.push({ tone: "bad", text: "No Stripe webhook handler was found. Payment events from Stripe have nowhere to land." });
30
+ } else {
31
+ if (!handler.verifiesSignature) staticFindings.push({ tone: "bad", text: "The webhook handler does not verify Stripe's signature. Anyone who finds the URL can forge payment events." });
32
+ else if (!handler.rawBodySeen) staticFindings.push({ tone: "warn", text: "Signature verification exists, but raw-body handling was not seen. Verification like this often fails at runtime." });
33
+ if (handler.missingEvents?.length) staticFindings.push({ tone: "warn", text: `The handler ignores ${handler.missingEvents.length} of the 7 lifecycle events: ${handler.missingEvents.join(", ")}.` });
34
+ if (staticFindings.length === 0) staticFindings.push({ tone: "ok", text: "Signature verified against the raw body, and all 7 lifecycle events are handled." });
35
+ }
36
+ const clientGate = (detection.accessDecisionSites || []).find((site) => site.clientSideOnly);
37
+ if (clientGate) staticFindings.push({ tone: "warn", text: `Paid access appears to be checked in the browser (${clientGate.file}), a gate anyone can step around with devtools.` });
38
+
39
+ const scenarioRows = (lifecycle?.results || []).map((result) => {
40
+ const mark = result.outcome === "pass" ? "✓"
41
+ : result.outcome === "fail" ? "✗"
42
+ : result.outcome === "reported" ? "•" : "?";
43
+ const cls = result.outcome === "pass" ? "ok"
44
+ : result.outcome === "fail" ? (result.critical ? "bad" : "warn")
45
+ : result.outcome === "reported" ? "note" : "mute";
46
+ const detail = result.outcome === "fail"
47
+ ? (result.expected ? "access should have been granted, but your app says no" : "access should have ended, but your app still grants it")
48
+ : result.outcome === "reported" ? `your app's policy: ${result.observed ? "keeps access" : "removes access"}`
49
+ : result.outcome === "could_not_test" ? escapeHtml(result.harnessError)
50
+ : result.outcome === "not_provable" ? escapeHtml(result.note || "not provable on this run") : "";
51
+ return `<div class="row ${cls}"><span class="mark">${mark}</span><span class="name">${escapeHtml(result.name)}</span><span class="detail">${detail}</span></div>`;
52
+ }).join("\n");
53
+
54
+ const findingRows = staticFindings.map((finding) =>
55
+ `<div class="row ${finding.tone}"><span class="mark">${finding.tone === "ok" ? "✓" : finding.tone === "bad" ? "✗" : "!"}</span><span class="name wide">${escapeHtml(finding.text)}</span></div>`,
56
+ ).join("\n");
57
+
58
+ const limits = [
59
+ "Only the billing lifecycle was tested. Not login, checkout UI, or anything else.",
60
+ "Events were delivered locally with your app's own webhook secret. If your handler re-fetches objects from Stripe's API, run the sandbox mode with your Stripe test key for full fidelity.",
61
+ detection.capabilities?.blockers?.length ? `Not possible on this project yet: ${detection.capabilities.blockers.join(" ")}` : null,
62
+ ].filter(Boolean).map((limit) => `<li>${escapeHtml(limit)}</li>`).join("\n");
63
+
64
+ return `<!doctype html>
65
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
66
+ <title>Akeso Check · ${escapeHtml(detection.framework?.packageName || "your app")}</title>
67
+ <style>
68
+ :root { --bg:#fcfcfd; --ink:#16181d; --ink2:#5b6270; --ink3:#8a919e; --line:#e6e8ec;
69
+ --ok:#12784b; --warn:#96620a; --bad:#b3261e; --note:#2f4a78; }
70
+ @media (prefers-color-scheme: dark) { :root { --bg:#111317; --ink:#e9ebef; --ink2:#a4abb8;
71
+ --ink3:#767d8a; --line:#282c34; --ok:#4cbe83; --warn:#dfa94c; --bad:#ef8578; --note:#8aa9dc; } }
72
+ * { box-sizing:border-box; }
73
+ body { margin:0; background:var(--bg); color:var(--ink); font:16px/1.55 ui-sans-serif,-apple-system,system-ui,sans-serif; -webkit-font-smoothing:antialiased; }
74
+ .shell { max-width:640px; margin:0 auto; padding:40px 24px 100px; }
75
+ .local { font-size:12.5px; color:var(--ink3); border:1px solid var(--line); border-radius:20px; display:inline-block; padding:3px 12px; margin-bottom:34px; }
76
+ .gradeCard { border:1px solid var(--line); border-radius:10px; padding:30px 28px; display:flex; gap:26px; align-items:center; }
77
+ .gradeLetter { font-size:76px; font-weight:700; line-height:1; letter-spacing:-.04em; }
78
+ .g-A,.g-B { color:var(--ok); } .g-C { color:var(--warn); } .g-D,.g-F { color:var(--bad); } .g-\\? { color:var(--ink3); }
79
+ .gradeCard h1 { margin:0 0 6px; font-size:22px; letter-spacing:-.02em; }
80
+ .gradeCard p { margin:0; color:var(--ink2); font-size:15px; }
81
+ .app { font-size:13px; color:var(--ink3); margin-top:10px; }
82
+ h2 { font-size:12px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--ink3); margin:44px 0 4px; }
83
+ .intro { margin:2px 0 12px; color:var(--ink2); font-size:14.5px; }
84
+ .rows { border-top:1px solid var(--line); }
85
+ .row { display:flex; gap:12px; padding:11px 0; border-bottom:1px solid var(--line); align-items:baseline; font-size:15px; }
86
+ .mark { width:18px; text-align:center; flex:none; font-weight:600; }
87
+ .row.ok .mark { color:var(--ok); } .row.warn .mark { color:var(--warn); }
88
+ .row.bad .mark { color:var(--bad); } .row.bad .name { color:var(--bad); font-weight:600; }
89
+ .row.note .mark { color:var(--note); } .row.mute { color:var(--ink3); }
90
+ .name { flex:1; } .name.wide { flex:auto; }
91
+ .detail { color:var(--ink3); font-size:13.5px; text-align:right; max-width:40%; }
92
+ ul.limits { margin:8px 0 0; padding-left:20px; color:var(--ink2); font-size:14.5px; }
93
+ ul.limits li { margin-bottom:7px; }
94
+ .cta { margin-top:48px; border:1px solid var(--line); border-radius:10px; padding:22px 24px; }
95
+ .cta h3 { margin:0 0 6px; font-size:17px; } .cta p { margin:0 0 14px; color:var(--ink2); font-size:14.5px; }
96
+ .cta a { display:inline-block; background:var(--ink); color:var(--bg); text-decoration:none; border-radius:7px; padding:10px 18px; font-size:15px; font-weight:500; }
97
+ footer { margin-top:44px; font-size:12.5px; color:var(--ink3); }
98
+ </style></head><body><div class="shell">
99
+ <div class="local">This report is a file on your computer. Nothing was sent anywhere.</div>
100
+ <div class="gradeCard">
101
+ <div class="gradeLetter g-${escapeHtml(grade.letter)}">${escapeHtml(grade.letter)}</div>
102
+ <div>
103
+ <h1>${escapeHtml(GRADE_COPY[grade.letter] || grade.reason)}</h1>
104
+ <p>${escapeHtml(grade.letter === "F" ? "This leaks money every day until it is fixed." : grade.reason)}</p>
105
+ <div class="app">${escapeHtml([
106
+ detection.framework?.packageName || detection.root,
107
+ { "next-app-router": "a Next.js app", "next-pages": "a Next.js app", express: "an Express app", "supabase-edge": "a Supabase Edge app", "node-other": "a Node app" }[detection.framework?.framework] || null,
108
+ detection.database?.kind && detection.database.kind !== "none-found" ? `with ${detection.database.kind === "supabase" ? "Supabase" : detection.database.kind}` : null,
109
+ detection.stripe?.secretKey ? `Stripe ${detection.stripe.secretKey.mode.toLowerCase()} mode` : null,
110
+ ].filter(Boolean).join(", "))}</div>
111
+ </div>
112
+ </div>
113
+
114
+ <h2>What we tested</h2>
115
+ <p class="intro">Akeso acted out ten billing situations against your app: paying, canceling, a failing card, a refund. After each one it asked your app the same question: does this customer still have paid access?</p>
116
+ <div class="rows">${scenarioRows || '<div class="row mute"><span class="mark">?</span><span class="name">The lifecycle test did not run on this project.</span></div>'}</div>
117
+
118
+ <h2>What your code shows</h2>
119
+ <p class="intro">Read from your webhook handler and access checks, before anything ran.</p>
120
+ <div class="rows">${findingRows}</div>
121
+
122
+ <h2>What this did not check</h2>
123
+ <ul class="limits">${limits}</ul>
124
+
125
+ <footer>Akeso Check · ${escapeHtml(generatedAt.toISOString().slice(0, 16).replace("T", " "))} · ${lifecycle ? `${lifecycle.scenarioCount} lifecycle scenarios` : "static analysis only"} · local run</footer>
126
+ </div></body></html>`;
127
+ }
@@ -0,0 +1,191 @@
1
+ import { signPayload } from "./stripe-events.mjs";
2
+
3
+ /* The sandbox driver: the highest-fidelity lifecycle test.
4
+ *
5
+ * Where the local driver synthesises Stripe-shaped events, this one creates a
6
+ * REAL customer and subscription in the founder's own Stripe test sandbox and
7
+ * lets Stripe author the events. We then fetch those events from Stripe's API
8
+ * and deliver them to the locally running app, signed with the app's own
9
+ * webhook secret — so no Stripe CLI login and no public tunnel is needed.
10
+ * The payloads are Stripe's, byte for byte.
11
+ *
12
+ * Safety rails, in order of importance:
13
+ * - refuses to run with anything but a test-mode key, checked here and not
14
+ * only at the call site
15
+ * - everything it creates is tagged and deleted afterwards
16
+ * - it writes only to the founder's own test sandbox, never to the app
17
+ */
18
+
19
+ const API = "https://api.stripe.com/v1";
20
+
21
+ function assertTestKey(key) {
22
+ if (!/^(sk|rk)_test_/.test(key || "")) {
23
+ throw new Error("The sandbox driver refuses to run without a test-mode key (sk_test_…). This is not configurable.");
24
+ }
25
+ }
26
+
27
+ async function stripe(key, method, path, params = {}) {
28
+ const body = new URLSearchParams();
29
+ const add = (name, value) => {
30
+ if (value === undefined || value === null) return;
31
+ if (typeof value === "object") for (const [k, v] of Object.entries(value)) add(`${name}[${k}]`, v);
32
+ else body.append(name, String(value));
33
+ };
34
+ for (const [name, value] of Object.entries(params)) add(name, value);
35
+
36
+ const response = await fetch(`${API}${path}`, {
37
+ method,
38
+ headers: {
39
+ Authorization: `Bearer ${key}`,
40
+ ...(method === "GET" ? {} : { "Content-Type": "application/x-www-form-urlencoded" }),
41
+ },
42
+ ...(method === "GET" ? {} : { body }),
43
+ });
44
+ const json = await response.json();
45
+ if (!response.ok) throw new Error(`Stripe ${method} ${path} -> ${response.status}: ${json.error?.message || "unknown"}`);
46
+ return json;
47
+ }
48
+
49
+ /* Find-or-create the test plan the driver subscribes to. Idempotent through a
50
+ lookup key, so repeated runs reuse one price instead of littering the
51
+ founder's sandbox. */
52
+ async function ensureTestPrice(key) {
53
+ const existing = await stripe(key, "GET", "/prices?lookup_keys[]=akeso-check-monthly&limit=1");
54
+ if (existing.data.length) return existing.data[0];
55
+ const product = await stripe(key, "POST", "/products", {
56
+ name: "Akeso Check test plan (safe to delete)",
57
+ metadata: { akeso: "check" },
58
+ });
59
+ return stripe(key, "POST", "/prices", {
60
+ product: product.id,
61
+ unit_amount: 2900,
62
+ currency: "usd",
63
+ recurring: { interval: "month" },
64
+ lookup_key: "akeso-check-monthly",
65
+ metadata: { akeso: "check" },
66
+ });
67
+ }
68
+
69
+ /* Stripe events relevant to our customer, oldest first, authored by Stripe. */
70
+ async function eventsFor(key, { customerId, since }) {
71
+ const page = await stripe(key, "GET", `/events?limit=100&created[gte]=${since - 5}`);
72
+ return page.data
73
+ .filter((event) => {
74
+ const object = event.data?.object || {};
75
+ return object.customer === customerId || object.id === customerId;
76
+ })
77
+ .sort((a, b) => a.created - b.created);
78
+ }
79
+
80
+ async function deliverAll(events, { webhookUrl, webhookSecret, alreadyDelivered }) {
81
+ const delivered = [];
82
+ for (const event of events) {
83
+ if (alreadyDelivered.has(event.id)) continue;
84
+ alreadyDelivered.add(event.id);
85
+ const rawBody = JSON.stringify(event);
86
+ const response = await fetch(webhookUrl, {
87
+ method: "POST",
88
+ headers: { "content-type": "application/json", "stripe-signature": signPayload(rawBody, webhookSecret) },
89
+ body: rawBody,
90
+ });
91
+ delivered.push({ id: event.id, type: event.type, status: response.status });
92
+ await new Promise((resolve) => setTimeout(resolve, 60));
93
+ }
94
+ return delivered;
95
+ }
96
+
97
+ async function probe(probeUrl, account) {
98
+ /* A probe deployed on a public app carries a guard token in its URL, so the
99
+ account parameter joins with & when a query already exists. */
100
+ const joiner = probeUrl.includes("?") ? "&" : "?";
101
+ const response = await fetch(`${probeUrl}${joiner}account=${encodeURIComponent(account)}`);
102
+ const body = await response.json();
103
+ return Boolean(body.billingEntitled);
104
+ }
105
+
106
+ /* Subscribe -> cancel-at-period-end -> cancel-now, with the app's entitlement
107
+ read after each phase. Trial and renewal phases need test clocks and land in
108
+ the next iteration; the report never claims them meanwhile. */
109
+ export async function runSandboxLifecycle({ stripeKey, webhookUrl, probeUrl, webhookSecret, log = () => {} }) {
110
+ assertTestKey(stripeKey);
111
+ const started = Math.floor(Date.now() / 1000);
112
+ const alreadyDelivered = new Set();
113
+ const phases = [];
114
+ let customerId = null;
115
+
116
+ try {
117
+ const price = await ensureTestPrice(stripeKey);
118
+ log(`test plan ready: ${price.id}`);
119
+
120
+ const customer = await stripe(stripeKey, "POST", "/customers", {
121
+ name: "Akeso Check synthetic customer",
122
+ metadata: { akeso: "check" },
123
+ });
124
+ customerId = customer.id;
125
+ await stripe(stripeKey, "POST", `/customers/${customerId}`, {
126
+ "invoice_settings[default_payment_method]": (await stripe(stripeKey, "POST", "/payment_methods/pm_card_visa/attach", { customer: customerId })).id,
127
+ });
128
+
129
+ const subscription = await stripe(stripeKey, "POST", "/subscriptions", {
130
+ customer: customerId,
131
+ "items[0][price]": price.id,
132
+ metadata: { akeso: "check" },
133
+ });
134
+ log(`subscribed: ${subscription.id} (${subscription.status})`);
135
+
136
+ let accessEverGranted = false;
137
+
138
+ const runPhase = async (name, expected) => {
139
+ /* Stripe writes events asynchronously; poll briefly rather than assuming. */
140
+ let delivered = [];
141
+ for (let i = 0; i < 10; i += 1) {
142
+ await new Promise((resolve) => setTimeout(resolve, 1200));
143
+ delivered = [...delivered, ...await deliverAll(
144
+ await eventsFor(stripeKey, { customerId, since: started }),
145
+ { webhookUrl, webhookSecret, alreadyDelivered },
146
+ )];
147
+ if (delivered.length) break;
148
+ }
149
+ const observed = await probe(probeUrl, customerId);
150
+ if (observed) accessEverGranted = true;
151
+
152
+ /* The vacuous-pass guard: "cancellation removes access" proves nothing
153
+ if access was never granted in the first place. A removal phase on an
154
+ app that never granted is reported as unprovable, not as a pass — the
155
+ same positive-control discipline as everywhere else in this project. */
156
+ const removalPhase = expected === false;
157
+ const outcome = removalPhase && !accessEverGranted
158
+ ? "not_provable"
159
+ : observed === expected ? "pass" : "fail";
160
+
161
+ phases.push({
162
+ phase: name,
163
+ expected,
164
+ observed,
165
+ outcome,
166
+ ...(outcome === "not_provable" ? { reason: "access was never granted, so its removal cannot be tested" } : {}),
167
+ eventsDelivered: delivered.map((d) => d.type),
168
+ });
169
+ log(`${name}: expected ${expected}, app says ${observed} -> ${outcome}`);
170
+ };
171
+
172
+ await runPhase("real subscription grants access", true);
173
+
174
+ await stripe(stripeKey, "POST", `/subscriptions/${subscription.id}`, { cancel_at_period_end: true });
175
+ await runPhase("cancel at period end keeps access until the period ends", true);
176
+
177
+ await stripe(stripeKey, "DELETE", `/subscriptions/${subscription.id}`);
178
+ await runPhase("cancellation removes access", false);
179
+
180
+ return {
181
+ driver: "stripe-sandbox",
182
+ phases,
183
+ passed: phases.every((p) => p.outcome === "pass"),
184
+ notProvable: phases.filter((p) => p.outcome === "not_provable").map((p) => p.phase),
185
+ criticalFailure: phases.find((p) => p.phase.includes("removes access") && p.outcome === "fail") || null,
186
+ };
187
+ } finally {
188
+ /* Leave the founder's sandbox the way we found it. */
189
+ if (customerId) await stripe(stripeKey, "DELETE", `/customers/${customerId}`).catch(() => {});
190
+ }
191
+ }