run402 2.22.0 → 2.23.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/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
+ }
@@ -0,0 +1,318 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ scanFileContent,
6
+ SCAN_SEVERITY,
7
+ _testOnly_hallucinatedNames,
8
+ _testOnly_authProperties,
9
+ } from "./doctor-source-scan.mjs";
10
+
11
+ describe("scanFileContent — hallucinated bare names", () => {
12
+ it("flags `import { getUser } from \"@run402/functions\"` as an error", () => {
13
+ const findings = scanFileContent(
14
+ `import { getUser } from "@run402/functions";\n`,
15
+ { filePath: "src/pages/index.astro" },
16
+ );
17
+ assert.ok(findings.length >= 1);
18
+ const f = findings.find((x) => x.attempted_name === "getUser" && x.line === 1);
19
+ assert.ok(f, "found getUser finding");
20
+ assert.equal(f.code, "R402_AUTH_UNKNOWN_EXPORT");
21
+ assert.equal(f.severity, SCAN_SEVERITY.ERROR);
22
+ assert.match(f.canonical_name, /auth\.user/);
23
+ assert.equal(f.file, "src/pages/index.astro");
24
+ });
25
+
26
+ it("flags `import { getSession, currentUser } from \"@run402/functions\"`", () => {
27
+ const findings = scanFileContent(
28
+ `import { getSession, currentUser } from "@run402/functions";`,
29
+ );
30
+ const names = new Set(findings.map((f) => f.attempted_name));
31
+ assert.ok(names.has("getSession"));
32
+ assert.ok(names.has("currentUser"));
33
+ });
34
+
35
+ it("flags bare `await getSession()` call sites", () => {
36
+ const findings = scanFileContent(`
37
+ const session = await getSession();
38
+ `);
39
+ assert.ok(findings.some((f) => f.attempted_name === "getSession"));
40
+ });
41
+
42
+ it("does not double-fire on `auth.getSession()` — that's the property scanner's job", () => {
43
+ const findings = scanFileContent(`
44
+ const session = await auth.getSession();
45
+ `);
46
+ // The bare-name scanner should NOT fire on `auth.getSession`; only
47
+ // the property scanner should. So exactly one finding for getSession.
48
+ const bareGetSession = findings.filter((f) => f.attempted_name === "getSession");
49
+ assert.equal(bareGetSession.length, 0, "bare getSession() must not fire on auth.getSession()");
50
+ const authGetSession = findings.filter((f) => f.attempted_name === "auth.getSession");
51
+ assert.equal(authGetSession.length, 1);
52
+ });
53
+
54
+ it("includes the canonical replacement and docs URL in the finding", () => {
55
+ const findings = scanFileContent(
56
+ `import { getUser } from "@run402/functions";`,
57
+ );
58
+ const f = findings[0];
59
+ assert.match(f.canonical_name, /auth\.user/);
60
+ assert.match(f.import_line, /@run402\/functions/);
61
+ assert.match(f.docs, /docs\.run402\.com\/auth\/sdk/);
62
+ });
63
+
64
+ it("covers every hallucinated name in the spec registry", () => {
65
+ const names = _testOnly_hallucinatedNames();
66
+ for (const entry of names) {
67
+ const findings = scanFileContent(`const x = ${entry.name}();`);
68
+ assert.ok(
69
+ findings.some((f) => f.attempted_name === entry.name),
70
+ `expected scanner to flag bare ${entry.name}()`,
71
+ );
72
+ }
73
+ });
74
+
75
+ it("does NOT fire on `auth.user()` (the canonical helper)", () => {
76
+ const findings = scanFileContent(`
77
+ const user = await auth.user();
78
+ const required = await auth.requireUser();
79
+ `);
80
+ // Should be zero R402_AUTH_UNKNOWN_EXPORT findings.
81
+ assert.equal(findings.length, 0, `unexpected findings: ${JSON.stringify(findings)}`);
82
+ });
83
+ });
84
+
85
+ describe("scanFileContent — auth.* property hallucinations", () => {
86
+ it("flags `auth.protect(...)` as an error pointing at auth.requireUser / auth.requireRole", () => {
87
+ const findings = scanFileContent(`const r = auth.protect({ role: "admin" });`);
88
+ const f = findings.find((x) => x.attempted_name === "auth.protect");
89
+ assert.ok(f);
90
+ assert.match(f.canonical_name, /requireUser|requireRole/);
91
+ });
92
+
93
+ it("flags `auth.signIn(...)` pointing at createResponseFromIdentity", () => {
94
+ const findings = scanFileContent(`return auth.signIn({ provider: "google" });`);
95
+ const f = findings.find((x) => x.attempted_name === "auth.signIn");
96
+ assert.ok(f);
97
+ assert.match(f.canonical_name, /createResponseFromIdentity|POST \/auth\/sign-in/);
98
+ });
99
+
100
+ it("covers every auth.* property in the registry", () => {
101
+ const props = _testOnly_authProperties();
102
+ for (const entry of props) {
103
+ const findings = scanFileContent(`const r = ${entry.name}();`);
104
+ assert.ok(
105
+ findings.some((f) => f.attempted_name === entry.name),
106
+ `expected scanner to flag ${entry.name}`,
107
+ );
108
+ }
109
+ });
110
+ });
111
+
112
+ describe("scanFileContent — browser-only patterns", () => {
113
+ it("flags `localStorage.getItem(\"wl_session\")`", () => {
114
+ const findings = scanFileContent(`
115
+ const token = localStorage.getItem("wl_session");
116
+ `);
117
+ assert.ok(findings.some((f) => f.attempted_name === "localStorage.wl_session"));
118
+ });
119
+
120
+ it("warns on Authorization: Bearer in browser-context code", () => {
121
+ const findings = scanFileContent(`
122
+ fetch("/api", { headers: { "Authorization": "Bearer " + token } });
123
+ `);
124
+ const f = findings.find((x) => x.attempted_name?.startsWith("Authorization"));
125
+ assert.ok(f);
126
+ assert.equal(f.severity, SCAN_SEVERITY.WARN, "Bearer is a warn, not an error");
127
+ });
128
+ });
129
+
130
+ describe("scanFileContent — prerendered pages calling auth.*", () => {
131
+ it("flags `export const prerender = true` + `auth.user()`", () => {
132
+ const findings = scanFileContent(
133
+ `---
134
+ export const prerender = true;
135
+ const user = await auth.user();
136
+ ---
137
+ <html><body>{user?.email}</body></html>`,
138
+ { filePath: "src/pages/me.astro" },
139
+ );
140
+ const f = findings.find((x) => x.code === "R402_AUTH_PRERENDERED");
141
+ assert.ok(f, "expected R402_AUTH_PRERENDERED");
142
+ assert.equal(f.severity, SCAN_SEVERITY.ERROR);
143
+ assert.match(f.docs, /rendering-modes/);
144
+ });
145
+
146
+ it("does NOT fire when `prerender = true` page never calls auth.*", () => {
147
+ const findings = scanFileContent(
148
+ `---
149
+ export const prerender = true;
150
+ const title = "static page";
151
+ ---
152
+ <html><body>{title}</body></html>`,
153
+ { filePath: "src/pages/static.astro" },
154
+ );
155
+ const f = findings.find((x) => x.code === "R402_AUTH_PRERENDERED");
156
+ assert.equal(f, undefined);
157
+ });
158
+
159
+ it("does NOT fire when the file lacks the prerender export", () => {
160
+ const findings = scanFileContent(
161
+ `const user = await auth.user();`,
162
+ { filePath: "src/pages/dynamic.astro" },
163
+ );
164
+ const f = findings.find((x) => x.code === "R402_AUTH_PRERENDERED");
165
+ assert.equal(f, undefined);
166
+ });
167
+ });
168
+
169
+ describe("scanFileContent — state-changing GET handlers", () => {
170
+ it("flags `export async function GET` with `db().insert(...)`", () => {
171
+ const findings = scanFileContent(`
172
+ import { db } from "@run402/functions";
173
+ export async function GET(req) {
174
+ await db().from("posts").insert({ title: "x" });
175
+ return new Response("ok");
176
+ }
177
+ `);
178
+ const f = findings.find((x) => x.code === "R402_AUTH_STATE_CHANGING_GET");
179
+ assert.ok(f);
180
+ assert.equal(f.severity, SCAN_SEVERITY.ERROR);
181
+ });
182
+
183
+ it("flags `export const GET` with `adminDb().sql(\"UPDATE ...\")`", () => {
184
+ const findings = scanFileContent(`
185
+ import { adminDb } from "@run402/functions";
186
+ export const GET = async () => {
187
+ await adminDb().sql("UPDATE foo SET x = 1");
188
+ return new Response("ok");
189
+ };
190
+ `);
191
+ const f = findings.find((x) => x.code === "R402_AUTH_STATE_CHANGING_GET");
192
+ assert.ok(f);
193
+ });
194
+
195
+ it("does NOT fire on read-only GET handlers", () => {
196
+ const findings = scanFileContent(`
197
+ import { db } from "@run402/functions";
198
+ export async function GET() {
199
+ const rows = await db().from("posts").select();
200
+ return Response.json(rows);
201
+ }
202
+ `);
203
+ const f = findings.find((x) => x.code === "R402_AUTH_STATE_CHANGING_GET");
204
+ assert.equal(f, undefined);
205
+ });
206
+ });
207
+
208
+ describe("scanFileContent — direct authz_version mutation", () => {
209
+ it("flags `UPDATE internal.sessions SET authz_version`", () => {
210
+ const findings = scanFileContent(`
211
+ adminDb().sql(\`UPDATE internal.sessions SET authz_version = authz_version + 1\`);
212
+ `);
213
+ const f = findings.find((x) => x.code === "R402_AUTH_AUTHZ_VERSION_PROHIBITED");
214
+ assert.ok(f);
215
+ assert.equal(f.severity, SCAN_SEVERITY.ERROR);
216
+ assert.match(f.docs, /authz-version/);
217
+ });
218
+
219
+ it("is case-insensitive (UPDATE / update / Update)", () => {
220
+ const variants = [
221
+ "update internal.sessions set authz_version = 5;",
222
+ "Update Internal.Sessions Set Authz_Version = 5;",
223
+ ];
224
+ for (const sql of variants) {
225
+ const findings = scanFileContent(sql);
226
+ assert.ok(
227
+ findings.some((f) => f.code === "R402_AUTH_AUTHZ_VERSION_PROHIBITED"),
228
+ `case variant failed: ${sql}`,
229
+ );
230
+ }
231
+ });
232
+ });
233
+
234
+ describe("scanFileContent — redundant user_id filter (R402_AUTH_REDUNDANT_USER_FILTER)", () => {
235
+ it("flags `.eq(\"user_id\", user.id)`", () => {
236
+ const content = [
237
+ 'import { db, auth } from "@run402/functions";',
238
+ "const user = await auth.requireUser();",
239
+ 'const rows = await db().from("posts").select("*").eq("user_id", user.id);',
240
+ ].join("\n");
241
+ const findings = scanFileContent(content);
242
+ const f = findings.find((x) => x.code === "R402_AUTH_REDUNDANT_USER_FILTER");
243
+ assert.ok(f, "expected a R402_AUTH_REDUNDANT_USER_FILTER finding");
244
+ assert.equal(f.severity, SCAN_SEVERITY.WARN);
245
+ assert.match(f.docs, /R402_AUTH_REDUNDANT_USER_FILTER/);
246
+ assert.equal(f.line, 3);
247
+ });
248
+
249
+ it("flags `.eq('user_id', actor.id)` with single quotes + different identifier", () => {
250
+ const content = "const r = q.eq('user_id', actor.id);";
251
+ const findings = scanFileContent(content);
252
+ assert.ok(findings.some((f) => f.code === "R402_AUTH_REDUNDANT_USER_FILTER"));
253
+ });
254
+
255
+ it("is silenced by `// run402-allow-user-filter:` on the same line", () => {
256
+ const content = [
257
+ 'import { db, auth } from "@run402/functions";',
258
+ "const user = await auth.requireUser();",
259
+ 'const rows = await db().from("posts").select("*").eq("user_id", user.id); // run402-allow-user-filter: explicit filter for analytics export',
260
+ ].join("\n");
261
+ const findings = scanFileContent(content);
262
+ assert.ok(
263
+ !findings.some((f) => f.code === "R402_AUTH_REDUNDANT_USER_FILTER"),
264
+ "annotated line should not produce a finding",
265
+ );
266
+ });
267
+
268
+ it("is silenced by `// run402-allow-user-filter:` on the preceding line", () => {
269
+ const content = [
270
+ 'import { db, auth } from "@run402/functions";',
271
+ "const user = await auth.requireUser();",
272
+ "// run402-allow-user-filter: this table's RLS scopes on org_id, not user_id",
273
+ 'const rows = await db().from("posts").select("*").eq("user_id", user.id);',
274
+ ].join("\n");
275
+ const findings = scanFileContent(content);
276
+ assert.ok(
277
+ !findings.some((f) => f.code === "R402_AUTH_REDUNDANT_USER_FILTER"),
278
+ "preceding annotation should silence",
279
+ );
280
+ });
281
+
282
+ it("does NOT flag `.eq(\"org_id\", user.org_id)` or `.eq(\"team_id\", ...)`", () => {
283
+ const content = [
284
+ 'const rows1 = q.eq("org_id", user.id);', // non-user_id column
285
+ "const rows2 = q.eq(\"team_id\", actor.team_id);",
286
+ 'const rows3 = q.eq("user_id", "fixed-uuid-literal");', // literal string, not <ident>.id
287
+ ].join("\n");
288
+ const findings = scanFileContent(content);
289
+ assert.ok(
290
+ !findings.some((f) => f.code === "R402_AUTH_REDUNDANT_USER_FILTER"),
291
+ "non-matching patterns should not fire",
292
+ );
293
+ });
294
+ });
295
+
296
+ describe("scanFileContent — line numbers + file paths", () => {
297
+ it("reports the line of the violation, not always line 1", () => {
298
+ const content = [
299
+ "// line 1: comment",
300
+ "// line 2: comment",
301
+ 'import { auth } from "@run402/functions";',
302
+ "// line 4: comment",
303
+ 'const u = await getSession(); // line 5',
304
+ "",
305
+ ].join("\n");
306
+ const findings = scanFileContent(content);
307
+ const f = findings.find((x) => x.attempted_name === "getSession");
308
+ assert.ok(f);
309
+ assert.equal(f.line, 5);
310
+ });
311
+
312
+ it("propagates filePath from the caller", () => {
313
+ const findings = scanFileContent(`const u = await getSession();`, {
314
+ filePath: "src/pages/account.astro",
315
+ });
316
+ assert.equal(findings[0].file, "src/pages/account.astro");
317
+ });
318
+ });
package/lib/doctor.mjs CHANGED
@@ -14,6 +14,11 @@
14
14
  import { existsSync, statSync } from "node:fs";
