nerdsystem 0.0.26 → 0.0.28

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.
@@ -16,7 +16,12 @@ export interface BuildOutput {
16
16
  * never uploads it, and it is rewritten by every build, so the copy is removed here instead of being
17
17
  * reported on every run with nothing for anyone to do about it. The file in the app's own folder stays.
18
18
  */
19
- export declare function dropLocalSecretCopies(build: BuildOutput): void;
19
+ export declare function dropLocalSecretCopies(build: BuildOutput): number;
20
+ /**
21
+ * The build's own output lists that copy ("Using secrets defined in .dev.vars", then the file among what
22
+ * it wrote), which reads as if local secrets were about to be uploaded. Said once, right after the build.
23
+ */
24
+ export declare const SECRET_COPY_NOTE = "The build tool listed a copy of your local secrets file (.dev.vars) in its output folder; it makes one for local previews. nerd has deleted that copy. It is never uploaded: the live app gets its secrets from `nerd secret set`.";
20
25
  export declare function readBuildOutput(root: string): BuildOutput;
21
26
  export declare function applyRemoteMigrations(root: string, api: ApiClient): Promise<{
22
27
  applied: string[];
@@ -32,11 +32,21 @@ function walk(dir, base = dir) {
32
32
  */
33
33
  export function dropLocalSecretCopies(build) {
34
34
  if (!existsSync(build.workerDir))
35
- return;
36
- for (const name of readdirSync(build.workerDir))
37
- if (ENV_FILES.test(name))
38
- rmSync(path.join(build.workerDir, name), { force: true });
35
+ return 0;
36
+ let dropped = 0;
37
+ for (const name of readdirSync(build.workerDir)) {
38
+ if (!ENV_FILES.test(name))
39
+ continue;
40
+ rmSync(path.join(build.workerDir, name), { force: true });
41
+ dropped++;
42
+ }
43
+ return dropped;
39
44
  }
45
+ /**
46
+ * The build's own output lists that copy ("Using secrets defined in .dev.vars", then the file among what
47
+ * it wrote), which reads as if local secrets were about to be uploaded. Said once, right after the build.
48
+ */
49
+ export const SECRET_COPY_NOTE = "The build tool listed a copy of your local secrets file (.dev.vars) in its output folder; it makes one for local previews. nerd has deleted that copy. It is never uploaded: the live app gets its secrets from `nerd secret set`.";
40
50
  export function readBuildOutput(root) {
41
51
  const dist = path.join(root, "dist");
42
52
  if (!existsSync(dist))
@@ -238,7 +248,8 @@ export async function deploy(opts) {
238
248
  await runOrThrow("npx", ["vite", "build"], { cwd: root, stdoutToStderr: true });
239
249
  }
240
250
  const build = readBuildOutput(root);
241
- dropLocalSecretCopies(build);
251
+ if (dropLocalSecretCopies(build))
252
+ log.info(SECRET_COPY_NOTE);
242
253
  const built = await runGates(BUILD_GATES, { root, build, knownSecrets, log: (l) => log.info(l) }, { stopOnFail: true });
243
254
  if (!built.ok)
244
255
  throw new CliError(`Blocked by: ${built.failed.map((g) => g.title).join(", ")}.`, 2);
@@ -192,4 +192,4 @@ export async function init(dir, args) {
192
192
  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}`);
193
193
  }
194
194
  // Kept in sync with packages/sdk/package.json by the release script.
195
- const SDK_VERSION = "0.0.26";
195
+ const SDK_VERSION = "0.0.28";
@@ -16,6 +16,8 @@ export type ReviewOutcome = {
16
16
  platformRead: boolean;
17
17
  /** Whose review the findings on file come from. */
18
18
  kind: "platform" | "session";
19
+ /** Findings recorded as real and deliberately left as they are. Counted, not listed: there is nothing to do about them. */
20
+ leftAsIs: number;
19
21
  } | {
20
22
  status: "skipped";
21
23
  reason: string;
@@ -33,16 +33,22 @@ export async function platformReview(api, sourcePath, dashboard) {
33
33
  }
34
34
  const kind = review.kind === "session" ? "session" : "platform";
35
35
  if (review.status !== "done")
36
- return { status: "declined", findings: [], dashboard, platformRead, kind };
36
+ return { status: "declined", findings: [], dashboard, platformRead, kind, leftAsIs: 0 };
37
37
  const findings = [];
38
+ let leftAsIs = 0;
38
39
  for (const f of review.findings ?? []) {
39
40
  const rule = typeof f.rule_id === "string" && f.rule_id.startsWith("review/") ? RULES[f.rule_id] : undefined;
40
41
  // A type this version of nerd does not know is left out rather than shown with words from elsewhere.
41
42
  if (!rule || typeof f.file !== "string" || !/^[\w./@-]{1,200}$/.test(f.file) || !Number.isInteger(f.line))
42
43
  continue;
44
+ // Recorded as deliberately left: not open, and not something to act on after a deploy.
45
+ if (f.leftAsIs === true) {
46
+ leftAsIs++;
47
+ continue;
48
+ }
43
49
  findings.push({ rule_id: f.rule_id, severity: rule.severity, audience: rule.audience, file: f.file, line: f.line, risk: rule.risk, fix: rule.fix });
44
50
  }
45
- return { status: "done", findings, dashboard, platformRead, kind };
51
+ return { status: "done", findings, dashboard, platformRead, kind, leftAsIs };
46
52
  }
47
53
  export function printReview(r) {
48
54
  if (r.status === "skipped") {
@@ -54,11 +60,12 @@ export function printReview(r) {
54
60
  return;
55
61
  }
56
62
  const whose = r.platformRead ? "nerdsystem read the code itself after this deploy" : r.kind === "session" ? "from the review this app's agent last reported" : "from nerdsystem's last own read of the code";
63
+ const left = r.leftAsIs ? `; ${r.leftAsIs} known and left as ${r.leftAsIs === 1 ? "it is" : "they are"}` : "";
57
64
  if (!r.findings.length) {
58
- log.ok(`Review: nothing open (${whose}). A plain-language summary for the person is at ${r.dashboard}`);
65
+ log.ok(`Review: nothing open (${whose}${left}). A plain-language summary for the person is at ${r.dashboard}`);
59
66
  return;
60
67
  }
61
- log.warn(`Review: ${r.findings.length} thing(s) open (${whose}). A review is a second opinion and can be wrong, so check each one against the code before changing anything. Say what you did about them in the next \`nerd review report\`.`);
68
+ log.warn(`Review: ${r.findings.length} thing(s) open (${whose}${left}). A review is a second opinion and can be wrong, so check each one against the code before changing anything. Say what you did about them in the next \`nerd review report\`.`);
62
69
  for (const f of r.findings) {
63
70
  log.info(` ${f.severity === "info" ? "·" : "!"} ${f.rule_id} ${f.file}:${f.line}${f.audience === "human" ? " (for the person to decide)" : ""}`);
64
71
  log.info(` ${f.risk}`);
@@ -20,7 +20,7 @@ export declare function reviewFiles(root: string): Hashes;
20
20
  /** Records the code as it stands as reviewed. */
21
21
  export declare function writeReviewed(root: string, files: Hashes): void;
22
22
  export interface ReviewDue {
23
- /** Whether code a review reads has changed since the last report from this folder (styles alone do not count). */
23
+ /** Whether the app's own code under `src/` has changed since the last report from this folder (a stylesheet, the README, or package.json alone does not count). */
24
24
  due: boolean;
25
25
  /** No report has been made from this folder: everything is unreviewed as far as this computer knows. */
26
26
  first: boolean;
@@ -64,7 +64,10 @@ export function reviewDue(root) {
64
64
  return { due: Object.keys(now).length > 0, first: true, changed: Object.keys(now).sort() };
65
65
  const changed = Object.keys(now).filter((p) => before[p] !== now[p]).sort();
66
66
  const removed = Object.keys(before).filter((p) => !(p in now));
67
- return { due: [...changed, ...removed].some((p) => !p.endsWith(".css")), first: false, changed };
67
+ // What asks for a review is the app's own code. A stylesheet, the README, or package.json alone (which
68
+ // `nerd upgrade` itself rewrites when it moves the SDK's version) is read with the next review and does not ask for one.
69
+ const code = (p) => p.startsWith("src/") && !p.endsWith(".css");
70
+ return { due: [...changed, ...removed].some(code), first: false, changed };
68
71
  }
69
72
  /** What is open from earlier reviews, as rule ids and places only. Empty when the platform cannot be asked. */
70
73
  async function openFindings(api, base) {
@@ -115,14 +118,14 @@ export function checklistText(c) {
115
118
  if (c.open.length) {
116
119
  lines.push("Open from earlier reviews. Check each against the code as it is now and answer it in the report:");
117
120
  for (const f of c.open)
118
- lines.push(`- ${f.rule_id} at ${f.file}:${f.line}${f.origin === "platform" ? " (from nerdsystem's own read)" : ""}${f.leftAsIs ? " (recorded as left as it is; it stays that way unless you report it differently)" : ""}`);
121
+ lines.push(`- ${f.rule_id} at ${f.file}:${f.line}${f.origin === "platform" ? " (first raised by nerdsystem's own read)" : ""}${f.leftAsIs ? " (recorded as left as it is; it stays that way unless you report it differently)" : ""}`);
119
122
  lines.push("");
120
123
  }
121
124
  lines.push("What to look for:");
122
125
  for (const r of c.rules) {
123
126
  lines.push(`- ${r.rule_id}: ${r.title}${r.audience === "human" ? " [for the person to decide]" : ""}`, ` Look for: ${r.lookFor}`, ` Fix: ${r.fix}`);
124
127
  }
125
- lines.push("", `The report, written to ${c.reportFile}:`, JSON.stringify(c.format, null, 2), "", "- findings: what is still in the code after your fixes and still needs someone to act or decide, each with the rule, the file, and the line that shows it. Leave it empty when there is nothing. A finding marked [for the person to decide] stays here until they have answered.", "- leftAsIs: what is real and deliberately left as it is, because the person decided so or because the rule's own advice is not to change working code for it. It is shown to the owner, kept on later reports, and not announced again to each new session.", "- fixed: what this review found and you then fixed, including findings from the open list above. If you fixed only part of one, list what remains under findings (or leftAsIs) at the line that still shows it, and do not list it as fixed.", "- dismissed: only for a finding from nerdsystem's own read that you checked against the code and that does not apply. The owner sees this as your judgement, so use it when you are sure.", "- A finding from nerdsystem's own read that the report does not mention stays open.", "- summary, does, missing are for the owner and are shown on their dashboard as written. Plain words only.", "", "Report what you found, not what would look good. The report is stored as this agent's own review, it never decides whether a deploy goes out, and a clean report that nerdsystem's own read later contradicts is recorded as a miss.");
128
+ lines.push("", `The report, written to ${c.reportFile}:`, JSON.stringify(c.format, null, 2), "", "- findings: what is still in the code after your fixes and still needs someone to act or decide, each with the rule, the file, and the line that shows it. Leave it empty when there is nothing. A finding marked [for the person to decide] stays here until they have answered.", "- leftAsIs: what is real and deliberately left as it is, because the person decided so or because the rule's own advice is not to change working code for it. Any finding can go here, whoever first raised it. It is shown to the owner, kept on later reports, and not announced again to each new session.", "- fixed: what this review found and you then fixed, including findings from the open list above. If you fixed only part of one, list what remains under findings (or leftAsIs) at the line that still shows it, and do not list it as fixed.", "- dismissed: only for a finding from nerdsystem's own read that you checked against the code and that does not apply. The owner sees this as your judgement, so use it when you are sure.", "- A finding from nerdsystem's own read that the report does not mention stays open.", "- summary, does, missing are for the owner and are shown on their dashboard as written. Plain words only.", "", "Report what you found, not what would look good. The report is stored as this agent's own review, it never decides whether a deploy goes out, and a clean report that nerdsystem's own read later contradicts is recorded as a miss.");
126
129
  return lines.join("\n");
127
130
  }
128
131
  /** `nerd review checklist`: what the agent reads before it reviews. */
@@ -15,6 +15,12 @@ export interface Attention {
15
15
  items: AttentionItem[];
16
16
  /** When the review the findings come from was made, so a finding already fixed since is not a surprise. */
17
17
  reviewedAt?: string;
18
+ /**
19
+ * The app's own code has changed since the last review reported from this folder, so `nerd check` will
20
+ * ask for one before the next deploy. Not something to raise with the person, and never part of what a
21
+ * new session is shown: work in progress is the normal state of a folder.
22
+ */
23
+ reviewDue?: boolean;
18
24
  }
19
25
  /** What is open from the latest review of this app (its agent's own, or the platform's read), mapped through this CLI's own catalog. */
20
26
  export declare function reviewItems(api: ApiClient, base: string): Promise<{
@@ -32,6 +38,8 @@ export declare function reviewItems(api: ApiClient, base: string): Promise<{
32
38
  * that any of it exists, let alone open a dashboard to find it.
33
39
  */
34
40
  export declare function gatherAttention(root: string): Promise<Attention>;
41
+ /** Said by `nerd status` when asked directly, so it does not answer "nothing" a moment before `nerd check` asks for a review. */
42
+ export declare const REVIEW_DUE_NOTE = "The app's code as it is now has not been reviewed from this folder. Nothing to raise with the person: `nerd check` will ask for the review (`nerd review checklist`) before the next deploy.";
35
43
  /** Short enough to sit at the top of a session: what, where, and one line on what to do about each kind. */
36
44
  export declare function attentionText(a: Attention): string;
37
45
  /**
@@ -5,6 +5,7 @@ import { RULES } from "../gates/rules.js";
5
5
  import { currentVersions, pinned, versionFindings } from "../gates/versions.js";
6
6
  import { findProjectRoot, projectPath, readNerdJson } from "../project.js";
7
7
  import { localDay, log } from "../util.js";
8
+ import { reviewDue } from "./session-review.js";
8
9
  import { cliVersion } from "./source.js";
9
10
  const within = (p, ms) => Promise.race([p.catch(() => null), new Promise((r) => setTimeout(() => r(null), ms))]);
10
11
  const item = (rule_id, extra = {}) => {
@@ -56,11 +57,19 @@ export async function gatherAttention(root) {
56
57
  items.push(it);
57
58
  }
58
59
  let reviewedAt;
60
+ let due = false;
59
61
  try {
60
62
  const nerd = readNerdJson(root);
61
63
  app = `${nerd.org}/${nerd.project}`;
62
64
  const api = ApiClient.fromConfig();
63
65
  const base = projectPath(nerd);
66
+ try {
67
+ // The same question the `review` check asks, under the same conditions (linked and signed in), so the two agree.
68
+ due = reviewDue(root).due;
69
+ }
70
+ catch {
71
+ // a folder that cannot be listed has nothing to say here
72
+ }
64
73
  const [review, project] = await Promise.all([reviewItems(api, base), within(api.get(base), 5000)]);
65
74
  items.push(...review.items);
66
75
  reviewedAt = review.reviewedAt;
@@ -78,8 +87,10 @@ export async function gatherAttention(root) {
78
87
  catch {
79
88
  // not linked, or not signed in: the versions above are all there is to say
80
89
  }
81
- return { app, items, ...(reviewedAt ? { reviewedAt } : {}) };
90
+ return { app, items, ...(reviewedAt ? { reviewedAt } : {}), ...(due ? { reviewDue: true } : {}) };
82
91
  }
92
+ /** Said by `nerd status` when asked directly, so it does not answer "nothing" a moment before `nerd check` asks for a review. */
93
+ export const REVIEW_DUE_NOTE = "The app's code as it is now has not been reviewed from this folder. Nothing to raise with the person: `nerd check` will ask for the review (`nerd review checklist`) before the next deploy.";
83
94
  /** Short enough to sit at the top of a session: what, where, and one line on what to do about each kind. */
84
95
  export function attentionText(a) {
85
96
  if (!a.items.length)
@@ -120,6 +131,9 @@ export async function status(opts) {
120
131
  process.stdout.write(`${text}\n`);
121
132
  else if (!opts.hook)
122
133
  log.ok(`Nothing needs attention${a.app ? ` for ${a.app}` : ""}.`);
134
+ // A new session is not told: unreviewed work in progress is not news, and the check asks at the right moment.
135
+ if (!opts.hook && a.reviewDue)
136
+ process.stdout.write(`${text ? "\n" : ""}${REVIEW_DUE_NOTE}\n`);
123
137
  }
124
138
  /** `nerd review`: only what is open from the latest review, with every fix written out. */
125
139
  export async function review(opts) {
@@ -43,6 +43,8 @@ export function upgradeProject(root, tpl, version) {
43
43
  // A workspace or file link is someone developing the platform itself; leave it alone.
44
44
  if (!current || !/^(workspace|file|link):/.test(current))
45
45
  pkg.dependencies["@nerdsystem/sdk"] = `^${version}`;
46
+ // Whether moving the SDK's version is all that happens to package.json: nothing below adds to it.
47
+ const versionOnly = JSON.stringify(pkg) !== before && Boolean(pkg.dependencies.zod && pkg.devDependencies.vitest && pkg.scripts.test);
46
48
  if (!pkg.dependencies.zod && tplPkg.dependencies?.zod)
47
49
  pkg.dependencies.zod = tplPkg.dependencies.zod;
48
50
  if (!pkg.devDependencies.vitest && tplPkg.devDependencies?.vitest)
@@ -116,6 +118,22 @@ export function upgradeProject(root, tpl, version) {
116
118
  const ignore = read(path.join(root, ".gitignore")) ?? "";
117
119
  if (!/^\.nerd\/?$/m.test(ignore))
118
120
  write(".gitignore", `${ignore}${ignore.endsWith("\n") || !ignore ? "" : "\n"}.nerd/\n`);
121
+ // Nothing to do after an upgrade that found everything current. When the only change is the SDK's version
122
+ // in package.json, the steps say so: the longer wording reads as if the app's code were behind.
123
+ if (!changed.length)
124
+ return { from, to: version, changed, next: [] };
125
+ if (versionOnly && changed.length === 1 && changed[0] === "package.json") {
126
+ return {
127
+ from,
128
+ to: version,
129
+ changed,
130
+ next: [
131
+ "Run `pnpm install`.",
132
+ "Run `nerd check`. Only the SDK's version changed, so expect what it reported before. Anything new is something the newer version checks that the older one did not: work through it as usual.",
133
+ "Run `nerd deploy` when the check passes, and tell the person in a sentence that the app was brought up to date.",
134
+ ],
135
+ };
136
+ }
119
137
  const next = ["Run `pnpm install`.", "Run `nerd check` and work through what it reports. It names what this app's own code should adopt (for example sign-in for every route, checked input, access tests)."];
120
138
  if (startersWritten.includes("test/access.test.ts"))
121
139
  next.push("`test/access.test.ts` is the starter's: keep the first test, and rewrite the owner test for this app's own routes. Then set `\"tests\": \"required\"` in nerd.json.");
@@ -134,9 +152,8 @@ export async function upgrade(opts) {
134
152
  return;
135
153
  }
136
154
  if (!r.changed.length)
137
- log.ok(`Already current with nerd ${r.to}. Nothing was changed.`);
138
- else
139
- log.ok(`Updated ${r.changed.length} file(s) for nerd ${r.to}${r.from && r.from !== r.to ? ` (the app was on SDK ${r.from})` : ""}: ${r.changed.join(", ")}`);
155
+ return log.ok(`Already current with nerd ${r.to}. Nothing was changed, and there is nothing to do.`);
156
+ log.ok(`Updated ${r.changed.length} file(s) for nerd ${r.to}${r.from && r.from !== r.to ? ` (the app was on SDK ${r.from})` : ""}: ${r.changed.join(", ")}`);
140
157
  log.info("Your app's own code was not touched. Next:");
141
158
  r.next.forEach((n, i) => log.info(` ${i + 1}. ${n}`));
142
159
  }
@@ -13,5 +13,13 @@ export declare const typecheckGate: Gate;
13
13
  export declare function depsRuleFor(severity: AdvisorySeverity): string;
14
14
  export declare const depsGate: Gate;
15
15
  export declare const policyGate: Gate;
16
+ /**
17
+ * Whether the code at `index` sits in a route that only people with a given role can call: the route
18
+ * names `requireRole("admin")` itself, or its app does with `.use(...)` before the route is declared.
19
+ * Reading every user's rows is what such a route is for (an admin's list, a count across everyone), so
20
+ * a query there with no user filter is not reported. A route that asks only for the role every new
21
+ * user gets is open to everyone signed in and does not count. `text` must have its comments blanked.
22
+ */
23
+ export declare function inRoleOnlyRoute(text: string, index: number): boolean;
16
24
  /** Heuristics for careless handling of personal data. Advisory: warns, never blocks. */
17
25
  export declare const piiGate: Gate;
@@ -2,13 +2,15 @@ import { spawn } from "node:child_process";
2
2
  import { existsSync, readFileSync, statSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { staticChecks } from "../commands/check.js";
5
- import { lineOf, sourceFiles } from "./fs.js";
5
+ import { shellCommand } from "../util.js";
6
+ import { lineOf, sourceFiles, withoutComments } from "./fs.js";
6
7
  import { finding } from "./secrets.js";
7
8
  import { BLOCKED_PACKAGES, MINING_POOLS, normalizeSeverity, osvSeverity } from "./text.js";
8
9
  import { counts, result, verdict } from "./types.js";
9
10
  export function exec(cmd, args, cwd) {
10
11
  return new Promise((resolve) => {
11
- const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
12
+ const how = shellCommand(cmd, args);
13
+ const child = spawn(how.file, how.args, { cwd, stdio: ["ignore", "pipe", "pipe"], shell: how.shell });
12
14
  let stdout = "";
13
15
  let stderr = "";
14
16
  child.stdout.on("data", (d) => (stdout += d));
@@ -165,6 +167,50 @@ export const policyGate = {
165
167
  return result(this, verdict(findings), findings.length ? counts(findings) : "No policy violations", findings, t);
166
168
  },
167
169
  };
170
+ /** Roles named in a `requireRole(...)` call that not every signed-in person has. "member" is what a new user gets. */
171
+ function restrictedRoles(args) {
172
+ if (!/^\s*(["'`][\w-]+["'`]\s*,?\s*)+$/.test(args))
173
+ return false;
174
+ return ![...args.matchAll(/["'`]([\w-]+)["'`]/g)].some((m) => m[1] === "member");
175
+ }
176
+ /**
177
+ * Whether the code at `index` sits in a route that only people with a given role can call: the route
178
+ * names `requireRole("admin")` itself, or its app does with `.use(...)` before the route is declared.
179
+ * Reading every user's rows is what such a route is for (an admin's list, a count across everyone), so
180
+ * a query there with no user filter is not reported. A route that asks only for the role every new
181
+ * user gets is open to everyone signed in and does not count. `text` must have its comments blanked.
182
+ */
183
+ export function inRoleOnlyRoute(text, index) {
184
+ const routeRe = /\b(\w+)\.(get|post|put|patch|delete|all)\(\s*(["'`])(\/[^"'`\n]*|\*)\3/g;
185
+ let route = null;
186
+ for (let m = routeRe.exec(text); m && m.index < index; m = routeRe.exec(text))
187
+ route = { app: m[1], path: m[4], index: m.index, open: m.index + m[0].indexOf("(") };
188
+ if (!route)
189
+ return false;
190
+ // The code has to be inside that route's own call, not in a function written after it.
191
+ let depth = 0;
192
+ for (let i = route.open; i < index; i++) {
193
+ const ch = text.charCodeAt(i);
194
+ if (ch === 40)
195
+ depth++;
196
+ else if (ch === 41 && --depth === 0)
197
+ return false;
198
+ }
199
+ const inline = /\brequireRole\(([^)]*)\)/.exec(text.slice(route.open, index));
200
+ if (inline && restrictedRoles(inline[1]))
201
+ return true;
202
+ const useRe = /\b(\w+)\.use\(\s*(?:(["'`])([^"'`\n]*)\2\s*,\s*)?requireRole\(([^)]*)\)/g;
203
+ for (let u = useRe.exec(text); u && u.index < route.index; u = useRe.exec(text)) {
204
+ if (u[1] !== route.app || !restrictedRoles(u[4]))
205
+ continue;
206
+ const pattern = u[3];
207
+ if (pattern === undefined || pattern === "*" || pattern === "/*" || pattern === route.path)
208
+ return true;
209
+ if (pattern.endsWith("/*") && (route.path === pattern.slice(0, -2) || route.path.startsWith(pattern.slice(0, -1))))
210
+ return true;
211
+ }
212
+ return false;
213
+ }
168
214
  /** Heuristics for careless handling of personal data. Advisory: warns, never blocks. */
169
215
  export const piiGate = {
170
216
  name: "pii",
@@ -185,6 +231,7 @@ export const piiGate = {
185
231
  for (const f of sourceFiles(ctx.root)) {
186
232
  if (!/\.(ts|tsx|js|jsx|mjs)$/.test(f.rel) || !f.rel.startsWith("src/"))
187
233
  continue;
234
+ let bare;
188
235
  for (const c of checks) {
189
236
  if (c.files && !c.files.test(f.rel))
190
237
  continue;
@@ -193,6 +240,9 @@ export const piiGate = {
193
240
  while ((m = c.re.exec(f.text))) {
194
241
  if (c.rule === "pii/unscoped-user-table" && !/user_id|userId/.test(f.text))
195
242
  continue;
243
+ // An admin's view across everyone is meant to have no user filter.
244
+ if (c.rule === "pii/unscoped-user-table" && inRoleOnlyRoute((bare ??= withoutComments(f.text)), m.index))
245
+ continue;
196
246
  findings.push(finding(c.rule, { file: f.rel, line: lineOf(f.text, m.index) }));
197
247
  }
198
248
  }
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { lineOf, sourceFiles } from "./fs.js";
3
+ import { lineOf, sourceFiles, withoutComments } from "./fs.js";
4
4
  import { finding } from "./secrets.js";
5
5
  import { counts, result, verdict } from "./types.js";
6
6
  /**
@@ -16,10 +16,6 @@ import { counts, result, verdict } from "./types.js";
16
16
  */
17
17
  const CODE = /\.(ts|tsx|js|jsx|mjs)$/;
18
18
  const LONG_FILE_LINES = 400;
19
- /** Blanks out comments, keeping every character position. */
20
- function withoutComments(text) {
21
- return text.replace(/\/\*[\s\S]*?\*\/|(^|[^:"'`\\])\/\/[^\n]*/g, (m) => m.replace(/[^\n]/g, " "));
22
- }
23
19
  /** "/api/todos/:id" → a pattern; ":id" and "*" match one or more characters of a segment. */
24
20
  function routePattern(p) {
25
21
  return new RegExp(`^${p.split("/").map((seg) => (seg.startsWith(":") ? "[^/]+" : seg === "*" ? ".*" : seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))).join("/")}/?$`);
@@ -4,4 +4,6 @@ export interface SourceFile {
4
4
  }
5
5
  /** Text files under root, skipping dependencies and build output. */
6
6
  export declare function sourceFiles(root: string): SourceFile[];
7
+ /** Blanks out comments, keeping every character position, so a pattern inside a comment is not a finding. */
8
+ export declare function withoutComments(text: string): string;
7
9
  export declare function lineOf(text: string, index: number): number;
package/dist/gates/fs.js CHANGED
@@ -26,6 +26,10 @@ export function sourceFiles(root) {
26
26
  walk(root);
27
27
  return out;
28
28
  }
29
+ /** Blanks out comments, keeping every character position, so a pattern inside a comment is not a finding. */
30
+ export function withoutComments(text) {
31
+ return text.replace(/\/\*[\s\S]*?\*\/|(^|[^:"'`\\])\/\/[^\n]*/g, (m) => m.replace(/[^\n]/g, " "));
32
+ }
29
33
  export function lineOf(text, index) {
30
34
  let line = 1;
31
35
  for (let i = 0; i < index && i < text.length; i++)
@@ -87,7 +87,7 @@ export const RULES = {
87
87
  "pii/log-request-body": { title: "Logging a request body", severity: "warn", audience: "agent", risk: "Request bodies often carry personal data; logging them keeps copies you did not mean to keep.", fix: "Log only the fields you need, never the whole body." },
88
88
  "pii/email-in-query-string": { title: "Email address in a URL", severity: "warn", audience: "agent", risk: "URLs end up in browser history, logs, and referrer headers.", fix: "Send it in the request body or use an id." },
89
89
  "pii/plaintext-password-column": { title: "A password column in the schema", severity: "warn", audience: "agent", risk: "Storing passwords yourself is unnecessary here and easy to get wrong.", fix: "Remove the column; the platform handles passwords. If you must store a secret, store a hash." },
90
- "pii/unscoped-user-table": { title: "Query on user data without a user filter", severity: "warn", audience: "agent", risk: "One user could see or change another user's rows.", fix: "Add .where(eq(table.userId, c.var.user.id)) to every read and write on tables with a user_id column.", example: { wrong: "db.select().from(schema.notes).all()", right: "db.select().from(schema.notes).where(eq(schema.notes.userId, c.var.user!.id)).all()" } },
90
+ "pii/unscoped-user-table": { title: "Query on user data without a user filter", severity: "warn", audience: "agent", risk: "One user could see or change another user's rows.", fix: "Add .where(eq(table.userId, c.var.user.id)) to every read and write on tables with a user_id column. A route that only a role such as admin can call (`requireRole(\"admin\")` on the route or on its app) is expected to read across users and is not reported.", example: { wrong: "db.select().from(schema.notes).all()", right: "db.select().from(schema.notes).where(eq(schema.notes.userId, c.var.user!.id)).all()" } },
91
91
  "pii/wildcard-cors": { title: "Wildcard CORS with cookies", severity: "warn", audience: "agent", risk: "Any website could call your API with the visitor's cookies.", fix: "Remove the wildcard. Same-origin requests need no CORS headers." },
92
92
  "suppressions/ignore-comment": { title: "A scanner ignore comment was added", severity: "block", audience: "human", risk: "Ignore comments turn off a check for that line. They are sometimes right and sometimes a way to hide a problem.", fix: "Review the line in the dashboard and approve the suppression if it is correct; the agent must not add or approve suppressions itself." },
93
93
  "suppressions/ignore-file": { title: "A scanner ignore file was changed", severity: "block", audience: "human", risk: "Ignore files silence checks across the whole project.", fix: "Review the change in the dashboard and approve it if it is correct." },
@@ -1,4 +1,4 @@
1
- import { lineOf, sourceFiles } from "./fs.js";
1
+ import { lineOf, sourceFiles, withoutComments } from "./fs.js";
2
2
  import { finding } from "./secrets.js";
3
3
  import { counts, result, verdict } from "./types.js";
4
4
  const SERVER = /^src\/server\//;
@@ -21,10 +21,6 @@ const CHECKS = [
21
21
  // An unchecked body: c.req.json() used directly instead of readBody(c, schema).
22
22
  { rule: "code/unvalidated-body", re: /\bc\.req\.(json|parseBody|formData)\(\)/g, files: SERVER },
23
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
24
  export const staticGate = {
29
25
  name: "static",
30
26
  title: "Unsafe code patterns",
package/dist/util.d.ts CHANGED
@@ -23,6 +23,17 @@ export interface RunOptions {
23
23
  /** Send the child's stdout to our stderr, so stdout stays reserved for `--json` output. */
24
24
  stdoutToStderr?: boolean;
25
25
  }
26
+ /**
27
+ * How a command is started. On Windows the tools an app uses (npx, pnpm) are `.cmd` files, which only
28
+ * start through the shell, and a shell takes one line. Handed a separate list of arguments, Node joins
29
+ * them with spaces and no quotes (and prints the DEP0190 warning about it), so a folder with a space in
30
+ * its name arrives as two arguments. The line is built here instead, with quotes round whatever needs them.
31
+ */
32
+ export declare function shellCommand(cmd: string, args: string[], platform?: NodeJS.Platform): {
33
+ file: string;
34
+ args: string[];
35
+ shell: boolean;
36
+ };
26
37
  /** Runs a command, streaming output. Resolves with the exit code. */
27
38
  export declare function run(cmd: string, args: string[], opts?: RunOptions): Promise<number>;
28
39
  export declare function runOrThrow(cmd: string, args: string[], opts?: RunOptions): Promise<void>;
package/dist/util.js CHANGED
@@ -22,10 +22,24 @@ export const log = {
22
22
  ok: (msg) => console.error(`\x1b[32m✓\x1b[0m ${msg}`),
23
23
  warn: (msg) => console.error(`\x1b[33m!\x1b[0m ${msg}`),
24
24
  };
25
+ /**
26
+ * How a command is started. On Windows the tools an app uses (npx, pnpm) are `.cmd` files, which only
27
+ * start through the shell, and a shell takes one line. Handed a separate list of arguments, Node joins
28
+ * them with spaces and no quotes (and prints the DEP0190 warning about it), so a folder with a space in
29
+ * its name arrives as two arguments. The line is built here instead, with quotes round whatever needs them.
30
+ */
31
+ export function shellCommand(cmd, args, platform = process.platform) {
32
+ if (platform !== "win32")
33
+ return { file: cmd, args, shell: false };
34
+ // Inside quotes, a program's own argument parser reads backslashes literally unless a quote follows them.
35
+ const quote = (a) => (/^[\w./:=@\\,+-]+$/.test(a) ? a : `"${a.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"`);
36
+ return { file: [cmd, ...args].map(quote).join(" "), args: [], shell: true };
37
+ }
25
38
  /** Runs a command, streaming output. Resolves with the exit code. */
26
39
  export function run(cmd, args, opts = {}) {
27
40
  return new Promise((resolve, reject) => {
28
- const child = spawn(cmd, args, { cwd: opts.cwd, env: { ...process.env, ...opts.env }, stdio: ["inherit", opts.stdoutToStderr ? "pipe" : "inherit", "inherit"], shell: process.platform === "win32" });
41
+ const how = shellCommand(cmd, args);
42
+ const child = spawn(how.file, how.args, { cwd: opts.cwd, env: { ...process.env, ...opts.env }, stdio: ["inherit", opts.stdoutToStderr ? "pipe" : "inherit", "inherit"], shell: how.shell });
29
43
  child.stdout?.pipe(process.stderr);
30
44
  child.on("error", reject);
31
45
  child.on("exit", (code) => resolve(code ?? 1));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nerdsystem",
3
- "version": "0.0.26",
3
+ "version": "0.0.28",
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": {