akeso-check 0.1.0 → 0.1.3
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/bin/akeso-check.mjs +1 -1
- package/package.json +16 -4
- package/src/probe.mjs +13 -5
- package/src/report.mjs +34 -9
- package/tests/probe.test.mjs +37 -0
- package/tests/report.test.mjs +51 -0
package/bin/akeso-check.mjs
CHANGED
|
@@ -84,7 +84,7 @@ if (base) {
|
|
|
84
84
|
up. Removal happens no matter how the run ends. */
|
|
85
85
|
let probeUrl = null;
|
|
86
86
|
let installed = null;
|
|
87
|
-
for (const candidate of [`${base}/api/__akeso_probe`, `${base}/__akeso_probe`]) {
|
|
87
|
+
for (const candidate of [`${base}/api/akeso-probe`, `${base}/akeso-probe`, `${base}/api/__akeso_probe`, `${base}/__akeso_probe`]) {
|
|
88
88
|
if (await probeAnswers(candidate)) { probeUrl = candidate; break; }
|
|
89
89
|
}
|
|
90
90
|
if (!probeUrl) {
|
package/package.json
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akeso-check",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Prove whether your app grants and revokes paid access correctly. Runs on your machine; nothing leaves it.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin": {
|
|
6
|
+
"bin": {
|
|
7
|
+
"akeso-check": "bin/akeso-check.mjs"
|
|
8
|
+
},
|
|
7
9
|
"license": "MIT",
|
|
8
|
-
"keywords": [
|
|
9
|
-
|
|
10
|
+
"keywords": [
|
|
11
|
+
"stripe",
|
|
12
|
+
"billing",
|
|
13
|
+
"webhooks",
|
|
14
|
+
"subscriptions",
|
|
15
|
+
"entitlement",
|
|
16
|
+
"saas",
|
|
17
|
+
"testing"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
10
22
|
"scripts": {
|
|
11
23
|
"check": "node bin/akeso-check.mjs",
|
|
12
24
|
"test": "node --test tests/*.test.mjs"
|
package/src/probe.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, rm, rmdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
/* The access probe: a temporary route the Check adds to the founder's app so
|
|
@@ -79,9 +79,14 @@ export async function chooseProbeTarget(root, accessDecisionSites) {
|
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/* No path segment may start with an underscore. Next.js treats an
|
|
83
|
+
underscore-prefixed folder as a PRIVATE folder and excludes it from routing
|
|
84
|
+
entirely, so the old `__akeso_probe` route silently never existed on any
|
|
85
|
+
real Next.js app. The fixtures are plain Node servers, which is why the
|
|
86
|
+
tests never caught it; a real user's run did. */
|
|
82
87
|
function probeRoutePath(root, framework) {
|
|
83
|
-
if (framework === "next-pages") return path.join(root, "pages", "api", "
|
|
84
|
-
return path.join(root, "app", "api", "
|
|
88
|
+
if (framework === "next-pages") return path.join(root, "pages", "api", "akeso-probe.ts");
|
|
89
|
+
return path.join(root, "app", "api", "akeso-probe", "route.ts");
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
/* Relative import from the generated route file to the access module, with the
|
|
@@ -146,7 +151,7 @@ export async function installProbe(root, detection) {
|
|
|
146
151
|
await writeFile(routeFile, content);
|
|
147
152
|
return {
|
|
148
153
|
routeFile,
|
|
149
|
-
urlPath: "/api/
|
|
154
|
+
urlPath: "/api/akeso-probe",
|
|
150
155
|
wired: Boolean(target.chosen),
|
|
151
156
|
reason: target.reason,
|
|
152
157
|
target: target.chosen || null,
|
|
@@ -162,6 +167,9 @@ export async function removeProbe(routeFile) {
|
|
|
162
167
|
await rm(routeFile);
|
|
163
168
|
/* tidy the wrapper dir Next requires, only if we created it and it is now empty */
|
|
164
169
|
const dir = path.dirname(routeFile);
|
|
165
|
-
|
|
170
|
+
/* rmdir, not rm: it removes the folder only when it is empty, so a folder
|
|
171
|
+
holding anything of the founder's is never touched. (rm without recursive
|
|
172
|
+
throws on a directory, which silently left an empty folder in their repo.) */
|
|
173
|
+
if (["akeso-probe", "__akeso_probe"].includes(path.basename(dir))) await rmdir(dir).catch(() => {});
|
|
166
174
|
return { removed: true };
|
|
167
175
|
}
|
package/src/report.mjs
CHANGED
|
@@ -21,7 +21,12 @@ const GRADE_COPY = {
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
export function renderReport({ detection, lifecycle, generatedAt = new Date() }) {
|
|
24
|
-
|
|
24
|
+
/* Static-only is a normal, successful outcome, not a broken run. Saying "the
|
|
25
|
+
run had problems" over a clean code read was the first thing a real user
|
|
26
|
+
hit, and it also let the page claim scenarios were acted out when nothing
|
|
27
|
+
had executed. Nothing on this page may describe work that did not happen. */
|
|
28
|
+
const staticOnly = !lifecycle;
|
|
29
|
+
const grade = lifecycle?.grade ?? null;
|
|
25
30
|
const handler = detection.webhookHandlers?.[0] || null;
|
|
26
31
|
|
|
27
32
|
const staticFindings = [];
|
|
@@ -36,6 +41,12 @@ export function renderReport({ detection, lifecycle, generatedAt = new Date() })
|
|
|
36
41
|
const clientGate = (detection.accessDecisionSites || []).find((site) => site.clientSideOnly);
|
|
37
42
|
if (clientGate) staticFindings.push({ tone: "warn", text: `Paid access appears to be checked in the browser (${clientGate.file}), a gate anyone can step around with devtools.` });
|
|
38
43
|
|
|
44
|
+
const edgeFunction = handler?.file?.startsWith("supabase/functions/");
|
|
45
|
+
const staticHeadline = !handler ? "No Stripe webhook handler was found."
|
|
46
|
+
: !handler.verifiesSignature ? "Your webhook does not verify Stripe's signature."
|
|
47
|
+
: handler.missingEvents?.length ? `Your webhook ignores ${handler.missingEvents.length} of the 7 billing events.`
|
|
48
|
+
: "Your billing code reads clean.";
|
|
49
|
+
|
|
39
50
|
const scenarioRows = (lifecycle?.results || []).map((result) => {
|
|
40
51
|
const mark = result.outcome === "pass" ? "✓"
|
|
41
52
|
: result.outcome === "fail" ? "✗"
|
|
@@ -56,8 +67,12 @@ export function renderReport({ detection, lifecycle, generatedAt = new Date() })
|
|
|
56
67
|
).join("\n");
|
|
57
68
|
|
|
58
69
|
const limits = [
|
|
59
|
-
|
|
60
|
-
|
|
70
|
+
staticOnly
|
|
71
|
+
? "Nothing was executed. This is a read of your code, so it cannot tell you what your app really does when a customer cancels."
|
|
72
|
+
: "Only the billing lifecycle was tested. Not login, checkout UI, or anything else.",
|
|
73
|
+
staticOnly
|
|
74
|
+
? null
|
|
75
|
+
: "Events were delivered locally with your app's own webhook secret. If your handler re-fetches objects from Stripe's API, run the sandbox mode with your Stripe test key for full fidelity.",
|
|
61
76
|
detection.capabilities?.blockers?.length ? `Not possible on this project yet: ${detection.capabilities.blockers.join(" ")}` : null,
|
|
62
77
|
].filter(Boolean).map((limit) => `<li>${escapeHtml(limit)}</li>`).join("\n");
|
|
63
78
|
|
|
@@ -95,13 +110,18 @@ export function renderReport({ detection, lifecycle, generatedAt = new Date() })
|
|
|
95
110
|
.cta h3 { margin:0 0 6px; font-size:17px; } .cta p { margin:0 0 14px; color:var(--ink2); font-size:14.5px; }
|
|
96
111
|
.cta a { display:inline-block; background:var(--ink); color:var(--bg); text-decoration:none; border-radius:7px; padding:10px 18px; font-size:15px; font-weight:500; }
|
|
97
112
|
footer { margin-top:44px; font-size:12.5px; color:var(--ink3); }
|
|
113
|
+
pre.cmd { background:var(--line); border-radius:8px; padding:13px 15px; overflow-x:auto;
|
|
114
|
+
font:13.5px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; margin:0; }
|
|
115
|
+
code.inline { font:13px ui-monospace,SFMono-Regular,Menlo,monospace; }
|
|
98
116
|
</style></head><body><div class="shell">
|
|
99
117
|
<div class="local">This report is a file on your computer. Nothing was sent anywhere.</div>
|
|
100
118
|
<div class="gradeCard">
|
|
101
|
-
|
|
119
|
+
${staticOnly ? "" : `<div class="gradeLetter g-${escapeHtml(grade.letter)}">${escapeHtml(grade.letter)}</div>`}
|
|
102
120
|
<div>
|
|
103
|
-
<h1>${escapeHtml(GRADE_COPY[grade.letter] || grade.reason)}</h1>
|
|
104
|
-
<p>${escapeHtml(
|
|
121
|
+
<h1>${escapeHtml(staticOnly ? staticHeadline : (GRADE_COPY[grade.letter] || grade.reason))}</h1>
|
|
122
|
+
<p>${escapeHtml(staticOnly
|
|
123
|
+
? "This is a read of your code. The live test, where a pretend customer pays and cancels, has not run yet."
|
|
124
|
+
: grade.letter === "F" ? "This leaks money every day until it is fixed." : grade.reason)}</p>
|
|
105
125
|
<div class="app">${escapeHtml([
|
|
106
126
|
detection.framework?.packageName || detection.root,
|
|
107
127
|
{ "next-app-router": "a Next.js app", "next-pages": "a Next.js app", express: "an Express app", "supabase-edge": "a Supabase Edge app", "node-other": "a Node app" }[detection.framework?.framework] || null,
|
|
@@ -111,12 +131,17 @@ export function renderReport({ detection, lifecycle, generatedAt = new Date() })
|
|
|
111
131
|
</div>
|
|
112
132
|
</div>
|
|
113
133
|
|
|
114
|
-
|
|
134
|
+
${staticOnly ? `<h2>The live test has not run yet</h2>
|
|
135
|
+
<p class="intro">Reading code can only tell you what your app is supposed to do. To see what it actually does when a customer cancels, Akeso needs to run against your app while it is running.</p>
|
|
136
|
+
${edgeFunction
|
|
137
|
+
? `<p class="intro">Your webhook is a Supabase Edge Function. The live test does not support that shape yet, so this code read is everything Akeso can prove about this project today.</p>`
|
|
138
|
+
: `<p class="intro">Start your app the way you normally do (often <code class="inline">npm run dev</code>), then run this in the same folder:</p>
|
|
139
|
+
<pre class="cmd">npx akeso-check --lifecycle-url http://localhost:3000</pre>`}` : `<h2>What we tested</h2>
|
|
115
140
|
<p class="intro">Akeso acted out ten billing situations against your app: paying, canceling, a failing card, a refund. After each one it asked your app the same question: does this customer still have paid access?</p>
|
|
116
|
-
<div class="rows">${scenarioRows
|
|
141
|
+
<div class="rows">${scenarioRows}</div>`}
|
|
117
142
|
|
|
118
143
|
<h2>What your code shows</h2>
|
|
119
|
-
<p class="intro">Read from your webhook handler and access checks
|
|
144
|
+
<p class="intro">Read from your webhook handler and access checks. Nothing was executed to produce this.</p>
|
|
120
145
|
<div class="rows">${findingRows}</div>
|
|
121
146
|
|
|
122
147
|
<h2>What this did not check</h2>
|
package/tests/probe.test.mjs
CHANGED
|
@@ -82,3 +82,40 @@ test("removal refuses a file that lost the Akeso marker", async () => {
|
|
|
82
82
|
const { rm } = await import("node:fs/promises");
|
|
83
83
|
await rm(path.dirname(file), { recursive: true });
|
|
84
84
|
});
|
|
85
|
+
|
|
86
|
+
/* Next.js treats any folder starting with "_" as a private folder and excludes
|
|
87
|
+
it from routing, so the probe's original `__akeso_probe` path silently never
|
|
88
|
+
existed on a real Next.js app. Proven against a live `next dev`: the
|
|
89
|
+
underscore path 404s, the hyphen path returns the entitlement. The fixtures
|
|
90
|
+
are plain Node servers, which is why only a real user's run caught it. */
|
|
91
|
+
test("no probe path segment may start with an underscore", async () => {
|
|
92
|
+
const { mkdtemp, rm } = await import("node:fs/promises");
|
|
93
|
+
const os = await import("node:os");
|
|
94
|
+
for (const framework of ["next-app-router", "next-pages", "node-other"]) {
|
|
95
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akeso-probe-path-"));
|
|
96
|
+
try {
|
|
97
|
+
const installed = await installProbe(root, { framework: { framework }, accessDecisionSites: [] });
|
|
98
|
+
const segments = path.relative(root, installed.routeFile).split(path.sep);
|
|
99
|
+
for (const segment of segments) {
|
|
100
|
+
assert.ok(!segment.startsWith("_"), `${framework}: "${segment}" would be invisible to Next.js routing`);
|
|
101
|
+
}
|
|
102
|
+
assert.ok(!installed.urlPath.split("/").some((s) => s.startsWith("_")), `${framework}: url path must not be private`);
|
|
103
|
+
} finally {
|
|
104
|
+
await rm(root, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("removal leaves nothing behind, not even the folder", async () => {
|
|
110
|
+
const { mkdtemp, rm, stat } = await import("node:fs/promises");
|
|
111
|
+
const os = await import("node:os");
|
|
112
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akeso-probe-clean-"));
|
|
113
|
+
try {
|
|
114
|
+
const installed = await installProbe(root, { framework: { framework: "next-app-router" }, accessDecisionSites: [] });
|
|
115
|
+
await removeProbe(installed.routeFile);
|
|
116
|
+
const dir = path.dirname(installed.routeFile);
|
|
117
|
+
await assert.rejects(() => stat(dir), "the probe folder must be gone, not left empty in their repo");
|
|
118
|
+
} finally {
|
|
119
|
+
await rm(root, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { renderReport } from "../src/report.mjs";
|
|
4
|
+
|
|
5
|
+
/* The report may never describe work that did not happen. A real user ran the
|
|
6
|
+
default static pass and was told "the run itself had problems" over a clean
|
|
7
|
+
code read, on a page claiming Akeso had acted out ten billing situations.
|
|
8
|
+
Nothing had executed. These tests exist so that cannot come back. */
|
|
9
|
+
|
|
10
|
+
const detection = {
|
|
11
|
+
root: "/tmp/app",
|
|
12
|
+
framework: { packageName: "watch-this", framework: "next-app-router" },
|
|
13
|
+
database: { kind: "supabase" },
|
|
14
|
+
stripe: { secretKey: { mode: "TEST", lastFour: "1234" } },
|
|
15
|
+
webhookHandlers: [{ file: "app/api/stripe/webhook/route.ts", verifiesSignature: true, rawBodySeen: true, handledEvents: ["checkout.session.completed"], missingEvents: [] }],
|
|
16
|
+
accessDecisionSites: [],
|
|
17
|
+
capabilities: { blockers: [] },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
test("a static-only run never claims the lifecycle was acted out", () => {
|
|
21
|
+
const html = renderReport({ detection, lifecycle: null });
|
|
22
|
+
assert.ok(!html.includes("acted out ten"), "must not claim ten situations were run");
|
|
23
|
+
assert.ok(!html.includes("run itself had problems"), "a clean code read is not a broken run");
|
|
24
|
+
assert.ok(html.includes("has not run yet"), "must say the live test has not run");
|
|
25
|
+
assert.ok(html.includes("--lifecycle-url"), "must show how to run the real test");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a static-only run leads with what the code actually showed", () => {
|
|
29
|
+
const clean = renderReport({ detection, lifecycle: null });
|
|
30
|
+
assert.match(clean, /<h1>Your billing code reads clean\./);
|
|
31
|
+
|
|
32
|
+
const broken = renderReport({
|
|
33
|
+
detection: { ...detection, webhookHandlers: [{ ...detection.webhookHandlers[0], missingEvents: ["invoice.paid", "customer.subscription.deleted"] }] },
|
|
34
|
+
lifecycle: null,
|
|
35
|
+
});
|
|
36
|
+
assert.match(broken, /<h1>Your webhook ignores 2 of the 7 billing events\./);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a graded run still shows its grade and scenarios", () => {
|
|
40
|
+
const html = renderReport({
|
|
41
|
+
detection,
|
|
42
|
+
lifecycle: {
|
|
43
|
+
scenarioCount: 1,
|
|
44
|
+
grade: { letter: "F", reason: "Customers who cancel keep their paid access." },
|
|
45
|
+
results: [{ id: "cancel", name: "Customer cancels", expected: false, observed: true, outcome: "fail", critical: true }],
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
assert.ok(html.includes(">F<"), "grade letter shown");
|
|
49
|
+
assert.ok(html.includes("acted out ten"), "the real run may describe itself");
|
|
50
|
+
assert.ok(html.includes("Customer cancels"), "scenario rows shown");
|
|
51
|
+
});
|