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 +18 -6
- 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 +66 -2
- package/lib/email.mjs +30 -12
- package/lib/functions.mjs +37 -12
- package/lib/init-astro.mjs +92 -3
- 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/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
|
|
25
|
-
--verbose
|
|
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>
|
|
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>
|
|
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>
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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(
|
|
338
|
+
console.log(JSON.stringify(entry));
|
|
314
339
|
}
|
|
315
340
|
if (fresh.length === 0 || !Number.isFinite(nextHighWaterMs)) return;
|
|
316
341
|
|
package/lib/init-astro.mjs
CHANGED
|
@@ -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,
|
|
218
|
+
import { db, auth, cache } from "@run402/functions";
|
|
219
219
|
|
|
220
220
|
export const POST: APIRoute = async ({ request }) => {
|
|
221
|
-
const user = await
|
|
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