Skip to content

Commit 81a0c54

Browse files
MajorTalclaude
andcommitted
feat(doctor): static-detect tenant-assertion mint missing auth.sessionMint capability
run402 doctor now flags createResponseFromTenantAssertion calls when no function declares the auth.sessionMint capability in run402.config.json, emitting R402_DOCTOR_AUTH_SESSION_MINT_CAPABILITY_MISSING (warn) with the exact capabilities spec edit. Without the capability the gateway returns R402_AUTH_UNTRUSTED_CONTEXT at runtime. readDeclaredCapabilities reads the capability union across functions.replace/set entries; scanSourceTree threads it so a declared capability suppresses the warning. Implements auth-hosted-surface-parity tasks 5.3 (doctor half) and 7.9. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0dd4afe commit 81a0c54

2 files changed

Lines changed: 182 additions & 1 deletion

File tree

cli/lib/doctor-source-scan.mjs

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,49 @@ export function scanFileContent(content, opts = {}) {
339339
});
340340
}
341341

342+
// 8) Tenant-assertion session-mint call without the declared capability.
343+
// `auth.sessions.createResponseFromTenantAssertion(...)` mints a browser
344+
// session from a tenant's vouching. It works ONLY in a function whose
345+
// deploy/apply spec declares `capabilities: ["auth.sessionMint"]`
346+
// (FunctionSpec.capabilities — sibling to `config`, since the platform
347+
// has no code-export metadata channel). Service-key presence is NOT
348+
// sufficient. Without the capability the gateway returns
349+
// R402_AUTH_UNTRUSTED_CONTEXT at runtime and mints no session.
350+
//
351+
// The pure file scanner can't see the per-function spec, so the caller
352+
// threads `opts.declaredCapabilities` (the union of capabilities declared
353+
// across run402.config.json function entries — see readDeclaredCapabilities).
354+
// We suppress the finding when "auth.sessionMint" is present anywhere in
355+
// that union. Global-union (not per-file) is a deliberate precision
356+
// trade-off: the file→function-entry mapping isn't reliable from source,
357+
// and the runtime gate catches the rare "function A declared it, function
358+
// B forgot" case. WARN severity (never block deploy): an inline/SDK spec
359+
// the doctor can't read might declare the capability.
360+
const declaredCaps =
361+
opts.declaredCapabilities instanceof Set
362+
? opts.declaredCapabilities
363+
: new Set(Array.isArray(opts.declaredCapabilities) ? opts.declaredCapabilities : []);
364+
if (!declaredCaps.has("auth.sessionMint")) {
365+
const mintCallRegex = /\bcreateResponseFromTenantAssertion\s*\(/g;
366+
let mintMatch;
367+
while ((mintMatch = mintCallRegex.exec(content)) !== null) {
368+
findings.push({
369+
code: "R402_DOCTOR_AUTH_SESSION_MINT_CAPABILITY_MISSING",
370+
severity: SCAN_SEVERITY.WARN,
371+
file: filePath,
372+
line: lineNumberFor(content, mintMatch.index),
373+
message:
374+
"createResponseFromTenantAssertion (tenant-assertion session mint) requires the " +
375+
'"auth.sessionMint" capability, which no function declares in run402.config.json. ' +
376+
"Without it the gateway returns R402_AUTH_UNTRUSTED_CONTEXT at runtime and mints no session.",
377+
fix:
378+
'Add "capabilities": ["auth.sessionMint"] to this function\'s entry in run402.config.json ' +
379+
'(under functions.replace.<name>, a sibling to "config"). A service key is NOT sufficient.',
380+
docs: "https://docs.run402.com/auth/tenant-assertion#capability",
381+
});
382+
}
383+
}
384+
342385
return findings;
343386
}
344387