15
15
  import { CONFIG_DIR, readAllowance, loadKeyStore } from "./config.mjs";
16
16
  import { getSdk } from "./sdk.mjs";
17
+ import {
18
+ resolveScanRoot,
19
+ scanSourceTree,
20
+ SCAN_SEVERITY,
21
+ } from "./doctor-source-scan.mjs";
17
22
 
18
23
  const HELP = `run402 doctor — Health and config diagnostics
19
24
 
@@ -21,8 +26,10 @@ Usage:
21
26
  run402 doctor [--json] [--verbose]
22
27
 
23
28
  Options:
24
- --json Emit a structured JSON report on stdout
25
- --verbose Include extra detail (timing, error messages)
29
+ --json Emit a structured JSON report on stdout
30
+ --verbose Include extra detail (timing, error messages)
31
+ --no-scan Skip the source-tree scan (config / health checks only)
32
+ --scan-dir D Scan a custom directory instead of \`<cwd>/src\`
26
33
 
27
34
  Checks performed:
28
35
  - Config directory exists and is writable
@@ -30,6 +37,11 @@ Checks performed:
30
37
  - Keystore has at least one wallet
31
38
  - API_BASE is reachable (network check via /health)
32
39
  - Active tier resolves and is not 'past_due' / 'frozen'
40
+ - Source scan: hallucinated SDK auth names (R402_AUTH_UNKNOWN_EXPORT),
41
+ state-changing GET handlers (R402_AUTH_STATE_CHANGING_GET),
42
+ auth.* calls in prerendered pages (R402_AUTH_PRERENDERED),
43
+ direct mutation of internal.sessions.authz_version
44
+ (R402_AUTH_AUTHZ_VERSION_PROHIBITED).
33
45
 
34
46
  Exit codes:
35
47
  0 — all checks pass
@@ -44,6 +56,9 @@ export async function run(sub, args = []) {
44
56
  }
45
57
  const json = all.includes("--json");
46
58
  const verbose = all.includes("--verbose");
59
+ const skipScan = all.includes("--no-scan");
60
+ const scanDirArgIdx = all.indexOf("--scan-dir");
61
+ const scanDirOverride = scanDirArgIdx >= 0 ? all[scanDirArgIdx + 1] : null;
47
62
 
48
63
  const checks = [];
49
64
 
@@ -222,6 +237,44 @@ export async function run(sub, args = []) {
222
237
  });
223
238
  }
224
239
 
240
+ // 7. Source-tree scan (auth-aware-ssr Section 9). Detects hallucinated
241
+ // SDK names, state-changing GETs, auth.* in prerendered pages, and
242
+ // direct mutation of internal.sessions.authz_version. Hits with severity
243
+ // `error` block deploy (`run402 deploy` wraps doctor and respects exit
244
+ // code). Skipped via --no-scan when the user wants config-only checks.
245
+ if (!skipScan) {
246
+ try {
247
+ const scanRoot = scanDirOverride ?? resolveScanRoot(process.cwd());
248
+ const findings = scanSourceTree(scanRoot, { cwd: process.cwd() });
249
+ const errorFindings = findings.filter((f) => f.severity === SCAN_SEVERITY.ERROR);
250
+ const warnFindings = findings.filter((f) => f.severity === SCAN_SEVERITY.WARN);
251
+ if (findings.length === 0) {
252
+ checks.push({ name: "source_scan", status: "ok", value: { scan_root: scanRoot, file_count_with_findings: 0 } });
253
+ } else {
254
+ checks.push({
255
+ name: "source_scan",
256
+ status: errorFindings.length > 0 ? "error" : "warning",
257
+ value: {
258
+ scan_root: scanRoot,
259
+ findings: errorFindings.length + warnFindings.length,
260
+ errors: errorFindings.length,
261
+ warnings: warnFindings.length,
262
+ details: findings,
263
+ },
264
+ hint: errorFindings.length > 0
265
+ ? "Fix the R402_AUTH_* findings above. `run402 deploy` will refuse to ship until these are resolved."
266
+ : "Source scan emitted warnings (non-blocking). Review and address when convenient.",
267
+ });
268
+ }
269
+ } catch (err) {
270
+ checks.push({
271
+ name: "source_scan",
272
+ status: "skipped",
273
+ message: err instanceof Error ? err.message : String(err),
274
+ });
275
+ }
276
+ }
277
+
225
278
  // 'warning' counts as ok for exit-code purposes — gaps are surfaced in
226
279
  // output but don't fail the doctor. Only hard 'error' / 'missing' /
227
280
  // 'empty' fail.
@@ -246,6 +299,17 @@ export async function run(sub, args = []) {
246
299
  if (c.value && c.value.gaps && Array.isArray(c.value.gaps)) {
247
300
  for (const gap of c.value.gaps) console.log(` • ${gap}`);
248
301
  }
302
+ // Source-scan details — print every finding with file:line + canonical fix-it.
303
+ if (c.name === "source_scan" && c.value && Array.isArray(c.value.details)) {
304
+ for (const f of c.value.details) {
305
+ const sev = f.severity === "error" ? "✗" : "⚠";
306
+ const location = f.line ? `${f.file}:${f.line}` : f.file;
307
+ console.log(` ${sev} ${f.code} ${location}`);
308
+ console.log(` ${f.message}`);
309
+ if (f.canonical_name) console.log(` fix: ${f.canonical_name}`);
310
+ if (f.docs) console.log(` docs: ${f.docs}`);
311
+ }
312
+ }
249
313
  }
250
314
  }
251
315
 
package/lib/email.mjs CHANGED
@@ -16,8 +16,11 @@ Subcommands:
16
16
  list [--limit <n>] [--after <cursor>] [--project <id>]
17
17
  List sent/received messages (paginated)
18
18
  get <message_id> [--project <id>] Get a message with replies
19
- get-raw <message_id> [--project <id>] [--output <file>]
20
- Fetch raw RFC-822 bytes (inbound only)
19
+ get-raw <message_id> --output <file> [--project <id>]
20
+ Fetch raw RFC-822 bytes (inbound only).
21
+ --output is required: bytes are written
22
+ to the file; stdout receives a JSON
23
+ envelope { message_id, bytes, output }.
21
24
  reply <message_id> --html "..." [--text "..."] [--subject "..."] [--from-name "..."] [--project <id>]
22
25
  Reply to an inbound message (threads via In-Reply-To)
23
26
  delete [<slug|mailbox_id>] --confirm [--project <id>]
@@ -123,7 +126,19 @@ compatibility; new code should use 'info'.
123
126
  "get-raw": `run402 email get-raw — Fetch raw RFC-822 bytes for an inbound message
