nerdsystem 0.0.14 → 0.0.16
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/deploy.js +4 -3
- package/dist/commands/init.js +1 -1
- package/dist/gates/index.js +2 -1
- package/dist/gates/rules.js +11 -0
- package/dist/gates/smoke.d.ts +6 -1
- package/dist/gates/smoke.js +15 -1
- package/dist/gates/static.d.ts +2 -0
- package/dist/gates/static.js +58 -0
- package/dist/gates/tests.d.ts +5 -0
- package/dist/gates/tests.js +12 -4
- package/package.json +1 -1
- package/template/.claude/skills/nerdsystem/SKILL.md +4 -3
- package/template/.cursor/rules/nerdsystem.mdc +5 -4
- package/template/.github/copilot-instructions.md +5 -4
- package/template/AGENTS.md +5 -4
- package/template/CLAUDE.md +2 -1
- package/template/GEMINI.md +5 -4
- package/template/package.json +2 -1
- package/template/src/server/index.ts +5 -1
- package/template/src/server/routes/todos.ts +10 -8
- package/template/test/todos.test.ts +3 -0
package/dist/commands/deploy.js
CHANGED
|
@@ -7,6 +7,7 @@ import { formatMb } from "../snapshot.js";
|
|
|
7
7
|
import { CliError, log, printJson, runOrThrow, table, dashboardUrl, ensureDeps } from "../util.js";
|
|
8
8
|
import { runChecks } from "./check.js";
|
|
9
9
|
import { appendLineage, cliVersion, LOCAL_ONLY_NOTE, packProject, readLineage, uploadSnapshot, uploadStopMessage } from "./source.js";
|
|
10
|
+
import { lockedGetPaths } from "../gates/tests.js";
|
|
10
11
|
import { BUILD_GATES, gateSummary, knownSecretsFrom, probeDeployment, runGates, skippedProbe, SOURCE_GATES, waitForDeployment } from "../gates/index.js";
|
|
11
12
|
const MODULE_EXT = new Set([".js", ".mjs", ".wasm"]);
|
|
12
13
|
function walk(dir, base = dir) {
|
|
@@ -62,7 +63,7 @@ export async function applyRemoteMigrations(root, api) {
|
|
|
62
63
|
* Probes the live URL after an upload, the same checks the builder runs after a git build.
|
|
63
64
|
* Informational: the app is already live, so findings are printed, never thrown.
|
|
64
65
|
*/
|
|
65
|
-
async function probeAfterDeploy(deployment, url, skip) {
|
|
66
|
+
async function probeAfterDeploy(root, deployment, url, skip) {
|
|
66
67
|
if (skip)
|
|
67
68
|
return skippedProbe("--skip-probe was given");
|
|
68
69
|
if (deployment.status !== "live")
|
|
@@ -71,7 +72,7 @@ async function probeAfterDeploy(deployment, url, skip) {
|
|
|
71
72
|
// headers or routes; probing that would report false auth failures. Wait, and say so when it never came.
|
|
72
73
|
if (!(await waitForDeployment(url)))
|
|
73
74
|
return skippedProbe("the app had not started answering within 30 seconds; open the URL to check, or run nerd deploy again to probe it");
|
|
74
|
-
return probeDeployment(url);
|
|
75
|
+
return probeDeployment(url, undefined, { lockedGets: lockedGetPaths(root) });
|
|
75
76
|
}
|
|
76
77
|
function printProbe(url, probes) {
|
|
77
78
|
if (probes.status === "skip")
|
|
@@ -183,7 +184,7 @@ export async function shipBuild(args) {
|
|
|
183
184
|
log.ok(`Deployed ${deployment.id} (${deployment.status}) → ${project.url}`);
|
|
184
185
|
if (!opts["skip-probe"] && deployment.status === "live")
|
|
185
186
|
log.step(`Probing ${project.url}`);
|
|
186
|
-
const probes = await probeAfterDeploy(deployment, project.url, Boolean(opts["skip-probe"]));
|
|
187
|
+
const probes = await probeAfterDeploy(root, deployment, project.url, Boolean(opts["skip-probe"]));
|
|
187
188
|
return { deployment, url: project.url, migrations: mig, probes, source, sourceSkipped, lineage, warnings: pack.warnings };
|
|
188
189
|
}
|
|
189
190
|
/** The one line about the code after a live deploy, keyed on what the API recorded, not on what we sent. */
|
package/dist/commands/init.js
CHANGED
|
@@ -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.16";
|
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 { staticGate } from "./static.js";
|
|
5
6
|
import { testsGate } from "./tests.js";
|
|
6
7
|
import { applyAttempts, MAX_ATTEMPTS } from "./attempts.js";
|
|
7
8
|
export { gateSummary, skippedGates } from "./types.js";
|
|
@@ -10,7 +11,7 @@ export { mask } from "./secrets.js";
|
|
|
10
11
|
export { MAX_ATTEMPTS, readAttempts } from "./attempts.js";
|
|
11
12
|
export { probeDeployment, skippedProbe, waitForDeployment } from "./smoke.js";
|
|
12
13
|
/** Gates that run on source, before the build. Suppressions first so a human sees them before anything else. */
|
|
13
|
-
export const SOURCE_GATES = [suppressionsGate, secretsGate, gitleaksGate, depsGate, rulesGate, semgrepGate, policyGate, piiGate, typecheckGate, testsGate];
|
|
14
|
+
export const SOURCE_GATES = [suppressionsGate, secretsGate, gitleaksGate, depsGate, rulesGate, staticGate, semgrepGate, policyGate, piiGate, typecheckGate, testsGate];
|
|
14
15
|
/** Gates that need the built output. */
|
|
15
16
|
export const BUILD_GATES = [bundleGate, policyGate];
|
|
16
17
|
function selectGates(gates, onlyRules) {
|
package/dist/gates/rules.js
CHANGED
|
@@ -36,6 +36,16 @@ 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
|
+
"code/raw-sql": { title: "SQL built from values", severity: "block", audience: "agent", risk: "A value from a request placed straight into SQL lets a visitor rewrite the query and read or delete other people's data.", fix: "Use the Drizzle query builder, or bind values as parameters (`.prepare(\"... where id = ?\").bind(id)`). Never put a variable inside the SQL text.", example: { wrong: "c.env.DB.prepare(`select * from notes where id = ${id}`)", right: "db(c.env.DB).select().from(schema.notes).where(eq(schema.notes.id, id))" } },
|
|
40
|
+
"code/eval": { title: "eval or new Function", severity: "block", audience: "agent", risk: "Running text as code lets anyone who can influence that text run their own code in the app.", fix: "Remove it. Parse data with JSON.parse and choose behaviour with ordinary code." },
|
|
41
|
+
"code/password-in-app": { title: "Password handling in app code", severity: "block", audience: "agent", risk: "Storing or comparing passwords yourself is unnecessary here and easy to get wrong.", fix: "Remove it. Sign-in is provided by the platform: read the signed-in user from `c.var.user` and protect routes with `requireAuth()`.", example: { wrong: "if (user.password === body.password) { ... }", right: "app.get(\"/api/me\", requireAuth(), (c) => c.json(c.var.user));" } },
|
|
42
|
+
"code/tokens-in-app": { title: "Signing or verifying tokens in app code", severity: "warn", audience: "agent", risk: "A second token system beside the platform's sessions is a second way in, and a common source of mistakes.", fix: "Use the session the SDK provides (`c.var.user`). If a third-party service requires its own signed token, keep that code in one file and say so to the person." },
|
|
43
|
+
"code/html-from-data": { title: "HTML built from data", severity: "warn", audience: "agent", risk: "Text a user typed, rendered as HTML, can carry a script that runs in other users' browsers.", fix: "Render the value as text and let React escape it. Do not use `dangerouslySetInnerHTML` or `innerHTML` with anything that is not a fixed string." },
|
|
44
|
+
"code/open-redirect": { title: "Redirect to an address from the request", severity: "warn", audience: "agent", risk: "A link to your app could send people on to a look-alike site.", fix: "Redirect only to paths inside the app: check that the value starts with a single `/` and is one of the pages you expect." },
|
|
45
|
+
"code/fetch-user-url": { title: "Fetching an address from the request", severity: "warn", audience: "agent", risk: "A visitor could make the server fetch addresses it should not reach.", fix: "Fetch only fixed addresses, or check the host against a short list you wrote." },
|
|
46
|
+
"code/weak-random": { title: "Math.random used for a secret", severity: "warn", audience: "agent", risk: "Math.random is predictable, so a token or code made with it can be guessed.", fix: "Use `crypto.randomUUID()` or `crypto.getRandomValues()`." },
|
|
47
|
+
"code/unvalidated-body": { title: "A request body read without a check", severity: "warn", audience: "agent", risk: "Fields of any type or size reach the route, which is how bad data and oversized input get into the database.", fix: "Read it with `readBody(c, schema)` from `@nerdsystem/sdk` and a zod schema that gives every field a type and a limit. It answers 400 for anything else.", example: { wrong: "const body = await c.req.json();", right: "const input = await readBody(c, z.object({ title: z.string().trim().min(1).max(200) }));" } },
|
|
48
|
+
"code/routes-open-by-default": { title: "Routes are open unless each one is protected", severity: "warn", audience: "agent", risk: "A route added without `requireAuth()` answers everyone on the internet.", fix: "Pass the list of public routes to `createNerdApp({ public: [\"GET /api/health\"] })` (needs @nerdsystem/sdk 0.0.15 or later). Every other route then requires sign-in automatically. Run the tests afterwards: a route that was meant to be public must be on the list." },
|
|
39
49
|
"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
50
|
"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
51
|
"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." },
|
|
@@ -60,6 +70,7 @@ export const RULES = {
|
|
|
60
70
|
"gates/retry-cap": { title: "Automatic fixing stopped after three attempts", severity: "block", audience: "human", risk: "The same rule has failed three runs in a row; further automatic attempts are unlikely to help and may make things worse.", fix: "Read the findings above and decide: fix it by hand, ask the agent a different way, or approve a suppression in the dashboard if the finding is wrong." },
|
|
61
71
|
"smoke/unreachable": { title: "Deployed app did not respond", severity: "block", audience: "human", risk: "The app is not serving requests.", fix: "Check `nerd logs` for startup errors and the deployment status." },
|
|
62
72
|
"smoke/header-missing": { title: "A security header is missing", severity: "warn", audience: "agent", risk: "Browsers lose a protection against clickjacking, content sniffing, or script injection.", fix: "Keep createNerdApp()'s default security headers; if you disabled them, re-enable or set the header yourself." },
|
|
73
|
+
"smoke/route-open": { title: "A locked route answers strangers on the live app", severity: "block", audience: "agent", risk: "A route the tests found locked is answering signed-out visitors on the live app, so its data may be readable by anyone right now.", fix: "Run `nerd rollback` to return to the previous version now. Then find why the live app differs from the tests (a route registered only in production, or the `public` list in `createNerdApp`), fix it, and deploy again." },
|
|
63
74
|
"smoke/auth-me": { title: "Session endpoint misbehaves", severity: "block", audience: "agent", risk: "Visitors cannot tell whether they are signed in, so the app's login flow is broken.", fix: "/api/auth/me must return 200 with {user:null} when signed out. Do not override the SDK's auth routes." },
|
|
64
75
|
"smoke/auth-login": { title: "Wrong credentials are not rejected", severity: "block", audience: "agent", risk: "The login endpoint does not return 401 for a wrong password.", fix: "Do not override the SDK's /api/auth/login route." },
|
|
65
76
|
};
|
package/dist/gates/smoke.d.ts
CHANGED
|
@@ -12,7 +12,12 @@ export declare function waitForDeployment(url: string, opts?: {
|
|
|
12
12
|
/**
|
|
13
13
|
* Runs the live-URL probes: security headers on GET /, GET /api/auth/me answers 200 with
|
|
14
14
|
* {"user":null} when signed out, and POST /api/auth/login with wrong credentials answers 401.
|
|
15
|
+
* With `lockedGets` (paths the app's access tests found locked), each is also fetched signed out on
|
|
16
|
+
* the live app and must still answer 401 or 403: the same question the tests asked, put to the real
|
|
17
|
+
* thing. Only GETs are tried, so a probe can never change anyone's data.
|
|
15
18
|
*/
|
|
16
|
-
export declare function probeDeployment(url: string, fetchImpl?: FetchLike
|
|
19
|
+
export declare function probeDeployment(url: string, fetchImpl?: FetchLike, opts?: {
|
|
20
|
+
lockedGets?: string[];
|
|
21
|
+
}): Promise<GateResult>;
|
|
17
22
|
/** The result recorded when the probe did not run, so --json consumers see why rather than a missing key. */
|
|
18
23
|
export declare function skippedProbe(reason: string): GateResult;
|
package/dist/gates/smoke.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import { finding, fingerprint } from "./secrets.js";
|
|
8
8
|
import { verdict } from "./types.js";
|
|
9
9
|
const PROBE_TIMEOUT_MS = 10_000;
|
|
10
|
+
/** Most routes fetched signed out after a deploy. */
|
|
11
|
+
const MAX_ROUTE_PROBES = 25;
|
|
10
12
|
const REQUIRED_HEADERS = ["x-content-type-options", "content-security-policy", "x-frame-options"];
|
|
11
13
|
/** Default fetch wrapped so it is never called with a foreign `this` (workerd throws "Illegal invocation"). */
|
|
12
14
|
const defaultFetch = (input, init) => fetch(input, init);
|
|
@@ -51,8 +53,11 @@ export async function waitForDeployment(url, opts = {}) {
|
|
|
51
53
|
/**
|
|
52
54
|
* Runs the live-URL probes: security headers on GET /, GET /api/auth/me answers 200 with
|
|
53
55
|
* {"user":null} when signed out, and POST /api/auth/login with wrong credentials answers 401.
|
|
56
|
+
* With `lockedGets` (paths the app's access tests found locked), each is also fetched signed out on
|
|
57
|
+
* the live app and must still answer 401 or 403: the same question the tests asked, put to the real
|
|
58
|
+
* thing. Only GETs are tried, so a probe can never change anyone's data.
|
|
54
59
|
*/
|
|
55
|
-
export async function probeDeployment(url, fetchImpl = defaultFetch) {
|
|
60
|
+
export async function probeDeployment(url, fetchImpl = defaultFetch, opts = {}) {
|
|
56
61
|
const startedAt = Date.now();
|
|
57
62
|
const base = url.replace(/\/$/, "");
|
|
58
63
|
const findings = [];
|
|
@@ -93,6 +98,15 @@ export async function probeDeployment(url, fetchImpl = defaultFetch) {
|
|
|
93
98
|
if (login.status !== 401)
|
|
94
99
|
findings.push(smokeFinding("smoke/auth-login", `Wrong credentials returned ${login.status} instead of 401.`, "/api/auth/login"));
|
|
95
100
|
}
|
|
101
|
+
for (const p of (opts.lockedGets ?? []).slice(0, MAX_ROUTE_PROBES)) {
|
|
102
|
+
const res = await get(p);
|
|
103
|
+
if (!res)
|
|
104
|
+
continue;
|
|
105
|
+
await res.body?.cancel().catch(() => { });
|
|
106
|
+
if (res.status !== 401 && res.status !== 403) {
|
|
107
|
+
findings.push(smokeFinding("smoke/route-open", `GET ${p} answered ${res.status} to a signed-out visitor on the live app, although the tests found it locked.`, `route:${p}`));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
96
110
|
return probeResult(findings, startedAt);
|
|
97
111
|
}
|
|
98
112
|
/** The result recorded when the probe did not run, so --json consumers see why rather than a missing key. */
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { lineOf, sourceFiles } from "./fs.js";
|
|
2
|
+
import { finding } from "./secrets.js";
|
|
3
|
+
import { counts, result, verdict } from "./types.js";
|
|
4
|
+
const SERVER = /^src\/server\//;
|
|
5
|
+
/**
|
|
6
|
+
* The patterns semgrep's nerdsystem rules look for (packages/cli/rules/semgrep.yml), as plain text
|
|
7
|
+
* matching, so they run on every machine and every deploy without anything installed. Semgrep reads the
|
|
8
|
+
* code's structure and stays the more exact tool where it runs; these are deliberately narrow so they
|
|
9
|
+
* do not cry wolf. Three block, as the same semgrep rules do; the rest warn.
|
|
10
|
+
*/
|
|
11
|
+
const CHECKS = [
|
|
12
|
+
// SQL assembled from values: a template with ${} or a string joined with +, handed to prepare/exec/sql.raw.
|
|
13
|
+
{ rule: "code/raw-sql", re: /(\.prepare|\.exec|sql\.raw)\(\s*(`[^`]*\$\{|["'][^"'\n]*["']\s*\+)/g },
|
|
14
|
+
{ rule: "code/eval", re: /(^|[^.\w])eval\s*\(|\bnew\s+Function\s*\(/g },
|
|
15
|
+
{ rule: "code/password-in-app", re: /\.password\s*={2,3}|={2,3}\s*\w+\.password\b|\b(bcrypt|bcryptjs|argon2)\.(compare|hash|verify)\w*\s*\(|from\s+["'](bcrypt|bcryptjs|argon2)["']/g, files: SERVER },
|
|
16
|
+
{ rule: "code/tokens-in-app", re: /\bjwt\.(sign|verify)\s*\(|from\s+["'](jsonwebtoken|jose|hono\/jwt)["']/g, files: SERVER },
|
|
17
|
+
{ rule: "code/html-from-data", re: /dangerouslySetInnerHTML\s*=\s*\{\{\s*__html\s*:(?!\s*["'][^"'\n]*["']\s*\}\})|\.innerHTML\s*\+?=(?![=])(?!\s*["'][^"'\n]*["']\s*(;|\n|$))/g },
|
|
18
|
+
{ rule: "code/open-redirect", re: /\.redirect\(\s*(c\.req\.(query|param|header)\(|\(?await\s+c\.req\.)/g, files: SERVER },
|
|
19
|
+
{ rule: "code/fetch-user-url", re: /\bfetch\(\s*(c\.req\.(query|param|header)\(|\(?await\s+c\.req\.)/g, files: SERVER },
|
|
20
|
+
{ rule: "code/weak-random", re: /\b\w*(token|secret|nonce|otp|passcode|apikey|api_key)\w*\s*[:=][^;\n]*Math\.random\(\)/gi },
|
|
21
|
+
// An unchecked body: c.req.json() used directly instead of readBody(c, schema).
|
|
22
|
+
{ rule: "code/unvalidated-body", re: /\bc\.req\.(json|parseBody|formData)\(\)/g, files: SERVER },
|
|
23
|
+
];
|
|
24
|
+
/** Blanks out comments, keeping every character position, so a pattern inside a comment is not a finding. */
|
|
25
|
+
function withoutComments(text) {
|
|
26
|
+
return text.replace(/\/\*[\s\S]*?\*\/|(^|[^:"'`\\])\/\/[^\n]*/g, (m) => m.replace(/[^\n]/g, " "));
|
|
27
|
+
}
|
|
28
|
+
export const staticGate = {
|
|
29
|
+
name: "static",
|
|
30
|
+
title: "Unsafe code patterns",
|
|
31
|
+
blocking: true,
|
|
32
|
+
rulePrefixes: ["code/"],
|
|
33
|
+
async run(ctx) {
|
|
34
|
+
const t = Date.now();
|
|
35
|
+
const findings = [];
|
|
36
|
+
for (const f of sourceFiles(ctx.root)) {
|
|
37
|
+
if (!/\.(ts|tsx|js|jsx|mjs)$/.test(f.rel) || !f.rel.startsWith("src/"))
|
|
38
|
+
continue;
|
|
39
|
+
const text = withoutComments(f.text);
|
|
40
|
+
for (const c of CHECKS) {
|
|
41
|
+
if (c.files && !c.files.test(f.rel))
|
|
42
|
+
continue;
|
|
43
|
+
c.re.lastIndex = 0;
|
|
44
|
+
let m;
|
|
45
|
+
let perFile = 0;
|
|
46
|
+
while ((m = c.re.exec(text)) && perFile < 10) {
|
|
47
|
+
findings.push(finding(c.rule, { file: f.rel, line: lineOf(text, m.index) }));
|
|
48
|
+
perFile++;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Sign-in for every route is opt-in for apps created before it existed; say so until it is on.
|
|
52
|
+
if (f.rel === "src/server/index.ts" && /createNerdApp\s*(<[^>]*>)?\s*\(/.test(text) && !/createNerdApp\s*(<[^>]*>)?\s*\(\s*\{[\s\S]*?\bpublic\s*:/.test(text)) {
|
|
53
|
+
findings.push(finding("code/routes-open-by-default", { file: f.rel }));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return result(this, verdict(findings), findings.length ? counts(findings) : "Nothing unsafe found", findings, t);
|
|
57
|
+
},
|
|
58
|
+
};
|
package/dist/gates/tests.d.ts
CHANGED
|
@@ -4,6 +4,11 @@ import { type Gate } from "./types.js";
|
|
|
4
4
|
* characters a "METHOD /path" needs survive and the length is capped: a route cannot carry a sentence.
|
|
5
5
|
*/
|
|
6
6
|
export declare function safeRoute(name: unknown): string | null;
|
|
7
|
+
/**
|
|
8
|
+
* Concrete paths for the GET routes the last test run found locked, for the live probe after a deploy:
|
|
9
|
+
* every `:param` becomes "1". Empty when there is no report (an app without access tests).
|
|
10
|
+
*/
|
|
11
|
+
export declare function lockedGetPaths(root: string): string[];
|
|
7
12
|
/**
|
|
8
13
|
* Runs the app's own tests and reads what they checked about access. The person who owns the app cannot
|
|
9
14
|
* read the code, and the agent that forgot a sign-in check tends to forget the test for it too, so the
|
package/dist/gates/tests.js
CHANGED
|
@@ -26,6 +26,18 @@ function readJson(file) {
|
|
|
26
26
|
return null;
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Concrete paths for the GET routes the last test run found locked, for the live probe after a deploy:
|
|
31
|
+
* every `:param` becomes "1". Empty when there is no report (an app without access tests).
|
|
32
|
+
*/
|
|
33
|
+
export function lockedGetPaths(root) {
|
|
34
|
+
const report = readJson(path.join(root, REPORT));
|
|
35
|
+
if (!report || report.everyRouteChecked !== true)
|
|
36
|
+
return [];
|
|
37
|
+
return list(report.locked)
|
|
38
|
+
.filter((r) => r.startsWith("GET ") && !r.includes("*"))
|
|
39
|
+
.map((r) => r.slice(4).split("/").map((seg) => (seg.startsWith(":") ? "1" : seg)).join("/"));
|
|
40
|
+
}
|
|
29
41
|
/** True when a file under test/ uses the SDK's harness, which is what writes the access report. */
|
|
30
42
|
function hasHarnessTests(root) {
|
|
31
43
|
const dir = path.join(root, "test");
|
|
@@ -62,10 +74,6 @@ export const testsGate = {
|
|
|
62
74
|
const f = finding(required ? "tests/required-missing" : "tests/missing", { file: "test/access.test.ts" });
|
|
63
75
|
return result(this, required ? "fail" : "warn", "No access tests in this app", [f], t);
|
|
64
76
|
}
|
|
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
77
|
if (!existsSync(path.join(ctx.root, "node_modules"))) {
|
|
70
78
|
return result(this, "fail", "Dependencies are not installed", [finding("tests/not-installed", {})], t);
|
|
71
79
|
}
|
package/package.json
CHANGED
|
@@ -36,7 +36,7 @@ Tell the person plainly what does not carry. Their users sign up again on the ne
|
|
|
36
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
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
|
-
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
|
|
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, the sign-in routes, and a signed-out GET of every route the tests found locked; a `smoke/route-open` finding means a locked route is answering strangers on the live app, so run `nerd rollback` first and investigate second). 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.
|
|
41
41
|
|
|
42
42
|
Run `nerd check --json` when you want a machine-readable list of problems. Its summary says how many gates ran and which were skipped (`skipped` in the JSON): semgrep and gitleaks only run locally when installed; git builds always run them in the builder.
|
|
@@ -99,10 +99,10 @@ Then in `src/server/index.ts`: `app.route("/api/notes", notes);`
|
|
|
99
99
|
|
|
100
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
101
|
|
|
102
|
-
1. **
|
|
102
|
+
1. **Every route is locked unless you open it.** `createNerdApp({ public: [...] })` in `src/server/index.ts` makes every route require a signed-in user except the ones named in that list, so a route you forget to protect answers 401 instead of answering everyone. Still put `requireAuth()` on each router (`router.use("*", requireAuth())`) and `requireRole(...)` where a role is needed: it says what the route expects and gives TypeScript a user. 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 `src/server/index.ts` and to the same list in `test/access.test.ts`, and tell the person in plain words that it is open. Never remove the `public` option or widen it to get something working.
|
|
103
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
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.**
|
|
105
|
+
4. **Check every input on the server.** Read a body with `readBody(c, schema)` and a query string with `readQuery(c, schema)` from `@nerdsystem/sdk`, with a zod schema that gives every field a type, a length, a range, or a list of allowed values (see `src/server/routes/todos.ts`). They answer 400 in the usual error shape for anything else, before your code runs. Do not call `c.req.json()` directly; `nerd check` flags it. Path parameters get the same care: turn an id into a number and let a missing row be a 404. The client's own checks are for the person's convenience; they protect nothing.
|
|
106
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
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
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 } }`.
|
|
@@ -161,6 +161,7 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
161
161
|
- New bindings in `wrangler.jsonc`. Only `DB`, `STORAGE`, and `ASSETS` exist. Durable Objects are not available.
|
|
162
162
|
- Scripts that call `wrangler deploy`, `wrangler secret`, or remote `wrangler d1` commands.
|
|
163
163
|
- Typecheck failures in either `src/server` or `src/client`.
|
|
164
|
+
- SQL built from values, `eval` or `new Function`, and password handling in server code (the `code/` rules; they block). It also flags, without blocking: tokens signed in app code, HTML built from data, redirects and fetches to an address from the request, `Math.random` for a secret, a body read without `readBody`, and an app that has not turned on `public` in `createNerdApp`.
|
|
164
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).
|
|
165
166
|
|
|
166
167
|
## Scheduled jobs and email
|
|
@@ -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 (e719563eac29); edit the skill, then run: nerd agents sync -->
|
|
6
6
|
|
|
7
7
|
# Building on nerdsystem
|
|
8
8
|
|
|
@@ -37,7 +37,7 @@ Tell the person plainly what does not carry. Their users sign up again on the ne
|
|
|
37
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
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
|
-
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
|
|
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, the sign-in routes, and a signed-out GET of every route the tests found locked; a `smoke/route-open` finding means a locked route is answering strangers on the live app, so run `nerd rollback` first and investigate second). 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.
|
|
42
42
|
|
|
43
43
|
Run `nerd check --json` when you want a machine-readable list of problems. Its summary says how many gates ran and which were skipped (`skipped` in the JSON): semgrep and gitleaks only run locally when installed; git builds always run them in the builder.
|
|
@@ -100,10 +100,10 @@ Then in `src/server/index.ts`: `app.route("/api/notes", notes);`
|
|
|
100
100
|
|
|
101
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
102
|
|
|
103
|
-
1. **
|
|
103
|
+
1. **Every route is locked unless you open it.** `createNerdApp({ public: [...] })` in `src/server/index.ts` makes every route require a signed-in user except the ones named in that list, so a route you forget to protect answers 401 instead of answering everyone. Still put `requireAuth()` on each router (`router.use("*", requireAuth())`) and `requireRole(...)` where a role is needed: it says what the route expects and gives TypeScript a user. 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 `src/server/index.ts` and to the same list in `test/access.test.ts`, and tell the person in plain words that it is open. Never remove the `public` option or widen it to get something working.
|
|
104
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
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.**
|
|
106
|
+
4. **Check every input on the server.** Read a body with `readBody(c, schema)` and a query string with `readQuery(c, schema)` from `@nerdsystem/sdk`, with a zod schema that gives every field a type, a length, a range, or a list of allowed values (see `src/server/routes/todos.ts`). They answer 400 in the usual error shape for anything else, before your code runs. Do not call `c.req.json()` directly; `nerd check` flags it. Path parameters get the same care: turn an id into a number and let a missing row be a 404. The client's own checks are for the person's convenience; they protect nothing.
|
|
107
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
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
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 } }`.
|
|
@@ -162,6 +162,7 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
162
162
|
- New bindings in `wrangler.jsonc`. Only `DB`, `STORAGE`, and `ASSETS` exist. Durable Objects are not available.
|
|
163
163
|
- Scripts that call `wrangler deploy`, `wrangler secret`, or remote `wrangler d1` commands.
|
|
164
164
|
- Typecheck failures in either `src/server` or `src/client`.
|
|
165
|
+
- SQL built from values, `eval` or `new Function`, and password handling in server code (the `code/` rules; they block). It also flags, without blocking: tokens signed in app code, HTML built from data, redirects and fetches to an address from the request, `Math.random` for a secret, a body read without `readBody`, and an app that has not turned on `public` in `createNerdApp`.
|
|
165
166
|
- A failing test, a missing access sweep, or a route with an id that no second user was tried against (the `tests/` rules above).
|
|
166
167
|
|
|
167
168
|
## Scheduled jobs and email
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- generated from .claude/skills/nerdsystem/SKILL.md (
|
|
1
|
+
<!-- generated from .claude/skills/nerdsystem/SKILL.md (e719563eac29); edit the skill, then run: nerd agents sync -->
|
|
2
2
|
|
|
3
3
|
# Building on nerdsystem
|
|
4
4
|
|
|
@@ -33,7 +33,7 @@ Tell the person plainly what does not carry. Their users sign up again on the ne
|
|
|
33
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
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
|
-
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
|
|
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, the sign-in routes, and a signed-out GET of every route the tests found locked; a `smoke/route-open` finding means a locked route is answering strangers on the live app, so run `nerd rollback` first and investigate second). 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.
|
|
38
38
|
|
|
39
39
|
Run `nerd check --json` when you want a machine-readable list of problems. Its summary says how many gates ran and which were skipped (`skipped` in the JSON): semgrep and gitleaks only run locally when installed; git builds always run them in the builder.
|
|
@@ -96,10 +96,10 @@ Then in `src/server/index.ts`: `app.route("/api/notes", notes);`
|
|
|
96
96
|
|
|
97
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
98
|
|
|
99
|
-
1. **
|
|
99
|
+
1. **Every route is locked unless you open it.** `createNerdApp({ public: [...] })` in `src/server/index.ts` makes every route require a signed-in user except the ones named in that list, so a route you forget to protect answers 401 instead of answering everyone. Still put `requireAuth()` on each router (`router.use("*", requireAuth())`) and `requireRole(...)` where a role is needed: it says what the route expects and gives TypeScript a user. 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 `src/server/index.ts` and to the same list in `test/access.test.ts`, and tell the person in plain words that it is open. Never remove the `public` option or widen it to get something working.
|
|
100
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
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.**
|
|
102
|
+
4. **Check every input on the server.** Read a body with `readBody(c, schema)` and a query string with `readQuery(c, schema)` from `@nerdsystem/sdk`, with a zod schema that gives every field a type, a length, a range, or a list of allowed values (see `src/server/routes/todos.ts`). They answer 400 in the usual error shape for anything else, before your code runs. Do not call `c.req.json()` directly; `nerd check` flags it. Path parameters get the same care: turn an id into a number and let a missing row be a 404. The client's own checks are for the person's convenience; they protect nothing.
|
|
103
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
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
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 } }`.
|
|
@@ -158,6 +158,7 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
158
158
|
- New bindings in `wrangler.jsonc`. Only `DB`, `STORAGE`, and `ASSETS` exist. Durable Objects are not available.
|
|
159
159
|
- Scripts that call `wrangler deploy`, `wrangler secret`, or remote `wrangler d1` commands.
|
|
160
160
|
- Typecheck failures in either `src/server` or `src/client`.
|
|
161
|
+
- SQL built from values, `eval` or `new Function`, and password handling in server code (the `code/` rules; they block). It also flags, without blocking: tokens signed in app code, HTML built from data, redirects and fetches to an address from the request, `Math.random` for a secret, a body read without `readBody`, and an app that has not turned on `public` in `createNerdApp`.
|
|
161
162
|
- A failing test, a missing access sweep, or a route with an id that no second user was tried against (the `tests/` rules above).
|
|
162
163
|
|
|
163
164
|
## Scheduled jobs and email
|
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 (e719563eac29); edit the skill, then run: nerd agents sync -->
|
|
2
2
|
|
|
3
3
|
# Building on nerdsystem
|
|
4
4
|
|
|
@@ -33,7 +33,7 @@ Tell the person plainly what does not carry. Their users sign up again on the ne
|
|
|
33
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
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
|
-
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
|
|
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, the sign-in routes, and a signed-out GET of every route the tests found locked; a `smoke/route-open` finding means a locked route is answering strangers on the live app, so run `nerd rollback` first and investigate second). 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.
|
|
38
38
|
|
|
39
39
|
Run `nerd check --json` when you want a machine-readable list of problems. Its summary says how many gates ran and which were skipped (`skipped` in the JSON): semgrep and gitleaks only run locally when installed; git builds always run them in the builder.
|
|
@@ -96,10 +96,10 @@ Then in `src/server/index.ts`: `app.route("/api/notes", notes);`
|
|
|
96
96
|
|
|
97
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
98
|
|
|
99
|
-
1. **
|
|
99
|
+
1. **Every route is locked unless you open it.** `createNerdApp({ public: [...] })` in `src/server/index.ts` makes every route require a signed-in user except the ones named in that list, so a route you forget to protect answers 401 instead of answering everyone. Still put `requireAuth()` on each router (`router.use("*", requireAuth())`) and `requireRole(...)` where a role is needed: it says what the route expects and gives TypeScript a user. 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 `src/server/index.ts` and to the same list in `test/access.test.ts`, and tell the person in plain words that it is open. Never remove the `public` option or widen it to get something working.
|
|
100
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
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.**
|
|
102
|
+
4. **Check every input on the server.** Read a body with `readBody(c, schema)` and a query string with `readQuery(c, schema)` from `@nerdsystem/sdk`, with a zod schema that gives every field a type, a length, a range, or a list of allowed values (see `src/server/routes/todos.ts`). They answer 400 in the usual error shape for anything else, before your code runs. Do not call `c.req.json()` directly; `nerd check` flags it. Path parameters get the same care: turn an id into a number and let a missing row be a 404. The client's own checks are for the person's convenience; they protect nothing.
|
|
103
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
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
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 } }`.
|
|
@@ -158,6 +158,7 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
158
158
|
- New bindings in `wrangler.jsonc`. Only `DB`, `STORAGE`, and `ASSETS` exist. Durable Objects are not available.
|
|
159
159
|
- Scripts that call `wrangler deploy`, `wrangler secret`, or remote `wrangler d1` commands.
|
|
160
160
|
- Typecheck failures in either `src/server` or `src/client`.
|
|
161
|
+
- SQL built from values, `eval` or `new Function`, and password handling in server code (the `code/` rules; they block). It also flags, without blocking: tokens signed in app code, HTML built from data, redirects and fetches to an address from the request, `Math.random` for a secret, a body read without `readBody`, and an app that has not turned on `public` in `createNerdApp`.
|
|
161
162
|
- A failing test, a missing access sweep, or a route with an id that no second user was tried against (the `tests/` rules above).
|
|
162
163
|
|
|
163
164
|
## Scheduled jobs and email
|
package/template/CLAUDE.md
CHANGED
|
@@ -28,7 +28,8 @@ nerdsystem deploys this app to Cloudflare and provides authentication and author
|
|
|
28
28
|
5. Keep every API route under `/api/`. Other paths are served from static assets and never reach the Worker.
|
|
29
29
|
6. Do not add Cloudflare bindings to `wrangler.jsonc`, and do not run `wrangler deploy`. Bindings are provisioned by the platform.
|
|
30
30
|
7. Secrets and environment variables for the deployed app are managed with `nerd` commands, never committed.
|
|
31
|
-
8. Every route is
|
|
31
|
+
8. Every route requires a signed-in user unless it is named in `createNerdApp({ public: [...] })` and in the same list 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.
|
|
32
|
+
9. Read request bodies with `readBody(c, schema)` and a zod schema, never `c.req.json()` directly.
|
|
32
33
|
|
|
33
34
|
## Roles
|
|
34
35
|
|
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 (e719563eac29); edit the skill, then run: nerd agents sync -->
|
|
2
2
|
|
|
3
3
|
# Building on nerdsystem
|
|
4
4
|
|
|
@@ -33,7 +33,7 @@ Tell the person plainly what does not carry. Their users sign up again on the ne
|
|
|
33
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
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
|
-
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
|
|
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, the sign-in routes, and a signed-out GET of every route the tests found locked; a `smoke/route-open` finding means a locked route is answering strangers on the live app, so run `nerd rollback` first and investigate second). 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.
|
|
38
38
|
|
|
39
39
|
Run `nerd check --json` when you want a machine-readable list of problems. Its summary says how many gates ran and which were skipped (`skipped` in the JSON): semgrep and gitleaks only run locally when installed; git builds always run them in the builder.
|
|
@@ -96,10 +96,10 @@ Then in `src/server/index.ts`: `app.route("/api/notes", notes);`
|
|
|
96
96
|
|
|
97
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
98
|
|
|
99
|
-
1. **
|
|
99
|
+
1. **Every route is locked unless you open it.** `createNerdApp({ public: [...] })` in `src/server/index.ts` makes every route require a signed-in user except the ones named in that list, so a route you forget to protect answers 401 instead of answering everyone. Still put `requireAuth()` on each router (`router.use("*", requireAuth())`) and `requireRole(...)` where a role is needed: it says what the route expects and gives TypeScript a user. 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 `src/server/index.ts` and to the same list in `test/access.test.ts`, and tell the person in plain words that it is open. Never remove the `public` option or widen it to get something working.
|
|
100
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
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.**
|
|
102
|
+
4. **Check every input on the server.** Read a body with `readBody(c, schema)` and a query string with `readQuery(c, schema)` from `@nerdsystem/sdk`, with a zod schema that gives every field a type, a length, a range, or a list of allowed values (see `src/server/routes/todos.ts`). They answer 400 in the usual error shape for anything else, before your code runs. Do not call `c.req.json()` directly; `nerd check` flags it. Path parameters get the same care: turn an id into a number and let a missing row be a 404. The client's own checks are for the person's convenience; they protect nothing.
|
|
103
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
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
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 } }`.
|
|
@@ -158,6 +158,7 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
158
158
|
- New bindings in `wrangler.jsonc`. Only `DB`, `STORAGE`, and `ASSETS` exist. Durable Objects are not available.
|
|
159
159
|
- Scripts that call `wrangler deploy`, `wrangler secret`, or remote `wrangler d1` commands.
|
|
160
160
|
- Typecheck failures in either `src/server` or `src/client`.
|
|
161
|
+
- SQL built from values, `eval` or `new Function`, and password handling in server code (the `code/` rules; they block). It also flags, without blocking: tokens signed in app code, HTML built from data, redirects and fetches to an address from the request, `Math.random` for a secret, a body read without `readBody`, and an app that has not turned on `public` in `createNerdApp`.
|
|
161
162
|
- A failing test, a missing access sweep, or a route with an id that no second user was tried against (the `tests/` rules above).
|
|
162
163
|
|
|
163
164
|
## Scheduled jobs and email
|
package/template/package.json
CHANGED
|
@@ -3,7 +3,11 @@ import todos from "./routes/todos";
|
|
|
3
3
|
|
|
4
4
|
// createNerdApp gives you a Hono app with sessions at /api/auth and c.var.user on every request.
|
|
5
5
|
// Static assets (the React client) are served by the platform for every path outside /api.
|
|
6
|
-
|
|
6
|
+
//
|
|
7
|
+
// Every route needs a signed-in user unless it is named in `public`. A route you forget to protect
|
|
8
|
+
// answers 401, not everyone. Add a route here only when the whole internet is meant to reach it and it
|
|
9
|
+
// returns nothing private, and add it to the same list in test/access.test.ts.
|
|
10
|
+
const app = createNerdApp({ public: ["GET /api/health"] });
|
|
7
11
|
|
|
8
12
|
app.get("/api/health", (c) => c.json({ ok: true, user: c.var.user?.email ?? null }));
|
|
9
13
|
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { and, desc, eq } from "drizzle-orm";
|
|
3
|
-
import {
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { readBody, requireAuth, type NerdHonoEnv } from "@nerdsystem/sdk";
|
|
4
5
|
import { db, schema } from "../db";
|
|
5
6
|
|
|
7
|
+
// What each request may contain. readBody() answers 400 for anything else, before the route runs.
|
|
8
|
+
const NewTodo = z.object({ title: z.string().trim().min(1, "title is required").max(200) });
|
|
9
|
+
const TodoChange = z.object({ done: z.boolean() });
|
|
10
|
+
|
|
6
11
|
// Every route here needs a signed-in user. c.var.user is set by the SDK.
|
|
7
12
|
const todos = new Hono<NerdHonoEnv>();
|
|
8
13
|
todos.use("*", requireAuth());
|
|
@@ -18,13 +23,10 @@ todos.get("/", async (c) => {
|
|
|
18
23
|
});
|
|
19
24
|
|
|
20
25
|
todos.post("/", async (c) => {
|
|
21
|
-
const
|
|
22
|
-
if (typeof body.title !== "string" || !body.title.trim()) {
|
|
23
|
-
return c.json({ error: { code: "bad_request", message: "title is required" } }, 400);
|
|
24
|
-
}
|
|
26
|
+
const input = await readBody(c, NewTodo);
|
|
25
27
|
const row = await db(c.env.DB)
|
|
26
28
|
.insert(schema.todos)
|
|
27
|
-
.values({ userId: c.var.user!.id, title:
|
|
29
|
+
.values({ userId: c.var.user!.id, title: input.title })
|
|
28
30
|
.returning()
|
|
29
31
|
.get();
|
|
30
32
|
return c.json({ todo: row }, 201);
|
|
@@ -32,10 +34,10 @@ todos.post("/", async (c) => {
|
|
|
32
34
|
|
|
33
35
|
todos.patch("/:id", async (c) => {
|
|
34
36
|
const id = Number(c.req.param("id"));
|
|
35
|
-
const
|
|
37
|
+
const input = await readBody(c, TodoChange);
|
|
36
38
|
const row = await db(c.env.DB)
|
|
37
39
|
.update(schema.todos)
|
|
38
|
-
.set({ done:
|
|
40
|
+
.set({ done: input.done })
|
|
39
41
|
.where(and(eq(schema.todos.id, id), eq(schema.todos.userId, c.var.user!.id)))
|
|
40
42
|
.returning()
|
|
41
43
|
.get();
|
|
@@ -26,6 +26,9 @@ describe("todos", () => {
|
|
|
26
26
|
const erin = await t.as("erin");
|
|
27
27
|
expect((await erin.post("/api/todos", {})).status).toBe(400);
|
|
28
28
|
expect((await erin.post("/api/todos", { title: " " })).status).toBe(400);
|
|
29
|
+
expect((await erin.post("/api/todos", { title: "x".repeat(201) })).status).toBe(400);
|
|
30
|
+
expect((await erin.post("/api/todos", { title: 42 })).status).toBe(400);
|
|
31
|
+
expect((await erin.patch("/api/todos/1", { done: "yes" })).status).toBe(400);
|
|
29
32
|
expect((await erin.patch("/api/todos/999999", { done: true })).status).toBe(404);
|
|
30
33
|
});
|
|
31
34
|
});
|