1import { AsyncLocalStorage } from "node:async_hooks"; 2import type { AuthContext } from "./types.js"; 3 4/** 5 * Actor context (TASK-0127 Phase 13) — the authenticated actor of the 6 * in-flight request, visible to the service layer WITHOUT threading a new 7 * parameter through every controller → service → assertCan call (81+ 8 * call sites; keeping them untouched is the backward-compat win). 9 * 10 * The global `ActorContextInterceptor` runs every request's handler inside 11 * `ActorContext.run(req.auth, …)`. Authorization helpers (`assertCan`, 12 * `assertRole` in `lib/authorize.ts`) consult the context: 13 * 14 * - `kind: "api"` — the request is an API key. The service's permission 15 * check becomes: (1) the asserted workspace is the key's own workspace, 16 * (2) the key's scopes cover the operation (VIEW → READ, anything else → 17 * WRITE). The resolved `userId` (the key's creator, via `requireUser`) 18 * is NEVER consulted for the membership decision — an API key acts as 19 * the workspace per docs/api/authentication.md §7, and its creator's 20 * role in another workspace must not leak access here. 21 * - `kind: "user"` / absent — the legacy membership path, unchanged. 22 * 23 * Absent context (workers, tests calling services directly) behaves 24 * exactly as before — the ALS is a pure capability addition. 25 */ 26const storage = new AsyncLocalStorage<AuthContext | undefined>(); 27 28export const ActorContext = { 29 /** Run `fn` with `auth` as the in-flight actor. */ 30 run<T>(auth: AuthContext | undefined, fn: () => T): T { 31 return storage.run(auth, fn); 32 }, 33 /** The in-flight actor (undefined outside an HTTP request). */ 34 get(): AuthContext | undefined { 35 return storage.getStore(); 36 } 37}; 38