124
127
 
125
128
  Usage:
126
- run402 email get-raw <message_id> [--output <file>] [--project <id>]
129
+ run402 email get-raw <message_id> --output <file> [--project <id>]
130
+
131
+ Arguments:
132
+ <message_id> Inbound message ID
133
+
134
+ Options:
135
+ --output <file> Required: destination file for the raw RFC-822 bytes.
136
+ stdout receives a JSON envelope
137
+ { message_id, bytes, output } — the MIME body is never
138
+ written to stdout, so the CLI stays pipeable.
139
+ --project <id> Project ID (defaults to the active project)
140
+ --mailbox <slug|id> Target a specific mailbox (required when the project
141
+ has more than one)
127
142
  `,
128
143
  create: `run402 email create — Create a project mailbox
129
144
 
@@ -321,21 +336,24 @@ async function getRaw(args) {
321
336
  fail({
322
337
  code: "BAD_USAGE",
323
338
  message: "Missing message_id.",
324
- hint: "run402 email get-raw <message_id> [--output <file>]",
339
+ hint: "run402 email get-raw <message_id> --output <file>",
340
+ });
341
+ }
342
+ if (!outputFile) {
343
+ fail({
344
+ code: "BAD_USAGE",
345
+ message: "Missing --output <file>. Raw MIME bytes must be written to a file, not stdout.",
346
+ hint: "run402 email get-raw <message_id> --output <file>",
347
+ details: { flag: "--output" },
325
348
  });
326
349
  }
