@nerdsystem/sdk 0.0.13 → 0.0.14
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 +22 -0
- package/dist/auth/jwks.d.ts +7 -0
- package/dist/auth/jwks.js +8 -0
- package/dist/testing.d.ts +92 -0
- package/dist/testing.js +164 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -15,6 +15,28 @@ export default app;
|
|
|
15
15
|
|
|
16
16
|
Start from the template with `npx nerdsystem init my-app`. Documentation lives in the template's `AGENTS.md` and `.claude/skills/nerdsystem/SKILL.md`.
|
|
17
17
|
|
|
18
|
+
## Testing who can reach what
|
|
19
|
+
|
|
20
|
+
`@nerdsystem/sdk/testing` runs your app's real routes in-process, as a signed-out visitor and as made-up signed-in users. It is for tests only; never import it from server code.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { testApp } from "@nerdsystem/sdk/testing";
|
|
24
|
+
|
|
25
|
+
const t = await testApp(app, { env }); // env needs DB and STORAGE, e.g. from wrangler's getPlatformProxy()
|
|
26
|
+
|
|
27
|
+
// Every route the app declares must refuse a signed-out visitor, except the ones named here.
|
|
28
|
+
await t.expectEveryRouteLocked({ public: ["GET /api/health"] });
|
|
29
|
+
|
|
30
|
+
// One person's data is out of reach for another.
|
|
31
|
+
const alice = await t.as("alice");
|
|
32
|
+
const bob = await t.as("bob");
|
|
33
|
+
const { todo } = await (await alice.post("/api/todos", { title: "mine" })).json();
|
|
34
|
+
await t.expectHiddenFrom(bob, "DELETE", `/api/todos/${todo.id}`);
|
|
35
|
+
await t.expectNotListed(bob, "/api/todos", "mine");
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Apps created with `nerd init` ship with these tests in `test/`, and `nerd check` runs them before every deploy.
|
|
39
|
+
|
|
18
40
|
## Adding the SDK to an existing Vite + Hono app
|
|
19
41
|
|
|
20
42
|
`nerd init` is the only scaffold and it refuses a folder that is not empty. A Vite client with a Hono server can be brought into the same shape by hand; `nerd check` refuses anything else; steps 1, 2, 4, 6, and 7 are its checks, and steps 3 and 5 are what the deploy relies on.
|
package/dist/auth/jwks.d.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
import type { HonoJsonWebKey } from "hono/utils/jwt/jws";
|
|
1
2
|
import type { NerdUser } from "../env";
|
|
3
|
+
/**
|
|
4
|
+
* Puts keys in the cache as if they had been fetched. Only `@nerdsystem/sdk/testing` calls this, so an app's
|
|
5
|
+
* tests can sign in made-up users without the network; `nerd check` refuses an app whose server code imports
|
|
6
|
+
* the testing module.
|
|
7
|
+
*/
|
|
8
|
+
export declare function seedJwks(jwksUrl: string, keys: HonoJsonWebKey[]): void;
|
|
2
9
|
export interface VerifiedToken extends NerdUser {
|
|
3
10
|
exp: number;
|
|
4
11
|
}
|
package/dist/auth/jwks.js
CHANGED
|
@@ -17,6 +17,14 @@ async function keysFor(jwksUrl, kid, fetchImpl) {
|
|
|
17
17
|
// Stale, or an unknown kid (key rotation): refetch.
|
|
18
18
|
return fetchKeys(jwksUrl, fetchImpl);
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Puts keys in the cache as if they had been fetched. Only `@nerdsystem/sdk/testing` calls this, so an app's
|
|
22
|
+
* tests can sign in made-up users without the network; `nerd check` refuses an app whose server code imports
|
|
23
|
+
* the testing module.
|
|
24
|
+
*/
|
|
25
|
+
export function seedJwks(jwksUrl, keys) {
|
|
26
|
+
cache.set(jwksUrl, { keys, fetchedAt: Date.now() });
|
|
27
|
+
}
|
|
20
28
|
/** Verifies an access token offline against the platform JWKS (cached per isolate). Returns null if invalid. */
|
|
21
29
|
export async function verifyAccessToken(token, opts) {
|
|
22
30
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test harness for apps on nerdsystem: `import { testApp } from "@nerdsystem/sdk/testing"`.
|
|
3
|
+
*
|
|
4
|
+
* It answers the two questions that matter most and that nobody looking at the screen can see:
|
|
5
|
+
* can a stranger reach this route, and can one user reach another user's data? It calls the app's own
|
|
6
|
+
* routes in-process (no server, no network) as a signed-out visitor and as made-up signed-in users, and
|
|
7
|
+
* writes what it checked to `.nerd/access-report.json`, which `nerd check` reads before every deploy.
|
|
8
|
+
*
|
|
9
|
+
* Never import this from server code. It can sign in anyone; it exists for tests only.
|
|
10
|
+
*/
|
|
11
|
+
import type { Hono } from "hono";
|
|
12
|
+
type AnyApp = Pick<Hono, "request" | "routes">;
|
|
13
|
+
type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
14
|
+
/** A route as the report and the `public` list name it, e.g. "GET /api/todos/:id". */
|
|
15
|
+
export type RouteName = `${Method} ${string}`;
|
|
16
|
+
export interface TestClient {
|
|
17
|
+
/** Who this client is signed in as, or null for the signed-out visitor. */
|
|
18
|
+
readonly user: {
|
|
19
|
+
id: string;
|
|
20
|
+
email: string;
|
|
21
|
+
name: string;
|
|
22
|
+
roles: string[];
|
|
23
|
+
} | null;
|
|
24
|
+
request(method: Method, path: string, body?: unknown): Promise<Response>;
|
|
25
|
+
get(path: string): Promise<Response>;
|
|
26
|
+
post(path: string, body?: unknown): Promise<Response>;
|
|
27
|
+
put(path: string, body?: unknown): Promise<Response>;
|
|
28
|
+
patch(path: string, body?: unknown): Promise<Response>;
|
|
29
|
+
delete(path: string, body?: unknown): Promise<Response>;
|
|
30
|
+
}
|
|
31
|
+
export interface AccessReport {
|
|
32
|
+
version: 1;
|
|
33
|
+
/** Every route the app declares outside the SDK's own sign-in routes. */
|
|
34
|
+
routes: RouteName[];
|
|
35
|
+
/** Routes a signed-out visitor was refused on (401 or 403). */
|
|
36
|
+
locked: RouteName[];
|
|
37
|
+
/** Routes the test declared open to everyone, on purpose. */
|
|
38
|
+
public: RouteName[];
|
|
39
|
+
/** Routes with an id in the path that a second signed-in user was refused on (403 or 404). */
|
|
40
|
+
ownerChecked: RouteName[];
|
|
41
|
+
/** Routes with an id in the path that the test declared shared between users, on purpose. */
|
|
42
|
+
shared: RouteName[];
|
|
43
|
+
/** True once `expectEveryRouteLocked` has run to the end. */
|
|
44
|
+
everyRouteChecked: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface TestAppOptions<E> {
|
|
47
|
+
/** The bindings the app runs with: at least `DB` and `STORAGE`, e.g. from wrangler's `getPlatformProxy()`. */
|
|
48
|
+
env: E;
|
|
49
|
+
/** Where the sign-in routes are mounted. Default "/api/auth". */
|
|
50
|
+
authPath?: string;
|
|
51
|
+
/** Called with the finished report after every change; the starter app writes it to `.nerd/access-report.json`. */
|
|
52
|
+
onReport?: (report: AccessReport) => void | Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
export interface TestHarness {
|
|
55
|
+
/** A signed-out visitor. */
|
|
56
|
+
readonly anonymous: TestClient;
|
|
57
|
+
/** A signed-in user. The same name is the same person every time; a different name is a different person. */
|
|
58
|
+
as(name: string, opts?: {
|
|
59
|
+
roles?: string[];
|
|
60
|
+
}): Promise<TestClient>;
|
|
61
|
+
/** Every route the app declares, as "METHOD /path". */
|
|
62
|
+
routes(): RouteName[];
|
|
63
|
+
/**
|
|
64
|
+
* Calls every route as a signed-out visitor and throws unless each one answers 401 or 403. Routes that
|
|
65
|
+
* are meant to be open to everyone are named in `public`; a route named there that is in fact locked,
|
|
66
|
+
* or that does not exist, also throws, so the list cannot go stale. A new route is covered the moment
|
|
67
|
+
* it exists, without touching the test.
|
|
68
|
+
*/
|
|
69
|
+
expectEveryRouteLocked(opts?: {
|
|
70
|
+
public?: RouteName[];
|
|
71
|
+
}): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* Throws unless `other` is refused (403 or 404) on a route that belongs to someone else, e.g.
|
|
74
|
+
* `await t.expectHiddenFrom(bob, "PATCH", `/api/todos/${id}`, { done: true })`.
|
|
75
|
+
*/
|
|
76
|
+
expectHiddenFrom(other: TestClient, method: Method, path: string, body?: unknown): Promise<void>;
|
|
77
|
+
/** Throws if the response `other` gets from `path` contains `needle`, e.g. the title of someone else's row in a list. */
|
|
78
|
+
expectNotListed(other: TestClient, path: string, needle: string): Promise<void>;
|
|
79
|
+
/** Declares that a route with an id in its path is meant to be readable by every signed-in user. Say why. */
|
|
80
|
+
shared(route: RouteName, reason: string): void;
|
|
81
|
+
/** What has been checked so far. */
|
|
82
|
+
report(): AccessReport;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Wraps the app for tests. `env` is merged with the three NERD_ values the SDK needs, pointed at a
|
|
86
|
+
* made-up auth service whose keys this harness holds, so signed-in test users are real to the app's
|
|
87
|
+
* `requireAuth()` and `requireRole()` and nothing leaves the machine.
|
|
88
|
+
*/
|
|
89
|
+
export declare function testApp<E extends object>(app: AnyApp, options: TestAppOptions<E>): Promise<TestHarness>;
|
|
90
|
+
/** The routes with an id in the path, which `nerd check` expects an owner test (or a `shared` declaration) for. */
|
|
91
|
+
export declare function routesWithId(report: AccessReport): RouteName[];
|
|
92
|
+
export {};
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { sign } from "hono/jwt";
|
|
2
|
+
import { seedJwks } from "./auth/jwks";
|
|
3
|
+
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
4
|
+
const TEST_AUTH_URL = "https://auth.test.invalid/auth/v1/t/prj_test";
|
|
5
|
+
const TEST_PROJECT_ID = "prj_test";
|
|
6
|
+
function pattern(path) {
|
|
7
|
+
const src = path
|
|
8
|
+
.split("/")
|
|
9
|
+
.map((seg) => (seg.startsWith(":") ? "[^/]+" : seg === "*" ? ".*" : seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
|
|
10
|
+
.join("/");
|
|
11
|
+
return new RegExp(`^${src}/?$`);
|
|
12
|
+
}
|
|
13
|
+
/** A concrete path for a declared one: every `:param` becomes "1", which no fresh test database has a row for. */
|
|
14
|
+
function sample(path) {
|
|
15
|
+
return path
|
|
16
|
+
.split("/")
|
|
17
|
+
.map((seg) => (seg.startsWith(":") ? "1" : seg === "*" ? "x" : seg))
|
|
18
|
+
.join("/");
|
|
19
|
+
}
|
|
20
|
+
function hasId(path) {
|
|
21
|
+
return path.split("/").some((seg) => seg.startsWith(":"));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Wraps the app for tests. `env` is merged with the three NERD_ values the SDK needs, pointed at a
|
|
25
|
+
* made-up auth service whose keys this harness holds, so signed-in test users are real to the app's
|
|
26
|
+
* `requireAuth()` and `requireRole()` and nothing leaves the machine.
|
|
27
|
+
*/
|
|
28
|
+
export async function testApp(app, options) {
|
|
29
|
+
const authPath = (options.authPath ?? "/api/auth").replace(/\/$/, "");
|
|
30
|
+
const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]));
|
|
31
|
+
const kid = `test-${crypto.randomUUID()}`;
|
|
32
|
+
const priv = { ...(await crypto.subtle.exportKey("jwk", pair.privateKey)), kid, alg: "ES256" };
|
|
33
|
+
const pub = { ...(await crypto.subtle.exportKey("jwk", pair.publicKey)), kid, alg: "ES256", use: "sig" };
|
|
34
|
+
seedJwks(`${TEST_AUTH_URL}/.well-known/jwks.json`, [pub]);
|
|
35
|
+
const env = { ...options.env, NERD_PROJECT_ID: TEST_PROJECT_ID, NERD_AUTH_URL: TEST_AUTH_URL, NERD_AUTH_SECRET: "nas_test_only" };
|
|
36
|
+
const declared = () => {
|
|
37
|
+
const seen = new Map();
|
|
38
|
+
for (const r of app.routes) {
|
|
39
|
+
const method = r.method.toUpperCase();
|
|
40
|
+
// Middleware registers as ALL; a handler registered with app.all() on a real path counts for every method.
|
|
41
|
+
const isHandlerPath = !r.path.endsWith("*");
|
|
42
|
+
if (r.path === authPath || r.path.startsWith(`${authPath}/`))
|
|
43
|
+
continue;
|
|
44
|
+
const methods = method === "ALL" ? (isHandlerPath ? METHODS : []) : METHODS.includes(method) ? [method] : [];
|
|
45
|
+
for (const m of methods) {
|
|
46
|
+
const name = `${m} ${r.path}`;
|
|
47
|
+
if (!seen.has(name))
|
|
48
|
+
seen.set(name, { method: m, path: r.path, name });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// app.use("/x/*") also lands in app.routes with a concrete method when written as app.get("/x/*", mw); those end in * and are skipped above.
|
|
52
|
+
return [...seen.values()];
|
|
53
|
+
};
|
|
54
|
+
const nameFor = (method, path) => {
|
|
55
|
+
const clean = path.split("?")[0] ?? path;
|
|
56
|
+
// Prefer the most literal match: "/api/todos/archive" over "/api/todos/:id".
|
|
57
|
+
const hits = declared().filter((r) => r.method === method && pattern(r.path).test(clean));
|
|
58
|
+
hits.sort((a, b) => a.path.split(":").length - b.path.split(":").length);
|
|
59
|
+
return hits[0]?.name ?? null;
|
|
60
|
+
};
|
|
61
|
+
const state = { locked: new Set(), public: new Set(), ownerChecked: new Set(), shared: new Set(), everyRouteChecked: false };
|
|
62
|
+
const report = () => ({
|
|
63
|
+
version: 1,
|
|
64
|
+
routes: declared().map((r) => r.name).sort(),
|
|
65
|
+
locked: [...state.locked].sort(),
|
|
66
|
+
public: [...state.public].sort(),
|
|
67
|
+
ownerChecked: [...state.ownerChecked].sort(),
|
|
68
|
+
shared: [...state.shared].sort(),
|
|
69
|
+
everyRouteChecked: state.everyRouteChecked,
|
|
70
|
+
});
|
|
71
|
+
const publish = async () => {
|
|
72
|
+
if (options.onReport)
|
|
73
|
+
await options.onReport(report());
|
|
74
|
+
};
|
|
75
|
+
const client = (user, token) => {
|
|
76
|
+
const request = async (method, path, body) => {
|
|
77
|
+
const headers = {};
|
|
78
|
+
if (token)
|
|
79
|
+
headers.authorization = `Bearer ${token}`;
|
|
80
|
+
if (body !== undefined)
|
|
81
|
+
headers["content-type"] = "application/json";
|
|
82
|
+
return app.request(path, { method, headers, body: body === undefined ? null : JSON.stringify(body) }, env);
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
user,
|
|
86
|
+
request,
|
|
87
|
+
get: (p) => request("GET", p),
|
|
88
|
+
post: (p, b) => request("POST", p, b),
|
|
89
|
+
put: (p, b) => request("PUT", p, b),
|
|
90
|
+
patch: (p, b) => request("PATCH", p, b),
|
|
91
|
+
delete: (p, b) => request("DELETE", p, b),
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
const anonymous = client(null, null);
|
|
95
|
+
return {
|
|
96
|
+
anonymous,
|
|
97
|
+
async as(name, opts = {}) {
|
|
98
|
+
const user = { id: `usr_test_${name}`, email: `${name}@example.test`, name, roles: opts.roles ?? [] };
|
|
99
|
+
const now = Math.floor(Date.now() / 1000);
|
|
100
|
+
const token = await sign({ sub: user.id, email: user.email, name: user.name, roles: user.roles, aud: TEST_PROJECT_ID, iat: now, exp: now + 3600 }, priv, "ES256");
|
|
101
|
+
return client(user, token);
|
|
102
|
+
},
|
|
103
|
+
routes: () => declared().map((r) => r.name).sort(),
|
|
104
|
+
async expectEveryRouteLocked(opts = {}) {
|
|
105
|
+
const open = new Set(opts.public ?? []);
|
|
106
|
+
const routes = declared();
|
|
107
|
+
const problems = [];
|
|
108
|
+
for (const name of open)
|
|
109
|
+
if (!routes.some((r) => r.name === name))
|
|
110
|
+
problems.push(`"${name}" is listed as public but the app has no such route. Remove it from the list.`);
|
|
111
|
+
for (const r of routes) {
|
|
112
|
+
const res = await anonymous.request(r.method, sample(r.path), r.method === "GET" || r.method === "DELETE" ? undefined : {});
|
|
113
|
+
const refused = res.status === 401 || res.status === 403;
|
|
114
|
+
if (open.has(r.name)) {
|
|
115
|
+
if (refused)
|
|
116
|
+
problems.push(`"${r.name}" is listed as public but refuses a signed-out visitor (${res.status}). Remove it from the list.`);
|
|
117
|
+
else
|
|
118
|
+
state.public.add(r.name);
|
|
119
|
+
}
|
|
120
|
+
else if (refused)
|
|
121
|
+
state.locked.add(r.name);
|
|
122
|
+
else
|
|
123
|
+
problems.push(`${r.name} answered ${res.status} to a signed-out visitor. Put requireAuth() (or requireRole) in front of it, before anything else runs. If everyone is meant to reach it, add "${r.name}" to the public list.`);
|
|
124
|
+
}
|
|
125
|
+
if (problems.length) {
|
|
126
|
+
await publish();
|
|
127
|
+
throw new Error(`Routes a stranger can reach:\n- ${problems.join("\n- ")}`);
|
|
128
|
+
}
|
|
129
|
+
state.everyRouteChecked = true;
|
|
130
|
+
await publish();
|
|
131
|
+
},
|
|
132
|
+
async expectHiddenFrom(other, method, path, body) {
|
|
133
|
+
if (!other.user)
|
|
134
|
+
throw new Error("expectHiddenFrom needs a signed-in user: the question is whether one person can reach another person's data. Use `await t.as(\"bob\")`.");
|
|
135
|
+
const res = await other.request(method, path, body);
|
|
136
|
+
if (res.status !== 403 && res.status !== 404) {
|
|
137
|
+
throw new Error(`${method} ${path} answered ${res.status} to ${other.user.name}, who does not own it. Filter the query by the signed-in user (where userId = c.var.user.id) so someone else's row is not found.`);
|
|
138
|
+
}
|
|
139
|
+
const name = nameFor(method, path);
|
|
140
|
+
if (name)
|
|
141
|
+
state.ownerChecked.add(name);
|
|
142
|
+
await publish();
|
|
143
|
+
},
|
|
144
|
+
async expectNotListed(other, path, needle) {
|
|
145
|
+
const res = await other.get(path);
|
|
146
|
+
const text = await res.text();
|
|
147
|
+
if (text.includes(needle))
|
|
148
|
+
throw new Error(`GET ${path} showed ${other.user?.name ?? "a signed-out visitor"} something that belongs to someone else. Filter the query by the signed-in user.`);
|
|
149
|
+
},
|
|
150
|
+
shared(route, reason) {
|
|
151
|
+
if (!reason.trim())
|
|
152
|
+
throw new Error("Say why the route is shared between users.");
|
|
153
|
+
if (!declared().some((r) => r.name === route))
|
|
154
|
+
throw new Error(`"${route}" is not a route of this app.`);
|
|
155
|
+
state.shared.add(route);
|
|
156
|
+
void publish();
|
|
157
|
+
},
|
|
158
|
+
report,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** The routes with an id in the path, which `nerd check` expects an owner test (or a `shared` declaration) for. */
|
|
162
|
+
export function routesWithId(report) {
|
|
163
|
+
return report.routes.filter((r) => hasId(r.slice(r.indexOf(" ") + 1)));
|
|
164
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nerdsystem/sdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.14",
|
|
4
4
|
"description": "Runtime for apps deployed on nerdsystem: auth, authorization, and platform bindings.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./testing": {
|
|
14
|
+
"types": "./dist/testing.d.ts",
|
|
15
|
+
"import": "./dist/testing.js"
|
|
12
16
|
}
|
|
13
17
|
},
|
|
14
18
|
"files": [
|