nerdsystem 0.0.12 → 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/dist/commands/admin.js +2 -0
- package/dist/commands/init.js +3 -3
- package/dist/commands/org.d.ts +1 -1
- package/dist/commands/org.js +14 -4
- package/dist/gates/index.js +2 -1
- package/dist/gates/rules.js +7 -0
- package/dist/gates/tests.d.ts +14 -0
- package/dist/gates/tests.js +103 -0
- package/package.json +5 -1
- package/template/.claude/skills/nerdsystem/SKILL.md +37 -5
- package/template/.cursor/rules/nerdsystem.mdc +38 -6
- package/template/.github/copilot-instructions.md +38 -6
- package/template/.nerd/access-report.json +29 -0
- package/template/AGENTS.md +38 -6
- package/template/CLAUDE.md +3 -1
- package/template/GEMINI.md +38 -6
- package/template/migrations/meta/0000_snapshot.json +64 -0
- package/template/migrations/meta/_journal.json +13 -0
- package/template/nerd.json +2 -1
- package/template/package.json +2 -0
- package/template/test/access.test.ts +42 -0
- package/template/test/global-setup.ts +7 -0
- package/template/test/setup.ts +40 -0
- package/template/test/todos.test.ts +31 -0
- package/template/vitest.config.ts +16 -0
package/dist/commands/admin.js
CHANGED
|
@@ -25,6 +25,8 @@ export async function adminSuspend(opts) {
|
|
|
25
25
|
if (opts.json)
|
|
26
26
|
return printJson(r);
|
|
27
27
|
log.ok(`Suspended ${r.org} at ${r.suspendedAt}: ${r.projectsTrashed} project(s) trashed, ${r.usersSuspended} user(s) suspended, ${r.tokensRevoked} token(s) revoked.`);
|
|
28
|
+
if (r.stillBilling)
|
|
29
|
+
log.warn(`Stripe subscription ${r.stillBilling} is still billing, and the owner can no longer reach the portal. Cancel or refund it in the Stripe dashboard.`);
|
|
28
30
|
}
|
|
29
31
|
export async function adminUnsuspend(opts) {
|
|
30
32
|
if (!opts.org)
|
package/dist/commands/init.js
CHANGED
|
@@ -29,7 +29,7 @@ async function pickOrg(api, wanted) {
|
|
|
29
29
|
return me.orgs[0].slug;
|
|
30
30
|
if (me.orgs.length === 0)
|
|
31
31
|
throw new CliError("You have no orgs. Create one in the dashboard first.");
|
|
32
|
-
throw new CliError(`Pick an org with --org. Yours: ${me.orgs.map((o) => o.slug).join(", ")}
|
|
32
|
+
throw new CliError(`Pick an org with --org. Yours: ${me.orgs.map((o) => o.slug).join(", ")}`, 1, { code: "org_required" });
|
|
33
33
|
}
|
|
34
34
|
/** Creates the remote project and writes nerd.json. Shared by init and link. */
|
|
35
35
|
export async function linkProject(root, args) {
|
|
@@ -171,7 +171,7 @@ export async function init(dir, args) {
|
|
|
171
171
|
const reason = err instanceof Error ? err.message : String(err);
|
|
172
172
|
const code = err instanceof CliError && err.code ? err.code : "error";
|
|
173
173
|
// A plan limit is not something to fix in the folder: the API's message says how to free a slot.
|
|
174
|
-
const link = `cd ${dir} && nerd link --slug ${slug}${args.org ? ` --org ${args.org}` : ""}`;
|
|
174
|
+
const link = `cd ${dir} && nerd link --slug ${slug}${args.org ? ` --org ${args.org}` : code === "org_required" ? " --org <one of yours>" : ""}`;
|
|
175
175
|
const next = code === "plan_limit" ? `When a slot is free, run: ${link}` : `Fix that, then run: ${link}`;
|
|
176
176
|
const message = `The folder is ready but the project was not created: ${reason}`;
|
|
177
177
|
// Same pattern as `nerd check --json`: the JSON goes to stdout, the exit code stays non-zero.
|
|
@@ -183,4 +183,4 @@ export async function init(dir, args) {
|
|
|
183
183
|
log.ok(`Ready. Next:\n cd ${dir}\n pnpm install\n nerd dev\n\nThen open the folder in your coding agent and ask it to build the first feature; the nerdsystem skill walks it through.\nDeploys go to ${project.url}`);
|
|
184
184
|
}
|
|
185
185
|
// Kept in sync with packages/sdk/package.json by the release script.
|
|
186
|
-
const SDK_VERSION = "0.0.
|
|
186
|
+
const SDK_VERSION = "0.0.14";
|
package/dist/commands/org.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export declare function domainsList(opts: {
|
|
|
17
17
|
export declare function domainsAdd(hostname: string | undefined, opts: {
|
|
18
18
|
json: boolean;
|
|
19
19
|
}): Promise<void>;
|
|
20
|
-
export declare function domainsRemove(
|
|
20
|
+
export declare function domainsRemove(idOrHostname: string | undefined, opts: {
|
|
21
21
|
json: boolean;
|
|
22
22
|
}): Promise<void>;
|
|
23
23
|
export declare function logs(opts: {
|
package/dist/commands/org.js
CHANGED
|
@@ -54,11 +54,21 @@ export async function domainsAdd(hostname, opts) {
|
|
|
54
54
|
return printJson(r);
|
|
55
55
|
log.ok(`Added ${r.domain.hostname}. Create this DNS record, then check with \`nerd domains\`:\n\n ${r.domain.hostname} CNAME ${r.cnameTarget}`);
|
|
56
56
|
}
|
|
57
|
-
export async function domainsRemove(
|
|
58
|
-
if (!
|
|
59
|
-
throw new CliError("Usage: nerd domains remove <
|
|
57
|
+
export async function domainsRemove(idOrHostname, opts) {
|
|
58
|
+
if (!idOrHostname)
|
|
59
|
+
throw new CliError("Usage: nerd domains remove <hostname or domain id>");
|
|
60
60
|
const nerd = readNerdJson(findProjectRoot());
|
|
61
|
-
const
|
|
61
|
+
const api = ApiClient.fromConfig();
|
|
62
|
+
// People (and agents) pass the hostname they added; the API deletes by id, so look it up.
|
|
63
|
+
let id = idOrHostname;
|
|
64
|
+
if (!idOrHostname.startsWith("dom_")) {
|
|
65
|
+
const { domains } = await api.get(projectPath(nerd, "/domains"));
|
|
66
|
+
const match = domains.find((d) => d.hostname === idOrHostname.toLowerCase());
|
|
67
|
+
if (!match)
|
|
68
|
+
throw new CliError(`No custom domain ${idOrHostname} on this project. \`nerd domains\` lists them.`);
|
|
69
|
+
id = match.id;
|
|
70
|
+
}
|
|
71
|
+
const r = await api.delete(projectPath(nerd, `/domains/${id}`));
|
|
62
72
|
if (opts.json)
|
|
63
73
|
return printJson(r);
|
|
64
74
|
log.ok("Removed.");
|
package/dist/gates/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { bundleGate, finding, secretsGate } from "./secrets.js";
|
|
|
2
2
|
import { depsGate, piiGate, policyGate, rulesGate, typecheckGate } from "./code.js";
|
|
3
3
|
import { gitleaksGate, semgrepGate } from "./tools.js";
|
|
4
4
|
import { suppressionsGate } from "./suppressions.js";
|
|
5
|
+
import { testsGate } from "./tests.js";
|
|
5
6
|
import { applyAttempts, MAX_ATTEMPTS } from "./attempts.js";
|
|
6
7
|
export { gateSummary, skippedGates } from "./types.js";
|
|
7
8
|
export { RULES, ruleFor } from "./rules.js";
|
|
@@ -9,7 +10,7 @@ export { mask } from "./secrets.js";
|
|
|
9
10
|
export { MAX_ATTEMPTS, readAttempts } from "./attempts.js";
|
|
10
11
|
export { probeDeployment, skippedProbe, waitForDeployment } from "./smoke.js";
|
|
11
12
|
/** Gates that run on source, before the build. Suppressions first so a human sees them before anything else. */
|
|
12
|
-
export const SOURCE_GATES = [suppressionsGate, secretsGate, gitleaksGate, depsGate, rulesGate, semgrepGate, policyGate, piiGate, typecheckGate];
|
|
13
|
+
export const SOURCE_GATES = [suppressionsGate, secretsGate, gitleaksGate, depsGate, rulesGate, semgrepGate, policyGate, piiGate, typecheckGate, testsGate];
|
|
13
14
|
/** Gates that need the built output. */
|
|
14
15
|
export const BUILD_GATES = [bundleGate, policyGate];
|
|
15
16
|
function selectGates(gates, onlyRules) {
|
package/dist/gates/rules.js
CHANGED
|
@@ -36,6 +36,13 @@ export const RULES = {
|
|
|
36
36
|
"rules/advisory": { title: "Platform advisory", severity: "warn", audience: "agent", risk: "Not blocking, but the project is missing something the platform expects.", fix: "Follow the instruction in the finding." },
|
|
37
37
|
"rules/shape": { title: "Not in the nerdsystem shape", severity: "warn", audience: "human", risk: "Every app on nerdsystem is one shape: a Vite client, a Hono server on @nerdsystem/sdk, SQLite on D1, and a bucket. This repository is not in that shape. A clean scan says nothing about whether it would deploy here.", fix: "Start a project with `nerd init` and have the agent port the screens into it. The server and the login are rebuilt on the platform's sign-in and database. The compare pages on nerdsystem.com say what carries over." },
|
|
38
38
|
"typecheck/error": { title: "TypeScript error", severity: "block", audience: "agent", risk: "The code would not compile reliably; the build may fail or behave unexpectedly.", fix: "Fix the type error at the file and line shown. Run `npx tsc -b` for the full message." },
|
|
39
|
+
"tests/missing": { title: "No access tests", severity: "warn", audience: "agent", risk: "Nothing checks that a signed-out visitor is refused, or that one user cannot reach another user's data. Those are the two mistakes that expose people's records, and nobody looking at the screen can see them.", fix: "Add the access tests: update @nerdsystem/sdk to the latest version, add vitest as a dev dependency with a `test` script (`vitest run`), and copy `test/setup.ts`, `test/global-setup.ts`, `test/access.test.ts`, and `vitest.config.ts` from a fresh `npx nerdsystem init` app. Then write one owner test for each route with an id in its path, set `\"tests\": \"required\"` in nerd.json, and run `nerd check`." },
|
|
40
|
+
"tests/required-missing": { title: "Access tests are missing", severity: "block", audience: "agent", risk: "This app is set to require access tests and has none, so nothing checks that a signed-out visitor is refused or that one user cannot reach another user's data.", fix: "Restore `test/access.test.ts` and the `test` script (`vitest run`) in package.json. Do not remove `\"tests\": \"required\"` from nerd.json to get past this." },
|
|
41
|
+
"tests/not-installed": { title: "Dependencies are not installed", severity: "block", audience: "agent", risk: "The tests cannot run, so nothing has been checked.", fix: "Run `pnpm install`, then `nerd check` again." },
|
|
42
|
+
"tests/failing": { title: "A test fails", severity: "block", audience: "agent", risk: "The app does not do what its tests say it should, or lets someone reach something they should not.", fix: "Run `pnpm test` and read which test failed and why. Fix the app's code. Change a test only when the behaviour it describes was meant to change, and never weaken an access test to make it pass." },
|
|
43
|
+
"tests/routes-not-checked": { title: "No test tried every route as a signed-out visitor", severity: "block", audience: "agent", risk: "A route added without a sign-in check would go live readable by anyone.", fix: "Keep the test in `test/access.test.ts` that calls `await t.expectEveryRouteLocked({ public: [...] })`. It covers every route automatically; list a route as public only when everyone on the internet is meant to reach it." },
|
|
44
|
+
"tests/owner-not-checked": { title: "A route with an id was not tried by a second user", severity: "block", audience: "agent", risk: "One signed-in user may be able to read, change, or delete another user's data by guessing its id.", fix: "Add an owner test for the route in `test/access.test.ts` using `t.expectHiddenFrom`.", example: { wrong: "// no test: nothing shows a second user is refused on /api/notes/:id", right: 'const note = await (await alice.post("/api/notes", { title: "mine" })).json();\nawait t.expectHiddenFrom(bob, "DELETE", `/api/notes/${note.note.id}`);' } },
|
|
45
|
+
"tests/harness-in-app": { title: "The test harness is imported by app code", severity: "block", audience: "agent", risk: "The test harness can sign in as anyone. In deployed code it would be a way around the login.", fix: "Remove the import of `@nerdsystem/sdk/testing` from files under `src/`. It belongs only in `test/`." },
|
|
39
46
|
"typecheck/failed": { title: "Typecheck failed", severity: "block", audience: "agent", risk: "The project does not compile.", fix: "Run `npx tsc -b` and fix the errors it prints." },
|
|
40
47
|
"policy/blocked-package": { title: "Package not allowed", severity: "block", audience: "agent", risk: "The package is a miner, proxy, or headless browser, which the acceptable use policy forbids and the platform cannot run.", fix: "Remove the package and the code that uses it. If the app genuinely needs a browser or scraping, say so to the person; it is not supported here." },
|
|
41
48
|
"policy/mining-pool": { title: "Mining pool reference", severity: "block", audience: "human", risk: "The code references a cryptocurrency mining pool, which is forbidden by the acceptable use policy.", fix: "Remove it. If this is unexpected, inspect the file: it may have been added by a compromised dependency." },
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Gate } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Route names come from the app's own code, and findings are read by a coding agent, so only the
|
|
4
|
+
* characters a "METHOD /path" needs survive and the length is capped: a route cannot carry a sentence.
|
|
5
|
+
*/
|
|
6
|
+
export declare function safeRoute(name: unknown): string | null;
|
|
7
|
+
/**
|
|
8
|
+
* Runs the app's own tests and reads what they checked about access. The person who owns the app cannot
|
|
9
|
+
* read the code, and the agent that forgot a sign-in check tends to forget the test for it too, so the
|
|
10
|
+
* gate does not trust that tests exist: the harness calls every route the app declares, and this gate
|
|
11
|
+
* refuses the deploy unless that sweep ran, every route with an id in its path was tried by a second user,
|
|
12
|
+
* and everything passed.
|
|
13
|
+
*/
|
|
14
|
+
export declare const testsGate: Gate;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { exec } from "./code.js";
|
|
4
|
+
import { sourceFiles } from "./fs.js";
|
|
5
|
+
import { finding } from "./secrets.js";
|
|
6
|
+
import { result } from "./types.js";
|
|
7
|
+
const HARNESS = "@nerdsystem/sdk/testing";
|
|
8
|
+
const REPORT = ".nerd/access-report.json";
|
|
9
|
+
/**
|
|
10
|
+
* Route names come from the app's own code, and findings are read by a coding agent, so only the
|
|
11
|
+
* characters a "METHOD /path" needs survive and the length is capped: a route cannot carry a sentence.
|
|
12
|
+
*/
|
|
13
|
+
export function safeRoute(name) {
|
|
14
|
+
if (typeof name !== "string")
|
|
15
|
+
return null;
|
|
16
|
+
const m = /^(GET|POST|PUT|PATCH|DELETE) (\/[A-Za-z0-9/:_.*-]{0,120})$/.exec(name);
|
|
17
|
+
return m ? `${m[1]} ${m[2]}` : null;
|
|
18
|
+
}
|
|
19
|
+
const list = (v) => (Array.isArray(v) ? v.map(safeRoute).filter((r) => r !== null) : []);
|
|
20
|
+
const hasId = (route) => route.split(" ")[1].split("/").some((seg) => seg.startsWith(":"));
|
|
21
|
+
function readJson(file) {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** True when a file under test/ uses the SDK's harness, which is what writes the access report. */
|
|
30
|
+
function hasHarnessTests(root) {
|
|
31
|
+
const dir = path.join(root, "test");
|
|
32
|
+
if (!existsSync(dir))
|
|
33
|
+
return false;
|
|
34
|
+
const walk = (d) => readdirSync(d, { withFileTypes: true }).some((e) => (e.isDirectory() ? walk(path.join(d, e.name)) : /\.(ts|tsx|mts|js|mjs)$/.test(e.name) && readFileSync(path.join(d, e.name), "utf8").includes(HARNESS)));
|
|
35
|
+
return walk(dir);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Runs the app's own tests and reads what they checked about access. The person who owns the app cannot
|
|
39
|
+
* read the code, and the agent that forgot a sign-in check tends to forget the test for it too, so the
|
|
40
|
+
* gate does not trust that tests exist: the harness calls every route the app declares, and this gate
|
|
41
|
+
* refuses the deploy unless that sweep ran, every route with an id in its path was tried by a second user,
|
|
42
|
+
* and everything passed.
|
|
43
|
+
*/
|
|
44
|
+
export const testsGate = {
|
|
45
|
+
name: "tests",
|
|
46
|
+
title: "Tests and access checks",
|
|
47
|
+
blocking: true,
|
|
48
|
+
rulePrefixes: ["tests/"],
|
|
49
|
+
async run(ctx) {
|
|
50
|
+
const t = Date.now();
|
|
51
|
+
const findings = [];
|
|
52
|
+
// The harness can sign in anyone. It must never be reachable from the code that is deployed.
|
|
53
|
+
for (const f of sourceFiles(ctx.root)) {
|
|
54
|
+
if (f.rel.startsWith("src/") && f.text.includes(HARNESS))
|
|
55
|
+
findings.push(finding("tests/harness-in-app", { file: f.rel }));
|
|
56
|
+
}
|
|
57
|
+
if (findings.length)
|
|
58
|
+
return result(this, "fail", "The test harness is imported by app code", findings, t);
|
|
59
|
+
const pkg = readJson(path.join(ctx.root, "package.json"));
|
|
60
|
+
const required = readJson(path.join(ctx.root, "nerd.json"))?.tests === "required";
|
|
61
|
+
if (!pkg?.scripts?.test || !hasHarnessTests(ctx.root)) {
|
|
62
|
+
const f = finding(required ? "tests/required-missing" : "tests/missing", { file: "test/access.test.ts" });
|
|
63
|
+
return result(this, required ? "fail" : "warn", "No access tests in this app", [f], t);
|
|
64
|
+
}
|
|
65
|
+
// Git builds do not run this yet (the build container has not been proven to run the local database);
|
|
66
|
+
// the terminal path, which every `nerd deploy` takes, does.
|
|
67
|
+
if (ctx.committed)
|
|
68
|
+
return result(this, "skip", "Runs before `nerd deploy`; not yet part of git builds", [], t);
|
|
69
|
+
if (!existsSync(path.join(ctx.root, "node_modules"))) {
|
|
70
|
+
return result(this, "fail", "Dependencies are not installed", [finding("tests/not-installed", {})], t);
|
|
71
|
+
}
|
|
72
|
+
rmSync(path.join(ctx.root, REPORT), { force: true });
|
|
73
|
+
const run = await exec("npx", ["vitest", "run"], ctx.root);
|
|
74
|
+
if (run.code !== 0) {
|
|
75
|
+
const files = new Set();
|
|
76
|
+
for (const line of `${run.stdout}\n${run.stderr}`.split("\n")) {
|
|
77
|
+
const m = /FAIL\s+(test\/[A-Za-z0-9_./-]{1,120}\.test\.[a-z]{2,3})/.exec(line);
|
|
78
|
+
if (m)
|
|
79
|
+
files.add(m[1]);
|
|
80
|
+
}
|
|
81
|
+
for (const file of [...files].slice(0, 10))
|
|
82
|
+
findings.push(finding("tests/failing", { file }));
|
|
83
|
+
if (!findings.length)
|
|
84
|
+
findings.push(finding("tests/failing", {}));
|
|
85
|
+
return result(this, "fail", `${findings.length} test file(s) failing`, findings, t, "vitest");
|
|
86
|
+
}
|
|
87
|
+
const report = readJson(path.join(ctx.root, REPORT));
|
|
88
|
+
const routes = list(report?.routes);
|
|
89
|
+
const open = list(report?.public);
|
|
90
|
+
if (!report || report.everyRouteChecked !== true) {
|
|
91
|
+
return result(this, "fail", "No test checked every route as a signed-out visitor", [finding("tests/routes-not-checked", { file: "test/access.test.ts" })], t, "vitest");
|
|
92
|
+
}
|
|
93
|
+
const covered = new Set([...list(report.ownerChecked), ...list(report.shared), ...open]);
|
|
94
|
+
for (const route of routes.filter(hasId)) {
|
|
95
|
+
if (!covered.has(route))
|
|
96
|
+
findings.push(finding("tests/owner-not-checked", { file: "test/access.test.ts", fix: `Add a test for ${route}: one user creates the thing, then \`await t.expectHiddenFrom(otherUser, "${route.split(" ")[0]}", <the path with the real id>)\`. If every signed-in user is meant to reach it, declare \`t.shared("${route}", "<why>")\` instead.`, fingerprint: `tests/owner-not-checked:${route}` }));
|
|
97
|
+
}
|
|
98
|
+
if (findings.length)
|
|
99
|
+
return result(this, "fail", `${findings.length} route(s) with an id were not tried by a second user`, findings, t, "vitest");
|
|
100
|
+
const locked = routes.length - open.length;
|
|
101
|
+
return result(this, "pass", `Tests passed; ${locked} route(s) refuse a signed-out visitor, ${open.length} public${open.length ? ` (${open.join(", ")})` : ""}`, [], t, "vitest");
|
|
102
|
+
},
|
|
103
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nerdsystem",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.14",
|
|
4
4
|
"description": "nerd: start, check, and deploy a web app on nerdsystem from the terminal or a coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
"directory": "packages/cli"
|
|
41
41
|
},
|
|
42
42
|
"homepage": "https://nerdsystem.com",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://nerdsystem.com/about",
|
|
45
|
+
"email": "support@nerdsystem.com"
|
|
46
|
+
},
|
|
43
47
|
"keywords": [
|
|
44
48
|
"cloudflare",
|
|
45
49
|
"deploy",
|
|
@@ -33,8 +33,8 @@ Tell the person plainly what does not carry. Their users sign up again on the ne
|
|
|
33
33
|
|
|
34
34
|
## The loop
|
|
35
35
|
|
|
36
|
-
1. Make the change.
|
|
37
|
-
2. `nerd check` (static rules, then
|
|
36
|
+
1. Make the change, and write or update its tests in the same step (see "Build it so it is safe and it works").
|
|
37
|
+
2. `nerd check` (static rules, typecheck, then the tests and access checks). Fix anything it reports.
|
|
38
38
|
3. `nerd dev` to run locally when you need to try it. The local app talks to the real auth service, so signup and login work exactly as in production.
|
|
39
39
|
4. `nerd deploy` when it is ready. It runs the checks, builds, stores a copy of the code, applies pending migrations, uploads, prints the URL, and probes the live URL (headers and the sign-in routes). With `--json` the output carries `probes`, the same shape as a check result, with `status: "skip"` and the reason when it did not run; probe findings never fail the deploy.
|
|
40
40
|
5. If something is wrong after deploying: `nerd logs` shows recent output from the deployed app (`--q` to search, `--pr 12` for a preview), `nerd deployments` shows status and errors, `nerd db query "..."` inspects production data.
|
|
@@ -95,13 +95,44 @@ export default notes;
|
|
|
95
95
|
|
|
96
96
|
Then in `src/server/index.ts`: `app.route("/api/notes", notes);`
|
|
97
97
|
|
|
98
|
+
## Build it so it is safe and it works
|
|
99
|
+
|
|
100
|
+
The person you are building for usually cannot read code. They will not spot a missing sign-in check or a query that returns someone else's rows, and neither will anyone looking at the screen. So the standard is yours to hold, every time, without being asked:
|
|
101
|
+
|
|
102
|
+
1. **Lock every route first.** Put `requireAuth()` or `requireRole(...)` in front of every route as middleware, before any input is read (`router.use("*", requireAuth())` for a whole router). A route is public only when everyone on the internet is meant to reach it and it returns nothing private; then add it to the `public` list in `test/access.test.ts` and tell the person in plain words that it is open.
|
|
103
|
+
2. **Scope every query to the signed-in user.** Reads, updates, and deletes on user data filter on `c.var.user.id`. On a route with an id in its path, put the id and the user id in the same `where`, and answer 404 when nothing matches, so someone else's row looks like it does not exist.
|
|
104
|
+
3. **Take identity from the session, never from the request.** The user id, the role, a price, an owner: none of these come from the body, the query string, or a header the browser sends. Read the user from `c.var.user` and look the rest up on the server.
|
|
105
|
+
4. **Check every input on the server.** Type, length, range, and allowed values, for every field of every body, query, and path parameter. Answer 400 with the usual error shape. The client's own checks are for the person's convenience; they protect nothing.
|
|
106
|
+
5. **Hiding a button is not access control.** If only admins may do something, the route says `requireRole("admin")`. What the page shows is decoration.
|
|
107
|
+
6. **Files:** check the type and the size before writing to `c.env.STORAGE`, store under a key that starts with the owner's user id, and serve them through a route that checks the owner. Never build a key from a name the browser sent without cleaning it.
|
|
108
|
+
7. **Say nothing useful in an error.** No stack traces, SQL, or internal ids in responses; log them with `console.error` and answer with `{ error: { code, message } }`.
|
|
109
|
+
8. **No raw HTML from data.** Let React render text. Do not use `dangerouslySetInnerHTML` or `innerHTML` with anything a user typed.
|
|
110
|
+
|
|
111
|
+
### Tests are part of the feature
|
|
112
|
+
|
|
113
|
+
The project ships with a test harness (`test/setup.ts`, from `@nerdsystem/sdk/testing`) that runs the real server code against a fresh local database and signs in made-up users. `pnpm test` runs it; `nerd check` and `nerd deploy` run it too and refuse the deploy when:
|
|
114
|
+
|
|
115
|
+
- a test fails (`tests/failing`);
|
|
116
|
+
- the test that calls `t.expectEveryRouteLocked({ public: [...] })` is gone (`tests/routes-not-checked`). That one call tries every route the app has as a signed-out visitor, so a new route is covered the moment it exists and fails until it is locked or deliberately listed as public;
|
|
117
|
+
- a route with an id in its path has not been tried by a second user (`tests/owner-not-checked`).
|
|
118
|
+
|
|
119
|
+
For every feature you build:
|
|
120
|
+
|
|
121
|
+
- **One owner test per route with an id**, in `test/access.test.ts`: one user creates the thing, then `await t.expectHiddenFrom(otherUser, "PATCH", `/api/things/${id}`, body)` for each method. For lists, `await t.expectNotListed(otherUser, "/api/things", "a title only the owner has")`. If every signed-in user is truly meant to reach it (a shared catalogue, a public profile), declare `t.shared("GET /api/things/:id", "why")` instead and tell the person.
|
|
122
|
+
- **One test file per feature** (`test/<feature>.test.ts`, like `test/todos.test.ts`): the main path a person takes from start to finish, and the inputs it must refuse.
|
|
123
|
+
- **A role test** for anything behind `requireRole`: a signed-in user without the role gets 403, one with it gets through (`await t.as("dana", { roles: ["admin"] })`).
|
|
124
|
+
|
|
125
|
+
When a test fails, fix the app. Change a test only when the behaviour it describes was meant to change. Never delete or weaken an access test, widen the `public` list, add `t.shared`, or remove `"tests": "required"` from `nerd.json` to get a deploy through; if you believe one of those is right, stop and ask the person in plain words first. Never import `@nerdsystem/sdk/testing` from anything under `src/`: it can sign in as anyone, and `nerd check` refuses it.
|
|
126
|
+
|
|
127
|
+
What the tests do not prove: that the feature is the one the person wanted, or that its rules (who may book which slot, what a discount applies to) are right. Say what you built and what is public when you hand it over, so they can tell you if it is wrong.
|
|
128
|
+
|
|
98
129
|
## Data rules
|
|
99
130
|
|
|
100
131
|
- Every table holding user data has a `user_id` text column, and every read and write filters on `c.var.user.id`. This is how users are kept apart.
|
|
101
132
|
- Schema changes are migrations. Edit `schema.ts`, run `pnpm db:generate`, commit the new file in `migrations/`. Never hand-write SQL into production; `nerd deploy` applies migrations in name order and records which ran.
|
|
102
133
|
- Migrations are forward-only. To undo, write another migration.
|
|
103
134
|
- Prefer small, additive migrations. SQLite cannot alter columns in place.
|
|
104
|
-
- `nerd db export` downloads a SQL dump of the production database (`<slug>-<date>.sql`, or `--out file.sql`); use it before a risky migration or when the person wants their data.
|
|
135
|
+
- `nerd db export` downloads a SQL dump of the production database (`<slug>-<date>.sql`, or `--out file.sql`); use it before a risky migration or when the person wants their data. The live app's database requests wait while it runs, so run it at a quiet moment.
|
|
105
136
|
|
|
106
137
|
## Errors
|
|
107
138
|
|
|
@@ -130,12 +161,13 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
130
161
|
- New bindings in `wrangler.jsonc`. Only `DB`, `STORAGE`, and `ASSETS` exist. Durable Objects are not available.
|
|
131
162
|
- Scripts that call `wrangler deploy`, `wrangler secret`, or remote `wrangler d1` commands.
|
|
132
163
|
- Typecheck failures in either `src/server` or `src/client`.
|
|
164
|
+
- A failing test, a missing access sweep, or a route with an id that no second user was tried against (the `tests/` rules above).
|
|
133
165
|
|
|
134
166
|
## Scheduled jobs and email
|
|
135
167
|
|
|
136
168
|
- **Scheduled jobs**: put the work in the `POST /api/cron` handler (already in `src/server/index.ts`, guarded by `verifyCron()`), then turn the schedule on with `nerd cron hourly` or `nerd cron daily`. The platform calls it; the app never needs its own timer.
|
|
137
|
-
- **Secrets** (Stripe keys, API tokens): never in code or in the client. Locally they live in `.dev.vars` (gitignored). For production, ask the person to run `nerd secret set NAME` and paste the value; it reaches the app as `c.env.NAME` on the next deploy. Pull request previews do not get it unless it was set with `--previews` (use that only for keys that are safe in a preview, such as a test-mode key). `nerd secret list` shows names only. Do not put a real secret value in any command you run yourself.
|
|
138
|
-
- **Email**: `sendEmail(c.env, { to, subject, text, html? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price.
|
|
169
|
+
- **Secrets** (Stripe keys, API tokens): never in code or in the client. Locally they live in `.dev.vars` (gitignored). For production, ask the person to add it under the app's Settings tab in the dashboard, or to run `nerd secret set NAME` and paste the value; it reaches the app as `c.env.NAME` on the next deploy. Pull request previews do not get it unless it was set with `--previews` (use that only for keys that are safe in a preview, such as a test-mode key). `nerd secret list` shows names only. Do not put a real secret value in any command you run yourself.
|
|
170
|
+
- **Email**: `sendEmail(c.env, { to, subject, text, html? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. On the Free plan the recipient must also have confirmed their address by finishing a password reset (403 otherwise); paid plans can write to every signed-up user. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price.
|
|
139
171
|
|
|
140
172
|
## Debugging production
|
|
141
173
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
description: How to build, run, and deploy this app on nerdsystem. Applies to server, database, auth, and deploy work.
|
|
3
3
|
alwaysApply: true
|
|
4
4
|
---
|
|
5
|
-
<!-- generated from .claude/skills/nerdsystem/SKILL.md (
|
|
5
|
+
<!-- generated from .claude/skills/nerdsystem/SKILL.md (37f79c2cf2a8); edit the skill, then run: nerd agents sync -->
|
|
6
6
|
|
|
7
7
|
# Building on nerdsystem
|
|
8
8
|
|
|
@@ -34,8 +34,8 @@ Tell the person plainly what does not carry. Their users sign up again on the ne
|
|
|
34
34
|
|
|
35
35
|
## The loop
|
|
36
36
|
|
|
37
|
-
1. Make the change.
|
|
38
|
-
2. `nerd check` (static rules, then
|
|
37
|
+
1. Make the change, and write or update its tests in the same step (see "Build it so it is safe and it works").
|
|
38
|
+
2. `nerd check` (static rules, typecheck, then the tests and access checks). Fix anything it reports.
|
|
39
39
|
3. `nerd dev` to run locally when you need to try it. The local app talks to the real auth service, so signup and login work exactly as in production.
|
|
40
40
|
4. `nerd deploy` when it is ready. It runs the checks, builds, stores a copy of the code, applies pending migrations, uploads, prints the URL, and probes the live URL (headers and the sign-in routes). With `--json` the output carries `probes`, the same shape as a check result, with `status: "skip"` and the reason when it did not run; probe findings never fail the deploy.
|
|
41
41
|
5. If something is wrong after deploying: `nerd logs` shows recent output from the deployed app (`--q` to search, `--pr 12` for a preview), `nerd deployments` shows status and errors, `nerd db query "..."` inspects production data.
|
|
@@ -96,13 +96,44 @@ export default notes;
|
|
|
96
96
|
|
|
97
97
|
Then in `src/server/index.ts`: `app.route("/api/notes", notes);`
|
|
98
98
|
|
|
99
|
+
## Build it so it is safe and it works
|
|
100
|
+
|
|
101
|
+
The person you are building for usually cannot read code. They will not spot a missing sign-in check or a query that returns someone else's rows, and neither will anyone looking at the screen. So the standard is yours to hold, every time, without being asked:
|
|
102
|
+
|
|
103
|
+
1. **Lock every route first.** Put `requireAuth()` or `requireRole(...)` in front of every route as middleware, before any input is read (`router.use("*", requireAuth())` for a whole router). A route is public only when everyone on the internet is meant to reach it and it returns nothing private; then add it to the `public` list in `test/access.test.ts` and tell the person in plain words that it is open.
|
|
104
|
+
2. **Scope every query to the signed-in user.** Reads, updates, and deletes on user data filter on `c.var.user.id`. On a route with an id in its path, put the id and the user id in the same `where`, and answer 404 when nothing matches, so someone else's row looks like it does not exist.
|
|
105
|
+
3. **Take identity from the session, never from the request.** The user id, the role, a price, an owner: none of these come from the body, the query string, or a header the browser sends. Read the user from `c.var.user` and look the rest up on the server.
|
|
106
|
+
4. **Check every input on the server.** Type, length, range, and allowed values, for every field of every body, query, and path parameter. Answer 400 with the usual error shape. The client's own checks are for the person's convenience; they protect nothing.
|
|
107
|
+
5. **Hiding a button is not access control.** If only admins may do something, the route says `requireRole("admin")`. What the page shows is decoration.
|
|
108
|
+
6. **Files:** check the type and the size before writing to `c.env.STORAGE`, store under a key that starts with the owner's user id, and serve them through a route that checks the owner. Never build a key from a name the browser sent without cleaning it.
|
|
109
|
+
7. **Say nothing useful in an error.** No stack traces, SQL, or internal ids in responses; log them with `console.error` and answer with `{ error: { code, message } }`.
|
|
110
|
+
8. **No raw HTML from data.** Let React render text. Do not use `dangerouslySetInnerHTML` or `innerHTML` with anything a user typed.
|
|
111
|
+
|
|
112
|
+
### Tests are part of the feature
|
|
113
|
+
|
|
114
|
+
The project ships with a test harness (`test/setup.ts`, from `@nerdsystem/sdk/testing`) that runs the real server code against a fresh local database and signs in made-up users. `pnpm test` runs it; `nerd check` and `nerd deploy` run it too and refuse the deploy when:
|
|
115
|
+
|
|
116
|
+
- a test fails (`tests/failing`);
|
|
117
|
+
- the test that calls `t.expectEveryRouteLocked({ public: [...] })` is gone (`tests/routes-not-checked`). That one call tries every route the app has as a signed-out visitor, so a new route is covered the moment it exists and fails until it is locked or deliberately listed as public;
|
|
118
|
+
- a route with an id in its path has not been tried by a second user (`tests/owner-not-checked`).
|
|
119
|
+
|
|
120
|
+
For every feature you build:
|
|
121
|
+
|
|
122
|
+
- **One owner test per route with an id**, in `test/access.test.ts`: one user creates the thing, then `await t.expectHiddenFrom(otherUser, "PATCH", `/api/things/${id}`, body)` for each method. For lists, `await t.expectNotListed(otherUser, "/api/things", "a title only the owner has")`. If every signed-in user is truly meant to reach it (a shared catalogue, a public profile), declare `t.shared("GET /api/things/:id", "why")` instead and tell the person.
|
|
123
|
+
- **One test file per feature** (`test/<feature>.test.ts`, like `test/todos.test.ts`): the main path a person takes from start to finish, and the inputs it must refuse.
|
|
124
|
+
- **A role test** for anything behind `requireRole`: a signed-in user without the role gets 403, one with it gets through (`await t.as("dana", { roles: ["admin"] })`).
|
|
125
|
+
|
|
126
|
+
When a test fails, fix the app. Change a test only when the behaviour it describes was meant to change. Never delete or weaken an access test, widen the `public` list, add `t.shared`, or remove `"tests": "required"` from `nerd.json` to get a deploy through; if you believe one of those is right, stop and ask the person in plain words first. Never import `@nerdsystem/sdk/testing` from anything under `src/`: it can sign in as anyone, and `nerd check` refuses it.
|
|
127
|
+
|
|
128
|
+
What the tests do not prove: that the feature is the one the person wanted, or that its rules (who may book which slot, what a discount applies to) are right. Say what you built and what is public when you hand it over, so they can tell you if it is wrong.
|
|
129
|
+
|
|
99
130
|
## Data rules
|
|
100
131
|
|
|
101
132
|
- Every table holding user data has a `user_id` text column, and every read and write filters on `c.var.user.id`. This is how users are kept apart.
|
|
102
133
|
- Schema changes are migrations. Edit `schema.ts`, run `pnpm db:generate`, commit the new file in `migrations/`. Never hand-write SQL into production; `nerd deploy` applies migrations in name order and records which ran.
|
|
103
134
|
- Migrations are forward-only. To undo, write another migration.
|
|
104
135
|
- Prefer small, additive migrations. SQLite cannot alter columns in place.
|
|
105
|
-
- `nerd db export` downloads a SQL dump of the production database (`<slug>-<date>.sql`, or `--out file.sql`); use it before a risky migration or when the person wants their data.
|
|
136
|
+
- `nerd db export` downloads a SQL dump of the production database (`<slug>-<date>.sql`, or `--out file.sql`); use it before a risky migration or when the person wants their data. The live app's database requests wait while it runs, so run it at a quiet moment.
|
|
106
137
|
|
|
107
138
|
## Errors
|
|
108
139
|
|
|
@@ -131,12 +162,13 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
131
162
|
- New bindings in `wrangler.jsonc`. Only `DB`, `STORAGE`, and `ASSETS` exist. Durable Objects are not available.
|
|
132
163
|
- Scripts that call `wrangler deploy`, `wrangler secret`, or remote `wrangler d1` commands.
|
|
133
164
|
- Typecheck failures in either `src/server` or `src/client`.
|
|
165
|
+
- A failing test, a missing access sweep, or a route with an id that no second user was tried against (the `tests/` rules above).
|
|
134
166
|
|
|
135
167
|
## Scheduled jobs and email
|
|
136
168
|
|
|
137
169
|
- **Scheduled jobs**: put the work in the `POST /api/cron` handler (already in `src/server/index.ts`, guarded by `verifyCron()`), then turn the schedule on with `nerd cron hourly` or `nerd cron daily`. The platform calls it; the app never needs its own timer.
|
|
138
|
-
- **Secrets** (Stripe keys, API tokens): never in code or in the client. Locally they live in `.dev.vars` (gitignored). For production, ask the person to run `nerd secret set NAME` and paste the value; it reaches the app as `c.env.NAME` on the next deploy. Pull request previews do not get it unless it was set with `--previews` (use that only for keys that are safe in a preview, such as a test-mode key). `nerd secret list` shows names only. Do not put a real secret value in any command you run yourself.
|
|
139
|
-
- **Email**: `sendEmail(c.env, { to, subject, text, html? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price.
|
|
170
|
+
- **Secrets** (Stripe keys, API tokens): never in code or in the client. Locally they live in `.dev.vars` (gitignored). For production, ask the person to add it under the app's Settings tab in the dashboard, or to run `nerd secret set NAME` and paste the value; it reaches the app as `c.env.NAME` on the next deploy. Pull request previews do not get it unless it was set with `--previews` (use that only for keys that are safe in a preview, such as a test-mode key). `nerd secret list` shows names only. Do not put a real secret value in any command you run yourself.
|
|
171
|
+
- **Email**: `sendEmail(c.env, { to, subject, text, html? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. On the Free plan the recipient must also have confirmed their address by finishing a password reset (403 otherwise); paid plans can write to every signed-up user. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price.
|
|
140
172
|
|
|
141
173
|
## Debugging production
|
|
142
174
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- generated from .claude/skills/nerdsystem/SKILL.md (
|
|
1
|
+
<!-- generated from .claude/skills/nerdsystem/SKILL.md (37f79c2cf2a8); edit the skill, then run: nerd agents sync -->
|
|
2
2
|
|
|
3
3
|
# Building on nerdsystem
|
|
4
4
|
|
|
@@ -30,8 +30,8 @@ Tell the person plainly what does not carry. Their users sign up again on the ne
|
|
|
30
30
|
|
|
31
31
|
## The loop
|
|
32
32
|
|
|
33
|
-
1. Make the change.
|
|
34
|
-
2. `nerd check` (static rules, then
|
|
33
|
+
1. Make the change, and write or update its tests in the same step (see "Build it so it is safe and it works").
|
|
34
|
+
2. `nerd check` (static rules, typecheck, then the tests and access checks). Fix anything it reports.
|
|
35
35
|
3. `nerd dev` to run locally when you need to try it. The local app talks to the real auth service, so signup and login work exactly as in production.
|
|
36
36
|
4. `nerd deploy` when it is ready. It runs the checks, builds, stores a copy of the code, applies pending migrations, uploads, prints the URL, and probes the live URL (headers and the sign-in routes). With `--json` the output carries `probes`, the same shape as a check result, with `status: "skip"` and the reason when it did not run; probe findings never fail the deploy.
|
|
37
37
|
5. If something is wrong after deploying: `nerd logs` shows recent output from the deployed app (`--q` to search, `--pr 12` for a preview), `nerd deployments` shows status and errors, `nerd db query "..."` inspects production data.
|
|
@@ -92,13 +92,44 @@ export default notes;
|
|
|
92
92
|
|
|
93
93
|
Then in `src/server/index.ts`: `app.route("/api/notes", notes);`
|
|
94
94
|
|
|
95
|
+
## Build it so it is safe and it works
|
|
96
|
+
|
|
97
|
+
The person you are building for usually cannot read code. They will not spot a missing sign-in check or a query that returns someone else's rows, and neither will anyone looking at the screen. So the standard is yours to hold, every time, without being asked:
|
|
98
|
+
|
|
99
|
+
1. **Lock every route first.** Put `requireAuth()` or `requireRole(...)` in front of every route as middleware, before any input is read (`router.use("*", requireAuth())` for a whole router). A route is public only when everyone on the internet is meant to reach it and it returns nothing private; then add it to the `public` list in `test/access.test.ts` and tell the person in plain words that it is open.
|
|
100
|
+
2. **Scope every query to the signed-in user.** Reads, updates, and deletes on user data filter on `c.var.user.id`. On a route with an id in its path, put the id and the user id in the same `where`, and answer 404 when nothing matches, so someone else's row looks like it does not exist.
|
|
101
|
+
3. **Take identity from the session, never from the request.** The user id, the role, a price, an owner: none of these come from the body, the query string, or a header the browser sends. Read the user from `c.var.user` and look the rest up on the server.
|
|
102
|
+
4. **Check every input on the server.** Type, length, range, and allowed values, for every field of every body, query, and path parameter. Answer 400 with the usual error shape. The client's own checks are for the person's convenience; they protect nothing.
|
|
103
|
+
5. **Hiding a button is not access control.** If only admins may do something, the route says `requireRole("admin")`. What the page shows is decoration.
|
|
104
|
+
6. **Files:** check the type and the size before writing to `c.env.STORAGE`, store under a key that starts with the owner's user id, and serve them through a route that checks the owner. Never build a key from a name the browser sent without cleaning it.
|
|
105
|
+
7. **Say nothing useful in an error.** No stack traces, SQL, or internal ids in responses; log them with `console.error` and answer with `{ error: { code, message } }`.
|
|
106
|
+
8. **No raw HTML from data.** Let React render text. Do not use `dangerouslySetInnerHTML` or `innerHTML` with anything a user typed.
|
|
107
|
+
|
|
108
|
+
### Tests are part of the feature
|
|
109
|
+
|
|
110
|
+
The project ships with a test harness (`test/setup.ts`, from `@nerdsystem/sdk/testing`) that runs the real server code against a fresh local database and signs in made-up users. `pnpm test` runs it; `nerd check` and `nerd deploy` run it too and refuse the deploy when:
|
|
111
|
+
|
|
112
|
+
- a test fails (`tests/failing`);
|
|
113
|
+
- the test that calls `t.expectEveryRouteLocked({ public: [...] })` is gone (`tests/routes-not-checked`). That one call tries every route the app has as a signed-out visitor, so a new route is covered the moment it exists and fails until it is locked or deliberately listed as public;
|
|
114
|
+
- a route with an id in its path has not been tried by a second user (`tests/owner-not-checked`).
|
|
115
|
+
|
|
116
|
+
For every feature you build:
|
|
117
|
+
|
|
118
|
+
- **One owner test per route with an id**, in `test/access.test.ts`: one user creates the thing, then `await t.expectHiddenFrom(otherUser, "PATCH", `/api/things/${id}`, body)` for each method. For lists, `await t.expectNotListed(otherUser, "/api/things", "a title only the owner has")`. If every signed-in user is truly meant to reach it (a shared catalogue, a public profile), declare `t.shared("GET /api/things/:id", "why")` instead and tell the person.
|
|
119
|
+
- **One test file per feature** (`test/<feature>.test.ts`, like `test/todos.test.ts`): the main path a person takes from start to finish, and the inputs it must refuse.
|
|
120
|
+
- **A role test** for anything behind `requireRole`: a signed-in user without the role gets 403, one with it gets through (`await t.as("dana", { roles: ["admin"] })`).
|
|
121
|
+
|
|
122
|
+
When a test fails, fix the app. Change a test only when the behaviour it describes was meant to change. Never delete or weaken an access test, widen the `public` list, add `t.shared`, or remove `"tests": "required"` from `nerd.json` to get a deploy through; if you believe one of those is right, stop and ask the person in plain words first. Never import `@nerdsystem/sdk/testing` from anything under `src/`: it can sign in as anyone, and `nerd check` refuses it.
|
|
123
|
+
|
|
124
|
+
What the tests do not prove: that the feature is the one the person wanted, or that its rules (who may book which slot, what a discount applies to) are right. Say what you built and what is public when you hand it over, so they can tell you if it is wrong.
|
|
125
|
+
|
|
95
126
|
## Data rules
|
|
96
127
|
|
|
97
128
|
- Every table holding user data has a `user_id` text column, and every read and write filters on `c.var.user.id`. This is how users are kept apart.
|
|
98
129
|
- Schema changes are migrations. Edit `schema.ts`, run `pnpm db:generate`, commit the new file in `migrations/`. Never hand-write SQL into production; `nerd deploy` applies migrations in name order and records which ran.
|
|
99
130
|
- Migrations are forward-only. To undo, write another migration.
|
|
100
131
|
- Prefer small, additive migrations. SQLite cannot alter columns in place.
|
|
101
|
-
- `nerd db export` downloads a SQL dump of the production database (`<slug>-<date>.sql`, or `--out file.sql`); use it before a risky migration or when the person wants their data.
|
|
132
|
+
- `nerd db export` downloads a SQL dump of the production database (`<slug>-<date>.sql`, or `--out file.sql`); use it before a risky migration or when the person wants their data. The live app's database requests wait while it runs, so run it at a quiet moment.
|
|
102
133
|
|
|
103
134
|
## Errors
|
|
104
135
|
|
|
@@ -127,12 +158,13 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
127
158
|
- New bindings in `wrangler.jsonc`. Only `DB`, `STORAGE`, and `ASSETS` exist. Durable Objects are not available.
|
|
128
159
|
- Scripts that call `wrangler deploy`, `wrangler secret`, or remote `wrangler d1` commands.
|
|
129
160
|
- Typecheck failures in either `src/server` or `src/client`.
|
|
161
|
+
- A failing test, a missing access sweep, or a route with an id that no second user was tried against (the `tests/` rules above).
|
|
130
162
|
|
|
131
163
|
## Scheduled jobs and email
|
|
132
164
|
|
|
133
165
|
- **Scheduled jobs**: put the work in the `POST /api/cron` handler (already in `src/server/index.ts`, guarded by `verifyCron()`), then turn the schedule on with `nerd cron hourly` or `nerd cron daily`. The platform calls it; the app never needs its own timer.
|
|
134
|
-
- **Secrets** (Stripe keys, API tokens): never in code or in the client. Locally they live in `.dev.vars` (gitignored). For production, ask the person to run `nerd secret set NAME` and paste the value; it reaches the app as `c.env.NAME` on the next deploy. Pull request previews do not get it unless it was set with `--previews` (use that only for keys that are safe in a preview, such as a test-mode key). `nerd secret list` shows names only. Do not put a real secret value in any command you run yourself.
|
|
135
|
-
- **Email**: `sendEmail(c.env, { to, subject, text, html? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price.
|
|
166
|
+
- **Secrets** (Stripe keys, API tokens): never in code or in the client. Locally they live in `.dev.vars` (gitignored). For production, ask the person to add it under the app's Settings tab in the dashboard, or to run `nerd secret set NAME` and paste the value; it reaches the app as `c.env.NAME` on the next deploy. Pull request previews do not get it unless it was set with `--previews` (use that only for keys that are safe in a preview, such as a test-mode key). `nerd secret list` shows names only. Do not put a real secret value in any command you run yourself.
|
|
167
|
+
- **Email**: `sendEmail(c.env, { to, subject, text, html? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. On the Free plan the recipient must also have confirmed their address by finishing a password reset (403 otherwise); paid plans can write to every signed-up user. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price.
|
|
136
168
|
|
|
137
169
|
## Debugging production
|
|
138
170
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"routes": [
|
|
4
|
+
"DELETE /api/todos/:id",
|
|
5
|
+
"GET /api/admin/ping",
|
|
6
|
+
"GET /api/health",
|
|
7
|
+
"GET /api/todos",
|
|
8
|
+
"PATCH /api/todos/:id",
|
|
9
|
+
"POST /api/cron",
|
|
10
|
+
"POST /api/todos"
|
|
11
|
+
],
|
|
12
|
+
"locked": [
|
|
13
|
+
"DELETE /api/todos/:id",
|
|
14
|
+
"GET /api/admin/ping",
|
|
15
|
+
"GET /api/todos",
|
|
16
|
+
"PATCH /api/todos/:id",
|
|
17
|
+
"POST /api/cron",
|
|
18
|
+
"POST /api/todos"
|
|
19
|
+
],
|
|
20
|
+
"public": [
|
|
21
|
+
"GET /api/health"
|
|
22
|
+
],
|
|
23
|
+
"ownerChecked": [
|
|
24
|
+
"DELETE /api/todos/:id",
|
|
25
|
+
"PATCH /api/todos/:id"
|
|
26
|
+
],
|
|
27
|
+
"shared": [],
|
|
28
|
+
"everyRouteChecked": true
|
|
29
|
+
}
|
package/template/AGENTS.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- generated from .claude/skills/nerdsystem/SKILL.md (
|
|
1
|
+
<!-- generated from .claude/skills/nerdsystem/SKILL.md (37f79c2cf2a8); edit the skill, then run: nerd agents sync -->
|
|
2
2
|
|
|
3
3
|
# Building on nerdsystem
|
|
4
4
|
|
|
@@ -30,8 +30,8 @@ Tell the person plainly what does not carry. Their users sign up again on the ne
|
|
|
30
30
|
|
|
31
31
|
## The loop
|
|
32
32
|
|
|
33
|
-
1. Make the change.
|
|
34
|
-
2. `nerd check` (static rules, then
|
|
33
|
+
1. Make the change, and write or update its tests in the same step (see "Build it so it is safe and it works").
|
|
34
|
+
2. `nerd check` (static rules, typecheck, then the tests and access checks). Fix anything it reports.
|
|
35
35
|
3. `nerd dev` to run locally when you need to try it. The local app talks to the real auth service, so signup and login work exactly as in production.
|
|
36
36
|
4. `nerd deploy` when it is ready. It runs the checks, builds, stores a copy of the code, applies pending migrations, uploads, prints the URL, and probes the live URL (headers and the sign-in routes). With `--json` the output carries `probes`, the same shape as a check result, with `status: "skip"` and the reason when it did not run; probe findings never fail the deploy.
|
|
37
37
|
5. If something is wrong after deploying: `nerd logs` shows recent output from the deployed app (`--q` to search, `--pr 12` for a preview), `nerd deployments` shows status and errors, `nerd db query "..."` inspects production data.
|
|
@@ -92,13 +92,44 @@ export default notes;
|
|
|
92
92
|
|
|
93
93
|
Then in `src/server/index.ts`: `app.route("/api/notes", notes);`
|
|
94
94
|
|
|
95
|
+
## Build it so it is safe and it works
|
|
96
|
+
|
|
97
|
+
The person you are building for usually cannot read code. They will not spot a missing sign-in check or a query that returns someone else's rows, and neither will anyone looking at the screen. So the standard is yours to hold, every time, without being asked:
|
|
98
|
+
|
|
99
|
+
1. **Lock every route first.** Put `requireAuth()` or `requireRole(...)` in front of every route as middleware, before any input is read (`router.use("*", requireAuth())` for a whole router). A route is public only when everyone on the internet is meant to reach it and it returns nothing private; then add it to the `public` list in `test/access.test.ts` and tell the person in plain words that it is open.
|
|
100
|
+
2. **Scope every query to the signed-in user.** Reads, updates, and deletes on user data filter on `c.var.user.id`. On a route with an id in its path, put the id and the user id in the same `where`, and answer 404 when nothing matches, so someone else's row looks like it does not exist.
|
|
101
|
+
3. **Take identity from the session, never from the request.** The user id, the role, a price, an owner: none of these come from the body, the query string, or a header the browser sends. Read the user from `c.var.user` and look the rest up on the server.
|
|
102
|
+
4. **Check every input on the server.** Type, length, range, and allowed values, for every field of every body, query, and path parameter. Answer 400 with the usual error shape. The client's own checks are for the person's convenience; they protect nothing.
|
|
103
|
+
5. **Hiding a button is not access control.** If only admins may do something, the route says `requireRole("admin")`. What the page shows is decoration.
|
|
104
|
+
6. **Files:** check the type and the size before writing to `c.env.STORAGE`, store under a key that starts with the owner's user id, and serve them through a route that checks the owner. Never build a key from a name the browser sent without cleaning it.
|
|
105
|
+
7. **Say nothing useful in an error.** No stack traces, SQL, or internal ids in responses; log them with `console.error` and answer with `{ error: { code, message } }`.
|
|
106
|
+
8. **No raw HTML from data.** Let React render text. Do not use `dangerouslySetInnerHTML` or `innerHTML` with anything a user typed.
|
|
107
|
+
|
|
108
|
+
### Tests are part of the feature
|
|
109
|
+
|
|
110
|
+
The project ships with a test harness (`test/setup.ts`, from `@nerdsystem/sdk/testing`) that runs the real server code against a fresh local database and signs in made-up users. `pnpm test` runs it; `nerd check` and `nerd deploy` run it too and refuse the deploy when:
|
|
111
|
+
|
|
112
|
+
- a test fails (`tests/failing`);
|
|
113
|
+
- the test that calls `t.expectEveryRouteLocked({ public: [...] })` is gone (`tests/routes-not-checked`). That one call tries every route the app has as a signed-out visitor, so a new route is covered the moment it exists and fails until it is locked or deliberately listed as public;
|
|
114
|
+
- a route with an id in its path has not been tried by a second user (`tests/owner-not-checked`).
|
|
115
|
+
|
|
116
|
+
For every feature you build:
|
|
117
|
+
|
|
118
|
+
- **One owner test per route with an id**, in `test/access.test.ts`: one user creates the thing, then `await t.expectHiddenFrom(otherUser, "PATCH", `/api/things/${id}`, body)` for each method. For lists, `await t.expectNotListed(otherUser, "/api/things", "a title only the owner has")`. If every signed-in user is truly meant to reach it (a shared catalogue, a public profile), declare `t.shared("GET /api/things/:id", "why")` instead and tell the person.
|
|
119
|
+
- **One test file per feature** (`test/<feature>.test.ts`, like `test/todos.test.ts`): the main path a person takes from start to finish, and the inputs it must refuse.
|
|
120
|
+
- **A role test** for anything behind `requireRole`: a signed-in user without the role gets 403, one with it gets through (`await t.as("dana", { roles: ["admin"] })`).
|
|
121
|
+
|
|
122
|
+
When a test fails, fix the app. Change a test only when the behaviour it describes was meant to change. Never delete or weaken an access test, widen the `public` list, add `t.shared`, or remove `"tests": "required"` from `nerd.json` to get a deploy through; if you believe one of those is right, stop and ask the person in plain words first. Never import `@nerdsystem/sdk/testing` from anything under `src/`: it can sign in as anyone, and `nerd check` refuses it.
|
|
123
|
+
|
|
124
|
+
What the tests do not prove: that the feature is the one the person wanted, or that its rules (who may book which slot, what a discount applies to) are right. Say what you built and what is public when you hand it over, so they can tell you if it is wrong.
|
|
125
|
+
|
|
95
126
|
## Data rules
|
|
96
127
|
|
|
97
128
|
- Every table holding user data has a `user_id` text column, and every read and write filters on `c.var.user.id`. This is how users are kept apart.
|
|
98
129
|
- Schema changes are migrations. Edit `schema.ts`, run `pnpm db:generate`, commit the new file in `migrations/`. Never hand-write SQL into production; `nerd deploy` applies migrations in name order and records which ran.
|
|
99
130
|
- Migrations are forward-only. To undo, write another migration.
|
|
100
131
|
- Prefer small, additive migrations. SQLite cannot alter columns in place.
|
|
101
|
-
- `nerd db export` downloads a SQL dump of the production database (`<slug>-<date>.sql`, or `--out file.sql`); use it before a risky migration or when the person wants their data.
|
|
132
|
+
- `nerd db export` downloads a SQL dump of the production database (`<slug>-<date>.sql`, or `--out file.sql`); use it before a risky migration or when the person wants their data. The live app's database requests wait while it runs, so run it at a quiet moment.
|
|
102
133
|
|
|
103
134
|
## Errors
|
|
104
135
|
|
|
@@ -127,12 +158,13 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
127
158
|
- New bindings in `wrangler.jsonc`. Only `DB`, `STORAGE`, and `ASSETS` exist. Durable Objects are not available.
|
|
128
159
|
- Scripts that call `wrangler deploy`, `wrangler secret`, or remote `wrangler d1` commands.
|
|
129
160
|
- Typecheck failures in either `src/server` or `src/client`.
|
|
161
|
+
- A failing test, a missing access sweep, or a route with an id that no second user was tried against (the `tests/` rules above).
|
|
130
162
|
|
|
131
163
|
## Scheduled jobs and email
|
|
132
164
|
|
|
133
165
|
- **Scheduled jobs**: put the work in the `POST /api/cron` handler (already in `src/server/index.ts`, guarded by `verifyCron()`), then turn the schedule on with `nerd cron hourly` or `nerd cron daily`. The platform calls it; the app never needs its own timer.
|
|
134
|
-
- **Secrets** (Stripe keys, API tokens): never in code or in the client. Locally they live in `.dev.vars` (gitignored). For production, ask the person to run `nerd secret set NAME` and paste the value; it reaches the app as `c.env.NAME` on the next deploy. Pull request previews do not get it unless it was set with `--previews` (use that only for keys that are safe in a preview, such as a test-mode key). `nerd secret list` shows names only. Do not put a real secret value in any command you run yourself.
|
|
135
|
-
- **Email**: `sendEmail(c.env, { to, subject, text, html? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price.
|
|
166
|
+
- **Secrets** (Stripe keys, API tokens): never in code or in the client. Locally they live in `.dev.vars` (gitignored). For production, ask the person to add it under the app's Settings tab in the dashboard, or to run `nerd secret set NAME` and paste the value; it reaches the app as `c.env.NAME` on the next deploy. Pull request previews do not get it unless it was set with `--previews` (use that only for keys that are safe in a preview, such as a test-mode key). `nerd secret list` shows names only. Do not put a real secret value in any command you run yourself.
|
|
167
|
+
- **Email**: `sendEmail(c.env, { to, subject, text, html? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. On the Free plan the recipient must also have confirmed their address by finishing a password reset (403 otherwise); paid plans can write to every signed-up user. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price.
|
|
136
168
|
|
|
137
169
|
## Debugging production
|
|
138
170
|
|
package/template/CLAUDE.md
CHANGED
|
@@ -5,7 +5,8 @@ nerdsystem deploys this app to Cloudflare and provides authentication and author
|
|
|
5
5
|
## Commands
|
|
6
6
|
|
|
7
7
|
- `nerd dev` starts the app locally with a real session flow against the platform auth service.
|
|
8
|
-
- `nerd check` typechecks both sides
|
|
8
|
+
- `nerd check` typechecks both sides, verifies the platform rules below, and runs the tests in `test/`. Run it before `nerd deploy`.
|
|
9
|
+
- `pnpm test` runs the tests alone: the real server code against a fresh local database, with made-up signed-in users.
|
|
9
10
|
- `nerd deploy` builds, stores a copy of the code, applies pending migrations to the production database, and ships the app. Prints the URL.
|
|
10
11
|
- `nerd save` keeps a copy of the code on nerdsystem without deploying; `nerd pull <org>/<project>` restores it on another computer.
|
|
11
12
|
- `nerd db query "select ..."` runs SQL against the production database. `nerd deployments` lists recent deploys.
|
|
@@ -27,6 +28,7 @@ nerdsystem deploys this app to Cloudflare and provides authentication and author
|
|
|
27
28
|
5. Keep every API route under `/api/`. Other paths are served from static assets and never reach the Worker.
|
|
28
29
|
6. Do not add Cloudflare bindings to `wrangler.jsonc`, and do not run `wrangler deploy`. Bindings are provisioned by the platform.
|
|
29
30
|
7. Secrets and environment variables for the deployed app are managed with `nerd` commands, never committed.
|
|
31
|
+
8. Every route is locked with `requireAuth()` or `requireRole()` unless it is listed as public in `test/access.test.ts`, and every route with an id in its path has a test showing a second user cannot reach it. Write the tests with the feature. `nerd check` refuses a deploy when they fail or are missing; never weaken one to get through.
|
|
30
32
|
|
|
31
33
|
## Roles
|
|
32
34
|
|
package/template/GEMINI.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- generated from .claude/skills/nerdsystem/SKILL.md (
|
|
1
|
+
<!-- generated from .claude/skills/nerdsystem/SKILL.md (37f79c2cf2a8); edit the skill, then run: nerd agents sync -->
|
|
2
2
|
|
|
3
3
|
# Building on nerdsystem
|
|
4
4
|
|
|
@@ -30,8 +30,8 @@ Tell the person plainly what does not carry. Their users sign up again on the ne
|
|
|
30
30
|
|
|
31
31
|
## The loop
|
|
32
32
|
|
|
33
|
-
1. Make the change.
|
|
34
|
-
2. `nerd check` (static rules, then
|
|
33
|
+
1. Make the change, and write or update its tests in the same step (see "Build it so it is safe and it works").
|
|
34
|
+
2. `nerd check` (static rules, typecheck, then the tests and access checks). Fix anything it reports.
|
|
35
35
|
3. `nerd dev` to run locally when you need to try it. The local app talks to the real auth service, so signup and login work exactly as in production.
|
|
36
36
|
4. `nerd deploy` when it is ready. It runs the checks, builds, stores a copy of the code, applies pending migrations, uploads, prints the URL, and probes the live URL (headers and the sign-in routes). With `--json` the output carries `probes`, the same shape as a check result, with `status: "skip"` and the reason when it did not run; probe findings never fail the deploy.
|
|
37
37
|
5. If something is wrong after deploying: `nerd logs` shows recent output from the deployed app (`--q` to search, `--pr 12` for a preview), `nerd deployments` shows status and errors, `nerd db query "..."` inspects production data.
|
|
@@ -92,13 +92,44 @@ export default notes;
|
|
|
92
92
|
|
|
93
93
|
Then in `src/server/index.ts`: `app.route("/api/notes", notes);`
|
|
94
94
|
|
|
95
|
+
## Build it so it is safe and it works
|
|
96
|
+
|
|
97
|
+
The person you are building for usually cannot read code. They will not spot a missing sign-in check or a query that returns someone else's rows, and neither will anyone looking at the screen. So the standard is yours to hold, every time, without being asked:
|
|
98
|
+
|
|
99
|
+
1. **Lock every route first.** Put `requireAuth()` or `requireRole(...)` in front of every route as middleware, before any input is read (`router.use("*", requireAuth())` for a whole router). A route is public only when everyone on the internet is meant to reach it and it returns nothing private; then add it to the `public` list in `test/access.test.ts` and tell the person in plain words that it is open.
|
|
100
|
+
2. **Scope every query to the signed-in user.** Reads, updates, and deletes on user data filter on `c.var.user.id`. On a route with an id in its path, put the id and the user id in the same `where`, and answer 404 when nothing matches, so someone else's row looks like it does not exist.
|
|
101
|
+
3. **Take identity from the session, never from the request.** The user id, the role, a price, an owner: none of these come from the body, the query string, or a header the browser sends. Read the user from `c.var.user` and look the rest up on the server.
|
|
102
|
+
4. **Check every input on the server.** Type, length, range, and allowed values, for every field of every body, query, and path parameter. Answer 400 with the usual error shape. The client's own checks are for the person's convenience; they protect nothing.
|
|
103
|
+
5. **Hiding a button is not access control.** If only admins may do something, the route says `requireRole("admin")`. What the page shows is decoration.
|
|
104
|
+
6. **Files:** check the type and the size before writing to `c.env.STORAGE`, store under a key that starts with the owner's user id, and serve them through a route that checks the owner. Never build a key from a name the browser sent without cleaning it.
|
|
105
|
+
7. **Say nothing useful in an error.** No stack traces, SQL, or internal ids in responses; log them with `console.error` and answer with `{ error: { code, message } }`.
|
|
106
|
+
8. **No raw HTML from data.** Let React render text. Do not use `dangerouslySetInnerHTML` or `innerHTML` with anything a user typed.
|
|
107
|
+
|
|
108
|
+
### Tests are part of the feature
|
|
109
|
+
|
|
110
|
+
The project ships with a test harness (`test/setup.ts`, from `@nerdsystem/sdk/testing`) that runs the real server code against a fresh local database and signs in made-up users. `pnpm test` runs it; `nerd check` and `nerd deploy` run it too and refuse the deploy when:
|
|
111
|
+
|
|
112
|
+
- a test fails (`tests/failing`);
|
|
113
|
+
- the test that calls `t.expectEveryRouteLocked({ public: [...] })` is gone (`tests/routes-not-checked`). That one call tries every route the app has as a signed-out visitor, so a new route is covered the moment it exists and fails until it is locked or deliberately listed as public;
|
|
114
|
+
- a route with an id in its path has not been tried by a second user (`tests/owner-not-checked`).
|
|
115
|
+
|
|
116
|
+
For every feature you build:
|
|
117
|
+
|
|
118
|
+
- **One owner test per route with an id**, in `test/access.test.ts`: one user creates the thing, then `await t.expectHiddenFrom(otherUser, "PATCH", `/api/things/${id}`, body)` for each method. For lists, `await t.expectNotListed(otherUser, "/api/things", "a title only the owner has")`. If every signed-in user is truly meant to reach it (a shared catalogue, a public profile), declare `t.shared("GET /api/things/:id", "why")` instead and tell the person.
|
|
119
|
+
- **One test file per feature** (`test/<feature>.test.ts`, like `test/todos.test.ts`): the main path a person takes from start to finish, and the inputs it must refuse.
|
|
120
|
+
- **A role test** for anything behind `requireRole`: a signed-in user without the role gets 403, one with it gets through (`await t.as("dana", { roles: ["admin"] })`).
|
|
121
|
+
|
|
122
|
+
When a test fails, fix the app. Change a test only when the behaviour it describes was meant to change. Never delete or weaken an access test, widen the `public` list, add `t.shared`, or remove `"tests": "required"` from `nerd.json` to get a deploy through; if you believe one of those is right, stop and ask the person in plain words first. Never import `@nerdsystem/sdk/testing` from anything under `src/`: it can sign in as anyone, and `nerd check` refuses it.
|
|
123
|
+
|
|
124
|
+
What the tests do not prove: that the feature is the one the person wanted, or that its rules (who may book which slot, what a discount applies to) are right. Say what you built and what is public when you hand it over, so they can tell you if it is wrong.
|
|
125
|
+
|
|
95
126
|
## Data rules
|
|
96
127
|
|
|
97
128
|
- Every table holding user data has a `user_id` text column, and every read and write filters on `c.var.user.id`. This is how users are kept apart.
|
|
98
129
|
- Schema changes are migrations. Edit `schema.ts`, run `pnpm db:generate`, commit the new file in `migrations/`. Never hand-write SQL into production; `nerd deploy` applies migrations in name order and records which ran.
|
|
99
130
|
- Migrations are forward-only. To undo, write another migration.
|
|
100
131
|
- Prefer small, additive migrations. SQLite cannot alter columns in place.
|
|
101
|
-
- `nerd db export` downloads a SQL dump of the production database (`<slug>-<date>.sql`, or `--out file.sql`); use it before a risky migration or when the person wants their data.
|
|
132
|
+
- `nerd db export` downloads a SQL dump of the production database (`<slug>-<date>.sql`, or `--out file.sql`); use it before a risky migration or when the person wants their data. The live app's database requests wait while it runs, so run it at a quiet moment.
|
|
102
133
|
|
|
103
134
|
## Errors
|
|
104
135
|
|
|
@@ -127,12 +158,13 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
127
158
|
- New bindings in `wrangler.jsonc`. Only `DB`, `STORAGE`, and `ASSETS` exist. Durable Objects are not available.
|
|
128
159
|
- Scripts that call `wrangler deploy`, `wrangler secret`, or remote `wrangler d1` commands.
|
|
129
160
|
- Typecheck failures in either `src/server` or `src/client`.
|
|
161
|
+
- A failing test, a missing access sweep, or a route with an id that no second user was tried against (the `tests/` rules above).
|
|
130
162
|
|
|
131
163
|
## Scheduled jobs and email
|
|
132
164
|
|
|
133
165
|
- **Scheduled jobs**: put the work in the `POST /api/cron` handler (already in `src/server/index.ts`, guarded by `verifyCron()`), then turn the schedule on with `nerd cron hourly` or `nerd cron daily`. The platform calls it; the app never needs its own timer.
|
|
134
|
-
- **Secrets** (Stripe keys, API tokens): never in code or in the client. Locally they live in `.dev.vars` (gitignored). For production, ask the person to run `nerd secret set NAME` and paste the value; it reaches the app as `c.env.NAME` on the next deploy. Pull request previews do not get it unless it was set with `--previews` (use that only for keys that are safe in a preview, such as a test-mode key). `nerd secret list` shows names only. Do not put a real secret value in any command you run yourself.
|
|
135
|
-
- **Email**: `sendEmail(c.env, { to, subject, text, html? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price.
|
|
166
|
+
- **Secrets** (Stripe keys, API tokens): never in code or in the client. Locally they live in `.dev.vars` (gitignored). For production, ask the person to add it under the app's Settings tab in the dashboard, or to run `nerd secret set NAME` and paste the value; it reaches the app as `c.env.NAME` on the next deploy. Pull request previews do not get it unless it was set with `--previews` (use that only for keys that are safe in a preview, such as a test-mode key). `nerd secret list` shows names only. Do not put a real secret value in any command you run yourself.
|
|
167
|
+
- **Email**: `sendEmail(c.env, { to, subject, text, html? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. On the Free plan the recipient must also have confirmed their address by finishing a password reset (403 otherwise); paid plans can write to every signed-up user. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price.
|
|
136
168
|
|
|
137
169
|
## Debugging production
|
|
138
170
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "6",
|
|
3
|
+
"dialect": "sqlite",
|
|
4
|
+
"id": "a804e9fb-b416-462b-a3e2-0349c283a4b7",
|
|
5
|
+
"prevId": "00000000-0000-0000-0000-000000000000",
|
|
6
|
+
"tables": {
|
|
7
|
+
"todos": {
|
|
8
|
+
"name": "todos",
|
|
9
|
+
"columns": {
|
|
10
|
+
"id": {
|
|
11
|
+
"name": "id",
|
|
12
|
+
"type": "integer",
|
|
13
|
+
"primaryKey": true,
|
|
14
|
+
"notNull": true,
|
|
15
|
+
"autoincrement": true
|
|
16
|
+
},
|
|
17
|
+
"user_id": {
|
|
18
|
+
"name": "user_id",
|
|
19
|
+
"type": "text",
|
|
20
|
+
"primaryKey": false,
|
|
21
|
+
"notNull": true,
|
|
22
|
+
"autoincrement": false
|
|
23
|
+
},
|
|
24
|
+
"title": {
|
|
25
|
+
"name": "title",
|
|
26
|
+
"type": "text",
|
|
27
|
+
"primaryKey": false,
|
|
28
|
+
"notNull": true,
|
|
29
|
+
"autoincrement": false
|
|
30
|
+
},
|
|
31
|
+
"done": {
|
|
32
|
+
"name": "done",
|
|
33
|
+
"type": "integer",
|
|
34
|
+
"primaryKey": false,
|
|
35
|
+
"notNull": true,
|
|
36
|
+
"autoincrement": false,
|
|
37
|
+
"default": false
|
|
38
|
+
},
|
|
39
|
+
"created_at": {
|
|
40
|
+
"name": "created_at",
|
|
41
|
+
"type": "text",
|
|
42
|
+
"primaryKey": false,
|
|
43
|
+
"notNull": true,
|
|
44
|
+
"autoincrement": false
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"indexes": {},
|
|
48
|
+
"foreignKeys": {},
|
|
49
|
+
"compositePrimaryKeys": {},
|
|
50
|
+
"uniqueConstraints": {},
|
|
51
|
+
"checkConstraints": {}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"views": {},
|
|
55
|
+
"enums": {},
|
|
56
|
+
"_meta": {
|
|
57
|
+
"schemas": {},
|
|
58
|
+
"tables": {},
|
|
59
|
+
"columns": {}
|
|
60
|
+
},
|
|
61
|
+
"internal": {
|
|
62
|
+
"indexes": {}
|
|
63
|
+
}
|
|
64
|
+
}
|
package/template/nerd.json
CHANGED
package/template/package.json
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"dev": "nerd dev",
|
|
8
8
|
"build": "vite build",
|
|
9
9
|
"check": "nerd check",
|
|
10
|
+
"test": "vitest run",
|
|
10
11
|
"deploy": "nerd deploy",
|
|
11
12
|
"typecheck": "tsc -p tsconfig.worker.json --noEmit && tsc -p tsconfig.client.json --noEmit",
|
|
12
13
|
"db:generate": "drizzle-kit generate",
|
|
@@ -34,6 +35,7 @@
|
|
|
34
35
|
"drizzle-kit": "^0.31.11",
|
|
35
36
|
"typescript": "^5.9.3",
|
|
36
37
|
"vite": "^8.3.5",
|
|
38
|
+
"vitest": "^4.1.0",
|
|
37
39
|
"wrangler": "^4.156.2"
|
|
38
40
|
}
|
|
39
41
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Who can reach what. `nerd check` runs these before every deploy and refuses the deploy when one fails,
|
|
2
|
+
// when the first test is missing, or when a route with an id in its path has no owner test below.
|
|
3
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
4
|
+
import type { TestHarness } from "@nerdsystem/sdk/testing";
|
|
5
|
+
import { startTestApp } from "./setup";
|
|
6
|
+
|
|
7
|
+
let t: TestHarness;
|
|
8
|
+
let stop: () => Promise<void>;
|
|
9
|
+
beforeAll(async () => ({ t, stop } = await startTestApp()));
|
|
10
|
+
afterAll(() => stop());
|
|
11
|
+
|
|
12
|
+
describe("a signed-out visitor", () => {
|
|
13
|
+
it("is refused on every route that is not public", async () => {
|
|
14
|
+
// This calls every route the app has, including ones added later. A new route fails here until it
|
|
15
|
+
// has requireAuth() or requireRole() in front of it. Only list a route as public when everyone on
|
|
16
|
+
// the internet is meant to reach it, and it returns nothing private.
|
|
17
|
+
await t.expectEveryRouteLocked({ public: ["GET /api/health"] });
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("one person's todos", () => {
|
|
22
|
+
it("cannot be seen, changed, or deleted by someone else", async () => {
|
|
23
|
+
const alice = await t.as("alice");
|
|
24
|
+
const bob = await t.as("bob");
|
|
25
|
+
const created = (await (await alice.post("/api/todos", { title: "only-alice-sees-this" })).json()) as { todo: { id: number } };
|
|
26
|
+
|
|
27
|
+
await t.expectNotListed(bob, "/api/todos", "only-alice-sees-this");
|
|
28
|
+
await t.expectHiddenFrom(bob, "PATCH", `/api/todos/${created.todo.id}`, { done: true });
|
|
29
|
+
await t.expectHiddenFrom(bob, "DELETE", `/api/todos/${created.todo.id}`);
|
|
30
|
+
|
|
31
|
+
// And it is still there, unchanged, for the person who owns it.
|
|
32
|
+
const mine = (await (await alice.get("/api/todos")).json()) as { todos: { id: number; done: boolean }[] };
|
|
33
|
+
expect(mine.todos).toEqual([expect.objectContaining({ id: created.todo.id, done: false })]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("the admin route", () => {
|
|
38
|
+
it("needs the admin role, not just a sign-in", async () => {
|
|
39
|
+
expect((await (await t.as("carol")).get("/api/admin/ping")).status).toBe(403);
|
|
40
|
+
expect((await (await t.as("dana", { roles: ["admin"] })).get("/api/admin/ping")).status).toBe(200);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Runs once before the tests: a report from an earlier run must not vouch for this one.
|
|
2
|
+
import { rmSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export default function setup(): void {
|
|
6
|
+
rmSync(path.resolve(import.meta.dirname, "..", ".nerd", "access-report.json"), { force: true });
|
|
7
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Starts the app for tests: the real server code, a fresh local database with every migration applied,
|
|
2
|
+
// and the nerdsystem test harness. Nothing here talks to the network or to the live app.
|
|
3
|
+
// You should not need to change this file. Add tests next to it.
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { getPlatformProxy } from "wrangler";
|
|
7
|
+
import { testApp, type AccessReport, type TestHarness } from "@nerdsystem/sdk/testing";
|
|
8
|
+
import app from "../src/server";
|
|
9
|
+
|
|
10
|
+
const root = path.resolve(import.meta.dirname, "..");
|
|
11
|
+
const reportFile = path.join(root, ".nerd", "access-report.json");
|
|
12
|
+
|
|
13
|
+
/** Each test file adds what it checked to the one report `nerd check` reads. */
|
|
14
|
+
function mergeReport(next: AccessReport): void {
|
|
15
|
+
const prev: AccessReport | null = existsSync(reportFile) ? JSON.parse(readFileSync(reportFile, "utf8")) : null;
|
|
16
|
+
const union = <T,>(a: T[] | undefined, b: T[]) => [...new Set([...(a ?? []), ...b])].sort();
|
|
17
|
+
const merged: AccessReport = {
|
|
18
|
+
version: 1,
|
|
19
|
+
routes: next.routes,
|
|
20
|
+
locked: union(prev?.locked, next.locked),
|
|
21
|
+
public: union(prev?.public, next.public),
|
|
22
|
+
ownerChecked: union(prev?.ownerChecked, next.ownerChecked),
|
|
23
|
+
shared: union(prev?.shared, next.shared),
|
|
24
|
+
everyRouteChecked: Boolean(prev?.everyRouteChecked) || next.everyRouteChecked,
|
|
25
|
+
};
|
|
26
|
+
mkdirSync(path.dirname(reportFile), { recursive: true });
|
|
27
|
+
writeFileSync(reportFile, `${JSON.stringify(merged, null, 2)}\n`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function startTestApp(): Promise<{ t: TestHarness; env: { DB: D1Database; STORAGE: R2Bucket }; stop: () => Promise<void> }> {
|
|
31
|
+
const platform = await getPlatformProxy<{ DB: D1Database; STORAGE: R2Bucket }>({ configPath: path.join(root, "wrangler.jsonc"), persist: false });
|
|
32
|
+
const dir = path.join(root, "migrations");
|
|
33
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) {
|
|
34
|
+
for (const statement of readFileSync(path.join(dir, file), "utf8").split("--> statement-breakpoint")) {
|
|
35
|
+
if (statement.trim()) await platform.env.DB.prepare(statement).run();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const t = await testApp(app, { env: platform.env, onReport: mergeReport });
|
|
39
|
+
return { t, env: platform.env, stop: () => platform.dispose() };
|
|
40
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Does the feature work? One test file per feature: the main path a person takes, and the input it must refuse.
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
3
|
+
import type { TestHarness } from "@nerdsystem/sdk/testing";
|
|
4
|
+
import { startTestApp } from "./setup";
|
|
5
|
+
|
|
6
|
+
let t: TestHarness;
|
|
7
|
+
let stop: () => Promise<void>;
|
|
8
|
+
beforeAll(async () => ({ t, stop } = await startTestApp()));
|
|
9
|
+
afterAll(() => stop());
|
|
10
|
+
|
|
11
|
+
describe("todos", () => {
|
|
12
|
+
it("adds a todo, lists it, marks it done, and deletes it", async () => {
|
|
13
|
+
const erin = await t.as("erin");
|
|
14
|
+
const added = await erin.post("/api/todos", { title: " Buy milk " });
|
|
15
|
+
expect(added.status).toBe(201);
|
|
16
|
+
const { todo } = (await added.json()) as { todo: { id: number; title: string; done: boolean } };
|
|
17
|
+
expect(todo).toMatchObject({ title: "Buy milk", done: false });
|
|
18
|
+
|
|
19
|
+
expect(((await (await erin.get("/api/todos")).json()) as { todos: unknown[] }).todos).toHaveLength(1);
|
|
20
|
+
expect(((await (await erin.patch(`/api/todos/${todo.id}`, { done: true })).json()) as { todo: { done: boolean } }).todo.done).toBe(true);
|
|
21
|
+
expect((await erin.delete(`/api/todos/${todo.id}`)).status).toBe(200);
|
|
22
|
+
expect(((await (await erin.get("/api/todos")).json()) as { todos: unknown[] }).todos).toHaveLength(0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("refuses a todo with no title, and answers 404 for one that does not exist", async () => {
|
|
26
|
+
const erin = await t.as("erin");
|
|
27
|
+
expect((await erin.post("/api/todos", {})).status).toBe(400);
|
|
28
|
+
expect((await erin.post("/api/todos", { title: " " })).status).toBe(400);
|
|
29
|
+
expect((await erin.patch("/api/todos/999999", { done: true })).status).toBe(404);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Tests run the server code in Node against a local database. This file keeps them apart from vite.config.ts,
|
|
2
|
+
// whose Cloudflare plugin is for building and for `nerd dev`.
|
|
3
|
+
import { defineConfig } from "vitest/config";
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
test: {
|
|
7
|
+
include: ["test/**/*.test.ts"],
|
|
8
|
+
// The SDK is published for bundlers, so let Vite load it instead of Node.
|
|
9
|
+
server: { deps: { inline: ["@nerdsystem/sdk"] } },
|
|
10
|
+
globalSetup: ["test/global-setup.ts"],
|
|
11
|
+
// One file at a time: each starts its own local database, and they share one access report.
|
|
12
|
+
fileParallelism: false,
|
|
13
|
+
testTimeout: 20_000,
|
|
14
|
+
hookTimeout: 30_000,
|
|
15
|
+
},
|
|
16
|
+
});
|