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.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # Akeso Check
2
+
3
+ Tests whether your Stripe-backed app grants and revokes paid access correctly.
4
+ When someone pays, cancels, fails a card, or gets a refund, does your app get
5
+ it right? Grade A to F, in plain English.
6
+
7
+ Runs on your machine. Nothing leaves it.
8
+
9
+ ## Use
10
+
11
+ In your project folder:
12
+
13
+ ```
14
+ npx akeso-check
15
+ ```
16
+
17
+ Reads your code, writes `akeso-report.html`, and opens it. Seconds.
18
+
19
+ The full test, with your dev server running:
20
+
21
+ ```
22
+ npx akeso-check --lifecycle-url http://localhost:3000
23
+ ```
24
+
25
+ A pretend customer is run through ten billing situations: checkout, trial end,
26
+ renewal, a failing card, cancel at period end, immediate cancel, reactivation,
27
+ refund, the same event delivered twice, events out of order. After each one,
28
+ your app's own code is asked whether that customer still has paid access. The
29
+ report gets the graded results. About a minute.
30
+
31
+ Options:
32
+
33
+ - `--account <id>` run every scenario against one real account id (for deployed
34
+ test environments; the account is reset between scenarios)
35
+ - `--webhook-secret <whsec_...>` override the signing secret (normally read
36
+ from your project's env files, locally)
37
+ - `--html <path>` where to write the report
38
+ - `--no-open` do not open the report
39
+ - `--json` machine-readable output instead
40
+
41
+ ## Privacy
42
+
43
+ There is no account and no server. Your code, keys, and customer data are read
44
+ locally and sent nowhere. The report is a file on your disk. The package has
45
+ zero dependencies and every line is plain JavaScript you can read.
46
+
47
+ Lifecycle tests refuse to run against a project configured with a live-mode
48
+ Stripe key. The temporary access probe it adds during a run is removed when
49
+ the run ends, and it never writes anything else to your app.
50
+
51
+ ## Honesty rules
52
+
53
+ - Every result comes from an executed run. Nothing is stubbed.
54
+ - A dead server, a rejected delivery, or any failure of the tool itself is
55
+ reported as "could not test", never as your app's grade.
56
+ - A pass that cannot be proven (for example, access that was already on) is
57
+ reported as not provable, never as a pass.
58
+
59
+ ## Covers
60
+
61
+ Stripe Billing subscriptions on Next.js or Node apps with Postgres or
62
+ Supabase. One plan field, one active subscription per account. Anything else
63
+ is out of scope and the report says so.
package/SKILL.md ADDED
@@ -0,0 +1,17 @@
1
+ ---
2
+ name: akeso-check
3
+ description: Test whether this Stripe-backed app grants and revokes paid access correctly. Run when the user asks to check their billing, paywall, subscription access, or Stripe webhooks. Nothing leaves the machine.
4
+ ---
5
+
6
+ You are running Akeso Check inside the user's own project. Nothing is
7
+ transmitted anywhere; every credential stays in their env files.
8
+
9
+ 1. Run `node <akeso-check>/bin/akeso-check.mjs .` and read the JSON with
10
+ `--json` if needed.
11
+ 2. If a webhook handler was found and a TEST-mode Stripe key exists, start the
12
+ user's dev server, then rerun with
13
+ `--lifecycle-url http://localhost:<port> --html akeso-report.html --open`.
14
+ 3. Never run lifecycle scenarios against a live-mode key. If only a live key
15
+ exists, say so and stop at static analysis.
16
+ 4. Open the report for the user and summarise the grade in one sentence of
17
+ plain English. Do not soften an F.
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { detect } from "../src/detect.mjs";
5
+ import { runLifecycle } from "../src/lifecycle.mjs";
6
+ import { renderReport } from "../src/report.mjs";
7
+ import { installProbe, removeProbe } from "../src/probe.mjs";
8
+
9
+ /* The whole Check, in the order a stranger meets it:
10
+ * npx akeso-check → static pass, graded report, opens
11
+ * npx akeso-check --lifecycle-url http://localhost:3000
12
+ * → probe auto-installed, scenarios run,
13
+ * probe removed, merged report, opens
14
+ * Options: --account <id> (map scenarios onto one real account, reset between),
15
+ * --webhook-secret <whsec_…>, --html <path>, --no-open, --json.
16
+ * Every default favours the founder who read nothing: something visible always
17
+ * comes out, and the terminal always says what to do next. */
18
+
19
+ const args = process.argv.slice(2);
20
+ const flagValue = (name) => {
21
+ const i = args.indexOf(name);
22
+ return i !== -1 ? args[i + 1] : null;
23
+ };
24
+ const root = path.resolve(args[0] && !args[0].startsWith("--") ? args[0] : process.cwd());
25
+
26
+ const detection = await detect(root);
27
+
28
+ /* The webhook signing secret comes from the project's own env files, read
29
+ locally and never printed. A shell export or flag can override. */
30
+ async function projectWebhookSecret() {
31
+ const flag = flagValue("--webhook-secret");
32
+ if (flag) return flag;
33
+ if (process.env.STRIPE_WEBHOOK_SECRET) return process.env.STRIPE_WEBHOOK_SECRET;
34
+ for (const file of [".env.local", ".env", ".env.development.local", ".env.development"]) {
35
+ const text = await readFile(path.join(root, file), "utf8").catch(() => null);
36
+ if (!text) continue;
37
+ const match = text.match(/^\s*STRIPE_WEBHOOK_SECRET\s*=\s*"?([^"\s#]+)/m);
38
+ if (match) return match[1];
39
+ }
40
+ return null;
41
+ }
42
+
43
+ async function probeAnswers(url) {
44
+ try {
45
+ const response = await fetch(`${url}?account=akeso-warmup`, { signal: AbortSignal.timeout(4000) });
46
+ if (!response.ok) return false;
47
+ const body = await response.json();
48
+ return typeof body.billingEntitled === "boolean";
49
+ } catch { return false; }
50
+ }
51
+
52
+ let lifecycle = null;
53
+ let probeNote = null;
54
+ const base = (flagValue("--lifecycle-url") || "").replace(/\/$/, "");
55
+ if (base) {
56
+ /* The promise on the tin: lifecycle tests never run against a live-mode
57
+ setup. Delivered events can change entitlement state in whatever database
58
+ the target app writes to. */
59
+ if (detection.stripe.liveKeyPresent && !detection.stripe.lifecycleTestable) {
60
+ console.error("\nThis project is configured with a LIVE Stripe key.");
61
+ console.error("The lifecycle test changes entitlement state in the app it points at,");
62
+ console.error("so it only runs against test-mode projects. Switch your env to a test");
63
+ console.error("key (sk_test_...) and run it again.\n");
64
+ process.exit(1);
65
+ }
66
+ const edgeFunction = detection.webhookHandlers[0]?.file.match(/^supabase\/functions\/([^/]+)\//)?.[1];
67
+ const webhookPath = edgeFunction
68
+ ? `/functions/v1/${edgeFunction}`
69
+ : detection.webhookHandlers[0]
70
+ ? "/" + detection.webhookHandlers[0].file.replace(/^app/, "api").replace(/\/route\.(ts|js|mjs|tsx)$/, "").replace(/^api\/api/, "api")
71
+ : "/api/stripe/webhook";
72
+ const webhookUrl = `${base}${webhookPath.startsWith("/api") || webhookPath.startsWith("/functions") ? webhookPath : "/api/stripe/webhook"}`;
73
+
74
+ const secret = await projectWebhookSecret();
75
+ if (!secret) {
76
+ console.error("\nNo STRIPE_WEBHOOK_SECRET found in this project's env files.");
77
+ console.error("The lifecycle pass signs events the way Stripe does, with your app's own");
78
+ console.error("signing secret. Without it every delivery would be rejected as forged.");
79
+ console.error("Add it to .env.local, or pass --webhook-secret whsec_…\n");
80
+ process.exit(1);
81
+ }
82
+
83
+ /* Find a probe, or install the temporary one and let the dev server pick it
84
+ up. Removal happens no matter how the run ends. */
85
+ let probeUrl = null;
86
+ let installed = null;
87
+ for (const candidate of [`${base}/api/__akeso_probe`, `${base}/__akeso_probe`]) {
88
+ if (await probeAnswers(candidate)) { probeUrl = candidate; break; }
89
+ }
90
+ if (!probeUrl) {
91
+ installed = await installProbe(root, detection);
92
+ if (!installed.wired) {
93
+ probeNote = `A probe stub was written to ${path.relative(root, installed.routeFile)}. The Check could not safely pick your access function (${installed.reason}). Complete the two marked lines (or ask your coding agent to), then run this again.`;
94
+ await removeProbe(installed.routeFile).catch(() => {});
95
+ installed = null;
96
+ } else {
97
+ process.stdout.write(`\nAdded a temporary probe (${path.relative(root, installed.routeFile)}, removed after the run), waiting for your dev server to pick it up`);
98
+ const candidate = `${base}${installed.urlPath}`;
99
+ for (let i = 0; i < 30 && !probeUrl; i += 1) {
100
+ if (await probeAnswers(candidate)) probeUrl = candidate;
101
+ else { process.stdout.write("."); await new Promise((r) => setTimeout(r, 1000)); }
102
+ }
103
+ console.log();
104
+ if (!probeUrl) probeNote = "The temporary probe never came up at " + candidate + ". Is the dev server running at that address? Start it and run this again.";
105
+ }
106
+ }
107
+
108
+ if (probeUrl) {
109
+ const account = flagValue("--account");
110
+ const local = /^https?:\/\/(localhost|127\.0\.0\.1)/.test(base);
111
+ try {
112
+ lifecycle = await runLifecycle({
113
+ webhookUrl,
114
+ probeUrl,
115
+ webhookSecret: secret,
116
+ ...(account ? { accountFor: () => account, resetBeforeEach: true } : {}),
117
+ settleMs: local ? 150 : 1000,
118
+ });
119
+ } finally {
120
+ if (installed) await removeProbe(installed.routeFile).catch(() => {});
121
+ }
122
+ }
123
+ }
124
+
125
+ if (args.includes("--json")) {
126
+ console.log(JSON.stringify({ detection, lifecycle }, null, 2));
127
+ process.exit(0);
128
+ }
129
+
130
+ const { framework, stripe, database, webhookHandlers, accessDecisionSites, capabilities } = detection;
131
+
132
+ console.log(`\nAkeso Check: looking at ${root}`);
133
+ console.log(`Scanned ${detection.scannedFiles} source files.\n`);
134
+
135
+ console.log(`App : ${framework.framework}${framework.packageName ? ` (${framework.packageName})` : ""}`);
136
+ console.log(`Stripe : ${stripe.secretKey ? `${stripe.secretKey.mode} key ending …${stripe.secretKey.lastFour}` : "no key found"}${stripe.sdkInstalled ? "" : stripe.secretKey ? " (SDK not installed)" : ""}`);
137
+ console.log(`Database : ${database.kind}${database.supabase ? ` (${database.supabase.urlHost})` : ""}`);
138
+
139
+ if (webhookHandlers.length) {
140
+ const handler = webhookHandlers[0];
141
+ console.log(`\nWebhook handler: ${handler.file}`);
142
+ console.log(` signature verified : ${handler.verifiesSignature ? "yes" : "NOT SEEN. Anyone could forge events"}`);
143
+ console.log(` raw body handling : ${handler.rawBodySeen ? "seen" : "not seen. Verification may fail at runtime"}`);
144
+ console.log(` events handled : ${handler.handledEvents.length ? handler.handledEvents.join(", ") : "none of the required set"}`);
145
+ if (handler.missingEvents.length) console.log(` events MISSING : ${handler.missingEvents.join(", ")}`);
146
+ } else {
147
+ console.log("\nWebhook handler: none found.");
148
+ }
149
+
150
+ if (accessDecisionSites.length) {
151
+ console.log("\nWhere paid access appears to be decided (ranked, needs confirming):");
152
+ for (const site of accessDecisionSites.slice(0, 5)) {
153
+ console.log(` ${String(site.score).padStart(2)} ${site.file}${site.clientSideOnly ? " [client-side, bypassable]" : ""}`);
154
+ console.log(` ${site.evidence.join("; ")}`);
155
+ }
156
+ } else {
157
+ console.log("\nWhere paid access is decided: could not find any candidate.");
158
+ }
159
+
160
+ if (lifecycle) {
161
+ console.log(`\nLifecycle: grade ${lifecycle.grade.letter}. ${lifecycle.grade.reason}`);
162
+ for (const result of lifecycle.results) {
163
+ const mark = result.outcome === "pass" ? "✓" : result.outcome === "fail" ? "✗" : "—";
164
+ console.log(` ${mark} ${result.name}`);
165
+ }
166
+ }
167
+ if (probeNote) console.log(`\n⚠ ${probeNote}`);
168
+ for (const blocker of capabilities.blockers) console.log(`⚠ ${blocker}`);
169
+
170
+ /* Something visible always comes out: the report, and the next step. */
171
+ const out = flagValue("--html") || path.join(root, "akeso-report.html");
172
+ await writeFile(out, renderReport({ detection, lifecycle }));
173
+ console.log(`\nReport: ${out}`);
174
+ if (process.env.CODESPACES) {
175
+ console.log(`To view it: right-click ${path.basename(out)} in the file list on the left and choose Download, then open the downloaded file.`);
176
+ } else if (!args.includes("--no-open")) {
177
+ const { spawn } = await import("node:child_process");
178
+ spawn(process.platform === "darwin" ? "open" : "xdg-open", [out], { stdio: "ignore", detached: true });
179
+ }
180
+
181
+ if (!lifecycle && !probeNote) {
182
+ if (detection.webhookHandlers[0]?.file.startsWith("supabase/functions/")) {
183
+ console.log(`\nThis app's webhook is a Supabase Edge Function. The full pretend-customer`);
184
+ console.log(`test for that shape is not supported yet; the report above covers`);
185
+ console.log(`everything that can be read from the code.`);
186
+ } else {
187
+ console.log(`\nNext, the real test (a pretend customer pays, cancels, gets refunded):`);
188
+ console.log(` 1. start your dev server (usually: npm run dev)`);
189
+ console.log(` 2. npx akeso-check --lifecycle-url http://localhost:3000`);
190
+ }
191
+ }
192
+ console.log();
@@ -0,0 +1,72 @@
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
3
+ <title>Akeso Check — broken-fixture</title>
4
+ <style>
5
+ :root { --bg:#fcfcfd; --ink:#16181d; --ink2:#5b6270; --ink3:#8a919e; --line:#e6e8ec;
6
+ --ok:#12784b; --warn:#96620a; --bad:#b3261e; --note:#2f4a78; }
7
+ @media (prefers-color-scheme: dark) { :root { --bg:#111317; --ink:#e9ebef; --ink2:#a4abb8;
8
+ --ink3:#767d8a; --line:#282c34; --ok:#4cbe83; --warn:#dfa94c; --bad:#ef8578; --note:#8aa9dc; } }
9
+ * { box-sizing:border-box; }
10
+ 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; }
11
+ .shell { max-width:640px; margin:0 auto; padding:40px 24px 100px; }
12
+ .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; }
13
+ .gradeCard { border:1px solid var(--line); border-radius:10px; padding:30px 28px; display:flex; gap:26px; align-items:center; }
14
+ .gradeLetter { font-size:76px; font-weight:700; line-height:1; letter-spacing:-.04em; }
15
+ .g-A,.g-B { color:var(--ok); } .g-C { color:var(--warn); } .g-D,.g-F { color:var(--bad); } .g-\? { color:var(--ink3); }
16
+ .gradeCard h1 { margin:0 0 6px; font-size:22px; letter-spacing:-.02em; }
17
+ .gradeCard p { margin:0; color:var(--ink2); font-size:15px; }
18
+ .app { font-size:13px; color:var(--ink3); margin-top:10px; }
19
+ h2 { font-size:12px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--ink3); margin:44px 0 4px; }
20
+ .rows { border-top:1px solid var(--line); }
21
+ .row { display:flex; gap:12px; padding:11px 0; border-bottom:1px solid var(--line); align-items:baseline; font-size:15px; }
22
+ .mark { width:18px; text-align:center; flex:none; font-weight:600; }
23
+ .row.ok .mark { color:var(--ok); } .row.warn .mark { color:var(--warn); }
24
+ .row.bad .mark { color:var(--bad); } .row.bad .name { color:var(--bad); font-weight:600; }
25
+ .row.note .mark { color:var(--note); } .row.mute { color:var(--ink3); }
26
+ .name { flex:1; } .name.wide { flex:auto; }
27
+ .detail { color:var(--ink3); font-size:13.5px; text-align:right; max-width:40%; }
28
+ ul.limits { margin:8px 0 0; padding-left:20px; color:var(--ink2); font-size:14.5px; }
29
+ ul.limits li { margin-bottom:7px; }
30
+ .cta { margin-top:48px; border:1px solid var(--line); border-radius:10px; padding:22px 24px; }
31
+ .cta h3 { margin:0 0 6px; font-size:17px; } .cta p { margin:0 0 14px; color:var(--ink2); font-size:14.5px; }
32
+ .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; }
33
+ footer { margin-top:44px; font-size:12.5px; color:var(--ink3); }
34
+ </style></head><body><div class="shell">
35
+ <div class="local">🔒 This report is a file on your computer. Nothing was sent anywhere.</div>
36
+ <div class="gradeCard">
37
+ <div class="gradeLetter g-F">F</div>
38
+ <div>
39
+ <h1>Canceled customers keep paid access.</h1>
40
+ <p>Customers who cancel keep their paid access. This leaks money every day until it is fixed.</p>
41
+ <div class="app">broken-fixture · next-app-router · supabase · Stripe test</div>
42
+ </div>
43
+ </div>
44
+
45
+ <h2>What a pretend customer went through</h2>
46
+ <div class="rows"><div class="row ok"><span class="mark">✓</span><span class="name">New payment unlocks access</span><span class="detail"></span></div>
47
+ <div class="row warn"><span class="mark">✗</span><span class="name">Trial ends, subscription becomes active</span><span class="detail">expected access, the app says no access</span></div>
48
+ <div class="row ok"><span class="mark">✓</span><span class="name">Monthly renewal payment keeps access</span><span class="detail"></span></div>
49
+ <div class="row warn"><span class="mark">✗</span><span class="name">Card fails, retries exhaust: access ends</span><span class="detail">expected no access, the app says access</span></div>
50
+ <div class="row bad"><span class="mark">✗</span><span class="name">Customer cancels; period ends: access ends</span><span class="detail">expected no access, the app says access</span></div>
51
+ <div class="row bad"><span class="mark">✗</span><span class="name">Immediate cancellation removes access</span><span class="detail">expected no access, the app says access</span></div>
52
+ <div class="row ok"><span class="mark">✓</span><span class="name">Customer un-cancels before the period ends</span><span class="detail"></span></div>
53
+ <div class="row note"><span class="mark">•</span><span class="name">Latest charge refunded (follows the app's own policy)</span><span class="detail">app's policy: keeps access</span></div>
54
+ <div class="row warn"><span class="mark">✗</span><span class="name">The same event delivered twice</span><span class="detail">expected no access, the app says access</span></div>
55
+ <div class="row warn"><span class="mark">✗</span><span class="name">An old 'still active' event arrives after cancellation</span><span class="detail">expected no access, the app says access</span></div></div>
56
+
57
+ <h2>What the code itself shows</h2>
58
+ <div class="rows"><div class="row bad"><span class="mark">✗</span><span class="name wide">The webhook handler does not verify Stripe's signature. Anyone who finds the URL can forge payment events.</span></div>
59
+ <div class="row warn"><span class="mark">!</span><span class="name wide">The handler ignores 6 of the 7 lifecycle events: invoice.paid, invoice.payment_failed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, charge.refunded.</span></div></div>
60
+
61
+ <h2>What this did not check</h2>
62
+ <ul class="limits"><li>Only the billing lifecycle was tested — not login, checkout UI, or anything else.</li>
63
+ <li>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.</li></ul>
64
+
65
+ <div class="cta">
66
+ <h3>Want this fixed?</h3>
67
+ <p>The Fix Plan is an automated repair — signature verification, every lifecycle event handled, and a nightly self-check — delivered as a pull request you (or your coding agent) apply. Then re-run this Check and watch it go green.</p>
68
+ <a href="https://akeso.dev/fix">Get the Fix Plan — $49</a>
69
+ </div>
70
+
71
+ <footer>Akeso Check · 2026-08-30 20:08 · 10 lifecycle scenarios · local run</footer>
72
+ </div></body></html>
@@ -0,0 +1,67 @@
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
3
+ <title>Akeso Check — fixed-fixture</title>
4
+ <style>
5
+ :root { --bg:#fcfcfd; --ink:#16181d; --ink2:#5b6270; --ink3:#8a919e; --line:#e6e8ec;
6
+ --ok:#12784b; --warn:#96620a; --bad:#b3261e; --note:#2f4a78; }
7
+ @media (prefers-color-scheme: dark) { :root { --bg:#111317; --ink:#e9ebef; --ink2:#a4abb8;
8
+ --ink3:#767d8a; --line:#282c34; --ok:#4cbe83; --warn:#dfa94c; --bad:#ef8578; --note:#8aa9dc; } }
9
+ * { box-sizing:border-box; }
10
+ 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; }
11
+ .shell { max-width:640px; margin:0 auto; padding:40px 24px 100px; }
12
+ .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; }
13
+ .gradeCard { border:1px solid var(--line); border-radius:10px; padding:30px 28px; display:flex; gap:26px; align-items:center; }
14
+ .gradeLetter { font-size:76px; font-weight:700; line-height:1; letter-spacing:-.04em; }
15
+ .g-A,.g-B { color:var(--ok); } .g-C { color:var(--warn); } .g-D,.g-F { color:var(--bad); } .g-\? { color:var(--ink3); }
16
+ .gradeCard h1 { margin:0 0 6px; font-size:22px; letter-spacing:-.02em; }
17
+ .gradeCard p { margin:0; color:var(--ink2); font-size:15px; }
18
+ .app { font-size:13px; color:var(--ink3); margin-top:10px; }
19
+ h2 { font-size:12px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--ink3); margin:44px 0 4px; }
20
+ .rows { border-top:1px solid var(--line); }
21
+ .row { display:flex; gap:12px; padding:11px 0; border-bottom:1px solid var(--line); align-items:baseline; font-size:15px; }
22
+ .mark { width:18px; text-align:center; flex:none; font-weight:600; }
23
+ .row.ok .mark { color:var(--ok); } .row.warn .mark { color:var(--warn); }
24
+ .row.bad .mark { color:var(--bad); } .row.bad .name { color:var(--bad); font-weight:600; }
25
+ .row.note .mark { color:var(--note); } .row.mute { color:var(--ink3); }
26
+ .name { flex:1; } .name.wide { flex:auto; }
27
+ .detail { color:var(--ink3); font-size:13.5px; text-align:right; max-width:40%; }
28
+ ul.limits { margin:8px 0 0; padding-left:20px; color:var(--ink2); font-size:14.5px; }
29
+ ul.limits li { margin-bottom:7px; }
30
+ .cta { margin-top:48px; border:1px solid var(--line); border-radius:10px; padding:22px 24px; }
31
+ .cta h3 { margin:0 0 6px; font-size:17px; } .cta p { margin:0 0 14px; color:var(--ink2); font-size:14.5px; }
32
+ .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; }
33
+ footer { margin-top:44px; font-size:12.5px; color:var(--ink3); }
34
+ </style></head><body><div class="shell">
35
+ <div class="local">🔒 This report is a file on your computer. Nothing was sent anywhere.</div>
36
+ <div class="gradeCard">
37
+ <div class="gradeLetter g-A">A</div>
38
+ <div>
39
+ <h1>Your billing lifecycle holds up.</h1>
40
+ <p>Every lifecycle scenario passed.</p>
41
+ <div class="app">fixed-fixture · next-app-router · supabase · Stripe test</div>
42
+ </div>
43
+ </div>
44
+
45
+ <h2>What a pretend customer went through</h2>
46
+ <div class="rows"><div class="row ok"><span class="mark">✓</span><span class="name">New payment unlocks access</span><span class="detail"></span></div>
47
+ <div class="row ok"><span class="mark">✓</span><span class="name">Trial ends, subscription becomes active</span><span class="detail"></span></div>
48
+ <div class="row ok"><span class="mark">✓</span><span class="name">Monthly renewal payment keeps access</span><span class="detail"></span></div>
49
+ <div class="row ok"><span class="mark">✓</span><span class="name">Card fails, retries exhaust: access ends</span><span class="detail"></span></div>
50
+ <div class="row ok"><span class="mark">✓</span><span class="name">Customer cancels; period ends: access ends</span><span class="detail"></span></div>
51
+ <div class="row ok"><span class="mark">✓</span><span class="name">Immediate cancellation removes access</span><span class="detail"></span></div>
52
+ <div class="row ok"><span class="mark">✓</span><span class="name">Customer un-cancels before the period ends</span><span class="detail"></span></div>
53
+ <div class="row note"><span class="mark">•</span><span class="name">Latest charge refunded (follows the app's own policy)</span><span class="detail">app's policy: removes access</span></div>
54
+ <div class="row ok"><span class="mark">✓</span><span class="name">The same event delivered twice</span><span class="detail"></span></div>
55
+ <div class="row ok"><span class="mark">✓</span><span class="name">An old 'still active' event arrives after cancellation</span><span class="detail"></span></div></div>
56
+
57
+ <h2>What the code itself shows</h2>
58
+ <div class="rows"><div class="row ok"><span class="mark">✓</span><span class="name wide">Signature verified against the raw body, and all 7 lifecycle events are handled.</span></div></div>
59
+
60
+ <h2>What this did not check</h2>
61
+ <ul class="limits"><li>Only the billing lifecycle was tested — not login, checkout UI, or anything else.</li>
62
+ <li>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.</li></ul>
63
+
64
+
65
+
66
+ <footer>Akeso Check · 2026-08-30 20:08 · 10 lifecycle scenarios · local run</footer>
67
+ </div></body></html>
@@ -0,0 +1,68 @@
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
3
+ <title>Akeso Check · watch-this</title>
4
+ <style>
5
+ :root { --bg:#fcfcfd; --ink:#16181d; --ink2:#5b6270; --ink3:#8a919e; --line:#e6e8ec;
6
+ --ok:#12784b; --warn:#96620a; --bad:#b3261e; --note:#2f4a78; }
7
+ @media (prefers-color-scheme: dark) { :root { --bg:#111317; --ink:#e9ebef; --ink2:#a4abb8;
8
+ --ink3:#767d8a; --line:#282c34; --ok:#4cbe83; --warn:#dfa94c; --bad:#ef8578; --note:#8aa9dc; } }
9
+ * { box-sizing:border-box; }
10
+ 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; }
11
+ .shell { max-width:640px; margin:0 auto; padding:40px 24px 100px; }
12
+ .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; }
13
+ .gradeCard { border:1px solid var(--line); border-radius:10px; padding:30px 28px; display:flex; gap:26px; align-items:center; }
14
+ .gradeLetter { font-size:76px; font-weight:700; line-height:1; letter-spacing:-.04em; }
15
+ .g-A,.g-B { color:var(--ok); } .g-C { color:var(--warn); } .g-D,.g-F { color:var(--bad); } .g-\? { color:var(--ink3); }
16
+ .gradeCard h1 { margin:0 0 6px; font-size:22px; letter-spacing:-.02em; }
17
+ .gradeCard p { margin:0; color:var(--ink2); font-size:15px; }
18
+ .app { font-size:13px; color:var(--ink3); margin-top:10px; }
19
+ h2 { font-size:12px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--ink3); margin:44px 0 4px; }
20
+ .intro { margin:2px 0 12px; color:var(--ink2); font-size:14.5px; }
21
+ .rows { border-top:1px solid var(--line); }
22
+ .row { display:flex; gap:12px; padding:11px 0; border-bottom:1px solid var(--line); align-items:baseline; font-size:15px; }
23
+ .mark { width:18px; text-align:center; flex:none; font-weight:600; }
24
+ .row.ok .mark { color:var(--ok); } .row.warn .mark { color:var(--warn); }
25
+ .row.bad .mark { color:var(--bad); } .row.bad .name { color:var(--bad); font-weight:600; }
26
+ .row.note .mark { color:var(--note); } .row.mute { color:var(--ink3); }
27
+ .name { flex:1; } .name.wide { flex:auto; }
28
+ .detail { color:var(--ink3); font-size:13.5px; text-align:right; max-width:40%; }
29
+ ul.limits { margin:8px 0 0; padding-left:20px; color:var(--ink2); font-size:14.5px; }
30
+ ul.limits li { margin-bottom:7px; }
31
+ .cta { margin-top:48px; border:1px solid var(--line); border-radius:10px; padding:22px 24px; }
32
+ .cta h3 { margin:0 0 6px; font-size:17px; } .cta p { margin:0 0 14px; color:var(--ink2); font-size:14.5px; }
33
+ .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; }
34
+ footer { margin-top:44px; font-size:12.5px; color:var(--ink3); }
35
+ </style></head><body><div class="shell">
36
+ <div class="local">This report is a file on your computer. Nothing was sent anywhere.</div>
37
+ <div class="gradeCard">
38
+ <div class="gradeLetter g-F">F</div>
39
+ <div>
40
+ <h1>Canceled customers keep paid access.</h1>
41
+ <p>This leaks money every day until it is fixed.</p>
42
+ <div class="app">watch-this, a Next.js app, with Supabase, Stripe test mode</div>
43
+ </div>
44
+ </div>
45
+
46
+ <h2>What we tested</h2>
47
+ <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>
48
+ <div class="rows"><div class="row ok"><span class="mark">✓</span><span class="name">New payment unlocks access</span><span class="detail"></span></div>
49
+ <div class="row mute"><span class="mark">?</span><span class="name">Trial ends, subscription becomes active</span><span class="detail">not provable: access was already on when this scenario started</span></div>
50
+ <div class="row mute"><span class="mark">?</span><span class="name">Monthly renewal payment keeps access</span><span class="detail">not provable: access was already on when this scenario started</span></div>
51
+ <div class="row warn"><span class="mark">✗</span><span class="name">Card fails, retries exhaust: access ends</span><span class="detail">access should have ended, but your app still grants it</span></div>
52
+ <div class="row bad"><span class="mark">✗</span><span class="name">Customer cancels; period ends: access ends</span><span class="detail">access should have ended, but your app still grants it</span></div>
53
+ <div class="row bad"><span class="mark">✗</span><span class="name">Immediate cancellation removes access</span><span class="detail">access should have ended, but your app still grants it</span></div>
54
+ <div class="row mute"><span class="mark">?</span><span class="name">Customer un-cancels before the period ends</span><span class="detail">not provable: access was already on when this scenario started</span></div>
55
+ <div class="row note"><span class="mark">•</span><span class="name">Latest charge refunded (follows the app's own policy)</span><span class="detail">your app's policy: keeps access</span></div>
56
+ <div class="row warn"><span class="mark">✗</span><span class="name">The same event delivered twice</span><span class="detail">access should have ended, but your app still grants it</span></div>
57
+ <div class="row warn"><span class="mark">✗</span><span class="name">An old 'still active' event arrives after cancellation</span><span class="detail">access should have ended, but your app still grants it</span></div></div>
58
+
59
+ <h2>What your code shows</h2>
60
+ <p class="intro">Read from your webhook handler and access checks, before anything ran.</p>
61
+ <div class="rows"><div class="row warn"><span class="mark">!</span><span class="name wide">The handler ignores 6 of the 7 lifecycle events: invoice.paid, invoice.payment_failed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, charge.refunded.</span></div></div>
62
+
63
+ <h2>What this did not check</h2>
64
+ <ul class="limits"><li>Only the billing lifecycle was tested. Not login, checkout UI, or anything else.</li>
65
+ <li>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.</li></ul>
66
+
67
+ <footer>Akeso Check · 2026-08-31 20:38 · 10 lifecycle scenarios · local run</footer>
68
+ </div></body></html>
@@ -0,0 +1,70 @@
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
3
+ <title>Akeso Check · watch-this</title>
4
+ <style>
5
+ :root { --bg:#fcfcfd; --ink:#16181d; --ink2:#5b6270; --ink3:#8a919e; --line:#e6e8ec;
6
+ --ok:#12784b; --warn:#96620a; --bad:#b3261e; --note:#2f4a78; }
7
+ @media (prefers-color-scheme: dark) { :root { --bg:#111317; --ink:#e9ebef; --ink2:#a4abb8;
8
+ --ink3:#767d8a; --line:#282c34; --ok:#4cbe83; --warn:#dfa94c; --bad:#ef8578; --note:#8aa9dc; } }
9
+ * { box-sizing:border-box; }
10
+ 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; }
11
+ .shell { max-width:640px; margin:0 auto; padding:40px 24px 100px; }
12
+ .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; }
13
+ .gradeCard { border:1px solid var(--line); border-radius:10px; padding:30px 28px; display:flex; gap:26px; align-items:center; }
14
+ .gradeLetter { font-size:76px; font-weight:700; line-height:1; letter-spacing:-.04em; }
15
+ .g-A,.g-B { color:var(--ok); } .g-C { color:var(--warn); } .g-D,.g-F { color:var(--bad); } .g-\? { color:var(--ink3); }
16
+ .gradeCard h1 { margin:0 0 6px; font-size:22px; letter-spacing:-.02em; }
17
+ .gradeCard p { margin:0; color:var(--ink2); font-size:15px; }
18
+ .app { font-size:13px; color:var(--ink3); margin-top:10px; }
19
+ h2 { font-size:12px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--ink3); margin:44px 0 4px; }
20
+ .intro { margin:2px 0 12px; color:var(--ink2); font-size:14.5px; }
21
+ .rows { border-top:1px solid var(--line); }
22
+ .row { display:flex; gap:12px; padding:11px 0; border-bottom:1px solid var(--line); align-items:baseline; font-size:15px; }
23
+ .mark { width:18px; text-align:center; flex:none; font-weight:600; }
24
+ .row.ok .mark { color:var(--ok); } .row.warn .mark { color:var(--warn); }
25
+ .row.bad .mark { color:var(--bad); } .row.bad .name { color:var(--bad); font-weight:600; }
26
+ .row.note .mark { color:var(--note); } .row.mute { color:var(--ink3); }
27
+ .name { flex:1; } .name.wide { flex:auto; }
28
+ .detail { color:var(--ink3); font-size:13.5px; text-align:right; max-width:40%; }
29
+ ul.limits { margin:8px 0 0; padding-left:20px; color:var(--ink2); font-size:14.5px; }
30
+ ul.limits li { margin-bottom:7px; }
31
+ .cta { margin-top:48px; border:1px solid var(--line); border-radius:10px; padding:22px 24px; }
32
+ .cta h3 { margin:0 0 6px; font-size:17px; } .cta p { margin:0 0 14px; color:var(--ink2); font-size:14.5px; }
33
+ .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; }
34
+ footer { margin-top:44px; font-size:12.5px; color:var(--ink3); }
35
+ </style></head><body><div class="shell">
36
+ <div class="local">This report is a file on your computer. Nothing was sent anywhere.</div>
37
+ <div class="gradeCard">
38
+ <div class="gradeLetter g-A">A</div>
39
+ <div>
40
+ <h1>Your billing lifecycle holds up.</h1>
41
+ <p>Every lifecycle scenario passed.</p>
42
+ <div class="app">watch-this, a Next.js app, with Supabase, Stripe test mode</div>
43
+ </div>
44
+ </div>
45
+
46
+ <h2>What we tested</h2>
47
+ <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>
48
+ <div class="rows"><div class="row ok"><span class="mark">✓</span><span class="name">New payment unlocks access</span><span class="detail"></span></div>
49
+ <div class="row ok"><span class="mark">✓</span><span class="name">Trial ends, subscription becomes active</span><span class="detail"></span></div>
50
+ <div class="row ok"><span class="mark">✓</span><span class="name">Monthly renewal payment keeps access</span><span class="detail"></span></div>
51
+ <div class="row ok"><span class="mark">✓</span><span class="name">Card fails, retries exhaust: access ends</span><span class="detail"></span></div>
52
+ <div class="row ok"><span class="mark">✓</span><span class="name">Customer cancels; period ends: access ends</span><span class="detail"></span></div>
53
+ <div class="row ok"><span class="mark">✓</span><span class="name">Immediate cancellation removes access</span><span class="detail"></span></div>
54
+ <div class="row ok"><span class="mark">✓</span><span class="name">Customer un-cancels before the period ends</span><span class="detail"></span></div>
55
+ <div class="row note"><span class="mark">•</span><span class="name">Latest charge refunded (follows the app's own policy)</span><span class="detail">your app's policy: removes access</span></div>
56
+ <div class="row ok"><span class="mark">✓</span><span class="name">The same event delivered twice</span><span class="detail"></span></div>
57
+ <div class="row ok"><span class="mark">✓</span><span class="name">An old 'still active' event arrives after cancellation</span><span class="detail"></span></div></div>
58
+
59
+ <h2>What your code shows</h2>
60
+ <p class="intro">Read from your webhook handler and access checks, before anything ran.</p>
61
+ <div class="rows"><div class="row ok"><span class="mark">✓</span><span class="name wide">Signature verified against the raw body, and all 7 lifecycle events are handled.</span></div></div>
62
+
63
+ <h2>What this did not check</h2>
64
+ <ul class="limits"><li>Only the billing lifecycle was tested. Not login, checkout UI, or anything else.</li>
65
+ <li>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.</li></ul>
66
+
67
+
68
+
69
+ <footer>Akeso Check · 2026-08-31 20:56 · 10 lifecycle scenarios · local run</footer>
70
+ </div></body></html>
@@ -0,0 +1,5 @@
1
+ # Fixture credentials — deliberately fake, test-mode shaped.
2
+ STRIPE_SECRET_KEY=sk_test_fixturefixturefixturefixture1234
3
+ STRIPE_WEBHOOK_SECRET=whsec_fixturefixturefixture5678
4
+ NEXT_PUBLIC_SUPABASE_URL=https://fixture.supabase.co
5
+ NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_pub_fixture
@@ -0,0 +1,18 @@
1
+ // The classic vibe-coded handler: grants on payment, never revokes.
2
+ // checkout.session.completed is handled; every cancellation and failure
3
+ // event is silently ignored, so canceled customers keep access forever.
4
+ import Stripe from "stripe";
5
+ import { createClient } from "@supabase/supabase-js";
6
+
7
+ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
8
+
9
+ export async function POST(request: Request) {
10
+ const body = await request.json(); // parsed body: signature can never verify
11
+ const event = body; // no constructEvent — anyone can forge this
12
+
13
+ if (event.type === "checkout.session.completed") {
14
+ const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);
15
+ await supabase.from("profiles").update({ is_pro: true }).eq("id", event.data.object.client_reference_id);
16
+ }
17
+ return Response.json({ received: true });
18
+ }
@@ -0,0 +1,8 @@
1
+ // Where this app decides who has paid access.
2
+ import { createClient } from "@supabase/supabase-js";
3
+
4
+ export async function isPro(userId: string) {
5
+ const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);
6
+ const { data } = await supabase.from("profiles").select("is_pro").eq("id", userId).single();
7
+ return Boolean(data?.is_pro);
8
+ }