run402 3.5.1 → 3.5.2
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/deploy-v2.mjs +89 -6
- package/lib/doctor-source-scan.mjs +41 -0
- package/lib/doctor-source-scan.test.mjs +104 -0
- package/lib/init-astro.mjs +4 -4
- package/package.json +1 -1
package/lib/deploy-v2.mjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* UTF-8 is the default; binary files pass `"encoding": "base64"`.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import { fstatSync, readFileSync } from "node:fs";
|
|
25
|
+
import { existsSync, fstatSync, readFileSync } from "node:fs";
|
|
26
26
|
import { resolve, dirname, isAbsolute } from "node:path";
|
|
27
27
|
import {
|
|
28
28
|
buildDeployResolveSummary,
|
|
@@ -646,6 +646,69 @@ async function mergeAstroReleaseSlice(spec, dirArg) {
|
|
|
646
646
|
spec.functions = { replace: { ...existingFns, ...sliceFns } };
|
|
647
647
|
}
|
|
648
648
|
|
|
649
|
+
/**
|
|
650
|
+
* Derive the list of on-disk source files a raw manifest `spec` references,
|
|
651
|
+
* resolved against `baseDir` (the manifest's directory, or cwd for
|
|
652
|
+
* --spec/stdin). Walks the file-bearing slices:
|
|
653
|
+
* - functions.replace[name].source
|
|
654
|
+
* - functions.patch.put[name].source (and functions.patch.set, an alias)
|
|
655
|
+
* - site.replace[key] (entry itself or entry.source)
|
|
656
|
+
* - site.patch.put[key] (entry itself or entry.source)
|
|
657
|
+
*
|
|
658
|
+
* A manifest file entry is `{ path }` (on-disk ref) OR `{ data, encoding? }`
|
|
659
|
+
* (inline bytes). Only `{ path }` entries with no `data` resolve to a disk
|
|
660
|
+
* file; inline entries are skipped (nothing to scan on disk). Returns a
|
|
661
|
+
* de-duped list of absolute paths. Existence + extension filtering is the
|
|
662
|
+
* caller's job (GH-409). Defensive: tolerates missing/odd shapes silently —
|
|
663
|
+
* the manifest normalizer is the authority on shape validity, not this
|
|
664
|
+
* best-effort extractor.
|
|
665
|
+
*/
|
|
666
|
+
function collectManifestSourceFiles(spec, baseDir) {
|
|
667
|
+
const out = new Set();
|
|
668
|
+
if (!spec || typeof spec !== "object") return [];
|
|
669
|
+
|
|
670
|
+
const resolveEntryPath = (entry) => {
|
|
671
|
+
// The on-disk ref can be the entry itself (`{ path }`) or nested under
|
|
672
|
+
// `.source` (function entries, asset put entries). Skip inline `{ data }`.
|
|
673
|
+
const node =
|
|
674
|
+
entry && typeof entry === "object" && entry.source && typeof entry.source === "object"
|
|
675
|
+
? entry.source
|
|
676
|
+
: entry;
|
|
677
|
+
if (
|
|
678
|
+
node &&
|
|
679
|
+
typeof node === "object" &&
|
|
680
|
+
typeof node.path === "string" &&
|
|
681
|
+
node.data === undefined
|
|
682
|
+
) {
|
|
683
|
+
out.add(isAbsolute(node.path) ? node.path : resolve(baseDir, node.path));
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
const eachValue = (map) => {
|
|
688
|
+
if (!map || typeof map !== "object") return;
|
|
689
|
+
for (const entry of Object.values(map)) resolveEntryPath(entry);
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
const fns = spec.functions;
|
|
693
|
+
if (fns && typeof fns === "object") {
|
|
694
|
+
eachValue(fns.replace);
|
|
695
|
+
if (fns.patch && typeof fns.patch === "object") {
|
|
696
|
+
eachValue(fns.patch.put);
|
|
697
|
+
eachValue(fns.patch.set);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const site = spec.site;
|
|
702
|
+
if (site && typeof site === "object") {
|
|
703
|
+
eachValue(site.replace);
|
|
704
|
+
if (site.patch && typeof site.patch === "object") {
|
|
705
|
+
eachValue(site.patch.put);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return [...out];
|
|
710
|
+
}
|
|
711
|
+
|
|
649
712
|
async function applyCmd(args) {
|
|
650
713
|
const opts = parseApplyArgs(args);
|
|
651
714
|
const { source, error: sourceError } = resolveApplySource(opts, hasStdinSource());
|
|
@@ -772,15 +835,35 @@ async function applyCmd(args) {
|
|
|
772
835
|
// RUN402_DEPLOY_SKIP_SCAN=1 — useful for forcing a deploy when
|
|
773
836
|
// the scanner has a false positive that the operator has confirmed
|
|
774
837
|
// is fine. Hits with severity `error` fail the deploy.
|
|
838
|
+
//
|
|
839
|
+
// Scope (GH-409): a `--dir` (Astro SSR build) deploy walks that dir —
|
|
840
|
+
// it IS the artifact. A manifest/spec/stdin deploy scans ONLY the
|
|
841
|
+
// on-disk source files the manifest actually references, resolved
|
|
842
|
+
// against the manifest's baseDir. We must NOT walk cwd/src for a
|
|
843
|
+
// manifest deploy: running from inside an unrelated source tree (e.g.
|
|
844
|
+
// the gateway monorepo, which legitimately has dozens of `getUser`
|
|
845
|
+
// references) would otherwise block a deploy of one unrelated HTML file.
|
|
775
846
|
if (process.env.RUN402_DEPLOY_SKIP_SCAN !== "1") {
|
|
776
847
|
try {
|
|
777
|
-
const {
|
|
848
|
+
const { scanSourceTree, scanSourceFiles, SCAN_SEVERITY } = await import(
|
|
778
849
|
"./doctor-source-scan.mjs"
|
|
779
850
|
);
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
851
|
+
const scannableExt = /\.(?:ts|tsx|js|jsx|mjs|cjs|astro)$/;
|
|
852
|
+
let findings;
|
|
853
|
+
if (opts.dir) {
|
|
854
|
+
const scanRoot = isAbsolute(opts.dir)
|
|
855
|
+
? opts.dir
|
|
856
|
+
: resolve(process.cwd(), opts.dir);
|
|
857
|
+
findings = scanSourceTree(scanRoot, { cwd: process.cwd() });
|
|
858
|
+
} else {
|
|
859
|
+
const baseDir = manifestPath ? dirname(manifestPath) : process.cwd();
|
|
860
|
+
const files = collectManifestSourceFiles(spec, baseDir).filter(
|
|
861
|
+
(p) => scannableExt.test(p) && existsSync(p),
|
|
862
|
+
);
|
|
863
|
+
findings = files.length > 0
|
|
864
|
+
? scanSourceFiles(files, { cwd: process.cwd() })
|
|
865
|
+
: [];
|
|
866
|
+
}
|
|
784
867
|
const errorFindings = findings.filter((f) => f.severity === SCAN_SEVERITY.ERROR);
|
|
785
868
|
if (errorFindings.length > 0) {
|
|
786
869
|
const summary = errorFindings.slice(0, 10).map((f) => {
|
|
@@ -422,6 +422,47 @@ export function scanSourceTree(srcDir, opts = {}) {
|
|
|
422
422
|
return findings;
|
|
423
423
|
}
|
|
424
424
|
|
|
425
|
+
/** Scan an explicit list of on-disk file paths — no directory walk.
|
|
426
|
+
* Used by `run402 deploy apply` for manifest/spec/stdin deploys, where
|
|
427
|
+
* the artifact is exactly the set of files the manifest references, NOT
|
|
428
|
+
* whatever happens to live under cwd/src (GH-409). Files without a
|
|
429
|
+
* scannable extension are ignored; unreadable files become a WARN
|
|
430
|
+
* finding (never throw). Returns the combined findings list, sorted by
|
|
431
|
+
* file + line for stable output, exactly like `scanSourceTree`. */
|
|
432
|
+
export function scanSourceFiles(filePaths, opts = {}) {
|
|
433
|
+
const findings = [];
|
|
434
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
435
|
+
// Same capability picture as scanSourceTree (#8 mint check).
|
|
436
|
+
const declaredCapabilities =
|
|
437
|
+
opts.declaredCapabilities ?? readDeclaredCapabilities(cwd);
|
|
438
|
+
for (const filePath of filePaths) {
|
|
439
|
+
if (!SCANNED_EXTENSIONS.has(extname(filePath))) continue;
|
|
440
|
+
let content;
|
|
441
|
+
try {
|
|
442
|
+
content = readFileSync(filePath, "utf8");
|
|
443
|
+
} catch (err) {
|
|
444
|
+
findings.push({
|
|
445
|
+
code: "R402_AUTH_SOURCE_SCAN_ERROR",
|
|
446
|
+
severity: SCAN_SEVERITY.WARN,
|
|
447
|
+
file: relative(cwd, filePath),
|
|
448
|
+
message: `failed to read file: ${err instanceof Error ? err.message : String(err)}`,
|
|
449
|
+
});
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
findings.push(
|
|
453
|
+
...scanFileContent(content, {
|
|
454
|
+
filePath: relative(cwd, filePath),
|
|
455
|
+
declaredCapabilities,
|
|
456
|
+
}),
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
findings.sort((a, b) => {
|
|
460
|
+
if (a.file !== b.file) return a.file < b.file ? -1 : 1;
|
|
461
|
+
return (a.line ?? 0) - (b.line ?? 0);
|
|
462
|
+
});
|
|
463
|
+
return findings;
|
|
464
|
+
}
|
|
465
|
+
|
|
425
466
|
function walk(dir, visitor) {
|
|
426
467
|
let entries;
|
|
427
468
|
try {
|
|
@@ -7,6 +7,7 @@ import { join } from "node:path";
|
|
|
7
7
|
import {
|
|
8
8
|
scanFileContent,
|
|
9
9
|
scanSourceTree,
|
|
10
|
+
scanSourceFiles,
|
|
10
11
|
readDeclaredCapabilities,
|
|
11
12
|
SCAN_SEVERITY,
|
|
12
13
|
_testOnly_hallucinatedNames,
|
|
@@ -415,3 +416,106 @@ describe("readDeclaredCapabilities — run402.config.json capability union", ()
|
|
|
415
416
|
);
|
|
416
417
|
});
|
|
417
418
|
});
|
|
419
|
+
|
|
420
|
+
describe("scanSourceFiles — explicit file list (GH-409)", () => {
|
|
421
|
+
function makeTwoFileDir() {
|
|
422
|
+
const dir = mkdtempSync(join(tmpdir(), "r402-gh409-"));
|
|
423
|
+
// A clean file referenced by a manifest — no auth violations.
|
|
424
|
+
writeFileSync(join(dir, "clean.ts"), "export const x = 1;\n");
|
|
425
|
+
// A sibling that legitimately imports a hallucinated name (e.g. the
|
|
426
|
+
// gateway source tree implementing the auth surface). NOT in the
|
|
427
|
+
// manifest, so it must never be scanned for a manifest-based deploy.
|
|
428
|
+
writeFileSync(
|
|
429
|
+
join(dir, "unrelated.ts"),
|
|
430
|
+
'import { getUser } from "@run402/functions";\n',
|
|
431
|
+
);
|
|
432
|
+
return dir;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
it("scanSourceTree (OLD behavior) WOULD flag the unrelated sibling — proving the bug", () => {
|
|
436
|
+
const dir = makeTwoFileDir();
|
|
437
|
+
const findings = scanSourceTree(dir, { cwd: dir });
|
|
438
|
+
const errs = findings.filter((f) => f.severity === SCAN_SEVERITY.ERROR);
|
|
439
|
+
assert.ok(
|
|
440
|
+
errs.some((f) => f.file === "unrelated.ts" && f.attempted_name === "getUser"),
|
|
441
|
+
"whole-tree scan should have surfaced the unrelated sibling (this is the bug)",
|
|
442
|
+
);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("scanSourceFiles([clean.ts]) returns ZERO error findings — GH-409 regression", () => {
|
|
446
|
+
const dir = makeTwoFileDir();
|
|
447
|
+
const findings = scanSourceFiles([join(dir, "clean.ts")], { cwd: dir });
|
|
448
|
+
const errs = findings.filter((f) => f.severity === SCAN_SEVERITY.ERROR);
|
|
449
|
+
assert.equal(
|
|
450
|
+
errs.length,
|
|
451
|
+
0,
|
|
452
|
+
`scoped scan must not walk the sibling; got: ${JSON.stringify(errs)}`,
|
|
453
|
+
);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("scanSourceFiles([unrelated.ts]) STILL flags a file that IS in scope", () => {
|
|
457
|
+
const dir = makeTwoFileDir();
|
|
458
|
+
const findings = scanSourceFiles([join(dir, "unrelated.ts")], { cwd: dir });
|
|
459
|
+
const f = findings.find(
|
|
460
|
+
(x) => x.attempted_name === "getUser" && x.code === "R402_AUTH_UNKNOWN_EXPORT",
|
|
461
|
+
);
|
|
462
|
+
assert.ok(f, "in-scope file with a violation must still be flagged");
|
|
463
|
+
assert.equal(f.severity, SCAN_SEVERITY.ERROR);
|
|
464
|
+
assert.equal(f.file, "unrelated.ts");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("labels findings relative to opts.cwd (mirroring scanSourceTree)", () => {
|
|
468
|
+
const dir = mkdtempSync(join(tmpdir(), "r402-gh409-rel-"));
|
|
469
|
+
mkdirSync(join(dir, "functions"));
|
|
470
|
+
const abs = join(dir, "functions", "login.ts");
|
|
471
|
+
writeFileSync(abs, 'import { getSession } from "@run402/functions";\n');
|
|
472
|
+
const findings = scanSourceFiles([abs], { cwd: dir });
|
|
473
|
+
const f = findings.find((x) => x.attempted_name === "getSession");
|
|
474
|
+
assert.ok(f);
|
|
475
|
+
assert.equal(f.file, join("functions", "login.ts"));
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("skips files without a scannable extension", () => {
|
|
479
|
+
const dir = mkdtempSync(join(tmpdir(), "r402-gh409-ext-"));
|
|
480
|
+
const html = join(dir, "index.html");
|
|
481
|
+
writeFileSync(html, '<script>const u = getSession();</script>\n');
|
|
482
|
+
const findings = scanSourceFiles([html], { cwd: dir });
|
|
483
|
+
assert.equal(findings.length, 0, ".html is not a scanned extension");
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("returns no findings for an empty file list", () => {
|
|
487
|
+
assert.deepEqual(scanSourceFiles([], { cwd: tmpdir() }), []);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("emits a warn finding for an unreadable (missing) file, never throws", () => {
|
|
491
|
+
const dir = mkdtempSync(join(tmpdir(), "r402-gh409-missing-"));
|
|
492
|
+
const missing = join(dir, "ghost.ts");
|
|
493
|
+
const findings = scanSourceFiles([missing], { cwd: dir });
|
|
494
|
+
const f = findings.find((x) => x.code === "R402_AUTH_SOURCE_SCAN_ERROR");
|
|
495
|
+
assert.ok(f, "unreadable file should produce a warn finding");
|
|
496
|
+
assert.equal(f.severity, SCAN_SEVERITY.WARN);
|
|
497
|
+
assert.equal(f.file, "ghost.ts");
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("respects opts.declaredCapabilities for the mint warning", () => {
|
|
501
|
+
const dir = mkdtempSync(join(tmpdir(), "r402-gh409-cap-"));
|
|
502
|
+
const abs = join(dir, "login.ts");
|
|
503
|
+
writeFileSync(
|
|
504
|
+
abs,
|
|
505
|
+
"export default async () => auth.sessions.createResponseFromTenantAssertion({});\n",
|
|
506
|
+
);
|
|
507
|
+
const withCap = scanSourceFiles([abs], {
|
|
508
|
+
cwd: dir,
|
|
509
|
+
declaredCapabilities: ["auth.sessionMint"],
|
|
510
|
+
});
|
|
511
|
+
assert.ok(
|
|
512
|
+
!withCap.some((f) => f.code === "R402_DOCTOR_AUTH_SESSION_MINT_CAPABILITY_MISSING"),
|
|
513
|
+
"declared capability should suppress the mint warning",
|
|
514
|
+
);
|
|
515
|
+
const without = scanSourceFiles([abs], { cwd: dir });
|
|
516
|
+
assert.ok(
|
|
517
|
+
without.some((f) => f.code === "R402_DOCTOR_AUTH_SESSION_MINT_CAPABILITY_MISSING"),
|
|
518
|
+
"absent capability should surface the mint warning",
|
|
519
|
+
);
|
|
520
|
+
});
|
|
521
|
+
});
|
package/lib/init-astro.mjs
CHANGED
|
@@ -94,9 +94,9 @@ export async function runInitAstro(args = []) {
|
|
|
94
94
|
preview: "astro preview",
|
|
95
95
|
},
|
|
96
96
|
dependencies: {
|
|
97
|
-
astro: "^
|
|
98
|
-
"@run402/astro": "^
|
|
99
|
-
"@run402/functions": "^
|
|
97
|
+
astro: "^7.0.0",
|
|
98
|
+
"@run402/astro": "^2.4.2",
|
|
99
|
+
"@run402/functions": "^3.0.0",
|
|
100
100
|
},
|
|
101
101
|
},
|
|
102
102
|
null,
|
|
@@ -108,7 +108,7 @@ export async function runInitAstro(args = []) {
|
|
|
108
108
|
content: `// Run402 Astro preset. One config line gets you:
|
|
109
109
|
// - SSR on Lambda with SnapStart
|
|
110
110
|
// - ISR cache with cache.invalidate() admin-edit visibility
|
|
111
|
-
// - AsyncLocalStorage context so db()/
|
|
111
|
+
// - AsyncLocalStorage context so db()/auth.* helpers work natively
|
|
112
112
|
// - Build-time detectors for unsupported Astro features
|
|
113
113
|
import run402 from "@run402/astro";
|
|
114
114
|
export default run402();
|
package/package.json
CHANGED