327
350
 
328
351
  try {
329
352
  const result = await getSdk().email.getRaw(projectId, messageId, { mailbox: mailbox ?? undefined });
330
353
  const buf = Buffer.from(result.bytes);
331
-
332
- if (outputFile) {
333
- const { writeFileSync } = await import("node:fs");
334
- writeFileSync(outputFile, buf);
335
- console.log(JSON.stringify({ message_id: messageId, bytes: buf.length, output: outputFile }));
336
- } else {
337
- process.stdout.write(buf);
338
- }
354
+ const { writeFileSync } = await import("node:fs");
355
+ writeFileSync(outputFile, buf);
356
+ console.log(JSON.stringify({ message_id: messageId, bytes: buf.length, output: outputFile }));
339
357
  } catch (err) {
340
358
  reportSdkError(err);
341
359
  }
package/lib/functions.mjs CHANGED
@@ -15,8 +15,12 @@ Usage:
15
15
  Subcommands:
16
16
  deploy <id> <name> --file <file> [--timeout <s>] [--memory <mb>] [--deps <pkg,...>] [--schedule <cron>]
17
17
  Deploy a function to a project
18
- invoke <id> <name> [--method <M>] [--body <json>]
19
- Invoke a deployed function
18
+ invoke <id> <name> [--method <M>] [--body <json>] [--raw]
19
+ Invoke a deployed function. Default
20
+ wraps the SDK result as JSON on stdout.
21
+ --raw prints the response body verbatim
22
+ (string body → text + newline, JSON
23
+ body → pretty-printed JSON).
20
24
  logs <id> <name> [--tail <n>] [--since <ts>] [--request-id <req_...>] [--follow]
