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 +18 -6
- package/lib/cache.mjs +11 -47
- package/lib/deploy-v2.mjs +38 -0
- package/lib/doctor-source-scan.mjs +424 -0
- package/lib/doctor-source-scan.test.mjs +318 -0
- package/lib/doctor.mjs +59 -26
- package/lib/email.mjs +30 -12
- package/lib/functions.mjs +37 -12
- package/lib/init-astro.mjs +127 -35
- package/lib/init.mjs +10 -10
- package/lib/logs.mjs +32 -37
- package/package.json +1 -1
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
|
-
--
|
|
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
|
-
--
|
|
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,
|
|
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 === "--
|
|
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.
|
|
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.
|
|
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
|
|
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, ["--
|
|
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
|
-
|
|
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, ["--
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|