run402 2.22.0 → 2.24.0

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/lib/assets.mjs CHANGED
@@ -52,7 +52,9 @@ Options:
52
52
  --meta <k=v> v1.50: attach caller-supplied metadata to the upload (repeatable). Number
53
53
  coercion on pure digits; comma-split into string[]; "true"/"false" → boolean.
54
54
  --exif-policy keep|strip v1.50: EXIF retention policy for image uploads (default keep).
55
- --json NDJSON progress events (for agent consumption)
55
+ --stream NDJSON per-file progress events (for agent consumption).
56
+ Without --stream, only the final results array is
57
+ printed. --json is a deprecated alias for --stream.
56
58
  --prefix <p> Prefix filter (ls only)
57
59
  --limit <n> Max results (ls only; default 100, max 1000)
58
60
  --sort <key> v1.50 ls only: key:asc | createdAt:asc | createdAt:desc (default key:asc).
@@ -99,7 +101,10 @@ Options:
99
101
  Examples: --meta uploaded_by=agent_abc --meta version=3 --meta tags=hero,banner
100
102
  --exif-policy keep|strip v1.50: EXIF retention policy. Default 'keep'. 'strip' discards EXIF
101
103
  from the stored bytes and the image_exif response field.
102
- --json Emit NDJSON progress events on stdout (for agent consumption)
104
+ --stream Emit NDJSON per-file progress events on stdout (for
105
+ agent consumption). Default: emit only the final
106
+ results array (also JSON). --json is a deprecated
107
+ alias for --stream.
103
108
 
104
109
  Examples:
105
110
  run402 assets put ./artifact.tgz --project prj_abc123
@@ -223,6 +228,7 @@ function parseArgs(rawArgs) {
223
228
  "--immutable",
224
229
  "--concurrency",
225
230
  "--no-resume",
231
+ "--stream",
226
232
  "--json",
227
233
  "--prefix",
228
234
  "--limit",
@@ -237,7 +243,7 @@ function parseArgs(rawArgs) {
237
243
  "-h",
238
244
  ], valueFlags);
239
245
  const out = { positional: [], project: null, key: null, private: false, immutable: false,
240
- concurrency: 4, resume: true, json: false, prefix: null, limit: null,
246
+ concurrency: 4, resume: true, stream: false, prefix: null, limit: null,
241
247
  output: null, ttl: null, contentType: null,
242
248
  metadata: null, exifPolicy: null, sort: null, filter: null };
243
249
  for (let i = 0; i < args.length; i++) {
@@ -249,7 +255,13 @@ function parseArgs(rawArgs) {
249
255
  else if (a === "--immutable") out.immutable = true;
250
256
  else if (a === "--concurrency") out.concurrency = parseIntegerFlag("--concurrency", args[++i], { min: 1 });
251
257
  else if (a === "--no-resume") out.resume = false;
252
- else if (a === "--json") out.json = true;
258
+ else if (a === "--stream") out.stream = true;
259
+ else if (a === "--json") {
260
+ out.stream = true;
261
+ process.stderr.write(
262
+ "# warning: `--json` on `assets put` is deprecated and will be removed in a future release. Use `--stream` (alias means the same thing — NDJSON progress events on stdout).\n",
263
+ );
264
+ }
253
265
  else if (a === "--prefix") out.prefix = args[++i];
254
266
  else if (a === "--limit") out.limit = parseIntegerFlag("--limit", args[++i], { min: 1, max: 1000 });
255
267
  else if (a === "--output" || a === "-o") out.output = args[++i];
@@ -478,7 +490,7 @@ async function put(projectId, argv) {
478
490
  reportSdkError(err);
479
491
  }
480
492
  }
481
- if (!opts.json) console.log(JSON.stringify(results, null, 2));
493
+ if (!opts.stream) console.log(JSON.stringify(results, null, 2));
482
494
  }
483
495
 
484
496
  // ---------------------------------------------------------------------------
@@ -622,7 +634,7 @@ function guessContentType(key) {
622
634
  }
623
635
 
624
636
  function log(opts, event) {
625
- if (opts.json) console.log(JSON.stringify(event));
637
+ if (opts.stream) console.log(JSON.stringify(event));
626
638
  }
627
639
 
628
640
  // ---------------------------------------------------------------------------
package/lib/cache.mjs CHANGED
@@ -56,15 +56,18 @@ Subcommands:
56
56
  invalidate --all --host <h> Invalidate all rows for a host
57
57
 
58
58
  Common flags:
59
- --json Machine-readable output
60
59
  --locale <code> (inspect only) Inspect a specific locale's row
61
60
  (default: project's default locale)
62
61
  --release-id <id> (inspect only) Inspect a specific release id
63
62
  (default: project's active release)
64
63
 
64
+ Output:
65
+ Stdout is JSON. inspect emits the cache row object; invalidate emits
66
+ { deleted, host?, path?, generation }.
67
+
65
68
  Examples:
66
69
  run402 cache inspect https://eagles.kychon.com/the-guys
67
- run402 cache inspect https://eagles.kychon.com/the-guys --locale es --json
70
+ run402 cache inspect https://eagles.kychon.com/the-guys --locale es
68
71
  run402 cache invalidate https://eagles.kychon.com/the-guys
69
72
  run402 cache invalidate --prefix /blog/ --host eagles.kychon.com
70
73
  run402 cache invalidate --all --host eagles.kychon.com
@@ -105,7 +108,7 @@ export async function run(sub, args) {
105
108
 
106
109
  async function inspect(args) {
107
110
  const parsed = normalizeArgv(args);
108
- assertKnownFlags(parsed, ["--json", "--locale", "--release-id", "--help", "-h"]);
111
+ assertKnownFlags(parsed, ["--locale", "--release-id", "--help", "-h"]);
109
112
  if (hasFlag(parsed, ["--help", "-h"])) {
110
113
  console.log(HELP);
111
114
  return;
@@ -130,19 +133,11 @@ async function inspect(args) {
130
133
 
131
134
  const locale = flagValue(parsed, "--locale");
132
135
  const releaseId = flagValue(parsed, "--release-id");
133
- const json = hasFlag(parsed, ["--json"]);
134
136
 
135
137
  try {
136
138
  const sdk = getSdk();
137
- // SDK shape — the gateway's cache inspect endpoint isn't yet wired
138
- // (separate task). For now the CLI POSTs to the same /cache/v1/
139
- // namespace with kind=inspect.
140
139
  const result = await sdk.cache.inspect(url, { locale, releaseId });
141
- if (json) {
142
- console.log(JSON.stringify(result, null, 2));
143
- } else {
144
- console.log(formatInspectResult(result));
145
- }
140
+ console.log(JSON.stringify(result, null, 2));
146
141
  } catch (err) {
147
142
  reportSdkError(err);
148
143
  }
@@ -150,14 +145,13 @@ async function inspect(args) {
150
145
 
151
146
  async function invalidate(args) {
152
147
  const parsed = normalizeArgv(args);
153
- assertKnownFlags(parsed, ["--json", "--prefix", "--host", "--all", "--help", "-h"]);
148
+ assertKnownFlags(parsed, ["--prefix", "--host", "--all", "--help", "-h"]);
154
149
  if (hasFlag(parsed, ["--help", "-h"])) {
155
150
  console.log(HELP);
156
151
  return;
157
152
  }
158
153
 
159
154
  const positionals = positionalArgs(parsed);
160
- const json = hasFlag(parsed, ["--json"]);
161
155
  const prefix = flagValue(parsed, "--prefix");
162
156
  const host = flagValue(parsed, "--host");
163
157
  const all = hasFlag(parsed, ["--all"]);
@@ -175,7 +169,7 @@ async function invalidate(args) {
175
169
  }
176
170
  try {
177
171
  const result = await sdk.cache.invalidateAll({ host });
178
- emit(result, json);
172
+ console.log(JSON.stringify(result, null, 2));
179
173
  } catch (err) {
180
174
  reportSdkError(err);
181
175
  }
@@ -198,7 +192,7 @@ async function invalidate(args) {
198
192
  }
199
193
  try {
200
194
  const result = await sdk.cache.invalidatePrefix({ host, prefix });
201
- emit(result, json);
195
+ console.log(JSON.stringify(result, null, 2));
202
196
  } catch (err) {
203
197
  reportSdkError(err);
204
198
  }
@@ -227,38 +221,8 @@ async function invalidate(args) {
227
221
  }
228
222
  try {
229
223
  const result = await sdk.cache.invalidate(url);
230
- emit(result, json);
224
+ console.log(JSON.stringify(result, null, 2));
231
225
  } catch (err) {
232
226
  reportSdkError(err);
233
227
  }
234
228
  }
235
-
236
- function emit(result, json) {
237
- if (json) {
238
- console.log(JSON.stringify(result, null, 2));
239
- } else {
240
- const parts = [`Invalidated ${result.deleted} cache row(s)`];
241
- if (result.host) parts.push(`on ${result.host}`);
242
- if (result.path) parts.push(`for ${result.path}`);
243
- parts.push(`(generation: ${result.generation})`);
244
- console.log(parts.join(" "));
245
- }
246
- }
247
-
248
- function formatInspectResult(result) {
249
- if (result.status === "MISS") {
250
- return `MISS — no cache row for ${result.url || "this URL"}.`;
251
- }
252
- const lines = [
253
- `${result.status} — ${result.host}${result.path}`,
254
- ` locale: ${result.locale}`,
255
- ` releaseId: ${result.releaseId}`,
256
- ` cachedAt: ${result.cachedAt}`,
257
- ` expiresAt: ${result.expiresAt}`,
258
- ` contentSha256: ${result.contentSha256}`,
259
- ];
260
- if (result.writtenUnderGeneration) {
261
- lines.push(` writtenUnderGen: ${result.writtenUnderGeneration}`);
262
- }
263
- return lines.join("\n");
264
- }
package/lib/deploy-v2.mjs CHANGED
@@ -753,6 +753,44 @@ async function applyCmd(args) {
753
753
  const releaseSpec = normalizedManifest.spec;
754
754
  const idempotencyKey = normalizedManifest.idempotencyKey;
755
755
 
756
+ // Pre-flight source scan (auth-aware-ssr Section 9). Bypass via
757
+ // RUN402_DEPLOY_SKIP_SCAN=1 — useful for forcing a deploy when
758
+ // the scanner has a false positive that the operator has confirmed
759
+ // is fine. Hits with severity `error` fail the deploy.
760
+ if (process.env.RUN402_DEPLOY_SKIP_SCAN !== "1") {
761
+ try {
762
+ const { resolveScanRoot, scanSourceTree, SCAN_SEVERITY } = await import(
763
+ "./doctor-source-scan.mjs"
764
+ );
765
+ const scanRoot = opts.dir
766
+ ? (isAbsolute(opts.dir) ? opts.dir : resolve(process.cwd(), opts.dir))
767
+ : resolveScanRoot(process.cwd());
768
+ const findings = scanSourceTree(scanRoot, { cwd: process.cwd() });
769
+ const errorFindings = findings.filter((f) => f.severity === SCAN_SEVERITY.ERROR);
770
+ if (errorFindings.length > 0) {
771
+ const summary = errorFindings.slice(0, 10).map((f) => {
772
+ const loc = f.line ? `${f.file}:${f.line}` : f.file;
773
+ return ` ${f.code} ${loc}\n ${f.message}${f.canonical_name ? `\n fix: ${f.canonical_name}` : ""}`;
774
+ }).join("\n");
775
+ const more = errorFindings.length > 10 ? `\n ...and ${errorFindings.length - 10} more` : "";
776
+ fail({
777
+ code: "R402_AUTH_PREFLIGHT_FAILED",
778
+ message: `Source scan blocked deploy: ${errorFindings.length} R402_AUTH_* finding(s). Run \`run402 doctor\` for the full list. Bypass with RUN402_DEPLOY_SKIP_SCAN=1 if you're sure.`,
779
+ details: { findings: errorFindings, summary: `${summary}${more}` },
780
+ });
781
+ }
782
+ } catch (err) {
783
+ if (err && typeof err === "object" && err.code === "R402_AUTH_PREFLIGHT_FAILED") {
784
+ throw err;
785
+ }
786
+ // Scanner crashed — warn but don't block. The scanner is a safety
787
+ // net; the deploy should proceed if it can't read the source tree.
788
+ console.warn(
789
+ `[deploy] source scan skipped: ${err instanceof Error ? err.message : String(err)}`,
790
+ );
791
+ }
792
+ }
793
+
756
794
  let sdkOpts;
757
795
  if (useGithubActionsOidc) {
758
796
  sdkOpts = {
@@ -0,0 +1,424 @@
1
+ /**
2
+ * `run402 doctor` source-scan module (auth-aware-ssr Section 9).
3
+ *
4
+ * Walks the project's `src/` directory and reports patterns the
5
+ * auth-aware-ssr design specifies as deploy-failing OR runtime warnings:
6
+ *
7
+ * - **Hallucinated SDK names.** `getUser`, `getSession`, `currentUser`,
8
+ * `getCurrentUser`, `getServerSession`, `auth.protect`, `auth.signIn`,
9
+ * `auth.logout`, `auth.middleware`, etc. Each hit emits
10
+ * `R402_AUTH_UNKNOWN_EXPORT` with a structured fix-it (attempted name,
11
+ * canonical replacement, import line, docs URL).
12
+ *
13
+ * - **State-changing GET handlers.** Astro pages that export a GET
14
+ * handler containing DB-mutation patterns (`db().insert`, `db().update`,
15
+ * `db().delete`, `adminDb().sql("UPDATE"`, etc.). Emit
16
+ * `R402_AUTH_STATE_CHANGING_GET`.
17
+ *
18
+ * - **`auth.*` calls in prerendered pages.** Astro pages declaring
19
+ * `export const prerender = true` that also call `auth.*` helpers.
20
+ * Emit `R402_AUTH_PRERENDERED`.
21
+ *
22
+ * - **Direct `internal.sessions.authz_version` mutation.** Consumer
23
+ * migrations that try to `UPDATE internal.sessions SET authz_version`
24
+ * manually. Emit `R402_AUTH_AUTHZ_VERSION_PROHIBITED`.
25
+ *
26
+ * The scanner is regex-based — fast, dependency-free, and good enough
27
+ * for the canonical patterns. The `run402 doctor --json` mode emits the
28
+ * structured envelope; the default mode prints a readable per-finding
29
+ * summary. Wired into `run402 deploy` pre-flight as a deploy-failing
30
+ * gate for the error severities; non-blocking for warning severities.
31
+ *
32
+ * @see openspec/changes/auth-aware-ssr/specs/functions-sdk-auth-model
33
+ */
34
+
35
+ import { readdirSync, readFileSync, statSync } from "node:fs";
36
+ import { extname, join, relative } from "node:path";
37
+
38
+ /** Severity ladder for scanner findings. `error` blocks deploy; `warn`
39
+ * reports but doesn't block. The `run402 doctor` exit code is non-zero
40
+ * whenever any `error`-severity finding is present. */
41
+ export const SCAN_SEVERITY = Object.freeze({
42
+ ERROR: "error",
43
+ WARN: "warn",
44
+ });
45
+
46
+ /** File extensions scanned. Astro frontmatter + TS/JS are the primary
47
+ * surface; the regex matchers fire equally on all of them. `.astro`
48
+ * matters because consumers write SSR pages there. */
49
+ const SCANNED_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".astro"]);
50
+
51
+ /** Directories the scanner refuses to descend into. We never report
52
+ * on platform-generated code or vendored dependencies. */
53
+ const SKIPPED_DIRECTORIES = new Set([
54
+ "node_modules",
55
+ ".git",
56
+ ".vscode",
57
+ ".idea",
58
+ "dist",
59
+ "build",
60
+ "out",
61
+ ".astro",
62
+ ".next",
63
+ ".vercel",
64
+ ".netlify",
65
+ "coverage",
66
+ ]);
67
+
68
+ /** Hallucinated-name registry from the auth-aware-ssr spec. Each entry
69
+ * carries the canonical replacement so the fix-it is actionable. The
70
+ * list is intentionally exhaustive — every name flagged here came up
71
+ * in pre-launch LLM hallucination samples. */
72
+ const HALLUCINATED_NAMES = [
73
+ // ESM named imports — caught by regex on import lines AND by call-site
74
+ // matching when consumers paste from training-data examples.
75
+ { name: "getUser", canonical: "auth.user() / auth.requireUser()", origin: "supabase / clerk / nextauth legacy" },
76
+ { name: "getUserId", canonical: "(await auth.user())?.id", origin: "run402 v0.x" },
77
+ { name: "getRole", canonical: "auth.requireRole(role)", origin: "run402 v0.x" },
78
+ { name: "getSession", canonical: "auth.user()", origin: "next-auth / nextauth" },
79
+ { name: "currentUser", canonical: "auth.user()", origin: "clerk" },
80
+ { name: "currentSession", canonical: "auth.user()", origin: "clerk" },
81
+ { name: "getCurrentUser", canonical: "auth.user()", origin: "generic" },
82
+ { name: "getCurrentSession", canonical: "auth.user()", origin: "generic" },
83
+ { name: "getServerSession", canonical: "auth.user()", origin: "next-auth" },
84
+ { name: "getAuth", canonical: "auth.user() / auth.requireUser()", origin: "clerk" },
85
+ { name: "requireAuth", canonical: "auth.requireUser()", origin: "generic" },
86
+ { name: "withAuth", canonical: "auth.requireUser() inside the handler", origin: "next-auth-style HOC" },
87
+ { name: "protectRoute", canonical: "auth.requireUser() inside the handler", origin: "generic" },
88
+ { name: "useUser", canonical: "auth.user() (note: server-only; not a React hook)", origin: "clerk / supabase" },
89
+ { name: "useSession", canonical: "auth.user() (note: server-only)", origin: "next-auth / clerk" },
90
+ { name: "createServerClient", canonical: "db() (use the bundled SDK; no client setup needed)", origin: "supabase" },
91
+ { name: "clerkClient", canonical: "auth.user() + db()", origin: "clerk" },
92
+ ];
93
+
94
+ /** Property-access hallucinations on the `auth` object. The SDK's
95
+ * Proxy catches these at runtime, but the source scanner fires
96
+ * earlier so the deploy fails before the bundle ships. */
97
+ const HALLUCINATED_AUTH_PROPERTIES = [
98
+ { name: "auth.session", canonical: "auth.user() then read .sessionId" },
99
+ { name: "auth.getSession", canonical: "auth.user()" },
100
+ { name: "auth.currentUser", canonical: "auth.user()" },
101
+ { name: "auth.currentSession", canonical: "auth.user()" },
102
+ { name: "auth.requireAuth", canonical: "auth.requireUser()" },
103
+ { name: "auth.middleware", canonical: "auth.csrfField() / @run402/astro middleware" },
104
+ { name: "auth.signIn", canonical: "POST /auth/sign-in or auth.sessions.createResponseFromIdentity({...})" },
105
+ { name: "auth.signOut", canonical: "auth.sessions.endResponse()" },
106
+ { name: "auth.signout", canonical: "auth.sessions.endResponse()" },
107
+ { name: "auth.logout", canonical: "auth.sessions.endResponse()" },
108
+ { name: "auth.login", canonical: "auth.sessions.createResponseFromIdentity({...})" },
109
+ { name: "auth.redirectToSignIn", canonical: "auth.requireUser() — platform handles redirect" },
110
+ { name: "auth.getUser", canonical: "auth.user()" },
111
+ { name: "auth.getToken", canonical: "auth.requireUser() then read .sessionId (tokens not exposed)" },
112
+ { name: "auth.protect", canonical: "auth.requireUser() / auth.requireRole(...)" },
113
+ ];
114
+
115
+ /** Browser-only patterns that should NEVER appear in SSR / Lambda code.
116
+ * These are caught at scan time because the SDK doesn't ship a
117
+ * shim — the line just fails to execute. */
118
+ const BROWSER_ONLY_PATTERNS = [
119
+ {
120
+ pattern: /localStorage\.getItem\(\s*['"]wl_session['"]\s*\)/g,
121
+ name: "localStorage.wl_session",
122
+ canonical: "auth.user() (browser sessions are HttpOnly cookies; no localStorage)",
123
+ },
124
+ {
125
+ // Matches: `Authorization: "Bearer ..."` (bare key + string value)
126
+ // `"Authorization": "Bearer ..."` (string key + string value)
127
+ // `'Authorization': 'Bearer ...'` (single quotes)
128
+ pattern: /['"]?Authorization['"]?\s*[:,]\s*['"]Bearer\s/g,
129
+ name: "Authorization: Bearer (in browser code)",
130
+ canonical: "Browser code doesn't carry JWTs. Use auth.fetch() for same-origin SSR fetches.",
131
+ severity: SCAN_SEVERITY.WARN, // Bearer is fine in server-side machine code; gated by file path.
132
+ },
133
+ ];
134
+
135
+ /** Scan a single file's content. Returns the array of findings (zero
136
+ * or more). Pure / no I/O — tests pass strings directly. */
137
+ export function scanFileContent(content, opts = {}) {
138
+ const filePath = opts.filePath ?? "<inline>";
139
+ const findings = [];
140
+
141
+ // 1) Hallucinated bare names — import { getSession } from ... OR
142
+ // bare call sites `await getSession(...)`.
143
+ for (const entry of HALLUCINATED_NAMES) {
144
+ // Match in an `import { … }` statement OR a bare function-call site.
145
+ // Negative-lookahead: don't fire on `auth.getSession` etc. (caught
146
+ // separately by the auth-property scanner below).
147
+ const importRegex = new RegExp(
148
+ `import\\s*\\{[^}]*\\b${escapeRegex(entry.name)}\\b[^}]*\\}\\s*from\\s*['"]@run402/functions['"]`,
149
+ "g",
150
+ );
151
+ const callRegex = new RegExp(
152
+ `(?<![.\\w])${escapeRegex(entry.name)}\\s*\\(`,
153
+ "g",
154
+ );
155
+ let match;
156
+ while ((match = importRegex.exec(content)) !== null) {
157
+ findings.push({
158
+ code: "R402_AUTH_UNKNOWN_EXPORT",
159
+ severity: SCAN_SEVERITY.ERROR,
160
+ file: filePath,
161
+ line: lineNumberFor(content, match.index),
162
+ attempted_name: entry.name,
163
+ canonical_name: entry.canonical,
164
+ import_line: 'import { auth } from "@run402/functions"',
165
+ docs: "https://docs.run402.com/auth/sdk",
166
+ message: `Import '${entry.name}' from @run402/functions is not a working export (origin: ${entry.origin}). Use ${entry.canonical}.`,
167
+ });
168
+ }
169
+ while ((match = callRegex.exec(content)) !== null) {
170
+ findings.push({
171
+ code: "R402_AUTH_UNKNOWN_EXPORT",
172
+ severity: SCAN_SEVERITY.ERROR,
173
+ file: filePath,
174
+ line: lineNumberFor(content, match.index),
175
+ attempted_name: entry.name,
176
+ canonical_name: entry.canonical,
177
+ import_line: 'import { auth } from "@run402/functions"',
178
+ docs: "https://docs.run402.com/auth/sdk",
179
+ message: `Call to '${entry.name}()' will throw R402_AUTH_UNKNOWN_EXPORT at runtime. Use ${entry.canonical}.`,
180
+ });
181
+ }
182
+ }
183
+
184
+ // 2) Hallucinated property access on `auth.*`. The SDK Proxy catches
185
+ // these at runtime; we catch earlier at deploy.
186
+ for (const entry of HALLUCINATED_AUTH_PROPERTIES) {
187
+ const regex = new RegExp(`(?<![\\w.])${escapeRegex(entry.name)}\\b`, "g");
188
+ let match;
189
+ while ((match = regex.exec(content)) !== null) {
190
+ findings.push({
191
+ code: "R402_AUTH_UNKNOWN_EXPORT",
192
+ severity: SCAN_SEVERITY.ERROR,
193
+ file: filePath,
194
+ line: lineNumberFor(content, match.index),
195
+ attempted_name: entry.name,
196
+ canonical_name: entry.canonical,
197
+ import_line: 'import { auth } from "@run402/functions"',
198
+ docs: "https://docs.run402.com/auth/sdk",
199
+ message: `'${entry.name}' is not a valid auth.* helper. Use ${entry.canonical}.`,
200
+ });
201
+ }
202
+ }
203
+
204
+ // 3) Browser-only / wrong-environment patterns.
205
+ for (const entry of BROWSER_ONLY_PATTERNS) {
206
+ let match;
207
+ while ((match = entry.pattern.exec(content)) !== null) {
208
+ findings.push({
209
+ code: "R402_AUTH_UNKNOWN_EXPORT",
210
+ severity: entry.severity ?? SCAN_SEVERITY.ERROR,
211
+ file: filePath,
212
+ line: lineNumberFor(content, match.index),
213
+ attempted_name: entry.name,
214
+ canonical_name: entry.canonical,
215
+ message: `'${entry.name}' is not supported. ${entry.canonical}.`,
216
+ });
217
+ }
218
+ }
219
+
220
+ // 4) Prerendered pages calling auth.*. The Astro adapter throws
221
+ // R402_AUTH_PRERENDERED at build time; this catches earlier.
222
+ if (filePath.endsWith(".astro") || filePath.endsWith(".ts") || filePath.endsWith(".tsx")) {
223
+ const declaresPrerender = /export\s+const\s+prerender\s*=\s*true/.test(content);
224
+ if (declaresPrerender) {
225
+ const authCallRegex = /\bauth\.(user|requireUser|requireRole|requireMembership|requireFresh|fetch|csrfToken|csrfField|sessions|identities)\b/g;
226
+ let match;
227
+ while ((match = authCallRegex.exec(content)) !== null) {
228
+ findings.push({
229
+ code: "R402_AUTH_PRERENDERED",
230
+ severity: SCAN_SEVERITY.ERROR,
231
+ file: filePath,
232
+ line: lineNumberFor(content, match.index),
233
+ message: `auth.${match[1]} called from a prerendered page. Convert to SSR (\`export const prerender = false\`) or use a server island.`,
234
+ docs: "https://docs.run402.com/auth/rendering-modes",
235
+ });
236
+ }
237
+ }
238
+ }
239
+
240
+ // 5) State-changing GET handlers. Heuristic: an Astro `export const GET`
241
+ // or a `GET` Web-handler containing db-mutation patterns.
242
+ const getHandlerRegex = /export\s+(?:async\s+)?(?:const\s+|function\s+)GET\s*[=(]/g;
243
+ const mutationInGetRegex = /\b(?:db|adminDb)\s*\(\s*\)?[^)]*\)?\s*\.(?:insert|update|delete)\s*\(/g;
244
+ const sqlMutationRegex = /\.sql\s*\(\s*['"`]\s*(?:UPDATE|INSERT|DELETE)\b/gi;
245
+ if (getHandlerRegex.test(content)) {
246
+ let match;
247
+ while ((match = mutationInGetRegex.exec(content)) !== null) {
248
+ findings.push({
249
+ code: "R402_AUTH_STATE_CHANGING_GET",
250
+ severity: SCAN_SEVERITY.ERROR,
251
+ file: filePath,
252
+ line: lineNumberFor(content, match.index),
253
+ message: "GET handler mutates state. Move the mutation to POST.",
254
+ docs: "https://docs.run402.com/auth/hosted-ui#post-only",
255
+ });
256
+ }
257
+ while ((match = sqlMutationRegex.exec(content)) !== null) {
258
+ findings.push({
259
+ code: "R402_AUTH_STATE_CHANGING_GET",
260
+ severity: SCAN_SEVERITY.ERROR,
261
+ file: filePath,
262
+ line: lineNumberFor(content, match.index),
263
+ message: "GET handler runs UPDATE/INSERT/DELETE SQL. Move the mutation to POST.",
264
+ docs: "https://docs.run402.com/auth/hosted-ui#post-only",
265
+ });
266
+ }
267
+ }
268
+
269
+ // 6) Direct mutation of internal.sessions.authz_version in consumer
270
+ // migrations or SQL strings. The platform is the sole writer.
271
+ const authzVersionRegex = /UPDATE\s+internal\.sessions\s+SET\s+authz_version\b/gi;
272
+ let m;
273
+ while ((m = authzVersionRegex.exec(content)) !== null) {
274
+ findings.push({
275
+ code: "R402_AUTH_AUTHZ_VERSION_PROHIBITED",
276
+ severity: SCAN_SEVERITY.ERROR,
277
+ file: filePath,
278
+ line: lineNumberFor(content, m.index),
279
+ message: "Consumer code may not mutate internal.sessions.authz_version directly. Register your grants table in the authz manifest so the platform installs the bump trigger.",
280
+ docs: "https://docs.run402.com/auth/db-actor-context#authz-version",
281
+ });
282
+ }
283
+
284
+ // 7) Redundant `.eq("user_id", user.id)` against RLS-bound tables.
285
+ // db() propagates the actor via the run402.actor.* settings — PostgREST
286
+ // enforces ownership in RLS. Filtering on user_id again is at best a
287
+ // no-op and at worst a code smell that suggests the developer doesn't
288
+ // trust RLS. Catch the most common shapes:
289
+ //
290
+ // .eq("user_id", user.id)
291
+ // .eq('user_id', actor.id)
292
+ // .eq("user_id", await auth.user()).id (rare; covered by the suffix)
293
+ //
294
+ // Opt-out via inline annotation comment on the preceding or same line:
295
+ // // run402-allow-user-filter: <reason>
296
+ // .eq("user_id", joinedRowOwner)
297
+ //
298
+ // Pattern is intentionally narrow (`user_id` literal column name + a
299
+ // value expression matching `<ident>.id`) to keep the false-positive
300
+ // rate low. Heuristic — RLS-binding is unknown at scan time; the rule
301
+ // fires on the shape, and the operator either annotates or fixes.
302
+ const redundantFilterRegex =
303
+ /\.eq\s*\(\s*['"]user_id['"]\s*,\s*([a-zA-Z_$][\w$]*)\.id\s*\)/g;
304
+ const lines = content.split(/\r?\n/);
305
+ const cumulativeOffsets = (() => {
306
+ const offsets = [0];
307
+ for (let i = 0; i < lines.length; i++) {
308
+ // +1 for the trailing newline we split on
309
+ offsets.push(offsets[i] + lines[i].length + 1);
310
+ }
311
+ return offsets;
312
+ })();
313
+ function lineIndexFor(charIndex) {
314
+ // Binary search would be faster; lines are typically <2k.
315
+ for (let i = 0; i < cumulativeOffsets.length - 1; i++) {
316
+ if (charIndex < cumulativeOffsets[i + 1]) return i;
317
+ }
318
+ return cumulativeOffsets.length - 2;
319
+ }
320
+ let f;
321
+ while ((f = redundantFilterRegex.exec(content)) !== null) {
322
+ const lineIdx = lineIndexFor(f.index);
323
+ const thisLine = lines[lineIdx] ?? "";
324
+ const prevLine = lineIdx > 0 ? (lines[lineIdx - 1] ?? "") : "";
325
+ const annotated =
326
+ /\/\/\s*run402-allow-user-filter/i.test(thisLine) ||
327
+ /\/\/\s*run402-allow-user-filter/i.test(prevLine);
328
+ if (annotated) continue;
329
+ findings.push({
330
+ code: "R402_AUTH_REDUNDANT_USER_FILTER",
331
+ severity: SCAN_SEVERITY.WARN,
332
+ file: filePath,
333
+ line: lineIdx + 1,
334
+ message:
335
+ `Redundant '.eq(\"user_id\", ${f[1]}.id)'. db() propagates the actor — PostgREST RLS enforces ownership server-side. ` +
336
+ `If this is intentional (e.g., the table's RLS scopes on something else and you want to filter additionally), ` +
337
+ `silence with: // run402-allow-user-filter: <reason>`,
338
+ docs: "https://run402.com/errors/#R402_AUTH_REDUNDANT_USER_FILTER",
339
+ });
340
+ }
341
+
342
+ return findings;
343
+ }
344
+
345
+ /** Recursively walk `srcDir` and scan every file with a relevant
346
+ * extension. Returns the combined findings list, sorted by file +
347
+ * line for stable output. */
348
+ export function scanSourceTree(srcDir, opts = {}) {
349
+ const findings = [];
350
+ walk(srcDir, (filePath) => {
351
+ if (!SCANNED_EXTENSIONS.has(extname(filePath))) return;
352
+ let content;
353
+ try {
354
+ content = readFileSync(filePath, "utf8");
355
+ } catch (err) {
356
+ findings.push({
357
+ code: "R402_AUTH_SOURCE_SCAN_ERROR",
358
+ severity: SCAN_SEVERITY.WARN,
359
+ file: relative(opts.cwd ?? srcDir, filePath),
360
+ message: `failed to read file: ${err instanceof Error ? err.message : String(err)}`,
361
+ });
362
+ return;
363
+ }
364
+ findings.push(
365
+ ...scanFileContent(content, { filePath: relative(opts.cwd ?? srcDir, filePath) }),
366
+ );
367
+ });
368
+ findings.sort((a, b) => {
369
+ if (a.file !== b.file) return a.file < b.file ? -1 : 1;
370
+ return (a.line ?? 0) - (b.line ?? 0);
371
+ });
372
+ return findings;
373
+ }
374
+
375
+ function walk(dir, visitor) {
376
+ let entries;
377
+ try {
378
+ entries = readdirSync(dir, { withFileTypes: true });
379
+ } catch {
380
+ return;
381
+ }
382
+ for (const entry of entries) {
383
+ if (entry.isDirectory()) {
384
+ if (SKIPPED_DIRECTORIES.has(entry.name)) continue;
385
+ walk(join(dir, entry.name), visitor);
386
+ } else if (entry.isFile()) {
387
+ visitor(join(dir, entry.name));
388
+ }
389
+ }
390
+ }
391
+
392
+ function lineNumberFor(content, index) {
393
+ let line = 1;
394
+ for (let i = 0; i < index; i++) {
395
+ if (content.charCodeAt(i) === 10) line++;
396
+ }
397
+ return line;
398
+ }
399
+
400
+ function escapeRegex(s) {
401
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
402
+ }
403
+
404
+ /** Convenience for tests: synchronous, no FS access. */
405
+ export function _testOnly_hallucinatedNames() {
406
+ return HALLUCINATED_NAMES.slice();
407
+ }
408
+
409
+ export function _testOnly_authProperties() {
410
+ return HALLUCINATED_AUTH_PROPERTIES.slice();
411
+ }
412
+
413
+ /** Resolve the project's src/ directory. Astro convention is `<root>/src`;
414
+ * bare Node projects use `<root>/src` or `<root>`. We prefer `src/` if
415
+ * it exists. */
416
+ export function resolveScanRoot(cwd = process.cwd()) {
417
+ const srcDir = join(cwd, "src");
418
+ try {
419
+ if (statSync(srcDir).isDirectory()) return srcDir;
420
+ } catch {
421
+ // Fall through.
422
+ }
423
+ return cwd;
424
+ }