21
25
  Get function logs
22
26
  update <id> <name> [--schedule <cron>] [--schedule-remove] [--timeout <s>] [--memory <mb>]
@@ -99,10 +103,22 @@ Arguments:
99
103
  Options:
100
104
  --method <M> HTTP method (default POST)
101
105
  --body <json> Request body (ignored for GET/HEAD)
106
+ --raw Skip JSON wrapping. Prints the response body verbatim:
107
+ string body → text + trailing newline; JSON body →
108
+ pretty-printed JSON. Useful when piping a text/plain
109
+ function response to another tool.
110
+
111
+ Output (default — without --raw):
112
+ Stdout is a single JSON envelope { http_status, body, duration_ms }.
113
+ Safe to pipe to jq even when the function returns a plain string.
114
+ The HTTP status is exposed as http_status (not status) so the payload
115
+ stays clean of the reserved top-level "status" field used in error
116
+ envelopes on stderr.
102
117
 
103
118
  Examples:
104
119
  run402 functions invoke prj_abc123 stripe-webhook --body '{"event":"test"}'
105
120
  run402 functions invoke prj_abc123 ping --method GET
121
+ run402 functions invoke prj_abc123 csv --raw > export.csv
106
122
  `,
107
123
  logs: `run402 functions logs — Fetch or tail function logs