@@ -347,6 +390,10 @@ export function scanFileContent(content, opts = {}) {
347390
* line for stable output. */
348391
export function scanSourceTree(srcDir, opts = {}) {
349392
const findings = [];
393+
// Capability picture for the tenant-assertion mint check (#8). Read from
394+
// run402.config.json unless the caller passed it explicitly (tests do).
395+
const declaredCapabilities =
396+
opts.declaredCapabilities ?? readDeclaredCapabilities(opts.cwd ?? srcDir);
350397
walk(srcDir, (filePath) => {
351398
if (!SCANNED_EXTENSIONS.has(extname(filePath))) return;
352399
let content;
@@ -362,7 +409,10 @@ export function scanSourceTree(srcDir, opts = {}) {
362409
return;
363410
}
364411
findings.push(
365-
...scanFileContent(content, { filePath: relative(opts.cwd ?? srcDir, filePath) }),
412+
...scanFileContent(content, {
413+
filePath: relative(opts.cwd ?? srcDir, filePath),
414+
declaredCapabilities,
415+
}),
366416
);
367417
});
368418
findings.sort((a, b) => {
@@ -410,6 +460,38 @@ export function _testOnly_authProperties() {
410460
return HALLUCINATED_AUTH_PROPERTIES.slice();
411461
}
412462

463+
/** Read the union of `capabilities` declared across all function entries in
464+
* `run402.config.json` (the apply spec). Used by the tenant-assertion mint
465+
* check (#8) to suppress the warning when "auth.sessionMint" is declared.
466+
*
467+
* Functions live under `functions.replace.<name>` / `functions.set.<name>`
468+
* with `capabilities?: string[]` as a sibling to `config`. Best-effort:
469+
* a missing or malformed config returns an empty set (the scanner then
470+
* warns, which is the safe default — the runtime gate is the hard
471+
* enforcement). Returns a `Set<string>`. */
472+
export function readDeclaredCapabilities(cwd = process.cwd()) {
473+
const caps = new Set();
474+
let parsed;
475+
try {
476+
parsed = JSON.parse(readFileSync(join(cwd, "run402.config.json"), "utf8"));
477+
} catch {
478+
return caps; // no config / unreadable / malformed → nothing declared
479+
}
480+
const fns = parsed?.functions;
481+
if (!fns || typeof fns !== "object") return caps;
482+
for (const bucket of ["replace", "set", "patch"]) {
483+
const entries = fns[bucket];
484+
if (!entries || typeof entries !== "object") continue;
485+
for (const entry of Object.values(entries)) {
486+
const declared = entry?.capabilities;
487+
if (Array.isArray(declared)) {
488+
for (const cap of declared) if (typeof cap === "string") caps.add(cap);
489+
}
490+
}
491+
}
492+
return caps;
493+
}
494+
413495
/** Resolve the project's src/ directory. Astro convention is `<root>/src`;
414496
* bare Node projects use `<root>/src` or `<root>`. We prefer `src/` if
415497
* it exists. */

cli/lib/doctor-source-scan.test.mjs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { describe, it } from "node:test";
22
import assert from "node:assert/strict";
3+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
36

47
import {
58
scanFileContent,
9+
scanSourceTree,
10+
readDeclaredCapabilities,
611
SCAN_SEVERITY,
712
_testOnly_hallucinatedNames,
813
_testOnly_authProperties,
@@ -316,3 +321,97 @@ describe("scanFileContent — line numbers + file paths", () => {
316321
assert.equal(findings[0].file, "src/pages/account.astro");
317322
});
318323
});
324+
325+
describe("scanFileContent — tenant-assertion session-mint capability (#8, §5.3 / 7.9)", () => {
326+
const MINT = 'auth.sessions.createResponseFromTenantAssertion({ tenant, user, method: "password" });';
327+
328+
it("flags a mint call when no capability is declared (default opts)", () => {
329+
const content = [
330+
'import { auth } from "@run402/functions";',
331+
"export default async (req) =>",
332+
` ${MINT}`,
333+
].join("\n");
334+
const findings = scanFileContent(content, { filePath: "src/pages/api/login.ts" });
335+
const f = findings.find(
336+
(x) => x.code === "R402_DOCTOR_AUTH_SESSION_MINT_CAPABILITY_MISSING",
337+
);
338+
assert.ok(f, "should flag the mint call");
339+
assert.equal(f.severity, SCAN_SEVERITY.WARN);
340+
assert.equal(f.line, 3);
341+
assert.equal(f.file, "src/pages/api/login.ts");
342+
assert.match(f.fix, /auth\.sessionMint/);
343+
assert.match(f.message, /R402_AUTH_UNTRUSTED_CONTEXT/);
344+
});
345+
346+
it("suppresses when declaredCapabilities (array) includes auth.sessionMint", () => {
347+
const findings = scanFileContent(MINT, {
348+
declaredCapabilities: ["auth.sessionMint"],
349+
});
350+
assert.ok(
351+
!findings.some((f) => f.code === "R402_DOCTOR_AUTH_SESSION_MINT_CAPABILITY_MISSING"),
352+
);
353+
});
354+
355+
it("suppresses when declaredCapabilities (Set) includes auth.sessionMint", () => {
356+
const findings = scanFileContent(MINT, {
357+
declaredCapabilities: new Set(["auth.sessionMint"]),
358+
});
359+
assert.ok(
360+
!findings.some((f) => f.code === "R402_DOCTOR_AUTH_SESSION_MINT_CAPABILITY_MISSING"),
361+
);
362+
});
363+
364+
it("does NOT flag the distinct createResponseFromIdentity proof path", () => {
365+
const content =
366+
"auth.sessions.createResponseFromIdentity({ provider, subject, proof, amr });";
367+
const findings = scanFileContent(content);
368+
assert.ok(
369+
!findings.some((f) => f.code === "R402_DOCTOR_AUTH_SESSION_MINT_CAPABILITY_MISSING"),
370+
);
371+
});
372+
});
373+
374+
describe("readDeclaredCapabilities — run402.config.json capability union", () => {
375+
function writeConfig(obj) {
376+
const dir = mkdtempSync(join(tmpdir(), "r402-doctor-cap-"));
377+
writeFileSync(join(dir, "run402.config.json"), JSON.stringify(obj));
378+
return dir;
379+
}
380+
381+
it("collects capabilities across functions.replace + functions.set", () => {
382+
const dir = writeConfig({
383+
functions: {
384+
replace: { api: { capabilities: ["auth.sessionMint"] } },
385+
set: { cron: { capabilities: ["other.cap"] } },
386+
},
387+
});
388+
const caps = readDeclaredCapabilities(dir);
389+
assert.ok(caps.has("auth.sessionMint"));
390+
assert.ok(caps.has("other.cap"));
391+
});
392+
393+
it("returns an empty set when no config / no capabilities", () => {
394+
const emptyDir = mkdtempSync(join(tmpdir(), "r402-doctor-nocfg-"));
395+
assert.equal(readDeclaredCapabilities(emptyDir).size, 0);
396+
const noCapDir = writeConfig({ functions: { replace: { api: { config: {} } } } });
397+
assert.equal(readDeclaredCapabilities(noCapDir).size, 0);
398+
});
399+
400+
it("scanSourceTree suppresses the mint warning when the config declares it", () => {
401+
const dir = mkdtempSync(join(tmpdir(), "r402-doctor-tree-"));
402+
mkdirSync(join(dir, "src"));
403+
writeFileSync(
404+
join(dir, "src", "login.ts"),
405+
"export default async () => auth.sessions.createResponseFromTenantAssertion({});",
406+
);
407+
writeFileSync(
408+
join(dir, "run402.config.json"),
409+
JSON.stringify({ functions: { replace: { api: { capabilities: ["auth.sessionMint"] } } } }),
410+
);
411+
const findings = scanSourceTree(join(dir, "src"), { cwd: dir });
412+
assert.ok(
413+
!findings.some((f) => f.code === "R402_DOCTOR_AUTH_SESSION_MINT_CAPABILITY_MISSING"),
414+
"config-declared capability should suppress the tree-scan warning",
415+
);
416+
});
417+
});

0 commit comments

Comments
 (0)