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,38 @@
1
+ // The single source of truth for this fixture's webhook behaviour.
2
+ // Deliberately the classic vibe-coded shape:
3
+ // - trusts the parsed JSON body outright (signature never checked)
4
+ // - grants on checkout.session.completed
5
+ // - ignores every cancellation, failure, and refund event
6
+ // The result: canceled customers keep access forever. The Check must catch it.
7
+ import { readFile, writeFile } from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const DB = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "data", "profiles.json");
12
+
13
+ export async function readProfiles() {
14
+ try { return JSON.parse(await readFile(DB, "utf8")); } catch { return {}; }
15
+ }
16
+
17
+ async function writeProfiles(profiles) {
18
+ await writeFile(DB, JSON.stringify(profiles, null, 2));
19
+ }
20
+
21
+ export async function handleWebhook(rawBody /* string */, _signatureHeader) {
22
+ const event = JSON.parse(rawBody); // no verification: anyone could forge this
23
+
24
+ if (event.type === "checkout.session.completed") {
25
+ const profiles = await readProfiles();
26
+ const account = event.data.object.client_reference_id;
27
+ profiles[account] = { ...profiles[account], is_pro: true };
28
+ await writeProfiles(profiles);
29
+ }
30
+ // every other event type: silently ignored
31
+ return { received: true };
32
+ }
33
+
34
+ // Where this app decides who has paid access.
35
+ export async function isPro(accountId) {
36
+ const profiles = await readProfiles();
37
+ return Boolean(profiles[accountId]?.is_pro);
38
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "broken-fixture",
3
+ "dependencies": { "next": "16.0.0", "stripe": "17.0.0", "@supabase/supabase-js": "2.0.0" }
4
+ }
@@ -0,0 +1,34 @@
1
+ // Minimal runnable server for the fixture: the webhook route plus the access
2
+ // probe the Check queries after each lifecycle transition.
3
+ import http from "node:http";
4
+ import { handleWebhook, isPro, readProfiles } from "./lib/handler.mjs";
5
+ import { writeFile } from "node:fs/promises";
6
+
7
+ const PORT = Number(process.env.PORT || 4101);
8
+
9
+ // Fresh state per run so a previous run's grants cannot leak into this one.
10
+ await writeFile(new URL("./data/profiles.json", import.meta.url), "{}");
11
+
12
+ http.createServer(async (req, res) => {
13
+ const url = new URL(req.url, `http://localhost:${PORT}`);
14
+ if (req.method === "POST" && url.pathname === "/api/stripe/webhook") {
15
+ let body = "";
16
+ req.on("data", (chunk) => { body += chunk; });
17
+ req.on("end", async () => {
18
+ try {
19
+ const out = await handleWebhook(body, req.headers["stripe-signature"]);
20
+ res.writeHead(200, { "content-type": "application/json" });
21
+ res.end(JSON.stringify(out));
22
+ } catch (error) {
23
+ res.writeHead(500); res.end(String(error?.message || error));
24
+ }
25
+ });
26
+ return;
27
+ }
28
+ if (url.pathname === "/__akeso_probe") {
29
+ res.writeHead(200, { "content-type": "application/json" });
30
+ res.end(JSON.stringify({ billingEntitled: await isPro(url.searchParams.get("account")) }));
31
+ return;
32
+ }
33
+ res.writeHead(404); res.end();
34
+ }).listen(PORT, () => console.log(`broken-app listening on ${PORT}`));
@@ -0,0 +1,4 @@
1
+ STRIPE_SECRET_KEY=sk_test_fixturefixturefixturefixture1234
2
+ STRIPE_WEBHOOK_SECRET=whsec_fixturefixturefixture5678
3
+ NEXT_PUBLIC_SUPABASE_URL=https://fixture.supabase.co
4
+ NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_pub_fixture
@@ -0,0 +1,23 @@
1
+ // The repaired handler the Fix Plan produces (static-pass view of lib/handler.mjs).
2
+ import Stripe from "stripe";
3
+
4
+ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
5
+
6
+ export async function POST(request: Request) {
7
+ const rawBody = await request.text();
8
+ const signature = request.headers.get("stripe-signature")!;
9
+ const event = stripe.webhooks.constructEvent(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET!);
10
+
11
+ switch (event.type) {
12
+ case "checkout.session.completed":
13
+ case "invoice.paid":
14
+ case "invoice.payment_failed":
15
+ case "customer.subscription.created":
16
+ case "customer.subscription.updated":
17
+ case "customer.subscription.deleted":
18
+ case "charge.refunded":
19
+ // handled in lib/handler.mjs (idempotent, ordered, status-derived)
20
+ break;
21
+ }
22
+ return Response.json({ received: true });
23
+ }
@@ -0,0 +1,80 @@
1
+ // What the Fix Plan produces: the same app, done right.
2
+ // - verifies the Stripe signature against the raw body
3
+ // - handles the full lifecycle event set
4
+ // - idempotent by event id; ignores events older than the last applied
5
+ // - entitlement derived from subscription status, not from event arrival
6
+ import { createHmac, timingSafeEqual } from "node:crypto";
7
+ import { readFile, writeFile } from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const DB = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "data", "profiles.json");
12
+ /* The signing secret is the HMAC key verbatim, whsec_ prefix included —
13
+ exactly what stripe-node's constructEvent does. */
14
+ const WHSEC = process.env.STRIPE_WEBHOOK_SECRET || "whsec_fixturefixturefixture5678";
15
+
16
+ async function read() {
17
+ try { return JSON.parse(await readFile(DB, "utf8")); } catch { return { accounts: {}, seenEvents: [] }; }
18
+ }
19
+ async function write(state) { await writeFile(DB, JSON.stringify(state, null, 2)); }
20
+
21
+ function verifySignature(rawBody, header) {
22
+ if (!header) return false;
23
+ const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
24
+ if (!parts.t || !parts.v1) return false;
25
+ const expected = createHmac("sha256", WHSEC).update(`${parts.t}.${rawBody}`).digest("hex");
26
+ try {
27
+ return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(parts.v1, "hex"));
28
+ } catch { return false; }
29
+ }
30
+
31
+ // Which subscription statuses mean "billing-entitled" under this app's policy:
32
+ // active and trialing yes; past_due still yes (grace); unpaid/canceled no.
33
+ const ENTITLED_STATUSES = new Set(["active", "trialing", "past_due"]);
34
+
35
+ export async function handleWebhook(rawBody, signatureHeader) {
36
+ if (!verifySignature(rawBody, signatureHeader)) {
37
+ return { status: 400, body: { error: "invalid signature" } };
38
+ }
39
+ const event = JSON.parse(rawBody);
40
+ const state = await read();
41
+
42
+ // Idempotency: the same event id applies once, ever.
43
+ if (state.seenEvents.includes(event.id)) return { status: 200, body: { received: true, duplicate: true } };
44
+ state.seenEvents.push(event.id);
45
+
46
+ const object = event.data.object;
47
+ const account = object.client_reference_id || object.metadata?.account || object.customer;
48
+ if (account) {
49
+ const current = state.accounts[account] || { lastEventCreated: 0 };
50
+ // Out-of-order guard: an older event never overrides a newer decision.
51
+ if (event.created > current.lastEventCreated) {
52
+ switch (event.type) {
53
+ case "checkout.session.completed":
54
+ case "customer.subscription.created":
55
+ current.billingEntitled = true; break;
56
+ case "invoice.paid":
57
+ current.billingEntitled = true; break;
58
+ case "customer.subscription.updated":
59
+ current.billingEntitled = ENTITLED_STATUSES.has(object.status); break;
60
+ case "invoice.payment_failed":
61
+ // grace: entitlement follows the subscription status events, not this
62
+ break;
63
+ case "customer.subscription.deleted":
64
+ current.billingEntitled = false; break;
65
+ case "charge.refunded":
66
+ current.billingEntitled = false; break; // this app's stated refund policy
67
+ default: break;
68
+ }
69
+ current.lastEventCreated = event.created;
70
+ state.accounts[account] = current;
71
+ }
72
+ }
73
+ await write(state);
74
+ return { status: 200, body: { received: true } };
75
+ }
76
+
77
+ export async function isPro(accountId) {
78
+ const state = await read();
79
+ return Boolean(state.accounts[accountId]?.billingEntitled);
80
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "fixed-fixture",
3
+ "dependencies": { "next": "16.0.0", "stripe": "17.0.0", "@supabase/supabase-js": "2.0.0" }
4
+ }
@@ -0,0 +1,34 @@
1
+ // Minimal runnable server for the fixture: the webhook route plus the access
2
+ // probe the Check queries after each lifecycle transition.
3
+ import http from "node:http";
4
+ import { handleWebhook, isPro } from "./lib/handler.mjs";
5
+ import { writeFile } from "node:fs/promises";
6
+
7
+ const PORT = Number(process.env.PORT || 4102);
8
+
9
+ // Fresh state per run so a previous run's grants cannot leak into this one.
10
+ await writeFile(new URL("./data/profiles.json", import.meta.url), JSON.stringify({ accounts: {}, seenEvents: [] }));
11
+
12
+ http.createServer(async (req, res) => {
13
+ const url = new URL(req.url, `http://localhost:${PORT}`);
14
+ if (req.method === "POST" && url.pathname === "/api/stripe/webhook") {
15
+ let body = "";
16
+ req.on("data", (chunk) => { body += chunk; });
17
+ req.on("end", async () => {
18
+ try {
19
+ const out = await handleWebhook(body, req.headers["stripe-signature"]);
20
+ res.writeHead(out.status || 200, { "content-type": "application/json" });
21
+ res.end(JSON.stringify(out.body ?? out));
22
+ } catch (error) {
23
+ res.writeHead(500); res.end(String(error?.message || error));
24
+ }
25
+ });
26
+ return;
27
+ }
28
+ if (url.pathname === "/__akeso_probe") {
29
+ res.writeHead(200, { "content-type": "application/json" });
30
+ res.end(JSON.stringify({ billingEntitled: await isPro(url.searchParams.get("account")) }));
31
+ return;
32
+ }
33
+ res.writeHead(404); res.end();
34
+ }).listen(PORT, () => console.log(`fixed-app listening on ${PORT}`));
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "akeso-check",
3
+ "version": "0.1.0",
4
+ "description": "Prove whether your app grants and revokes paid access correctly. Runs on your machine; nothing leaves it.",
5
+ "type": "module",
6
+ "bin": { "akeso-check": "bin/akeso-check.mjs" },
7
+ "license": "MIT",
8
+ "keywords": ["stripe", "billing", "webhooks", "subscriptions", "entitlement", "saas", "testing"],
9
+ "engines": { "node": ">=20" },
10
+ "scripts": {
11
+ "check": "node bin/akeso-check.mjs",
12
+ "test": "node --test tests/*.test.mjs"
13
+ }
14
+ }
@@ -0,0 +1,128 @@
1
+ /* Emits the two functions the whole Akeso relationship eventually runs on:
2
+ *
3
+ * getBillingEntitlement(accountId) — the app's one honest answer
4
+ * restoreBillingEntitlement(...) — the one write Akeso may ever make
5
+ *
6
+ * Generated for the founder to read and commit — never installed silently.
7
+ * The comments inside the generated code are part of the product: the founder
8
+ * (or their coding agent) has to see WHY the six fields are separate, because
9
+ * collapsing them back into one boolean is how the company-ending write
10
+ * (restoring access to a banned account) becomes possible again.
11
+ */
12
+
13
+ const SIX_FIELDS = ` // The six fields, and why they never collapse into one boolean:
14
+ // billingEntitled — derived from Stripe. THE ONLY FIELD AKESO MAY CHANGE.
15
+ // manualComplimentaryAccess — a human gave free access on purpose. Not Akeso's.
16
+ // manualBillingOverride — a human forced billing state on purpose. Not Akeso's.
17
+ // administrativeBlock — an admin locked this account. Never overridden.
18
+ // securityOrAbuseBlock — fraud/abuse lock. NEVER overridden, whatever Stripe says.
19
+ // finalAccessDecision — what the app actually does. Read for reports, never written.`;
20
+
21
+ export function renderAdapter({ tableName = "profiles", accountColumn = "id", entitledColumn = "billing_entitled", language = "ts" } = {}) {
22
+ const ts = language === "ts";
23
+ return `// AKESO ADAPTER — generated by Akeso Check. Read it, then commit it.
24
+ // Two functions. Nothing else. Akeso's entire access to this app goes through
25
+ // them, which means this file is also the complete list of what Akeso can do.
26
+
27
+ export const AKESO_ADAPTER_VERSION = "1";
28
+ export const AKESO_RULE_VERSION = "1"; // bump when your billing policy changes
29
+
30
+ ${SIX_FIELDS}
31
+ export async function getBillingEntitlement(accountId${ts ? ": string" : ""}) {
32
+ // TODO(you or your agent): replace this query with your real data source.
33
+ // The Check found billing state around: table "${tableName}", column "${entitledColumn}".
34
+ const row = await db
35
+ .from("${tableName}")
36
+ .select("${accountColumn}, ${entitledColumn}")
37
+ .eq("${accountColumn}", accountId)
38
+ .single();
39
+
40
+ return {
41
+ accountId,
42
+ billingEntitled: Boolean(row?.${entitledColumn}),
43
+ manualComplimentaryAccess: false, // wire to your comp flag if one exists
44
+ manualBillingOverride: false,
45
+ administrativeBlock: false, // wire to your admin-lock flag if one exists
46
+ securityOrAbuseBlock: false, // wire to your abuse-lock flag if one exists
47
+ finalAccessDecision: Boolean(row?.${entitledColumn}), // what your gates actually use
48
+ ruleVersion: AKESO_RULE_VERSION,
49
+ adapterVersion: AKESO_ADAPTER_VERSION,
50
+ decisionSource: "${tableName}.${entitledColumn}",
51
+ readAt: new Date().toISOString(),
52
+ };
53
+ }
54
+
55
+ // The one write. Changes billingEntitled and NOTHING else, and only when the
56
+ // row still looks the way the caller last saw it (compare-and-set) — a restore
57
+ // racing a newer change must lose, loudly, not win, silently.
58
+ export async function restoreBillingEntitlement(
59
+ accountId${ts ? ": string" : ""},
60
+ expectedCurrentState${ts ? ": { billingEntitled: boolean }" : ""},
61
+ targetState${ts ? ": { billingEntitled: boolean }" : ""},
62
+ ruleVersion${ts ? ": string" : ""},
63
+ idempotencyKey${ts ? ": string" : ""},
64
+ reasonCode${ts ? ": string" : ""},
65
+ ) {
66
+ if (ruleVersion !== AKESO_RULE_VERSION) {
67
+ return { result: "conflict"${ts ? " as const" : ""}, reason: "rule version changed since this restore was prepared" };
68
+ }
69
+
70
+ const before = await getBillingEntitlement(accountId);
71
+
72
+ // Blocks are load-bearing. A blocked account is never restored by billing
73
+ // logic, whatever Stripe says — this is the line between a billing tool and
74
+ // an incident.
75
+ if (before.administrativeBlock || before.securityOrAbuseBlock) {
76
+ return { result: "unsupported"${ts ? " as const" : ""}, reason: "account carries an administrative or security block", before };
77
+ }
78
+ if (before.billingEntitled === targetState.billingEntitled) {
79
+ return { result: "no_op"${ts ? " as const" : ""}, before };
80
+ }
81
+ if (before.billingEntitled !== expectedCurrentState.billingEntitled) {
82
+ return { result: "conflict"${ts ? " as const" : ""}, reason: "state changed since it was read", before };
83
+ }
84
+
85
+ // TODO(you or your agent): the actual compare-and-set update, e.g.
86
+ // UPDATE ${tableName} SET ${entitledColumn} = $target
87
+ // WHERE ${accountColumn} = $accountId AND ${entitledColumn} = $expected
88
+ // and record { idempotencyKey, reasonCode, before, after } in an audit table.
89
+ const updated = await db
90
+ .from("${tableName}")
91
+ .update({ ${entitledColumn}: targetState.billingEntitled })
92
+ .eq("${accountColumn}", accountId)
93
+ .eq("${entitledColumn}", expectedCurrentState.billingEntitled)
94
+ .select();
95
+
96
+ const after = await getBillingEntitlement(accountId);
97
+ const applied = after.billingEntitled === targetState.billingEntitled;
98
+ return {
99
+ result: applied ? ${ts ? '"applied" as const' : '"applied"'} : ${ts ? '"failed" as const' : '"failed"'},
100
+ idempotencyKey,
101
+ reasonCode,
102
+ before,
103
+ after,
104
+ verified: applied, // success is claimed only after the re-read agrees
105
+ };
106
+ }
107
+ `;
108
+ }
109
+
110
+ /* A runnable reference implementation of the same contract against a plain
111
+ JSON store — what the fixtures use, and what the generated code's tests can
112
+ exercise so the contract itself is proven even before a founder wires it. */
113
+ export function renderReferenceAdapter() {
114
+ return renderAdapter({ language: "mjs" })
115
+ .replace(/const row = await db[\s\S]*?\.single\(\);/,
116
+ `const store = JSON.parse(await (await import("node:fs/promises")).readFile(process.env.AKESO_REF_DB, "utf8"));
117
+ const row = store[accountId] || null;`)
118
+ .replace(/const updated = await db[\s\S]*?\.select\(\);/,
119
+ `const fs = await import("node:fs/promises");
120
+ const store = JSON.parse(await fs.readFile(process.env.AKESO_REF_DB, "utf8"));
121
+ if (store[accountId] && Boolean(store[accountId].billing_entitled) === expectedCurrentState.billingEntitled) {
122
+ store[accountId].billing_entitled = targetState.billingEntitled;
123
+ await fs.writeFile(process.env.AKESO_REF_DB, JSON.stringify(store));
124
+ }`)
125
+ .replace(/Boolean\(row\?\.billing_entitled\)/g, "Boolean(row?.billing_entitled)")
126
+ .replace(/administrativeBlock: false,.*$/m, "administrativeBlock: Boolean(row?.admin_block),")
127
+ .replace(/securityOrAbuseBlock: false,.*$/m, "securityOrAbuseBlock: Boolean(row?.abuse_block),");
128
+ }
package/src/detect.mjs ADDED
@@ -0,0 +1,255 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ /* Works out what this project is, using only what is on disk.
5
+ *
6
+ * This is the part of the Check that decides whether everything downstream is
7
+ * possible: which framework, where the env lives, whether there are Stripe
8
+ * keys and what mode they are in, where the webhook handler is, and — the hard
9
+ * one — where the app decides who has paid access.
10
+ *
11
+ * Two disciplines carried over from the product-care engine:
12
+ * - Report only what was actually observed. "Not found" is a finding, not an
13
+ * error; a wrong confident answer is worse than an honest gap.
14
+ * - Never print a secret. Keys are reported as mode + last four characters.
15
+ */
16
+
17
+ const IGNORE_DIRS = new Set([
18
+ "node_modules", ".git", ".next", "dist", "build", ".vercel", ".turbo",
19
+ "coverage", ".cache", "out", ".wrangler", ".vinext",
20
+ ]);
21
+
22
+ const ENV_FILES = [".env", ".env.local", ".env.development.local", ".env.production.local", ".env.development", ".env.production"];
23
+
24
+ async function exists(file) {
25
+ try { await stat(file); return true; } catch { return false; }
26
+ }
27
+
28
+ async function readIfThere(file) {
29
+ try { return await readFile(file, "utf8"); } catch { return null; }
30
+ }
31
+
32
+ function parseEnv(source) {
33
+ const values = {};
34
+ for (const rawLine of source.split(/\r?\n/)) {
35
+ const line = rawLine.trim();
36
+ if (!line || line.startsWith("#")) continue;
37
+ const eq = line.indexOf("=");
38
+ if (eq < 1) continue;
39
+ let value = line.slice(eq + 1).trim();
40
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
41
+ value = value.slice(1, -1);
42
+ }
43
+ values[line.slice(0, eq).trim()] = value;
44
+ }
45
+ return values;
46
+ }
47
+
48
+ /* Safe to show: enough to recognise a key, never enough to use one. */
49
+ function describeKey(value) {
50
+ if (!value) return null;
51
+ const mode = value.startsWith("sk_live_") || value.startsWith("rk_live_") ? "LIVE"
52
+ : value.startsWith("sk_test_") || value.startsWith("rk_test_") ? "test"
53
+ : value.startsWith("pk_live_") ? "LIVE (publishable)"
54
+ : value.startsWith("pk_test_") ? "test (publishable)"
55
+ : value.startsWith("whsec_") ? "webhook secret"
56
+ : "unrecognised";
57
+ return { mode, lastFour: value.slice(-4), length: value.length };
58
+ }
59
+
60
+ async function detectFramework(root) {
61
+ const pkgRaw = await readIfThere(path.join(root, "package.json"));
62
+ if (!pkgRaw) return { framework: "unknown", reason: "No package.json found." };
63
+ let pkg;
64
+ try { pkg = JSON.parse(pkgRaw); } catch { return { framework: "unknown", reason: "package.json is not valid JSON." }; }
65
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
66
+
67
+ if (deps.next) {
68
+ const hasAppDir = await exists(path.join(root, "app")) || await exists(path.join(root, "src", "app"));
69
+ const hasPagesDir = await exists(path.join(root, "pages")) || await exists(path.join(root, "src", "pages"));
70
+ return {
71
+ framework: hasAppDir ? "next-app-router" : hasPagesDir ? "next-pages" : "next-unknown-layout",
72
+ nextVersion: deps.next,
73
+ packageName: pkg.name || null,
74
+ };
75
+ }
76
+ if (deps.express) return { framework: "express", packageName: pkg.name || null };
77
+ return { framework: "node-other", packageName: pkg.name || null, dependencies: Object.keys(deps).slice(0, 20) };
78
+ }
79
+
80
+ async function readEnv(root) {
81
+ const merged = {};
82
+ const filesFound = [];
83
+ for (const name of ENV_FILES) {
84
+ const raw = await readIfThere(path.join(root, name));
85
+ if (raw === null) continue;
86
+ filesFound.push(name);
87
+ Object.assign(merged, parseEnv(raw));
88
+ }
89
+ return { filesFound, values: merged };
90
+ }
91
+
92
+ function detectStripe(env, pkgDeps) {
93
+ const secretKey = env.STRIPE_SECRET_KEY || env.STRIPE_API_KEY || env.STRIPE_SK || null;
94
+ const webhookSecret = env.STRIPE_WEBHOOK_SECRET || env.STRIPE_WEBHOOK_SIGNING_SECRET || null;
95
+ return {
96
+ sdkInstalled: Boolean(pkgDeps?.stripe),
97
+ secretKey: describeKey(secretKey),
98
+ webhookSecret: describeKey(webhookSecret),
99
+ /* The lifecycle pass may only ever run against a test-mode key. */
100
+ lifecycleTestable: Boolean(secretKey && !secretKey.includes("_live_")),
101
+ liveKeyPresent: Boolean(secretKey && secretKey.includes("_live_")),
102
+ };
103
+ }
104
+
105
+ function detectDatabase(env, pkgDeps) {
106
+ const supabaseUrl = env.SUPABASE_URL || env.NEXT_PUBLIC_SUPABASE_URL || null;
107
+ const postgresUrl = env.DATABASE_URL || env.POSTGRES_URL || env.POSTGRES_PRISMA_URL || null;
108
+ return {
109
+ kind: supabaseUrl ? "supabase" : postgresUrl ? "postgres" : "none-found",
110
+ supabase: supabaseUrl ? {
111
+ urlHost: (() => { try { return new URL(supabaseUrl).host; } catch { return "unparseable"; } })(),
112
+ /* Anonymous/publishable key is fine to use; service-role never is. Its
113
+ mere presence in an env file is worth telling the founder about. */
114
+ serviceRoleKeyPresentInEnv: Boolean(env.SUPABASE_SERVICE_ROLE_KEY || env.SUPABASE_SECRET_KEY),
115
+ publishableKeyPresent: Boolean(env.SUPABASE_ANON_KEY || env.NEXT_PUBLIC_SUPABASE_ANON_KEY || env.SUPABASE_PUBLISHABLE_KEY),
116
+ } : null,
117
+ postgresUrlPresent: Boolean(postgresUrl),
118
+ ormInstalled: pkgDeps?.prisma ? "prisma" : pkgDeps?.drizzle ? "drizzle" : pkgDeps?.["drizzle-orm"] ? "drizzle" : null,
119
+ };
120
+ }
121
+
122
+ /* Walk the source tree once, collecting every file that mentions Stripe.
123
+ Bounded: skips build output and gives up past a sane file count so a huge
124
+ monorepo cannot hang the Check. */
125
+ async function collectSourceFiles(root, limit = 4000) {
126
+ const files = [];
127
+ async function walk(dir, depth) {
128
+ if (depth > 8 || files.length >= limit) return;
129
+ let entries;
130
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
131
+ for (const entry of entries) {
132
+ if (files.length >= limit) return;
133
+ if (entry.isDirectory()) {
134
+ if (!IGNORE_DIRS.has(entry.name) && !entry.name.startsWith(".")) await walk(path.join(dir, entry.name), depth + 1);
135
+ } else if (/\.(ts|tsx|js|jsx|mjs)$/.test(entry.name)) {
136
+ files.push(path.join(dir, entry.name));
137
+ }
138
+ }
139
+ }
140
+ await walk(root, 0);
141
+ return files;
142
+ }
143
+
144
+ const REQUIRED_EVENTS = [
145
+ "checkout.session.completed",
146
+ "invoice.paid",
147
+ "invoice.payment_failed",
148
+ "customer.subscription.created",
149
+ "customer.subscription.updated",
150
+ "customer.subscription.deleted",
151
+ "charge.refunded",
152
+ ];
153
+
154
+ async function findWebhookHandler(root, sourceFiles) {
155
+ const candidates = [];
156
+ for (const file of sourceFiles) {
157
+ const relative = path.relative(root, file);
158
+ const looksLikeRoute = /webhook|stripe/i.test(relative);
159
+ const content = await readIfThere(file);
160
+ if (!content) continue;
161
+ /* Must be a real call, not the word. A comment saying "no constructEvent
162
+ here" made an earlier version report the signature as verified — the
163
+ exact false pass this tool exists to catch in other people's code. */
164
+ const code = content.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, "");
165
+ const mentionsConstruct = /constructEvent(Async)?\s*\(/.test(code);
166
+ const mentionsEvents = REQUIRED_EVENTS.filter((event) => code.includes(event));
167
+ if (!mentionsConstruct && mentionsEvents.length === 0 && !(looksLikeRoute && /stripe/i.test(content))) continue;
168
+
169
+ candidates.push({
170
+ file: relative,
171
+ verifiesSignature: mentionsConstruct,
172
+ /* constructEvent on a parsed body fails at runtime; the raw body is the
173
+ thing that must be verified. Heuristic, so it is reported as evidence
174
+ ("raw body handling not seen") rather than as a verdict. */
175
+ rawBodySeen: /req\.text\(\)|request\.text\(\)|rawBody|buffer\(|arrayBuffer\(|micro|bodyParser:\s*false|await buffer/.test(content),
176
+ handledEvents: mentionsEvents,
177
+ missingEvents: REQUIRED_EVENTS.filter((event) => !code.includes(event)),
178
+ });
179
+ }
180
+ candidates.sort((a, b) => (b.verifiesSignature - a.verifiesSignature) || (b.handledEvents.length - a.handledEvents.length));
181
+ return candidates;
182
+ }
183
+
184
+ /* The judgement problem: where does this app decide who has paid access?
185
+ We do not decide — we gather every plausible site with its evidence and let
186
+ the report (and eventually the founder) confirm. A ranked shortlist that is
187
+ honest about confidence beats a confident wrong answer. */
188
+ const ACCESS_HINTS = [
189
+ { pattern: /\b(is_pro|isPro|is_premium|isPremium|is_paid|isPaid|has_access|hasAccess|is_subscribed|isSubscribed|subscribed)\b/, why: "boolean paid flag" },
190
+ { pattern: /\b(plan|tier|subscription_status|subscriptionStatus|subscription_tier)\b\s*(===?|!==?|\.eq\(|:)/, why: "plan/status comparison" },
191
+ { pattern: /\.from\(["'`](subscriptions|subscribers|entitlements|customers|profiles|users|accounts|billing)["'`]\)/, why: "reads a billing-ish table" },
192
+ { pattern: /\bstatus\s*===?\s*["'`](active|trialing|past_due|canceled)["'`]/, why: "compares a Stripe status value" },
193
+ { pattern: /getBillingEntitlement|billingEntitled/, why: "already has an entitlement function" },
194
+ ];
195
+
196
+ async function findAccessDecisionSites(root, sourceFiles) {
197
+ const sites = [];
198
+ for (const file of sourceFiles) {
199
+ const content = await readIfThere(file);
200
+ if (!content) continue;
201
+ const relative = path.relative(root, file);
202
+ const reasons = ACCESS_HINTS.filter((hint) => hint.pattern.test(content)).map((hint) => hint.why);
203
+ if (!reasons.length) continue;
204
+ const clientSide = /["']use client["']/.test(content) || /components\/|hooks\//.test(relative) || /from ["']react["']/.test(content);
205
+ sites.push({
206
+ file: relative,
207
+ evidence: [...new Set(reasons)],
208
+ /* A paid gate that only exists in the browser can be bypassed by anyone
209
+ who opens devtools. Worth its own flag. */
210
+ clientSideOnly: clientSide,
211
+ score: reasons.length + (relative.includes("lib/") || relative.includes("server") || relative.includes("api/") ? 2 : 0) - (clientSide ? 1 : 0),
212
+ });
213
+ }
214
+ return sites.sort((a, b) => b.score - a.score).slice(0, 10);
215
+ }
216
+
217
+ export async function detect(root) {
218
+ const framework = await detectFramework(root);
219
+ const env = await readEnv(root);
220
+ const pkgRaw = await readIfThere(path.join(root, "package.json"));
221
+ let deps = {};
222
+ try { const pkg = JSON.parse(pkgRaw || "{}"); deps = { ...pkg.dependencies, ...pkg.devDependencies }; } catch { /* reported by detectFramework */ }
223
+
224
+ const stripe = detectStripe(env.values, deps);
225
+ const database = detectDatabase(env.values, deps);
226
+ const sourceFiles = await collectSourceFiles(root);
227
+ const webhookHandlers = await findWebhookHandler(root, sourceFiles);
228
+ const accessDecisionSites = await findAccessDecisionSites(root, sourceFiles);
229
+
230
+ return {
231
+ root,
232
+ scannedFiles: sourceFiles.length,
233
+ framework,
234
+ envFiles: env.filesFound,
235
+ stripe,
236
+ database,
237
+ webhookHandlers,
238
+ accessDecisionSites,
239
+ /* What downstream stages are possible, decided here in one place. */
240
+ capabilities: {
241
+ staticPass: true,
242
+ lifecyclePass: stripe.lifecycleTestable && webhookHandlers.length > 0,
243
+ liveSnapshot: stripe.liveKeyPresent || stripe.lifecycleTestable,
244
+ blockers: [
245
+ /* A found webhook handler IS evidence of a Stripe app, even when the
246
+ SDK arrives by URL import (Deno edge functions) and keys live in the
247
+ platform, not in env files. */
248
+ !stripe.sdkInstalled && !stripe.secretKey && webhookHandlers.length === 0 ? "No Stripe SDK or key found. Is this a Stripe-backed app?" : null,
249
+ stripe.liveKeyPresent && !stripe.lifecycleTestable ? "Only a LIVE Stripe key found. Lifecycle tests run only against test mode; add a test key." : null,
250
+ webhookHandlers.length === 0 ? "No Stripe webhook handler found." : null,
251
+ database.kind === "none-found" ? "No database connection found in env files." : null,
252
+ ].filter(Boolean),
253
+ },
254
+ };
255
+ }