108
124
 
@@ -117,7 +133,10 @@ Options:
117
133
  --tail <n> Number of most-recent entries (default 50, max 1000)
118
134
  --since <ts> ISO timestamp or epoch ms; only entries after this
119
135
  --request-id <id> Only entries correlated to this req_... request id
120
- --follow Poll every 3s and stream new entries (Ctrl-C to stop)
136
+ --follow Poll every 3s and stream new entries (Ctrl-C to stop).
137
+ Emits NDJSON: one JSON log entry per line, no wrapping
138
+ "logs:" envelope (the wrapping object is only used in
139
+ the non-follow batch mode).
121
140
 
122
141
  Examples:
123
142
  run402 functions logs prj_abc123 stripe-webhook --tail 100
@@ -208,12 +227,13 @@ async function deploy(projectId, name, args) {
208
227
  }
209
228
 
210
229
  async function invoke(projectId, name, args) {
211
- assertRequiredProjectAndName(projectId, name, "run402 functions invoke <project_id> <name> [--method <M>] [--body <json>]");
212
- assertKnownFlags(args, ["--method", "--body", "--help", "-h"], ["--method", "--body"]);
213
- const opts = { method: "POST", body: undefined };
230
+ assertRequiredProjectAndName(projectId, name, "run402 functions invoke <project_id> <name> [--method <M>] [--body <json>] [--raw]");
231
+ assertKnownFlags(args, ["--method", "--body", "--raw", "--help", "-h"], ["--method", "--body"]);
232
+ const opts = { method: "POST", body: undefined, raw: false };
214
233
  for (let i = 0; i < args.length; i++) {
215
234
  if (args[i] === "--method" && args[i + 1]) opts.method = args[++i];
216
235
  if (args[i] === "--body" && args[i + 1]) opts.body = args[++i];
236
+ if (args[i] === "--raw") opts.raw = true;
217
237
  }
218
238
  const invokeOpts = { method: opts.method };
219
239
  if (opts.body !== undefined && opts.method !== "GET" && opts.method !== "HEAD") {
@@ -221,12 +241,17 @@ async function invoke(projectId, name, args) {
221
241
  }
222
242
  try {
223
243
  const result = await getSdk().functions.invoke(projectId, name, invokeOpts);
224
- const body = result.body;
225
- if (typeof body === "string") {
226
- process.stdout.write(body + "\n");
227
- } else {
228
- console.log(JSON.stringify(body, null, 2));
244
+ if (opts.raw) {
245
+ const body = result.body;
246
+ if (typeof body === "string") {
247
+ process.stdout.write(body + "\n");
248
+ } else {
249
+ console.log(JSON.stringify(body, null, 2));
250
+ }
251
+ return;
229
252
  }
253
+ const { status, ...rest } = result;
254
+ console.log(JSON.stringify({ http_status: status, ...rest }, null, 2));
230
255
  } catch (err) {
231
256
  reportSdkError(err);
232
257
  }
@@ -310,7 +335,7 @@ async function logs(projectId, name, args) {
310
335
  }
311
336
 
312
337
  for (const { entry } of fresh) {
313
- console.log(`[${entry.timestamp}] ${entry.message}`);
338
+ console.log(JSON.stringify(entry));
314
339
  }
315
340
  if (fresh.length === 0 || !Number.isFinite(nextHighWaterMs)) return;
316
341
 
@@ -215,11 +215,10 @@ const { title, ogImage, canonical } = Astro.props;
215
215
  // 2. DB update.
216
216
  // 3. cache.invalidate() so the public URL re-renders fresh on next visit.
217
217
  import type { APIRoute } from "astro";
218
- import { db, getUser, cache } from "@run402/functions";
218
+ import { db, auth, cache } from "@run402/functions";
219
219
 
220
220
  export const POST: APIRoute = async ({ request }) => {
221
- const user = await getUser();
222
- if (!user) return new Response("Unauthorized", { status: 401 });
221
+ const user = await auth.requireUser();
223
222
 
224
223
  const body = (await request.json()) as { slug: string; title: string; html: string };
225
224
  if (!body?.slug) return new Response("Missing slug", { status: 400 });
@@ -236,6 +235,96 @@ export const POST: APIRoute = async ({ request }) => {
236
235
  headers: { "content-type": "application/json" },
237
236
  });
238
237
  };
238
+ `,
239
+ },
240
+ {
241
+ path: "AGENTS.md",
242
+ content: `# AGENTS.md
243
+
244
+ This file documents the brutally-small Run402 surface this Astro project
245
+ uses. Coding agents: read this first. The platform is intentionally small —
246
+ there are no other auth helpers, no other client surfaces, and no other
247
+ hidden APIs.
248
+
249
+ ## The auth surface
250
+
251
+ \`auth\` is the entire user-auth surface. Import from \`@run402/functions\`:
252
+
253
+ \`\`\`ts
254
+ import { auth } from "@run402/functions";
255
+
256
+ // In SSR pages and API routes:
257
+ const user = await auth.user(); // Actor | null
258
+ const user = await auth.requireUser(); // Actor; throws R402_AUTH_REQUIRED
259
+ const { user, role } = await auth.requireRole("admin");
260
+ const { user, membership } = await auth.requireMembership("member");
261
+ await auth.requireFresh({ maxAge: "10m", amr: ["passkey"] });
262
+
263
+ // CSRF for hosted forms (server-side, in <form> rendering):
264
+ const field = auth.csrfField();
265
+ // → <input type="hidden" name="_csrf" value="..." />
266
+
267
+ // Cross-origin-safe fetch (auto-forwards actor context to same-origin):
268
+ const res = await auth.fetch("/api/internal"); // relative URLs only
269
+ \`\`\`
270
+
271
+ ## The four Never rules
272
+
273
+ 1. **Never \`try\`/\`catch\` auth errors.** Let them bubble. The platform turns
274
+ \`R402_AUTH_REQUIRED\` into a 303 to \`/auth/sign-in?return_to=…\` and
275
+ \`R402_AUTH_INSUFFICIENT_ROLE\` into 403 with a fix-it response. Catching
276
+ them creates silent-null bugs.
277
+
278
+ 2. **Never \`.eq("user_id", user.id)\`.** \`db()\` propagates the actor to
279
+ PostgREST so RLS enforces ownership server-side. The redundant filter is
280
+ a code smell that \`run402 doctor\` flags as
281
+ \`R402_AUTH_REDUNDANT_USER_FILTER\`.
282
+
283
+ 3. **Never set client-supplied actor headers.** \`x-run402-actor-*\`,
284
+ \`run402.actor.*\`, \`x-r402-actor-*\` are platform-owned channel headers.
285
+ The gateway strips inbound spoofing attempts and emits
286
+ \`R402_AUTH_ACTOR_HEADER_SPOOF\` in strict mode.
287
+
288
+ 4. **Never mint a session from a raw \`userId\`.** Use
289
+ \`auth.sessions.createResponseFromIdentity({ provider, subject, proof, amr })\`
290
+ with a verified identity proof. No \`createSessionForUserId(uuid)\` API exists.
291
+
292
+ ## Hosted UI components
293
+
294
+ For sign-in, sign-up, and sign-out chrome, use the platform's
295
+ \`@run402/astro\` components — they emit forms posting to platform hosted
296
+ routes (\`/auth/v1/sign-in\` etc.) with the CSRF token already wired:
297
+
298
+ \`\`\`astro
299
+ ---
300
+ import { SignIn, SignUp, UserButton, SignedIn, SignedOut } from "@run402/astro";
301
+ ---
302
+
303
+ <SignedIn>
304
+ <UserButton />
305
+ </SignedIn>
306
+ <SignedOut>
307
+ <SignIn returnTo="/dashboard" />
308
+ </SignedOut>
309
+ \`\`\`
310
+
311
+ Do NOT roll your own sign-in form. The hosted routes handle CSRF, returnTo
312
+ validation, OAuth provider bridges, and passkey ceremonies.
313
+
314
+ ## Rendering-mode quick map
315
+
316
+ \`auth.*\` calls run at request time, so the page must be SSR or a
317
+ server-island. Calling \`auth.user()\` from a prerendered page throws
318
+ \`R402_AUTH_PRERENDERED\`.
319
+
320
+ | Mode | When | Auth-aware |
321
+ | ----------------------------- | ----------------------------------- | ----------------------- |
322
+ | SSR (default) | Personalized pages | \`auth.user()\` works |
323
+ | Prerendered | Marketing pages, never sees actor | \`auth.*\` throws |
324
+ | Server island | Prerendered page + personalized slot| \`auth.*\` in the island|
325
+ | Client hydrate | Visibility-only, no SSR pass | Component hits session |
326
+
327
+ For error-code reference: https://run402.com/errors/#R402_AUTH_REQUIRED
239
328
  `,
240
329
  },
241
330
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run402",
3
- "version": "2.22.0",
3
+ "version": "2.23.0",
4
4
  "description": "CLI for Run402 — provision Postgres databases, deploy static sites, generate images, and manage wallets via x402 and MPP micropayments.",
5
5
  "type": "module",
6
6
  "bin": {