@@ -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 = / \b c r e a t e R e s p o n s e F r o m T e n a n t A s s e r t i o n \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. */
348391export 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. */
0 commit comments