run402 4.83.0 → 4.85.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.
Files changed (63) hide show
  1. package/README.md +3 -3
  2. package/gitvault-surface.json +1 -1
  3. package/lib/deploy-v2.mjs +30 -68
  4. package/lib/doctor-source-scan.mjs +2 -547
  5. package/lib/doctor.mjs +28 -8
  6. package/lib/sdk-errors.mjs +7 -0
  7. package/lib/status.mjs +1 -1
  8. package/lib/up.mjs +29 -10
  9. package/lib/update-check.mjs +20 -10
  10. package/lib/update-check.test.mjs +57 -5
  11. package/package.json +1 -1
  12. package/sdk/dist/actions.d.ts +19 -4
  13. package/sdk/dist/actions.d.ts.map +1 -1
  14. package/sdk/dist/config.d.ts +1 -1
  15. package/sdk/dist/config.d.ts.map +1 -1
  16. package/sdk/dist/config.js.map +1 -1
  17. package/sdk/dist/index.d.ts +2 -0
  18. package/sdk/dist/index.d.ts.map +1 -1
  19. package/sdk/dist/index.js +2 -0
  20. package/sdk/dist/index.js.map +1 -1
  21. package/sdk/dist/namespaces/deploy.d.ts.map +1 -1
  22. package/sdk/dist/namespaces/deploy.js +2 -1
  23. package/sdk/dist/namespaces/deploy.js.map +1 -1
  24. package/sdk/dist/namespaces/deploy.types.d.ts +6 -0
  25. package/sdk/dist/namespaces/deploy.types.d.ts.map +1 -1
  26. package/sdk/dist/namespaces/edge-evidence.d.ts +22 -0
  27. package/sdk/dist/namespaces/edge-evidence.d.ts.map +1 -0
  28. package/sdk/dist/namespaces/edge-evidence.js +38 -0
  29. package/sdk/dist/namespaces/edge-evidence.js.map +1 -0
  30. package/sdk/dist/namespaces/projects.d.ts.map +1 -1
  31. package/sdk/dist/namespaces/projects.js +9 -7
  32. package/sdk/dist/namespaces/projects.js.map +1 -1
  33. package/sdk/dist/node/actions-node.d.ts.map +1 -1
  34. package/sdk/dist/node/actions-node.js +73 -58
  35. package/sdk/dist/node/actions-node.js.map +1 -1
  36. package/sdk/dist/node/app-scope.d.ts +24 -0
  37. package/sdk/dist/node/app-scope.d.ts.map +1 -0
  38. package/sdk/dist/node/app-scope.js +47 -0
  39. package/sdk/dist/node/app-scope.js.map +1 -0
  40. package/sdk/dist/node/deploy-manifest.d.ts +6 -0
  41. package/sdk/dist/node/deploy-manifest.d.ts.map +1 -1
  42. package/sdk/dist/node/deploy-manifest.js +61 -0
  43. package/sdk/dist/node/deploy-manifest.js.map +1 -1
  44. package/sdk/dist/node/index.d.ts +5 -0
  45. package/sdk/dist/node/index.d.ts.map +1 -1
  46. package/sdk/dist/node/index.js +5 -0
  47. package/sdk/dist/node/index.js.map +1 -1
  48. package/sdk/dist/node/manifest-export.d.ts +5 -0
  49. package/sdk/dist/node/manifest-export.d.ts.map +1 -0
  50. package/sdk/dist/node/manifest-export.js +78 -0
  51. package/sdk/dist/node/manifest-export.js.map +1 -0
  52. package/sdk/dist/node/preflight.d.ts +65 -0
  53. package/sdk/dist/node/preflight.d.ts.map +1 -0
  54. package/sdk/dist/node/preflight.js +23 -0
  55. package/sdk/dist/node/preflight.js.map +1 -0
  56. package/sdk/dist/node/source-scan.d.ts +73 -0
  57. package/sdk/dist/node/source-scan.d.ts.map +1 -0
  58. package/sdk/dist/node/source-scan.js +552 -0
  59. package/sdk/dist/node/source-scan.js.map +1 -0
  60. package/sdk/dist/rest-diagnostics.d.ts +15 -0
  61. package/sdk/dist/rest-diagnostics.d.ts.map +1 -0
  62. package/sdk/dist/rest-diagnostics.js +19 -0
  63. package/sdk/dist/rest-diagnostics.js.map +1 -0
