{"version":3,"file":"authz-DfMzvyFZ.mjs","names":[],"sources":["../src/auth/authz.ts"],"sourcesContent":["/**\n * Per-request authorization resolution.\n *\n * Turns \"this user, authenticated this way\" into a decision table the rest\n * of the request can consult without touching the database again:\n *\n *   session   grants = resolve(policies of the user's role)\n *   token     grants = owner's grants ∩ resolve(token's policies)\n *\n * The role's policies are cached for a few seconds per isolate. Admin\n * mutations bump the cache in-process; other isolates simply age out. A\n * short window in which a just-edited policy is still in force on a sibling\n * isolate is the accepted cost of not re-reading roles on every request.\n */\n\nimport {\n\tbuiltinPolicies,\n\tbuiltinRoleForLevel,\n\temptyGrants,\n\tintersectGrants,\n\tresolveGrants,\n\ttype EffectiveGrants,\n\ttype PolicyLike,\n\ttype User,\n} from \"@premium-cms/auth\";\nimport type { Kysely } from \"kysely\";\n\nimport { AuthzRepository } from \"../database/repositories/authz.js\";\nimport type { Database } from \"../database/types.js\";\n\nexport interface ResolvedRole {\n\tid: string;\n\tslug: string;\n\tname: string;\n\tlevel: number;\n\tbuiltin: boolean;\n}\n\nexport interface RequestAuthz {\n\t/** The user with `authz` attached — assign this to `locals.user`. */\n\tuser: User;\n\trole: ResolvedRole | null;\n\t/** What this request may do. For tokens, already clamped to the owner. */\n\tgrants: EffectiveGrants;\n\t/** The owner's own grants — identical to `grants` for session auth. */\n\townerGrants: EffectiveGrants;\n\t/** Slugs of the policies the owner's role holds. */\n\trolePolicies: readonly string[];\n}\n\n// ── Cache ────────────────────────────────────────────────────────────────\n\nconst CACHE_TTL_MS = 10_000;\n\ninterface CachedRole {\n\trole: ResolvedRole;\n\tpolicies: PolicyLike[];\n\texpires: number;\n}\n\n// Keyed by role id. Lives on globalThis so Vite's SSR module duplication in\n// dev cannot fork it; a Symbol key keeps it out of anyone else's way.\nconst CACHE_KEY = Symbol.for(\"emdash.authz.roleCache\");\nfunction cache(): Map<string, CachedRole> {\n\tconst g = globalThis as Record<symbol, unknown>;\n\tlet map = g[CACHE_KEY] as Map<string, CachedRole> | undefined;\n\tif (!map) {\n\t\tmap = new Map();\n\t\tg[CACHE_KEY] = map;\n\t}\n\treturn map;\n}\n\n/** Drop cached role/policy data. Call after any role or policy mutation. */\nexport function invalidateAuthzCache(): void {\n\tcache().clear();\n}\n\nasync function loadRole(db: Kysely<Database>, roleId: string): Promise<CachedRole | null> {\n\tconst now = Date.now();\n\tconst hit = cache().get(roleId);\n\tif (hit && hit.expires > now) return hit;\n\n\t// A read failure (missing tables mid-migration, a stubbed db) must not\n\t// throw on the request path: return null so the caller synthesises the\n\t// built-in tier for the user's level in memory — never more access than\n\t// the legacy level would have granted.\n\ttry {\n\t\tconst repo = new AuthzRepository(db);\n\t\tconst role = await repo.roleSummary(roleId);\n\t\tif (!role) {\n\t\t\tcache().delete(roleId);\n\t\t\treturn null;\n\t\t}\n\t\tconst policies = await repo.policiesForRole(roleId);\n\t\tconst entry: CachedRole = { role, policies, expires: now + CACHE_TTL_MS };\n\t\tcache().set(roleId, entry);\n\t\treturn entry;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n// ── Resolution ───────────────────────────────────────────────────────────\n\n/**\n * Resolve a user's role and grants.\n *\n * Users without a role id (rows written before migration 072, or by a\n * code path that has not been updated) are treated as holding the built-in\n * role for their level, read from the database so an admin's edits to that\n * role apply. If even that is missing — a database mid-migration — the\n * built-in definition is used directly. Nothing here can produce more\n * access than the legacy level would have.\n */\n/** The slice of a user the resolver needs. */\nexport interface RoleLinkage {\n\trole: number;\n\troleId: string | null;\n}\n\nexport async function resolveUserAuthz(\n\tdb: Kysely<Database>,\n\tuser: RoleLinkage,\n): Promise<{ role: ResolvedRole | null; grants: EffectiveGrants; rolePolicies: string[] }> {\n\tconst fallbackSlug = builtinRoleForLevel(user.role).slug;\n\tconst roleId = user.roleId ?? `role:${fallbackSlug}`;\n\n\tlet loaded = await loadRole(db, roleId);\n\tif (!loaded && user.roleId) {\n\t\t// Dangling role id: the role was removed under the user. Fall back to\n\t\t// the level's built-in role rather than granting nothing, which would\n\t\t// lock the account out of even its own profile.\n\t\tloaded = await loadRole(db, `role:${fallbackSlug}`);\n\t}\n\n\tif (loaded) {\n\t\treturn {\n\t\t\trole: loaded.role,\n\t\t\tgrants: resolveGrants(loaded.policies),\n\t\t\trolePolicies: loaded.policies.map((p) => p.slug),\n\t\t};\n\t}\n\n\t// No roles table content at all — synthesise the built-in tier.\n\tconst builtin = builtinRoleForLevel(user.role);\n\tconst policy = builtinPolicies().find((p) => p.slug === builtin.policy);\n\tconst policies: PolicyLike[] = policy ? [{ slug: policy.slug, rules: policy.rules }] : [];\n\treturn {\n\t\trole: {\n\t\t\tid: `role:${builtin.slug}`,\n\t\t\tslug: builtin.slug,\n\t\t\tname: builtin.name,\n\t\t\tlevel: builtin.level,\n\t\t\tbuiltin: true,\n\t\t},\n\t\tgrants: policies.length ? resolveGrants(policies) : emptyGrants(),\n\t\trolePolicies: policies.map((p) => p.slug),\n\t};\n}\n\n/**\n * Resolve grants for the request and attach them to the user.\n *\n * `tokenPolicies` is the slug list carried by a policy-based API token, or\n * null for session auth and legacy scoped tokens.\n */\nexport async function attachRequestAuthz(\n\tdb: Kysely<Database>,\n\tuser: User,\n\ttokenPolicies: readonly string[] | null,\n): Promise<RequestAuthz> {\n\tconst owner = await resolveUserAuthz(db, user);\n\n\tlet grants = owner.grants;\n\tif (tokenPolicies) {\n\t\tconst repo = new AuthzRepository(db);\n\t\tconst policies = await repo.policiesBySlugs(tokenPolicies);\n\t\t// A token whose policies no longer exist has no grants — not the\n\t\t// owner's. Revoking a policy must revoke the tokens built on it.\n\t\tgrants =\n\t\t\tpolicies.length === 0\n\t\t\t\t? emptyGrants()\n\t\t\t\t: intersectGrants(owner.grants, resolveGrants(policies));\n\t}\n\n\tconst withAuthz: User = { ...user, authz: grants };\n\treturn {\n\t\tuser: withAuthz,\n\t\trole: owner.role,\n\t\tgrants,\n\t\townerGrants: owner.grants,\n\t\trolePolicies: owner.rolePolicies,\n\t};\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAoDA,MAAM,eAAe;AAUrB,MAAM,YAAY,OAAO,IAAI,yBAAyB;AACtD,SAAS,QAAiC;CACzC,MAAM,IAAI;CACV,IAAI,MAAM,EAAE;AACZ,KAAI,CAAC,KAAK;AACT,wBAAM,IAAI,KAAK;AACf,IAAE,aAAa;;AAEhB,QAAO;;;AAIR,SAAgB,uBAA6B;AAC5C,QAAO,CAAC,OAAO;;AAGhB,eAAe,SAAS,IAAsB,QAA4C;CACzF,MAAM,MAAM,KAAK,KAAK;CACtB,MAAM,MAAM,OAAO,CAAC,IAAI,OAAO;AAC/B,KAAI,OAAO,IAAI,UAAU,IAAK,QAAO;AAMrC,KAAI;EACH,MAAM,OAAO,IAAI,gBAAgB,GAAG;EACpC,MAAM,OAAO,MAAM,KAAK,YAAY,OAAO;AAC3C,MAAI,CAAC,MAAM;AACV,UAAO,CAAC,OAAO,OAAO;AACtB,UAAO;;EAGR,MAAM,QAAoB;GAAE;GAAM,UADjB,MAAM,KAAK,gBAAgB,OAAO;GACP,SAAS,MAAM;GAAc;AACzE,SAAO,CAAC,IAAI,QAAQ,MAAM;AAC1B,SAAO;SACA;AACP,SAAO;;;AAsBT,eAAsB,iBACrB,IACA,MAC0F;CAC1F,MAAM,eAAe,oBAAoB,KAAK,KAAK,CAAC;CAGpD,IAAI,SAAS,MAAM,SAAS,IAFb,KAAK,UAAU,QAAQ,eAEC;AACvC,KAAI,CAAC,UAAU,KAAK,OAInB,UAAS,MAAM,SAAS,IAAI,QAAQ,eAAe;AAGpD,KAAI,OACH,QAAO;EACN,MAAM,OAAO;EACb,QAAQ,cAAc,OAAO,SAAS;EACtC,cAAc,OAAO,SAAS,KAAK,MAAM,EAAE,KAAK;EAChD;CAIF,MAAM,UAAU,oBAAoB,KAAK,KAAK;CAC9C,MAAM,SAAS,iBAAiB,CAAC,MAAM,MAAM,EAAE,SAAS,QAAQ,OAAO;CACvE,MAAM,WAAyB,SAAS,CAAC;EAAE,MAAM,OAAO;EAAM,OAAO,OAAO;EAAO,CAAC,GAAG,EAAE;AACzF,QAAO;EACN,MAAM;GACL,IAAI,QAAQ,QAAQ;GACpB,MAAM,QAAQ;GACd,MAAM,QAAQ;GACd,OAAO,QAAQ;GACf,SAAS;GACT;EACD,QAAQ,SAAS,SAAS,cAAc,SAAS,GAAG,aAAa;EACjE,cAAc,SAAS,KAAK,MAAM,EAAE,KAAK;EACzC;;;;;;;;AASF,eAAsB,mBACrB,IACA,MACA,eACwB;CACxB,MAAM,QAAQ,MAAM,iBAAiB,IAAI,KAAK;CAE9C,IAAI,SAAS,MAAM;AACnB,KAAI,eAAe;EAElB,MAAM,WAAW,MADJ,IAAI,gBAAgB,GAAG,CACR,gBAAgB,cAAc;AAG1D,WACC,SAAS,WAAW,IACjB,aAAa,GACb,gBAAgB,MAAM,QAAQ,cAAc,SAAS,CAAC;;AAI3D,QAAO;EACN,MAFuB;GAAE,GAAG;GAAM,OAAO;GAAQ;EAGjD,MAAM,MAAM;EACZ;EACA,aAAa,MAAM;EACnB,cAAc,MAAM;EACpB"}