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 +63 -0
- package/SKILL.md +17 -0
- package/bin/akeso-check.mjs +192 -0
- package/examples/report-broken.html +72 -0
- package/examples/report-fixed.html +67 -0
- package/examples/report-reelpals-broken.html +68 -0
- package/examples/report-reelpals-fixed.html +70 -0
- package/fixtures/broken-app/.env.local +5 -0
- package/fixtures/broken-app/app/api/stripe/webhook/route.ts +18 -0
- package/fixtures/broken-app/lib/access.ts +8 -0
- package/fixtures/broken-app/lib/handler.mjs +38 -0
- package/fixtures/broken-app/package.json +4 -0
- package/fixtures/broken-app/server.mjs +34 -0
- package/fixtures/fixed-app/.env.local +4 -0
- package/fixtures/fixed-app/app/api/stripe/webhook/route.ts +23 -0
- package/fixtures/fixed-app/lib/handler.mjs +80 -0
- package/fixtures/fixed-app/package.json +4 -0
- package/fixtures/fixed-app/server.mjs +34 -0
- package/package.json +14 -0
- package/src/adapter.mjs +128 -0
- package/src/detect.mjs +255 -0
- package/src/lifecycle.mjs +147 -0
- package/src/probe.mjs +167 -0
- package/src/report.mjs +127 -0
- package/src/sandbox.mjs +191 -0
- package/src/snapshot.mjs +97 -0
- package/src/stripe-events.mjs +190 -0
- package/tests/adapter.test.mjs +73 -0
- package/tests/lifecycle-acceptance.test.mjs +79 -0
- package/tests/probe.test.mjs +84 -0
- package/tests/snapshot.test.mjs +61 -0
|
@@ -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,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,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,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
|
+
}
|
package/src/adapter.mjs
ADDED
|
@@ -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
|
+
}
|