@@ -1,547 +1,2 @@
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 the auth-aware-ssr OpenSpec change (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
- // 8) Tenant-assertion session-mint call without the declared capability.
343
- // `auth.sessions.createResponseFromTenantAssertion(...)` mints a browser
344
- // session from a tenant's vouching. It works ONLY in a function whose
345
- // deploy/apply spec declares `capabilities: ["auth.sessionMint"]`
346
- // (FunctionSpec.capabilities — sibling to `config`, since the platform
347
- // has no code-export metadata channel). Service-key presence is NOT
348
- // sufficient. Without the capability the gateway returns
349
- // R402_AUTH_UNTRUSTED_CONTEXT at runtime and mints no session.
350
- //
351
- // The pure file scanner can't see the per-function spec, so the caller
352
- // threads `opts.declaredCapabilities` (the union of capabilities declared
353
- // across run402.config.json function entries — see readDeclaredCapabilities).
354
- // We suppress the finding when "auth.sessionMint" is present anywhere in
355
- // that union. Global-union (not per-file) is a deliberate precision
356
- // trade-off: the file→function-entry mapping isn't reliable from source,
357
- // and the runtime gate catches the rare "function A declared it, function
358
- // B forgot" case. WARN severity (never block deploy): an inline/SDK spec
359
- // the doctor can't read might declare the capability.
360
- const declaredCaps =
361
- opts.declaredCapabilities instanceof Set
362
- ? opts.declaredCapabilities
363
- : new Set(Array.isArray(opts.declaredCapabilities) ? opts.declaredCapabilities : []);
364
- if (!declaredCaps.has("auth.sessionMint")) {
365
- const mintCallRegex = /\bcreateResponseFromTenantAssertion\s*\(/g;
366
- let mintMatch;
367
- while ((mintMatch = mintCallRegex.exec(content)) !== null) {
368
- findings.push({
369
- code: "R402_DOCTOR_AUTH_SESSION_MINT_CAPABILITY_MISSING",
370
- severity: SCAN_SEVERITY.WARN,
371
- file: filePath,
372
- line: lineNumberFor(content, mintMatch.index),
373
- message:
374
- "createResponseFromTenantAssertion (tenant-assertion session mint) requires the " +
375
- '"auth.sessionMint" capability, which no function declares in run402.config.json. ' +
376
- "Without it the gateway returns R402_AUTH_UNTRUSTED_CONTEXT at runtime and mints no session.",
377
- fix:
378
- 'Add "capabilities": ["auth.sessionMint"] to this function\'s entry in run402.config.json ' +
379
- '(under functions.replace.<name>, a sibling to "config"). A service key is NOT sufficient.',
380
- docs: "https://docs.run402.com/auth/tenant-assertion#capability",
381
- });
382
- }
383
- }
384
-
385
- return findings;
386
- }
387
-
388
- /** Recursively walk `srcDir` and scan every file with a relevant
389
- * extension. Returns the combined findings list, sorted by file +
390
- * line for stable output. */
391
- export function scanSourceTree(srcDir, opts = {}) {
392
- const findings = [];
393
- // Capability picture for the tenant-assertion mint check (#8). Read from
394
- // run402.config.json unless the caller passed it explicitly (tests do).
395
- const declaredCapabilities =
396
- opts.declaredCapabilities ?? readDeclaredCapabilities(opts.cwd ?? srcDir);
397
- walk(srcDir, (filePath) => {
398
- if (!SCANNED_EXTENSIONS.has(extname(filePath))) return;
399
- let content;
400
- try {
401
- content = readFileSync(filePath, "utf8");
402
- } catch (err) {
403
- findings.push({
404
- code: "R402_AUTH_SOURCE_SCAN_ERROR",
405
- severity: SCAN_SEVERITY.WARN,
406
- file: relative(opts.cwd ?? srcDir, filePath),
407
- message: `failed to read file: ${err instanceof Error ? err.message : String(err)}`,
408
- });
409
- return;
410
- }
411
- findings.push(
412
- ...scanFileContent(content, {
413
- filePath: relative(opts.cwd ?? srcDir, filePath),
414
- declaredCapabilities,
415
- }),
416
- );
417
- });
418
- findings.sort((a, b) => {
419
- if (a.file !== b.file) return a.file < b.file ? -1 : 1;
420
- return (a.line ?? 0) - (b.line ?? 0);
421
- });
422
- return findings;
423
- }
424
-
425
- /** Scan an explicit list of on-disk file paths — no directory walk.
426
- * Used by `run402 deploy apply` for manifest/spec/stdin deploys, where
427
- * the artifact is exactly the set of files the manifest references, NOT
428
- * whatever happens to live under cwd/src. Files without a
429
- * scannable extension are ignored; unreadable files become a WARN
430
- * finding (never throw). Returns the combined findings list, sorted by
431
- * file + line for stable output, exactly like `scanSourceTree`. */
432
- export function scanSourceFiles(filePaths, opts = {}) {
433
- const findings = [];
434
- const cwd = opts.cwd ?? process.cwd();
435
- // Same capability picture as scanSourceTree (#8 mint check).
436
- const declaredCapabilities =
437
- opts.declaredCapabilities ?? readDeclaredCapabilities(cwd);
438
- for (const filePath of filePaths) {
439
- if (!SCANNED_EXTENSIONS.has(extname(filePath))) continue;
440
- let content;
441
- try {
442
- content = readFileSync(filePath, "utf8");
443
- } catch (err) {
444
- findings.push({
445
- code: "R402_AUTH_SOURCE_SCAN_ERROR",
446
- severity: SCAN_SEVERITY.WARN,
447
- file: relative(cwd, filePath),
448
- message: `failed to read file: ${err instanceof Error ? err.message : String(err)}`,
449
- });
450
- continue;
451
- }
452
- findings.push(
453
- ...scanFileContent(content, {
454
- filePath: relative(cwd, filePath),
455
- declaredCapabilities,
456
- }),
457
- );
458
- }
459
- findings.sort((a, b) => {
460
- if (a.file !== b.file) return a.file < b.file ? -1 : 1;
461
- return (a.line ?? 0) - (b.line ?? 0);
462
- });
463
- return findings;
464
- }
465
-
466
- function walk(dir, visitor) {
467
- let entries;
468
- try {
469
- entries = readdirSync(dir, { withFileTypes: true });
470
- } catch {
471
- return;
472
- }
473
- for (const entry of entries) {
474
- if (entry.isDirectory()) {
475
- if (SKIPPED_DIRECTORIES.has(entry.name)) continue;
476
- walk(join(dir, entry.name), visitor);
477
- } else if (entry.isFile()) {
478
- visitor(join(dir, entry.name));
479
- }
480
- }
481
- }
482
-
483
- function lineNumberFor(content, index) {
484
- let line = 1;
485
- for (let i = 0; i < index; i++) {
486
- if (content.charCodeAt(i) === 10) line++;
487
- }
488
- return line;
489
- }
490
-
491
- function escapeRegex(s) {
492
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
493
- }
494
-
495
- /** Convenience for tests: synchronous, no FS access. */
496
- export function _testOnly_hallucinatedNames() {
497
- return HALLUCINATED_NAMES.slice();
498
- }
499
-
500
- export function _testOnly_authProperties() {
501
- return HALLUCINATED_AUTH_PROPERTIES.slice();
502
- }
503
-
504
- /** Read the union of `capabilities` declared across all function entries in
505
- * `run402.config.json` (the apply spec). Used by the tenant-assertion mint
506
- * check (#8) to suppress the warning when "auth.sessionMint" is declared.
507
- *
508
- * Functions live under `functions.replace.<name>` / `functions.set.<name>`
509
- * with `capabilities?: string[]` as a sibling to `config`. Best-effort:
510
- * a missing or malformed config returns an empty set (the scanner then
511
- * warns, which is the safe default — the runtime gate is the hard
512
- * enforcement). Returns a `Set<string>`. */
513
- export function readDeclaredCapabilities(cwd = process.cwd()) {
514
- const caps = new Set();
515
- let parsed;
516
- try {
517
- parsed = JSON.parse(readFileSync(join(cwd, "run402.config.json"), "utf8"));
518
- } catch {
519
- return caps; // no config / unreadable / malformed → nothing declared
520
- }
521
- const fns = parsed?.functions;
522
- if (!fns || typeof fns !== "object") return caps;
523
- for (const bucket of ["replace", "set", "patch"]) {
524
- const entries = fns[bucket];
525
- if (!entries || typeof entries !== "object") continue;
526
- for (const entry of Object.values(entries)) {
527
- const declared = entry?.capabilities;
528
- if (Array.isArray(declared)) {
529
- for (const cap of declared) if (typeof cap === "string") caps.add(cap);
530
- }
531
- }
532
- }
533
- return caps;
534
- }
535
-
536
- /** Resolve the project's src/ directory. Astro convention is `<root>/src`;
537
- * bare Node projects use `<root>/src` or `<root>`. We prefer `src/` if
538
- * it exists. */
539
- export function resolveScanRoot(cwd = process.cwd()) {
540
- const srcDir = join(cwd, "src");
541
- try {
542
- if (statSync(srcDir).isDirectory()) return srcDir;
543
- } catch {
544
- // Fall through.
545
- }
546
- return cwd;
547
- }
1
+ // Shared with Node up, deploy apply, and scoped doctor.
2
+ export { scanFileContent, scanSourceTree, scanSourceFiles, resolveScanRoot, readDeclaredCapabilities, SCAN_SEVERITY, _testOnly_hallucinatedNames, _testOnly_authProperties } from "#sdk/node";
package/lib/doctor.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { resolveApplicationScope, loadApplicationScanInput, scanDeploymentSources } from "#sdk/node";
1
2
  /**
2
3
  * run402 doctor — Health and config diagnostics.
3
4
  *
@@ -28,7 +29,7 @@ import { fail } from "./sdk-errors.mjs";
28
29
  import { normalizeArgv, assertKnownFlags, flagValue } from "./argparse.mjs";
29
30
 
30
31
  /** Value-taking flags — the flag set doctor actually parses; anything else is BAD_USAGE via assertKnownFlags, never silently ignored. */
31
- const DOCTOR_VALUE_FLAGS = ["--scan-dir", "--buzz-agent", "--project", "--only"];
32
+ const DOCTOR_VALUE_FLAGS = ["--scan-dir", "--dir", "--manifest", "--buzz-agent", "--project", "--only"];
32
33
 
33
34
  /**
34
35
  * The stable, complete registry of ordinary-mode check names. One entry
@@ -113,7 +114,9 @@ Options:
113
114
  reports fresh/age_ms/refresh_attempted/refresh_failed.
114
115
  --no-scan Skip the source-tree scan (config / health checks only). Implied
115
116
  by any --only that omits source_scan.
116
- --scan-dir D Scan a custom directory instead of \`<cwd>/src\`
117
+ --dir D Select the application directory for deployment diagnostics.
118
+ --manifest P Explicitly select a manifest (executable configs are trusted code).
119
+ --scan-dir D Advanced arbitrary scan; does not claim deployment readiness
117
120
  --project <id> Target THIS project's gitvault check instead of the repo-standing
118
121
  default (the 4.38.0 pin / run402 remote / RUN402_PROJECT_ID / active
119
122
  project, in that order — see \`gitvault-target.mjs\`). Scoped to the
@@ -265,6 +268,8 @@ function redactAllowanceForDiagnostics(allowance) {
265
268
  if (!allowance || typeof allowance !== "object") return allowance;
266
269
  const safe = { ...allowance };
267
270
  delete safe.privateKey;
271
+ if (typeof safe.funded === "boolean") safe.faucet_used = safe.funded;
272
+ delete safe.funded;
268
273
  return safe;
269
274
  }
270
275
 
@@ -506,7 +511,7 @@ export async function run(sub, args = []) {
506
511
  lifecycle,
507
512
  active,
508
513
  organization_lifecycle_state: lifecycle,
509
- lease_expires_at: tier?.lease_expires_at ?? null,
514
+ lease_expires_at: tier?.lease_perpetual === true ? null : tier?.lease_expires_at ?? null,
510
515
  reachable_projects: reachableProjects,
511
516
  };
512
517
  if (status === "ok") {
@@ -942,17 +947,30 @@ export async function run(sub, args = []) {
942
947
  // otherwise bury the gitvault diagnosis under thousands of hits.
943
948
  if (!skipScan && wanted("source_scan")) {
944
949
  try {
945
- const scanRoot = scanDirOverride ?? resolveScanRoot(process.cwd());
946
- const findings = scanSourceTree(scanRoot, { cwd: process.cwd() });
950
+ const scope = await resolveApplicationScope({ dir: flagValue(all, "--dir") ?? undefined, manifest: flagValue(all, "--manifest") ?? undefined });
951
+ if (!scanDirOverride && !scope.selected) {
952
+ checks.push({ name: "source_scan", status: "skipped", value: { scope: "unscoped", app_root: scope.app_root }, message: "No application selected. Run doctor --dir <app> or --manifest <path>; sibling applications are not deployment blockers." });
953
+ } else {
954
+ const scanRoot = scanDirOverride ?? resolveScanRoot(scope.app_root);
955
+ const scanContext = { scope: scanDirOverride ? "explicit_scan_directory" : "application", app_root: scope.app_root, manifest_path: scope.manifest_path };
956
+ let findings;
957
+ if (scanDirOverride) findings = scanSourceTree(scanRoot, { cwd: scope.app_root });
958
+ else {
959
+ const selected = await loadApplicationScanInput(scope.manifest_path);
960
+ scanContext.build_outputs = selected.build_deferred ? "deferred_until_build" : "not_deferred";
961
+ findings = scanDeploymentSources(selected.spec, scope.app_root).findings;
962
+ }
947
963
  const errorFindings = findings.filter((f) => f.severity === SCAN_SEVERITY.ERROR);
948
964
  const warnFindings = findings.filter((f) => f.severity === SCAN_SEVERITY.WARN);
949
965
  if (findings.length === 0) {
950
- checks.push({ name: "source_scan", status: "ok", value: { scan_root: scanRoot, file_count_with_findings: 0 } });
966
+ checks.push({ name: "source_scan", status: "ok", value: { ...scanContext, scan_root: scanRoot, file_count_with_findings: 0 } });
951
967
  } else {
952
968
  checks.push({
953
969
  name: "source_scan",
954
970
  status: errorFindings.length > 0 ? "error" : "warning",
971
+ ...(scanDirOverride ? { severity: "advisory" } : {}),
955
972
  value: {
973
+ ...scanContext,
956
974
  scan_root: scanRoot,
957
975
  findings: errorFindings.length + warnFindings.length,
958
976
  errors: errorFindings.length,
@@ -960,14 +978,16 @@ export async function run(sub, args = []) {
960
978
  details: findings,
961
979
  },
962
980
  hint: errorFindings.length > 0
963
- ? "Fix the R402_AUTH_* findings above. `run402 deploy` will refuse to ship until these are resolved."
981
+ ? scanDirOverride ? "Findings are from the explicit arbitrary scan directory; they do not establish that an application deploy will be refused." : "Fix the findings in this application. The same scoped source scan gates up and deploy apply."
964
982
  : "Source scan emitted warnings (non-blocking). Review and address when convenient.",
965
983
  });
966
984
  }
985
+ }
967
986
  } catch (err) {
968
987
  checks.push({
969
988
  name: "source_scan",
970
- status: "skipped",
989
+ status: "error",
990
+ value: { code: err?.code ?? "APPLICATION_SCAN_FAILED", details: err?.details ?? null },
971
991
  message: err instanceof Error ? err.message : String(err),
972
992
  });
973
993
  }
@@ -123,6 +123,13 @@ export function reportSdkError(err) {
123
123
  mergeStructuredErrorFields(payload, err);
124
124
  }
125
125
 
126
+ if (err?.code === "REST_PERMISSION_DENIED") {
127
+ payload.code = err.code;
128
+ payload.message = err.message;
129
+ payload.details = err.details;
130
+ payload.upstream_body = err.body;
131
+ }
132
+
126
133
  // Org-owned control plane (gateway v1.77+): a NOT_AUTHORIZED denial means the
127
134
  // wallet authenticated but the resolved principal lacks the org role/grant for
128
135
  // this control-plane action — distinct from a missing-auth or payment error.
package/lib/status.mjs CHANGED
@@ -158,7 +158,7 @@ export async function run(args = []) {
158
158
  held_usd_micros: hasBilling ? (billing.held_usd_micros ?? 0) : null,
159
159
  },
160
160
  tier: tier && tier.tier
161
- ? { name: tier.tier, status: tier.status, expires: tier.lease_expires_at }
161
+ ? { name: tier.tier, status: tier.status, expires: tier.lease_perpetual === true ? null : tier.lease_expires_at }
162
162
  : null,
163
163
  // v1.57: lifecycle state and the per-organization escape hatch moved to the
164
164
  // organization. Surface them at the top level so agents don't have to