@murumets-ee/auth 0.16.3 → 0.16.5

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.
@@ -1,2 +1,2 @@
1
- import{t as e}from"../permissions-BbVD2CmT.mjs";const t=/^[a-z][a-z0-9-]{0,49}$/;function n(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function r(e,t){return n({error:e},t)}function i(i){let{getStatements:a,loadRoles:o,saveRoles:s,onSave:c}=i;return{prefix:`permissions`,resource:`permissions`,actions:[`view`,`create`,`update`,`delete`],handlers:{GET:async(t,{segments:r})=>{let i=a(),s=await o()??{};return r.length===1&&r[0]===`roles`?n({roles:Object.keys(s),builtInRoles:[...e]}):n({statements:i,roles:s,builtInRoles:[...e]})},PATCH:async(e,{user:t,audit:i})=>{let{roles:l}=await e.json();if(!l||typeof l!=`object`)return r(`Body must contain "roles" object`,400);if(`admin`in l)return r(`Cannot modify admin role permissions (admin always has full access)`,400);if(t.role&&t.role!==`admin`&&t.role in l)return r(`Cannot modify permissions for your own role`,403);let u=await o()??{};for(let e of Object.keys(l))if(!(e in u))return r(`Role '${e}' does not exist. Create it first via POST /permissions/roles`,400);let d=a();for(let[e,t]of Object.entries(l)){if(typeof e!=`string`||!e)return r(`Role names must be non-empty strings`,400);if(typeof t!=`object`||!t||Array.isArray(t))return r(`Permissions for role '${e}' must be an object`,400);for(let[n,i]of Object.entries(t)){if(!Array.isArray(i)||!i.every(e=>typeof e==`string`))return r(`Actions for '${n}' in role '${e}' must be a string array`,400);let t=d[n];if(!t)return r(`Unknown resource: ${n}`,400);for(let e of i)if(!t.includes(e))return r(`Invalid action '${e}' for resource '${n}'. Valid: ${t.join(`, `)}`,400)}}let f={...u};for(let[e,t]of Object.entries(l))f[e]=t;return await s(f),c?.(),i?.({action:`permissions.update`,entityType:`permissions`,userId:t.id,...t.name!==void 0&&{userName:t.name},changes:{roles:l},metadata:{rolesModified:Object.keys(l)}}),n({ok:!0})},POST:async(i,{segments:a,user:l,audit:u})=>{if(a.length!==1||a[0]!==`roles`)return r(`POST only supported at /permissions/roles`,400);let{name:d}=await i.json();if(!d||typeof d!=`string`)return r(`Body must contain "name" string`,400);if(!t.test(d))return r(`Role name must be lowercase alphanumeric with hyphens, start with a letter, max 50 chars`,400);if(e.includes(d))return r(`Cannot create role with built-in name: ${d}`,400);let f=await o()??{};return d in f?r(`Role already exists: ${d}`,409):(f[d]={},await s(f),c?.(),u?.({action:`permissions.role.create`,entityType:`permissions`,userId:l.id,...l.name!==void 0&&{userName:l.name},changes:{roleName:d}}),n({name:d,permissions:{}},201))},DELETE:async(t,{segments:i,user:a,audit:l})=>{if(i.length!==2||i[0]!==`roles`)return r(`DELETE only supported at /permissions/roles/:name`,400);let u=i[1];if(!u)return r(`Missing role name`,400);if(e.includes(u))return r(`Cannot delete built-in role: ${u}`,400);let d=await o()??{};if(!(u in d))return r(`Role not found: ${u}`,404);let f=d[u];return delete d[u],await s(d),c?.(),l?.({action:`permissions.role.delete`,entityType:`permissions`,userId:a.id,...a.name!==void 0&&{userName:a.name},changes:{roleName:u,permissions:f}}),n({deleted:u})}}}}export{i as permissionRoutes};
1
+ import{t as e}from"../permissions-D2ac2RR_.mjs";const t=/^[a-z][a-z0-9-]{0,49}$/;function n(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function r(e,t){return n({error:e},t)}function i(i){let{getStatements:a,loadRoles:o,saveRoles:s,onSave:c}=i;return{prefix:`permissions`,resource:`permissions`,actions:[`view`,`create`,`update`,`delete`],handlers:{GET:async(t,{segments:r})=>{let i=a(),s=await o()??{};return r.length===1&&r[0]===`roles`?n({roles:Object.keys(s),builtInRoles:[...e]}):n({statements:i,roles:s,builtInRoles:[...e]})},PATCH:async(e,{user:t,audit:i})=>{let{roles:l}=await e.json();if(!l||typeof l!=`object`)return r(`Body must contain "roles" object`,400);if(`admin`in l)return r(`Cannot modify admin role permissions (admin always has full access)`,400);if(t.role&&t.role!==`admin`&&t.role in l)return r(`Cannot modify permissions for your own role`,403);let u=await o()??{};for(let e of Object.keys(l))if(!(e in u))return r(`Role '${e}' does not exist. Create it first via POST /permissions/roles`,400);let d=a();for(let[e,t]of Object.entries(l)){if(typeof e!=`string`||!e)return r(`Role names must be non-empty strings`,400);if(typeof t!=`object`||!t||Array.isArray(t))return r(`Permissions for role '${e}' must be an object`,400);for(let[n,i]of Object.entries(t)){if(!Array.isArray(i)||!i.every(e=>typeof e==`string`))return r(`Actions for '${n}' in role '${e}' must be a string array`,400);let t=d[n];if(!t)return r(`Unknown resource: ${n}`,400);for(let e of i)if(!t.includes(e))return r(`Invalid action '${e}' for resource '${n}'. Valid: ${t.join(`, `)}`,400)}}let f={...u};for(let[e,t]of Object.entries(l))f[e]=t;return await s(f),c?.(),i?.({action:`permissions.update`,entityType:`permissions`,userId:t.id,...t.name!==void 0&&{userName:t.name},changes:{roles:l},metadata:{rolesModified:Object.keys(l)}}),n({ok:!0})},POST:async(i,{segments:a,user:l,audit:u})=>{if(a.length!==1||a[0]!==`roles`)return r(`POST only supported at /permissions/roles`,400);let{name:d}=await i.json();if(!d||typeof d!=`string`)return r(`Body must contain "name" string`,400);if(!t.test(d))return r(`Role name must be lowercase alphanumeric with hyphens, start with a letter, max 50 chars`,400);if(e.includes(d))return r(`Cannot create role with built-in name: ${d}`,400);let f=await o()??{};return d in f?r(`Role already exists: ${d}`,409):(f[d]={},await s(f),c?.(),u?.({action:`permissions.role.create`,entityType:`permissions`,userId:l.id,...l.name!==void 0&&{userName:l.name},changes:{roleName:d}}),n({name:d,permissions:{}},201))},DELETE:async(t,{segments:i,user:a,audit:l})=>{if(i.length!==2||i[0]!==`roles`)return r(`DELETE only supported at /permissions/roles/:name`,400);let u=i[1];if(!u)return r(`Missing role name`,400);if(e.includes(u))return r(`Cannot delete built-in role: ${u}`,400);let d=await o()??{};if(!(u in d))return r(`Role not found: ${u}`,404);let f=d[u];return delete d[u],await s(d),c?.(),l?.({action:`permissions.role.delete`,entityType:`permissions`,userId:a.id,...a.name!==void 0&&{userName:a.name},changes:{roleName:u,permissions:f}}),n({deleted:u})}}}}export{i as permissionRoutes};
2
2
  //# sourceMappingURL=index.mjs.map
package/dist/index.d.mts CHANGED
@@ -45,7 +45,8 @@ declare function buildInitialRoleDefinitions(): Record<string, Record<string, st
45
45
  */
46
46
  declare function buildPermissionChecker(roleDefinitions: Record<string, Record<string, string[]>>): PermissionChecker;
47
47
  /**
48
- * Build a complete resource catalog from entities and admin routes.
48
+ * Build a complete resource catalog from entities and plugin-contributed
49
+ * resources.
49
50
  *
50
51
  * Used by:
51
52
  * - `permissionRoutes()` config (`getStatements` callback)
@@ -53,17 +54,28 @@ declare function buildPermissionChecker(roleDefinitions: Record<string, Record<s
53
54
  *
54
55
  * Entities automatically get CRUD actions. Publishable entities also get
55
56
  * the `publish` action, which gates who can set status to 'published'.
56
- * Routes with `resource` and `actions` are added if not already present.
57
+ *
58
+ * Plugin resources (`PluginResource[]` — `{ name, actions }`) are added
59
+ * if not already present. The settings plugin auto-derives one entry per
60
+ * settings namespace (`settings:<namespace>`) with actions `['view',
61
+ * 'update']`; without that pass-through the Permissions admin UI rejects
62
+ * grants on those resources with "Unknown resource".
63
+ *
64
+ * Historical note: the second parameter used to be typed as
65
+ * `{ resource?: string; actions?: readonly string[] }[]` (admin-route
66
+ * shape), but the only caller in the CLI was passing `pluginResources`
67
+ * (shape `{ name, actions }`), so every plugin resource was silently
68
+ * dropped from the catalog. The typed param ensures we don't regress.
57
69
  */
58
70
  declare function buildResourceCatalog(entities: {
59
71
  name: string;
60
72
  behaviors?: {
61
73
  name: string;
62
74
  }[];
63
- }[], routes?: {
64
- resource?: string;
75
+ }[], pluginResources?: ReadonlyArray<{
76
+ name: string;
65
77
  actions?: readonly string[];
66
- }[]): Record<string, string[]>;
78
+ }>): Record<string, string[]>;
67
79
  //#endregion
68
80
  //#region src/seed-roles.d.ts
69
81
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/context.ts","../src/permissions.ts","../src/seed-roles.ts"],"mappings":";;;;AA8BA;;;;;;;;;;;;;;;;;;;AAAA,iBAAsB,kBAAA,CAAmB,IAAA,EAAM,IAAA,EAAM,OAAA,EAAS,OAAA,GAAU,OAAA,CAAQ,cAAA;;;;cCLnE,cAAA;;cA2BA,gBAAA,EAAkB,MAAA;;;;;;;iBAiBf,2BAAA,CAAA,GAA+B,MAAA,SAAe,MAAA;;;AA5C9D;;;;;AA2BA;iBAiCgB,sBAAA,CACd,eAAA,EAAiB,MAAA,SAAe,MAAA,sBAC/B,iBAAA;;;;AAlBH;;;;;AAgBA;;;iBAwCgB,oBAAA,CACd,QAAA;EAAY,IAAA;EAAc,SAAA;IAAc,IAAA;EAAA;AAAA,KACxC,MAAA;EAAW,QAAA;EAAmB,OAAA;AAAA,MAC7B,MAAA;;;;;;;;;;;;ADlGH;;;;;;;;;;;;;;;;;;;;KEEY,OAAA,GAAU,MAAA,SAAe,MAAA;AAAA,UAEpB,YAAA;;EAEf,OAAA;EDXkF;ECalF,KAAA;AAAA;;;;AD+BF;;iBCvBsB,kBAAA,CACpB,IAAA,QAAY,OAAA,CAAQ,OAAA,sBACpB,KAAA,GAAQ,KAAA,EAAO,OAAA,KAAY,OAAA,SAC1B,OAAA,CAAQ,YAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/context.ts","../src/permissions.ts","../src/seed-roles.ts"],"mappings":";;;;AA8BA;;;;;;;;;;;;;;;;;;;AAAA,iBAAsB,kBAAA,CAAmB,IAAA,EAAM,IAAA,EAAM,OAAA,EAAS,OAAA,GAAU,OAAA,CAAQ,cAAA;;;;cCLnE,cAAA;;cA2BA,gBAAA,EAAkB,MAAA;;;;;;;iBAiBf,2BAAA,CAAA,GAA+B,MAAA,SAAe,MAAA;;;AA5C9D;;;;;AA2BA;iBAiCgB,sBAAA,CACd,eAAA,EAAiB,MAAA,SAAe,MAAA,sBAC/B,iBAAA;;;;AAlBH;;;;;AAgBA;;;;;;;;;;;;;;AAoDA;iBAAgB,oBAAA,CACd,QAAA;EAAY,IAAA;EAAc,SAAA;IAAc,IAAA;EAAA;AAAA,KACxC,eAAA,GAAkB,aAAA;EAAgB,IAAA;EAAc,OAAA;AAAA,KAC/C,MAAA;;;;;;;;;;;;AD9GH;;;;;;;;;;;;;;;;;;;;KEEY,OAAA,GAAU,MAAA,SAAe,MAAA;AAAA,UAEpB,YAAA;;EAEf,OAAA;EDXkF;ECalF,KAAA;AAAA;;;;AD+BF;;iBCvBsB,kBAAA,CACpB,IAAA,QAAY,OAAA,CAAQ,OAAA,sBACpB,KAAA,GAAQ,KAAA,EAAO,OAAA,KAAY,OAAA,SAC1B,OAAA,CAAQ,YAAA"}
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{a as e,i as t,n,o as r,t as i}from"./permissions-BbVD2CmT.mjs";import{a,i as o,n as s,r as c,t as l}from"./runtime-Cj8SEWhk.mjs";import"server-only";async function u(e,t){let n=await e.api.getSession({headers:t});if(!n)return{requestId:crypto.randomUUID()};let r=n.user.role,i=typeof r==`string`&&r.length>0?r:`public`;return{user:{id:n.user.id,groups:[i],name:n.user.name??void 0,email:n.user.email??void 0},requestId:crypto.randomUUID()}}async function d(e,n){let r=await e()??{},i=t(),a={...r},o=[];for(let[e,t]of Object.entries(i))e in a||(a[e]=t,o.push(e));return o.length>0&&await n(a),{created:o.length,roles:o}}export{i as BUILT_IN_ROLES,n as METHOD_TO_ACTION,l as _setAuth,s as _setAuthConfig,t as buildInitialRoleDefinitions,e as buildPermissionChecker,r as buildResourceCatalog,a as createAuthServer,c as getAuth,o as isSignupEnabled,u as resolveAuthContext,d as upsertBuiltInRoles};
1
+ import{a as e,i as t,n,o as r,t as i}from"./permissions-D2ac2RR_.mjs";import{a,i as o,n as s,r as c,t as l}from"./runtime-WUuhlRlH.mjs";import"server-only";async function u(e,t){let n=await e.api.getSession({headers:t});if(!n)return{requestId:crypto.randomUUID()};let r=n.user.role,i=typeof r==`string`&&r.length>0?r:`public`;return{user:{id:n.user.id,groups:[i],name:n.user.name??void 0,email:n.user.email??void 0},requestId:crypto.randomUUID()}}async function d(e,n){let r=await e()??{},i=t(),a={...r},o=[];for(let[e,t]of Object.entries(i))e in a||(a[e]=t,o.push(e));return o.length>0&&await n(a),{created:o.length,roles:o}}export{i as BUILT_IN_ROLES,n as METHOD_TO_ACTION,l as _setAuth,s as _setAuthConfig,t as buildInitialRoleDefinitions,e as buildPermissionChecker,r as buildResourceCatalog,a as createAuthServer,c as getAuth,o as isSignupEnabled,u as resolveAuthContext,d as upsertBuiltInRoles};
2
2
  //# sourceMappingURL=index.mjs.map
@@ -1,2 +1,2 @@
1
- import"better-auth/plugins/access";const e=[`view`,`create`,`update`,`delete`],t=[`view`,`create`,`update`,`delete`,`publish`],n=[`admin`,`public`,`authenticated`,`agent`],r={ticket:[`view`,`create`,`update`],ticket_message:[`view`,`update`],ticket_attachment:[`view`,`create`],department:[`view`],ticket_tag:[`view`]},i={GET:`view`,POST:`create`,PATCH:`update`,DELETE:`delete`};function a(){return{public:{},authenticated:{},agent:{...r}}}function o(e){let t=new Map;for(let[n,r]of Object.entries(e)){let e=new Map;for(let[t,n]of Object.entries(r))e.set(t,new Set(n));t.set(n,e)}return(e,n,r)=>e===`admin`?!0:t.get(e)?.get(n)?.has(r)??!1}function s(e){return e.behaviors?.some(e=>e.name===`publishable`)??!1}function c(e,t){let n={};for(let t of e)n[t.name]=s(t)?[`view`,`create`,`update`,`delete`,`publish`]:[`view`,`create`,`update`,`delete`];if(t)for(let e of t)e.resource&&e.actions&&!(e.resource in n)&&(n[e.resource]=[...e.actions]);return n}const l={user:[`create`,`list`,`set-role`,`ban`,`impersonate`,`delete`,`set-password`,`get`,`update`],session:[`list`,`revoke`,`delete`]};function u(n){let r={...l};for(let i of n)r[i.name]=s(i)?t:e;return r}function d(e,t){let n={},i={},a={};n.user=[...l.user],n.session=[...l.session],i.user=[],i.session=[],a.user=[],a.session=[];for(let e of t)n[e.name]=s(e)?[`view`,`create`,`update`,`delete`,`publish`]:[`view`,`create`,`update`,`delete`],i[e.name]=[`view`],a[e.name]=r[e.name]??[`view`];return{admin:e.newRole(n),authenticated:e.newRole(i),agent:e.newRole(a)}}export{o as a,a as i,i as n,c as o,d as r,u as s,n as t};
2
- //# sourceMappingURL=permissions-BbVD2CmT.mjs.map
1
+ import"better-auth/plugins/access";const e=[`view`,`create`,`update`,`delete`],t=[`view`,`create`,`update`,`delete`,`publish`],n=[`admin`,`public`,`authenticated`,`agent`],r={ticket:[`view`,`create`,`update`],ticket_message:[`view`,`update`],ticket_attachment:[`view`,`create`],department:[`view`],ticket_tag:[`view`]},i={GET:`view`,POST:`create`,PATCH:`update`,DELETE:`delete`};function a(){return{public:{},authenticated:{},agent:{...r}}}function o(e){let t=new Map;for(let[n,r]of Object.entries(e)){let e=new Map;for(let[t,n]of Object.entries(r))e.set(t,new Set(n));t.set(n,e)}return(e,n,r)=>e===`admin`?!0:t.get(e)?.get(n)?.has(r)??!1}function s(e){return e.behaviors?.some(e=>e.name===`publishable`)??!1}function c(e,t){let n={};for(let t of e)n[t.name]=s(t)?[`view`,`create`,`update`,`delete`,`publish`]:[`view`,`create`,`update`,`delete`];if(t)for(let e of t)e.name&&e.actions&&!(e.name in n)&&(n[e.name]=[...e.actions]);return n}const l={user:[`create`,`list`,`set-role`,`ban`,`impersonate`,`delete`,`set-password`,`get`,`update`],session:[`list`,`revoke`,`delete`]};function u(n){let r={...l};for(let i of n)r[i.name]=s(i)?t:e;return r}function d(e,t){let n={},i={},a={};n.user=[...l.user],n.session=[...l.session],i.user=[],i.session=[],a.user=[],a.session=[];for(let e of t)n[e.name]=s(e)?[`view`,`create`,`update`,`delete`,`publish`]:[`view`,`create`,`update`,`delete`],i[e.name]=[`view`],a[e.name]=r[e.name]??[`view`];return{admin:e.newRole(n),authenticated:e.newRole(i),agent:e.newRole(a)}}export{o as a,a as i,i as n,c as o,d as r,u as s,n as t};
2
+ //# sourceMappingURL=permissions-D2ac2RR_.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permissions-D2ac2RR_.mjs","names":[],"sources":["../src/permissions.ts"],"sourcesContent":["/**\n * Permission system — bridges entity access definitions to better-auth's access control,\n * and provides the configurable permission checker for the admin API.\n *\n * Two layers:\n * 1. **better-auth integration**: `buildStatements` / `buildDefaultRoles` — feeds into\n * better-auth's `createAccessControl` for its internal permission system.\n * 2. **Admin API enforcement**: `buildPermissionChecker` / `buildInitialRoleDefinitions` —\n * the toolkit's own firewall-model permission system, persisted in settings.\n */\n\nimport type { PermissionChecker } from '@murumets-ee/core'\nimport type { Entity } from '@murumets-ee/entity'\nimport { createAccessControl } from 'better-auth/plugins/access'\n\nconst ACTIONS = ['view', 'create', 'update', 'delete'] as const\nconst ACTIONS_WITH_PUBLISH = ['view', 'create', 'update', 'delete', 'publish'] as const\n\nexport { ACTIONS, ACTIONS_WITH_PUBLISH }\n\n// ---------------------------------------------------------------------------\n// Built-in roles & constants\n// ---------------------------------------------------------------------------\n\n/** Built-in role names — cannot be deleted via the roles editor. */\nexport const BUILT_IN_ROLES = ['admin', 'public', 'authenticated', 'agent'] as const\n\n/**\n * Default permissions for the built-in `agent` role.\n *\n * Agents handle the ticketing inbox: they can view + update tickets,\n * messages, and attachments, and view the metadata that scopes them\n * (departments, tags). Delete actions and all email-template management\n * stay admin-only. Consumers can add more resources via the roles editor.\n *\n * SECURITY (#82): `ticket_message:create` is admin-only by design. Agents\n * authoring replies go through `/api/admin/ticketing/reply` (gated on\n * `ticket:update`), which uses `createSystemAdminClient` to perform the\n * write under elevated permissions while still recording the real agent\n * in the audit log. Allowing `ticket_message:create` for agents would\n * re-open the generic-CRUD impersonation path where an agent can spoof\n * another agent's `senderUserId` / `senderEmail` / `senderName`.\n */\nconst AGENT_DEFAULT_PERMISSIONS: Record<string, string[]> = {\n ticket: ['view', 'create', 'update'],\n ticket_message: ['view', 'update'],\n ticket_attachment: ['view', 'create'],\n department: ['view'],\n ticket_tag: ['view'],\n}\n\n/** Maps HTTP methods to permission action names. */\nexport const METHOD_TO_ACTION: Record<string, string> = {\n GET: 'view',\n POST: 'create',\n PATCH: 'update',\n DELETE: 'delete',\n}\n\n// ---------------------------------------------------------------------------\n// Admin API permission system (firewall model)\n// ---------------------------------------------------------------------------\n\n/**\n * Build initial role definitions for first run (no settings saved yet).\n *\n * All built-in non-admin roles start with ZERO permissions.\n * Admin is never stored — it's hardcoded in the checker.\n */\nexport function buildInitialRoleDefinitions(): Record<string, Record<string, string[]>> {\n return {\n public: {},\n authenticated: {},\n agent: { ...AGENT_DEFAULT_PERMISSIONS },\n }\n}\n\n/**\n * Build a synchronous permission checker from role definitions.\n *\n * Rules:\n * - `admin` role: ALWAYS returns `true` (hardcoded safety net, ignores settings)\n * - All other roles: exact match from `roleDefinitions` (deny-by-default)\n * - Unknown role / unknown resource / unknown action → `false`\n */\nexport function buildPermissionChecker(\n roleDefinitions: Record<string, Record<string, string[]>>,\n): PermissionChecker {\n // Pre-build lookup maps for O(1) checks\n const perms = new Map<string, Map<string, Set<string>>>()\n\n for (const [role, resources] of Object.entries(roleDefinitions)) {\n const resourceMap = new Map<string, Set<string>>()\n for (const [resource, actions] of Object.entries(resources)) {\n resourceMap.set(resource, new Set(actions))\n }\n perms.set(role, resourceMap)\n }\n\n return (role: string, resource: string, action: string): boolean => {\n if (role === 'admin') return true // Safety net — admin always passes\n return perms.get(role)?.get(resource)?.has(action) ?? false\n }\n}\n\n// ---------------------------------------------------------------------------\n// Resource catalog builder\n// ---------------------------------------------------------------------------\n\n/** Check if an entity has the publishable behavior. */\nfunction isPublishable(entity: { behaviors?: { name: string }[] }): boolean {\n return entity.behaviors?.some((b) => b.name === 'publishable') ?? false\n}\n\n/**\n * Build a complete resource catalog from entities and plugin-contributed\n * resources.\n *\n * Used by:\n * - `permissionRoutes()` config (`getStatements` callback)\n * - Server-side permission page data loaders\n *\n * Entities automatically get CRUD actions. Publishable entities also get\n * the `publish` action, which gates who can set status to 'published'.\n *\n * Plugin resources (`PluginResource[]` — `{ name, actions }`) are added\n * if not already present. The settings plugin auto-derives one entry per\n * settings namespace (`settings:<namespace>`) with actions `['view',\n * 'update']`; without that pass-through the Permissions admin UI rejects\n * grants on those resources with \"Unknown resource\".\n *\n * Historical note: the second parameter used to be typed as\n * `{ resource?: string; actions?: readonly string[] }[]` (admin-route\n * shape), but the only caller in the CLI was passing `pluginResources`\n * (shape `{ name, actions }`), so every plugin resource was silently\n * dropped from the catalog. The typed param ensures we don't regress.\n */\nexport function buildResourceCatalog(\n entities: { name: string; behaviors?: { name: string }[] }[],\n pluginResources?: ReadonlyArray<{ name: string; actions?: readonly string[] }>,\n): Record<string, string[]> {\n const catalog: Record<string, string[]> = {}\n\n for (const entity of entities) {\n catalog[entity.name] = isPublishable(entity)\n ? ['view', 'create', 'update', 'delete', 'publish']\n : ['view', 'create', 'update', 'delete']\n }\n\n if (pluginResources) {\n for (const resource of pluginResources) {\n if (resource.name && resource.actions && !(resource.name in catalog)) {\n catalog[resource.name] = [...resource.actions]\n }\n }\n }\n\n return catalog\n}\n\n// ---------------------------------------------------------------------------\n// better-auth integration (unchanged, used by createAuthServer)\n// ---------------------------------------------------------------------------\n\n/** Admin plugin's built-in resources — must be included for listUsers, ban, etc. */\nconst ADMIN_STATEMENTS = {\n user: [\n 'create',\n 'list',\n 'set-role',\n 'ban',\n 'impersonate',\n 'delete',\n 'set-password',\n 'get',\n 'update',\n ] as const,\n session: ['list', 'revoke', 'delete'] as const,\n}\n\n/**\n * Build a permission statement object from all registered entities,\n * plus the admin plugin's built-in user/session resources.\n *\n * Publishable entities get the additional `publish` action.\n * Result shape: `{ user: [...], session: [...], article: ['view', ...], category: [...] }`\n */\nexport function buildStatements(entities: Entity[]) {\n const statement: Record<string, readonly string[]> = {\n ...ADMIN_STATEMENTS,\n }\n for (const entity of entities) {\n statement[entity.name] = isPublishable(entity) ? ACTIONS_WITH_PUBLISH : ACTIONS\n }\n return statement\n}\n\n/**\n * Build the default toolkit roles for better-auth's access control.\n *\n * - **admin**: full CRUD on all entities + user/session management\n * - **authenticated**: view only (better-auth's `defaultRole`)\n * - **agent**: view + create + update on the ticketing surface (see\n * `AGENT_DEFAULT_PERMISSIONS`). Non-ticketing entities get view only,\n * matching `authenticated`.\n */\nexport function buildDefaultRoles(ac: ReturnType<typeof createAccessControl>, entities: Entity[]) {\n const adminPerms: Record<string, string[]> = {}\n const authenticatedPerms: Record<string, string[]> = {}\n const agentPerms: Record<string, string[]> = {}\n\n // Admin gets full control of user/session management\n adminPerms.user = [...ADMIN_STATEMENTS.user]\n adminPerms.session = [...ADMIN_STATEMENTS.session]\n // Non-admin roles get no user/session management\n authenticatedPerms.user = []\n authenticatedPerms.session = []\n agentPerms.user = []\n agentPerms.session = []\n\n for (const entity of entities) {\n adminPerms[entity.name] = isPublishable(entity)\n ? ['view', 'create', 'update', 'delete', 'publish']\n : ['view', 'create', 'update', 'delete']\n authenticatedPerms[entity.name] = ['view']\n // Agents get the seeded ticketing actions where defined; everything else\n // defaults to view-only (same as authenticated).\n agentPerms[entity.name] = AGENT_DEFAULT_PERMISSIONS[entity.name] ?? ['view']\n }\n\n return {\n admin: ac.newRole(adminPerms),\n authenticated: ac.newRole(authenticatedPerms),\n agent: ac.newRole(agentPerms),\n }\n}\n\nexport { createAccessControl }\n"],"mappings":"mCAeA,MAAM,EAAU,CAAC,OAAQ,SAAU,SAAU,SAAS,CAChD,EAAuB,CAAC,OAAQ,SAAU,SAAU,SAAU,UAAU,CASjE,EAAiB,CAAC,QAAS,SAAU,gBAAiB,QAAQ,CAkBrE,EAAsD,CAC1D,OAAQ,CAAC,OAAQ,SAAU,SAAS,CACpC,eAAgB,CAAC,OAAQ,SAAS,CAClC,kBAAmB,CAAC,OAAQ,SAAS,CACrC,WAAY,CAAC,OAAO,CACpB,WAAY,CAAC,OAAO,CACrB,CAGY,EAA2C,CACtD,IAAK,OACL,KAAM,SACN,MAAO,SACP,OAAQ,SACT,CAYD,SAAgB,GAAwE,CACtF,MAAO,CACL,OAAQ,EAAE,CACV,cAAe,EAAE,CACjB,MAAO,CAAE,GAAG,EAA2B,CACxC,CAWH,SAAgB,EACd,EACmB,CAEnB,IAAM,EAAQ,IAAI,IAElB,IAAK,GAAM,CAAC,EAAM,KAAc,OAAO,QAAQ,EAAgB,CAAE,CAC/D,IAAM,EAAc,IAAI,IACxB,IAAK,GAAM,CAAC,EAAU,KAAY,OAAO,QAAQ,EAAU,CACzD,EAAY,IAAI,EAAU,IAAI,IAAI,EAAQ,CAAC,CAE7C,EAAM,IAAI,EAAM,EAAY,CAG9B,OAAQ,EAAc,EAAkB,IAClC,IAAS,QAAgB,GACtB,EAAM,IAAI,EAAK,EAAE,IAAI,EAAS,EAAE,IAAI,EAAO,EAAI,GAS1D,SAAS,EAAc,EAAqD,CAC1E,OAAO,EAAO,WAAW,KAAM,GAAM,EAAE,OAAS,cAAc,EAAI,GA0BpE,SAAgB,EACd,EACA,EAC0B,CAC1B,IAAM,EAAoC,EAAE,CAE5C,IAAK,IAAM,KAAU,EACnB,EAAQ,EAAO,MAAQ,EAAc,EAAO,CACxC,CAAC,OAAQ,SAAU,SAAU,SAAU,UAAU,CACjD,CAAC,OAAQ,SAAU,SAAU,SAAS,CAG5C,GAAI,MACG,IAAM,KAAY,EACjB,EAAS,MAAQ,EAAS,SAAW,EAAE,EAAS,QAAQ,KAC1D,EAAQ,EAAS,MAAQ,CAAC,GAAG,EAAS,QAAQ,EAKpD,OAAO,EAQT,MAAM,EAAmB,CACvB,KAAM,CACJ,SACA,OACA,WACA,MACA,cACA,SACA,eACA,MACA,SACD,CACD,QAAS,CAAC,OAAQ,SAAU,SAAS,CACtC,CASD,SAAgB,EAAgB,EAAoB,CAClD,IAAM,EAA+C,CACnD,GAAG,EACJ,CACD,IAAK,IAAM,KAAU,EACnB,EAAU,EAAO,MAAQ,EAAc,EAAO,CAAG,EAAuB,EAE1E,OAAO,EAYT,SAAgB,EAAkB,EAA4C,EAAoB,CAChG,IAAM,EAAuC,EAAE,CACzC,EAA+C,EAAE,CACjD,EAAuC,EAAE,CAG/C,EAAW,KAAO,CAAC,GAAG,EAAiB,KAAK,CAC5C,EAAW,QAAU,CAAC,GAAG,EAAiB,QAAQ,CAElD,EAAmB,KAAO,EAAE,CAC5B,EAAmB,QAAU,EAAE,CAC/B,EAAW,KAAO,EAAE,CACpB,EAAW,QAAU,EAAE,CAEvB,IAAK,IAAM,KAAU,EACnB,EAAW,EAAO,MAAQ,EAAc,EAAO,CAC3C,CAAC,OAAQ,SAAU,SAAU,SAAU,UAAU,CACjD,CAAC,OAAQ,SAAU,SAAU,SAAS,CAC1C,EAAmB,EAAO,MAAQ,CAAC,OAAO,CAG1C,EAAW,EAAO,MAAQ,EAA0B,EAAO,OAAS,CAAC,OAAO,CAG9E,MAAO,CACL,MAAO,EAAG,QAAQ,EAAW,CAC7B,cAAe,EAAG,QAAQ,EAAmB,CAC7C,MAAO,EAAG,QAAQ,EAAW,CAC9B"}
@@ -1,2 +1,2 @@
1
- import{l as e}from"./schema-Je6e5yt2.mjs";import{r as t,s as n}from"./permissions-BbVD2CmT.mjs";import{createAccessControl as r}from"better-auth/plugins/access";import{betterAuth as i}from"better-auth";import{drizzleAdapter as a}from"better-auth/adapters/drizzle";import{nextCookies as o}from"better-auth/next-js";import{admin as s}from"better-auth/plugins";import{organization as c}from"better-auth/plugins/organization";import{APIError as l,createAuthMiddleware as u}from"better-auth/api";function d(e,t){return n=>{e?.log(n).catch(e=>{t.warn({err:e,action:n.action},`audit log write failed`)})}}function f(e){let t=e?.context?.session??void 0,n=t?.user;return{id:n?.id??t?.userId,name:n?.name}}function p(e){return e.context.returned?.status}function m(e){return e.body??void 0}function h(e){if(typeof e==`string`)return e.length<=256?e:e.slice(0,256)}const g=new Set([`updatedAt`,`createdAt`]);function _(e){let t=new WeakMap;return{user:{create:{after:async t=>{e({action:`auth.signup`,entityType:`user`,...t.id!==void 0&&{entityId:t.id,userId:t.id},...t.name!==void 0&&{userName:t.name},changes:{fields:{name:t.name,email:t.email}}})}},update:{before:async(e,n)=>{if(!n)return;let r={};for(let[t,n]of Object.entries(e))g.has(t)||t===`id`||(r[t]=n);Object.keys(r).length>0&&t.set(n,r)},after:async(n,r)=>{let i=f(r),a=null;r&&(a=t.get(r)??null,a&&t.delete(r)),e({action:`auth.user.update`,entityType:`user`,...n.id!==void 0&&{entityId:n.id},...i.id!==void 0&&{userId:i.id},...i.name!==void 0&&{userName:i.name},...a?{changes:{fields:a}}:{},metadata:{targetUser:n.name}})}},delete:{after:async(t,n)=>{let r=f(n);e({action:`auth.user.delete`,entityType:`user`,...t.id!==void 0&&{entityId:t.id},...r.id!==void 0&&{userId:r.id},...r.name!==void 0&&{userName:r.name},metadata:{targetUser:t.name}})}}}}}const v=[`/sign-in/email`,`/sign-in/social`],y={"/change-password":`auth.password.change`,"/forget-password":`auth.password.reset_request`,"/reset-password":`auth.password.reset`},b={"/admin/set-role":`auth.admin.set_role`,"/admin/ban-user":`auth.admin.ban`,"/admin/unban-user":`auth.admin.unban`,"/admin/create-user":`auth.admin.create_user`,"/admin/remove-user":`auth.admin.remove_user`,"/admin/impersonate-user":`auth.admin.impersonate`,"/admin/stop-impersonating":`auth.admin.stop_impersonating`,"/admin/revoke-session":`auth.admin.revoke_session`,"/admin/revoke-sessions":`auth.admin.revoke_sessions`};function x(e){return{after:u(async t=>{let n=t;if(v.some(e=>n.path.startsWith(e))){let t=p(n);if(!t)return;if(t>=400){let r=h(m(n)?.email);e({action:`auth.login.failed`,metadata:{...r?{email:r}:{},status:t,path:n.path}})}else{let t=n.context.newSession;t?.user?.id&&e({action:`auth.login`,entityType:`user`,entityId:t.user.id,userId:t.user.id,...t.user.name!==void 0&&{userName:t.user.name}})}return}if(n.path===`/sign-out`){let t=p(n);if(!t||t<400){let t=f(n);t.id&&e({action:`auth.logout`,entityType:`user`,entityId:t.id,userId:t.id,...t.name!==void 0&&{userName:t.name}})}return}let r=y[n.path];if(r){let t=p(n);if(!t||t<400){let t=f(n),i=h(m(n)?.email);e({action:r,entityType:`user`,...t.id!==void 0&&{userId:t.id},...t.name!==void 0&&{userName:t.name},metadata:{...i?{email:i}:{},path:n.path}})}return}let i=b[n.path];if(!i)return;let a=p(n);if(a&&a>=400)return;let o=f(n),s=m(n);e({action:i,entityType:`user`,...typeof s?.userId==`string`&&{entityId:s.userId},...o.id!==void 0&&{userId:o.id},...o.name!==void 0&&{userName:o.name},metadata:{...typeof s?.userId==`string`?{targetUserId:s.userId}:{},...typeof s?.role==`string`?{role:s.role}:{},path:n.path}})})}}function S(e){let{enabled:t,auditLogger:n,logger:r}=e,i=d(n,r);return u(async e=>{let n=e;if(n.path!==`/sign-up/email`||t)return;let r=n.body??void 0,a=f(n),o=r?.email,s=typeof o==`string`&&o.length>0?o.slice(0,256):void 0;throw i({action:`auth.signup.blocked`,metadata:{path:n.path,...s?{email:s}:{},...a.id?{actorId:a.id}:{}}}),new l(`FORBIDDEN`,{message:`Sign-up is closed`})})}function C(e,t){let n={};return e.social?.google&&(n.google={...e.social.google,...t?{disableSignUp:!0}:{}}),e.social?.github&&(n.github={...e.social.github,...t?{disableSignUp:!0}:{}}),n}function w(e,t=process.env){if(t.NODE_ENV===`production`&&!(t.NEXT_PHASE===`phase-production-build`||t.NEXT_PHASE===`phase-export`)){if(!t.BETTER_AUTH_SECRET)throw Error("@murumets-ee/auth: BETTER_AUTH_SECRET is required in production. Generate one with `openssl rand -base64 32` and set it in the environment.");if(!e.baseURL&&!t.BETTER_AUTH_URL)throw Error('@murumets-ee/auth: a public baseURL is required in production. Set `auth({ baseURL: "https://..." })` or the `BETTER_AUTH_URL` env var.')}}function T(i,l,u,f=process.env){let p=[...l.entities.values()],m=r(n(p)),h=t(m,p),g=i.baseURL??f.BETTER_AUTH_URL,v=i.signup?.enabled??!1,y=C(i,!v),b=i.schema??e,w=d(u,l.logger),T=i.rateLimit?.storage??`memory`;return{database:a(l.db.readWrite,{provider:`pg`,schema:b}),...g?{baseURL:g}:{},...i.trustedOrigins?{trustedOrigins:i.trustedOrigins}:{},emailAndPassword:{enabled:i.providers?.includes(`email`)??!0,requireEmailVerification:i.signup?.requireEmailVerification??v},socialProviders:y,session:{expiresIn:i.session?.expiresIn??3600*2,updateAge:i.session?.updateAge??3600},rateLimit:{enabled:!0,window:60,max:100,storage:T,customRules:{"/sign-in/email":{window:60,max:5},"/sign-in/social":{window:60,max:10},"/sign-up/email":{window:60,max:3},"/forget-password":{window:60,max:3},"/reset-password":{window:60,max:5},"/admin/*":{window:60,max:20}}},databaseHooks:_(w),hooks:{before:S({enabled:v,auditLogger:u,logger:l.logger}),...u?x(w):{}},plugins:[s({ac:m,roles:h,defaultRole:`authenticated`}),...i.organizations?[c({ac:m,roles:h})]:[],...i.betterAuthPlugins??[],o()]}}function E(e,t,n){if(globalThis.window!==void 0)throw Error(`@murumets-ee/auth: createAuthServer must not run in a browser environment. Construct the auth server from server code only (RSC, route handlers, CLI, worker).`);return w(e),i(T(e,t,n))}let D=null,O=null;function k(e){D=e}function A(e){O=e}function j(){if(!D)throw Error(`@murumets-ee/auth not initialized. Add auth() to your plugins array.`);return D}function M(){return O?.signup?.enabled===!0}export{E as a,M as i,A as n,j as r,k as t};
2
- //# sourceMappingURL=runtime-Cj8SEWhk.mjs.map
1
+ import{l as e}from"./schema-Je6e5yt2.mjs";import{r as t,s as n}from"./permissions-D2ac2RR_.mjs";import{createAccessControl as r}from"better-auth/plugins/access";import{betterAuth as i}from"better-auth";import{drizzleAdapter as a}from"better-auth/adapters/drizzle";import{nextCookies as o}from"better-auth/next-js";import{admin as s}from"better-auth/plugins";import{organization as c}from"better-auth/plugins/organization";import{APIError as l,createAuthMiddleware as u}from"better-auth/api";function d(e,t){return n=>{e?.log(n).catch(e=>{t.warn({err:e,action:n.action},`audit log write failed`)})}}function f(e){let t=e?.context?.session??void 0,n=t?.user;return{id:n?.id??t?.userId,name:n?.name}}function p(e){return e.context.returned?.status}function m(e){return e.body??void 0}function h(e){if(typeof e==`string`)return e.length<=256?e:e.slice(0,256)}const g=new Set([`updatedAt`,`createdAt`]);function _(e){let t=new WeakMap;return{user:{create:{after:async t=>{e({action:`auth.signup`,entityType:`user`,...t.id!==void 0&&{entityId:t.id,userId:t.id},...t.name!==void 0&&{userName:t.name},changes:{fields:{name:t.name,email:t.email}}})}},update:{before:async(e,n)=>{if(!n)return;let r={};for(let[t,n]of Object.entries(e))g.has(t)||t===`id`||(r[t]=n);Object.keys(r).length>0&&t.set(n,r)},after:async(n,r)=>{let i=f(r),a=null;r&&(a=t.get(r)??null,a&&t.delete(r)),e({action:`auth.user.update`,entityType:`user`,...n.id!==void 0&&{entityId:n.id},...i.id!==void 0&&{userId:i.id},...i.name!==void 0&&{userName:i.name},...a?{changes:{fields:a}}:{},metadata:{targetUser:n.name}})}},delete:{after:async(t,n)=>{let r=f(n);e({action:`auth.user.delete`,entityType:`user`,...t.id!==void 0&&{entityId:t.id},...r.id!==void 0&&{userId:r.id},...r.name!==void 0&&{userName:r.name},metadata:{targetUser:t.name}})}}}}}const v=[`/sign-in/email`,`/sign-in/social`],y={"/change-password":`auth.password.change`,"/forget-password":`auth.password.reset_request`,"/reset-password":`auth.password.reset`},b={"/admin/set-role":`auth.admin.set_role`,"/admin/ban-user":`auth.admin.ban`,"/admin/unban-user":`auth.admin.unban`,"/admin/create-user":`auth.admin.create_user`,"/admin/remove-user":`auth.admin.remove_user`,"/admin/impersonate-user":`auth.admin.impersonate`,"/admin/stop-impersonating":`auth.admin.stop_impersonating`,"/admin/revoke-session":`auth.admin.revoke_session`,"/admin/revoke-sessions":`auth.admin.revoke_sessions`};function x(e){return{after:u(async t=>{let n=t;if(v.some(e=>n.path.startsWith(e))){let t=p(n);if(!t)return;if(t>=400){let r=h(m(n)?.email);e({action:`auth.login.failed`,metadata:{...r?{email:r}:{},status:t,path:n.path}})}else{let t=n.context.newSession;t?.user?.id&&e({action:`auth.login`,entityType:`user`,entityId:t.user.id,userId:t.user.id,...t.user.name!==void 0&&{userName:t.user.name}})}return}if(n.path===`/sign-out`){let t=p(n);if(!t||t<400){let t=f(n);t.id&&e({action:`auth.logout`,entityType:`user`,entityId:t.id,userId:t.id,...t.name!==void 0&&{userName:t.name}})}return}let r=y[n.path];if(r){let t=p(n);if(!t||t<400){let t=f(n),i=h(m(n)?.email);e({action:r,entityType:`user`,...t.id!==void 0&&{userId:t.id},...t.name!==void 0&&{userName:t.name},metadata:{...i?{email:i}:{},path:n.path}})}return}let i=b[n.path];if(!i)return;let a=p(n);if(a&&a>=400)return;let o=f(n),s=m(n);e({action:i,entityType:`user`,...typeof s?.userId==`string`&&{entityId:s.userId},...o.id!==void 0&&{userId:o.id},...o.name!==void 0&&{userName:o.name},metadata:{...typeof s?.userId==`string`?{targetUserId:s.userId}:{},...typeof s?.role==`string`?{role:s.role}:{},path:n.path}})})}}function S(e){let{enabled:t,auditLogger:n,logger:r}=e,i=d(n,r);return u(async e=>{let n=e;if(n.path!==`/sign-up/email`||t)return;let r=n.body??void 0,a=f(n),o=r?.email,s=typeof o==`string`&&o.length>0?o.slice(0,256):void 0;throw i({action:`auth.signup.blocked`,metadata:{path:n.path,...s?{email:s}:{},...a.id?{actorId:a.id}:{}}}),new l(`FORBIDDEN`,{message:`Sign-up is closed`})})}function C(e,t){let n={};return e.social?.google&&(n.google={...e.social.google,...t?{disableSignUp:!0}:{}}),e.social?.github&&(n.github={...e.social.github,...t?{disableSignUp:!0}:{}}),n}function w(e,t=process.env){if(t.NODE_ENV===`production`&&!(t.NEXT_PHASE===`phase-production-build`||t.NEXT_PHASE===`phase-export`)){if(!t.BETTER_AUTH_SECRET)throw Error("@murumets-ee/auth: BETTER_AUTH_SECRET is required in production. Generate one with `openssl rand -base64 32` and set it in the environment.");if(!e.baseURL&&!t.BETTER_AUTH_URL)throw Error('@murumets-ee/auth: a public baseURL is required in production. Set `auth({ baseURL: "https://..." })` or the `BETTER_AUTH_URL` env var.')}}function T(i,l,u,f=process.env){let p=[...l.entities.values()],m=r(n(p)),h=t(m,p),g=i.baseURL??f.BETTER_AUTH_URL,v=i.signup?.enabled??!1,y=C(i,!v),b=i.schema??e,w=d(u,l.logger),T=i.rateLimit?.storage??`memory`;return{database:a(l.db.readWrite,{provider:`pg`,schema:b}),...g?{baseURL:g}:{},...i.trustedOrigins?{trustedOrigins:i.trustedOrigins}:{},emailAndPassword:{enabled:i.providers?.includes(`email`)??!0,requireEmailVerification:i.signup?.requireEmailVerification??v},socialProviders:y,session:{expiresIn:i.session?.expiresIn??3600*2,updateAge:i.session?.updateAge??3600},rateLimit:{enabled:!0,window:60,max:100,storage:T,customRules:{"/sign-in/email":{window:60,max:5},"/sign-in/social":{window:60,max:10},"/sign-up/email":{window:60,max:3},"/forget-password":{window:60,max:3},"/reset-password":{window:60,max:5},"/admin/*":{window:60,max:20}}},databaseHooks:_(w),hooks:{before:S({enabled:v,auditLogger:u,logger:l.logger}),...u?x(w):{}},plugins:[s({ac:m,roles:h,defaultRole:`authenticated`}),...i.organizations?[c({ac:m,roles:h})]:[],...i.betterAuthPlugins??[],o()]}}function E(e,t,n){if(globalThis.window!==void 0)throw Error(`@murumets-ee/auth: createAuthServer must not run in a browser environment. Construct the auth server from server code only (RSC, route handlers, CLI, worker).`);return w(e),i(T(e,t,n))}let D=null,O=null;function k(e){D=e}function A(e){O=e}function j(){if(!D)throw Error(`@murumets-ee/auth not initialized. Add auth() to your plugins array.`);return D}function M(){return O?.signup?.enabled===!0}export{E as a,M as i,A as n,j as r,k as t};
2
+ //# sourceMappingURL=runtime-WUuhlRlH.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"runtime-Cj8SEWhk.mjs","names":["defaultAuthSchema"],"sources":["../src/audit-hooks.ts","../src/signup-gate.ts","../src/server.ts","../src/runtime.ts"],"sourcesContent":["/**\n * Audit hooks for better-auth events.\n *\n * Two layers:\n * 1. `buildDatabaseHooks` — fires on user CRUD via better-auth's `databaseHooks`.\n * Captures `auth.signup`, `auth.user.update`, `auth.user.delete`.\n * 2. `buildRequestHooks` — fires on HTTP request lifecycle via the `hooks.after`\n * middleware. Captures `auth.login`, `auth.login.failed`, `auth.logout`,\n * `auth.password.*`, and `auth.admin.*` operations.\n *\n * All audit writes are fire-and-forget — they never block auth operations,\n * but failures are logged via the application logger so silent loss of audit\n * trail does not pass unnoticed.\n */\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { AuditLogger } from '@murumets-ee/logging'\nimport { createAuthMiddleware } from 'better-auth/api'\n\n// ---------------------------------------------------------------------------\n// Structural types\n// ---------------------------------------------------------------------------\n//\n// We avoid importing `GenericEndpointContext` / `User` from better-auth\n// because their generic parameters and zod-internal references trip\n// tsdown's DTS emit (TS2742). Instead, we declare exactly the shape these\n// hooks read — narrower than `Record<string, unknown>`, fully typed.\n\n/** Subset of better-auth's `User` row we read in audit payloads. */\ninterface UserRow {\n id?: string\n name?: string\n email?: string\n}\n\n/** Subset of better-auth's `GenericEndpointContext` we read in audit hooks. */\nexport interface AuthHookCtx {\n path: string\n body?: unknown\n context: {\n session?: { user?: UserRow; userId?: string } | null\n returned?: { status?: number } | unknown\n newSession?: { user?: UserRow } | null\n }\n}\n\n/** Body fields the hooks inspect for audit metadata. */\ninterface AuthBody {\n email?: unknown\n userId?: unknown\n role?: unknown\n}\n\n/** Audit-log entry shape (mirrors AuditLogger.log signature). */\ntype AuditEntry = Parameters<AuditLogger['log']>[0]\n\n/** Fire-and-forget audit emitter. Logs swallow reasons via `logger.warn` so\n * audit-write failures aren't entirely silent (CLAUDE.md: empty `.catch`\n * needs justification, and \"logger.warn on swallow\" IS the justification). */\nexport function makeAuditFn(\n auditLogger: AuditLogger | undefined,\n logger: Logger,\n): (entry: AuditEntry) => void {\n return (entry) => {\n auditLogger?.log(entry).catch((err: unknown) => {\n logger.warn({ err, action: entry.action }, 'audit log write failed')\n })\n }\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** Read the actor (current session user) from a hook context. */\nexport function getActor(ctx: AuthHookCtx | null | undefined): {\n id: string | undefined\n name: string | undefined\n} {\n const session = ctx?.context?.session ?? undefined\n const user = session?.user\n return {\n id: user?.id ?? session?.userId,\n name: user?.name,\n }\n}\n\n/** Read the response status the route handler produced (if any). */\nfunction getReturnedStatus(ctx: AuthHookCtx): number | undefined {\n const returned = ctx.context.returned as { status?: number } | undefined\n return returned?.status\n}\n\n/** Read the request body as a typed-but-narrow `AuthBody`. */\nfunction getBody(ctx: AuthHookCtx): AuthBody | undefined {\n return (ctx.body ?? undefined) as AuthBody | undefined\n}\n\n/** Maximum length for user-supplied strings written to the audit log.\n * Caps the blast-radius of a malicious client sending megabyte-sized\n * inputs (`email`, etc.) into the audit table. RFC 5321 caps email at\n * 254 chars; we round to 256. */\nconst AUDIT_STRING_CAP = 256\n\n/** Cap a user-supplied string for audit-log emission. Returns undefined\n * for non-strings so callers can spread it into metadata conditionally. */\nfunction capForAudit(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined\n return value.length <= AUDIT_STRING_CAP ? value : value.slice(0, AUDIT_STRING_CAP)\n}\n\n/** Fields stripped from user-update audit payloads (noise). */\nconst SKIP_USER_UPDATE_FIELDS = new Set(['updatedAt', 'createdAt'])\n\n// ---------------------------------------------------------------------------\n// Database hooks (user CRUD)\n// ---------------------------------------------------------------------------\n//\n// HIGH-2 fix (#TBD): the previous implementation used a closure-scoped\n// `pendingFields` variable to bridge `before` → `after`. That bridge was\n// shared across ALL concurrent requests handled by the same Node process,\n// causing audit-log integrity bugs (request A's diff could be logged\n// against user B). Replaced with a `WeakMap` keyed by the per-request\n// context object — better-auth passes the same `GenericEndpointContext`\n// instance to `before` and `after` of the same operation.\n\ninterface DatabaseHookCallbacks {\n user: {\n create: { after: (user: UserRow, ctx: AuthHookCtx | null) => Promise<void> }\n update: {\n before: (userData: Record<string, unknown>, ctx: AuthHookCtx | null) => Promise<void>\n after: (user: UserRow, ctx: AuthHookCtx | null) => Promise<void>\n }\n delete: { after: (user: UserRow, ctx: AuthHookCtx | null) => Promise<void> }\n }\n}\n\nexport function buildDatabaseHooks(audit: (entry: AuditEntry) => void): DatabaseHookCallbacks {\n /** Per-request scratch space bridging `update.before` → `update.after`.\n * Keyed by the better-auth hook context object — concurrent requests get\n * isolated entries because better-auth passes the same context instance\n * to `before` and `after` of the same operation. */\n const pendingFieldsByCtx = new WeakMap<object, Record<string, unknown>>()\n\n return {\n user: {\n create: {\n after: async (user) => {\n // Signup — actor is the new user themselves.\n // NOTE: first-user auto-promotion was removed. Bootstrap the first\n // admin via `lumi create-admin` (requires shell access). Public\n // signup is closed by default; see AuthConfig.signup.enabled.\n audit({\n action: 'auth.signup',\n entityType: 'user',\n ...(user.id !== undefined && { entityId: user.id, userId: user.id }),\n ...(user.name !== undefined && { userName: user.name }),\n changes: {\n fields: { name: user.name, email: user.email },\n },\n })\n },\n },\n update: {\n // `before` receives only the changed fields — capture them.\n // When ctx is null (internal-adapter writes outside an HTTP request,\n // e.g. background jobs) we skip the bridge entirely and let `after`\n // fire without a diff. The previous fallback used a closure variable\n // shared across all ctx-less calls, which would race the same way\n // the WeakMap fix removed for HTTP requests.\n before: async (userData, ctx) => {\n if (!ctx) return\n const fields: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(userData)) {\n if (SKIP_USER_UPDATE_FIELDS.has(key) || key === 'id') continue\n fields[key] = value\n }\n if (Object.keys(fields).length > 0) {\n pendingFieldsByCtx.set(ctx, fields)\n }\n },\n // `after` has full user (name) + ctx (actor session) — log everything.\n after: async (user, ctx) => {\n const actor = getActor(ctx)\n let fields: Record<string, unknown> | null = null\n if (ctx) {\n fields = pendingFieldsByCtx.get(ctx) ?? null\n if (fields) pendingFieldsByCtx.delete(ctx)\n }\n audit({\n action: 'auth.user.update',\n entityType: 'user',\n ...(user.id !== undefined && { entityId: user.id }),\n ...(actor.id !== undefined && { userId: actor.id }),\n ...(actor.name !== undefined && { userName: actor.name }),\n ...(fields ? { changes: { fields } } : {}),\n metadata: {\n targetUser: user.name,\n },\n })\n },\n },\n delete: {\n after: async (user, ctx) => {\n const actor = getActor(ctx)\n audit({\n action: 'auth.user.delete',\n entityType: 'user',\n ...(user.id !== undefined && { entityId: user.id }),\n ...(actor.id !== undefined && { userId: actor.id }),\n ...(actor.name !== undefined && { userName: actor.name }),\n metadata: {\n targetUser: user.name,\n },\n })\n },\n },\n },\n }\n}\n\n// ---------------------------------------------------------------------------\n// Request hooks (auth lifecycle: login, logout, password, admin ops)\n// ---------------------------------------------------------------------------\n\n/** Auth paths where a non-2xx response means a failed attempt worth logging. */\nconst SIGN_IN_PATHS = ['/sign-in/email', '/sign-in/social'] as const\n\n/** Auth paths for session/password lifecycle events. */\nconst SIGN_OUT_PATH = '/sign-out'\nconst PASSWORD_PATHS: Record<string, string> = {\n '/change-password': 'auth.password.change',\n '/forget-password': 'auth.password.reset_request',\n '/reset-password': 'auth.password.reset',\n}\n\n/** Paths that should be audit-logged when successful. */\nconst AUDIT_ADMIN_PATHS: Record<string, string> = {\n '/admin/set-role': 'auth.admin.set_role',\n '/admin/ban-user': 'auth.admin.ban',\n '/admin/unban-user': 'auth.admin.unban',\n '/admin/create-user': 'auth.admin.create_user',\n '/admin/remove-user': 'auth.admin.remove_user',\n '/admin/impersonate-user': 'auth.admin.impersonate',\n '/admin/stop-impersonating': 'auth.admin.stop_impersonating',\n '/admin/revoke-session': 'auth.admin.revoke_session',\n '/admin/revoke-sessions': 'auth.admin.revoke_sessions',\n}\n\nexport function buildRequestHooks(audit: (entry: AuditEntry) => void): {\n after: ReturnType<typeof createAuthMiddleware>\n} {\n return {\n after: createAuthMiddleware(async (rawCtx) => {\n const ctx = rawCtx as unknown as AuthHookCtx\n\n // --- Login attempt logging ---\n if (SIGN_IN_PATHS.some((p) => ctx.path.startsWith(p))) {\n const status = getReturnedStatus(ctx)\n if (!status) return\n\n if (status >= 400) {\n const email = capForAudit(getBody(ctx)?.email)\n audit({\n action: 'auth.login.failed',\n metadata: {\n ...(email ? { email } : {}),\n status,\n path: ctx.path,\n },\n })\n } else {\n const newSession = ctx.context.newSession\n if (newSession?.user?.id) {\n audit({\n action: 'auth.login',\n entityType: 'user',\n entityId: newSession.user.id,\n userId: newSession.user.id,\n ...(newSession.user.name !== undefined && { userName: newSession.user.name }),\n })\n }\n }\n return\n }\n\n // --- Logout logging ---\n if (ctx.path === SIGN_OUT_PATH) {\n const status = getReturnedStatus(ctx)\n if (!status || status < 400) {\n const actor = getActor(ctx)\n if (actor.id) {\n audit({\n action: 'auth.logout',\n entityType: 'user',\n entityId: actor.id,\n userId: actor.id,\n ...(actor.name !== undefined && { userName: actor.name }),\n })\n }\n }\n return\n }\n\n // --- Password change/reset logging ---\n const passwordAction = PASSWORD_PATHS[ctx.path]\n if (passwordAction) {\n const status = getReturnedStatus(ctx)\n if (!status || status < 400) {\n const actor = getActor(ctx)\n const body = getBody(ctx)\n const email = capForAudit(body?.email)\n audit({\n action: passwordAction,\n entityType: 'user',\n ...(actor.id !== undefined && { userId: actor.id }),\n ...(actor.name !== undefined && { userName: actor.name }),\n metadata: {\n // For reset requests, log the email (not sensitive — it's the input)\n ...(email ? { email } : {}),\n path: ctx.path,\n },\n })\n }\n return\n }\n\n // --- Admin operation audit logging (impersonation, role changes, bans, etc.) ---\n const auditAction = AUDIT_ADMIN_PATHS[ctx.path]\n if (!auditAction) return\n\n const status = getReturnedStatus(ctx)\n if (status && status >= 400) return // failed — skip\n\n const actor = getActor(ctx)\n const body = getBody(ctx)\n audit({\n action: auditAction,\n entityType: 'user',\n ...(typeof body?.userId === 'string' && { entityId: body.userId }),\n ...(actor.id !== undefined && { userId: actor.id }),\n ...(actor.name !== undefined && { userName: actor.name }),\n metadata: {\n ...(typeof body?.userId === 'string' ? { targetUserId: body.userId } : {}),\n ...(typeof body?.role === 'string' ? { role: body.role } : {}),\n path: ctx.path,\n },\n })\n }),\n }\n}\n","/**\n * Signup gate — rejects public registration on `/sign-up/email` unless\n * explicitly enabled via `auth({ signup: { enabled: true } })`.\n *\n * Closed by default. The first admin is bootstrapped via `lumi create-admin`,\n * which writes through `auth.$context.internalAdapter` below the HTTP layer\n * so this gate never fires for CLI bootstrap.\n *\n * Note: this gate covers `/sign-up/email` only. Social-provider signup is\n * gated separately by passing `disableSignUp: true` per provider in\n * `createAuthServer` when `signup.enabled === false` (see SECURITY-HIGH-1).\n */\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { AuditLogger } from '@murumets-ee/logging'\nimport { APIError, createAuthMiddleware } from 'better-auth/api'\nimport type { AuthHookCtx } from './audit-hooks.js'\nimport { getActor, makeAuditFn } from './audit-hooks.js'\n\nconst SIGN_UP_EMAIL_PATH = '/sign-up/email'\n\nexport interface SignupGateOptions {\n enabled: boolean\n /** Optional audit logger — when present, blocked attempts are logged. */\n auditLogger?: AuditLogger | undefined\n /** Application logger used to swallow audit-write failures non-silently. */\n logger: Logger\n}\n\nexport function buildSignupGate(\n options: SignupGateOptions,\n): ReturnType<typeof createAuthMiddleware> {\n const { enabled, auditLogger, logger } = options\n const audit = makeAuditFn(auditLogger, logger)\n\n return createAuthMiddleware(async (rawCtx) => {\n const ctx = rawCtx as unknown as AuthHookCtx\n if (ctx.path !== SIGN_UP_EMAIL_PATH) return\n if (enabled) return\n\n // Log the blocked attempt before throwing — gives the SOC visibility\n // into pattern-of-attempts without relying solely on rate-limit counters.\n const body = (ctx.body ?? undefined) as { email?: unknown } | undefined\n const actor = getActor(ctx)\n // Cap the user-supplied email at 256 chars — caps blast radius of a\n // malicious client sending a megabyte-sized `email` field into our\n // audit table. RFC 5321 caps real emails at 254.\n const rawEmail = body?.email\n const email =\n typeof rawEmail === 'string' && rawEmail.length > 0 ? rawEmail.slice(0, 256) : undefined\n audit({\n action: 'auth.signup.blocked',\n metadata: {\n path: ctx.path,\n ...(email ? { email } : {}),\n ...(actor.id ? { actorId: actor.id } : {}),\n },\n })\n\n throw new APIError('FORBIDDEN', { message: 'Sign-up is closed' })\n })\n}\n","/**\n * Server-side auth instance factory.\n *\n * Creates a configured `betterAuth()` instance using the toolkit's database\n * connection and entity definitions. This is server-only code.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { AuditLogger } from '@murumets-ee/logging'\nimport type { Auth as BetterAuthBase, BetterAuthOptions } from 'better-auth'\nimport { betterAuth } from 'better-auth'\nimport { drizzleAdapter } from 'better-auth/adapters/drizzle'\nimport { nextCookies } from 'better-auth/next-js'\nimport { admin } from 'better-auth/plugins'\nimport type { Role } from 'better-auth/plugins/access'\nimport { createAccessControl } from 'better-auth/plugins/access'\nimport { organization } from 'better-auth/plugins/organization'\nimport { buildDatabaseHooks, buildRequestHooks, makeAuditFn } from './audit-hooks.js'\nimport { buildDefaultRoles, buildStatements } from './permissions.js'\nimport * as defaultAuthSchema from './schema.js'\nimport { buildSignupGate } from './signup-gate.js'\nimport type { AdminUser, AuthConfig } from './types.js'\n\n// ---------------------------------------------------------------------------\n// Provider config — wires the closed-signup contract through to OAuth\n// ---------------------------------------------------------------------------\n\nexport interface SocialProviderConfig {\n clientId: string\n clientSecret: string\n /** When `true`, OAuth login refuses to create an account for an unknown\n * email. Existing users sign in normally. SECURITY-HIGH-1: required when\n * `signup.enabled === false`, otherwise the closed-signup gate is bypassed. */\n disableSignUp?: boolean\n}\n\n/** @internal Exported for unit tests — not part of the public API. */\nexport function buildSocialProviders(\n config: AuthConfig,\n signupClosed: boolean,\n): Record<string, SocialProviderConfig> {\n const out: Record<string, SocialProviderConfig> = {}\n if (config.social?.google) {\n out.google = {\n ...config.social.google,\n ...(signupClosed ? { disableSignUp: true } : {}),\n }\n }\n if (config.social?.github) {\n out.github = {\n ...config.social.github,\n ...(signupClosed ? { disableSignUp: true } : {}),\n }\n }\n return out\n}\n\n// ---------------------------------------------------------------------------\n// Production env asserts (SECURITY-MED-1, SECURITY-MED-5)\n// ---------------------------------------------------------------------------\n\n/**\n * Throw if production *runtime* is missing the env vars better-auth needs\n * for stable cookies and CSRF anchoring.\n *\n * Called from `createAuthServer` at plugin init time so a misconfigured\n * deploy crashes loudly instead of silently degrading (random per-process\n * secret = sessions invalidate on every restart; missing baseURL = CSRF\n * Origin check anchored to whatever header arrives first).\n *\n * NOTE: skipped during `next build` (`NEXT_PHASE=phase-production-build`).\n * Build-time page-data collection initializes the toolkit and would\n * otherwise crash builds where env vars are injected at runtime only\n * (typical Docker setup). The assert still fires when the production\n * server actually starts (`phase-production-server`) — that's where a\n * misconfigured deploy actually matters. Same reasoning for static\n * export (`phase-export`).\n *\n * @internal Exported for unit tests — not part of the public API.\n */\nexport function assertProductionEnv(\n config: AuthConfig,\n env: NodeJS.ProcessEnv = process.env,\n): void {\n if (env.NODE_ENV !== 'production') return\n if (env.NEXT_PHASE === 'phase-production-build' || env.NEXT_PHASE === 'phase-export') return\n if (!env.BETTER_AUTH_SECRET) {\n throw new Error(\n '@murumets-ee/auth: BETTER_AUTH_SECRET is required in production. ' +\n 'Generate one with `openssl rand -base64 32` and set it in the environment.',\n )\n }\n if (!config.baseURL && !env.BETTER_AUTH_URL) {\n throw new Error(\n '@murumets-ee/auth: a public baseURL is required in production. ' +\n 'Set `auth({ baseURL: \"https://...\" })` or the `BETTER_AUTH_URL` env var.',\n )\n }\n}\n\n// ---------------------------------------------------------------------------\n// Pure option builder (so tests can exercise wiring without `betterAuth()`)\n// ---------------------------------------------------------------------------\n\n/**\n * Build the `BetterAuthOptions` that `createAuthServer` hands to `betterAuth()`.\n *\n * Pure, side-effect-free, no env-var reads except `BETTER_AUTH_URL` as a\n * fallback for `config.baseURL`. Extracted so unit tests can inspect the\n * resulting options object (rate-limit storage, social-provider gating,\n * email-verification default) without invoking better-auth's full setup.\n *\n * @internal Exported for unit tests — not part of the public API.\n */\nexport function buildAuthOptions(\n config: AuthConfig,\n app: ToolkitApp,\n auditLogger?: AuditLogger,\n env: NodeJS.ProcessEnv = process.env,\n): BetterAuthOptions {\n const entities = [...app.entities.values()]\n const statement = buildStatements(entities)\n const ac = createAccessControl(statement)\n const roles: Record<string, Role> = buildDefaultRoles(ac, entities)\n\n const baseURL = config.baseURL ?? env.BETTER_AUTH_URL\n const signupEnabled = config.signup?.enabled ?? false\n const socialProviders = buildSocialProviders(config, !signupEnabled)\n const schema = config.schema ?? defaultAuthSchema\n const audit = makeAuditFn(auditLogger, app.logger)\n const rateLimitStorage: 'memory' | 'database' | 'secondary-storage' =\n config.rateLimit?.storage ?? 'memory'\n\n return {\n database: drizzleAdapter(app.db.readWrite, {\n provider: 'pg',\n schema,\n }),\n\n ...(baseURL ? { baseURL } : {}),\n ...(config.trustedOrigins ? { trustedOrigins: config.trustedOrigins } : {}),\n\n emailAndPassword: {\n enabled: config.providers?.includes('email') ?? true,\n // SECURITY-MED-3: when public signup is enabled, default to\n // requiring email verification — without it, attackers can register\n // any email and immediately sign in.\n requireEmailVerification: config.signup?.requireEmailVerification ?? signupEnabled,\n },\n\n socialProviders,\n\n session: {\n expiresIn: config.session?.expiresIn ?? 60 * 60 * 2, // 2 hours (admin CMS — short-lived)\n updateAge: config.session?.updateAge ?? 60 * 60, // 1 hour\n },\n\n // Rate limiting — strict on sensitive paths, relaxed global default\n rateLimit: {\n enabled: true,\n window: 60, // 60s global window\n max: 100, // 100 req/min default\n storage: rateLimitStorage,\n customRules: {\n '/sign-in/email': { window: 60, max: 5 }, // 5 login attempts/min\n '/sign-in/social': { window: 60, max: 10 },\n '/sign-up/email': { window: 60, max: 3 }, // 3 signups/min\n '/forget-password': { window: 60, max: 3 }, // 3 resets/min\n '/reset-password': { window: 60, max: 5 },\n '/admin/*': { window: 60, max: 20 }, // admin ops capped\n },\n },\n\n databaseHooks: buildDatabaseHooks(audit),\n hooks: {\n before: buildSignupGate({ enabled: signupEnabled, auditLogger, logger: app.logger }),\n ...(auditLogger ? buildRequestHooks(audit) : {}),\n },\n\n plugins: [\n admin({ ac, roles, defaultRole: 'authenticated' }),\n ...(config.organizations ? [organization({ ac, roles })] : []),\n ...(config.betterAuthPlugins ?? []),\n // nextCookies() must be last — required for Next.js server actions.\n nextCookies(),\n ],\n }\n}\n\n// ---------------------------------------------------------------------------\n// Server factory\n// ---------------------------------------------------------------------------\n\n/**\n * Create a better-auth server instance wired to the toolkit.\n *\n * Called during plugin init — the returned instance powers:\n * - `auth.api.getSession()` for session resolution\n * - `auth.api.listUsers()` for admin user management\n * - Route handler via `toNextJsHandler(auth)`\n *\n * IMPORTANT: Plugins are inlined in the `betterAuth()` call so TypeScript\n * preserves the literal plugin types. Extracting them into a `BetterAuthPlugin[]`\n * variable erases specific endpoint types (admin, organization, etc.).\n *\n * The explicit `BetterAuthBase` return type annotation is required because\n * better-auth 1.6's internals use zod@4 types that tsdown's dts generator\n * cannot name portably across pnpm's `.pnpm/zod@4.x` symlink paths (TS2742).\n * The annotation widens to better-auth's generic `Auth` type — specific\n * plugin endpoint types (`auth.api.listUsers` etc.) are still accessible\n * because better-auth's `InferAPI` helper resolves them at the consumer's\n * compile time against their installed better-auth version.\n */\nexport function createAuthServer(\n config: AuthConfig,\n app: ToolkitApp,\n auditLogger?: AuditLogger,\n): BetterAuthBase {\n // Runtime belt-and-suspenders: `createAuthServer` is exposed via the\n // `/runtime` subpath (which has no `server-only` marker so the CLI / worker\n // can load `lumi.config.ts` under jiti / tsx). The marker-less export trades\n // a clean build-time error for a cryptic bundler error if a client\n // component ever tries to import it. This guard adds a runtime failure\n // with a clear message in case that reaches a browser somehow.\n if (typeof (globalThis as { window?: unknown }).window !== 'undefined') {\n throw new Error(\n '@murumets-ee/auth: createAuthServer must not run in a browser environment. ' +\n 'Construct the auth server from server code only (RSC, route handlers, CLI, worker).',\n )\n }\n\n // SECURITY-MED-1 + SECURITY-MED-5: refuse to start a production deploy\n // missing `BETTER_AUTH_SECRET` or a stable `baseURL`. Catches the\n // misconfig early instead of after the first session-invalidation\n // surprise.\n assertProductionEnv(config)\n\n // Widen to BetterAuthOptions so betterAuth() returns Auth<BetterAuthOptions>\n // (= BetterAuthBase). Required because:\n // 1. Without the return type annotation, tsdown fails with TS2742\n // (zod@4 internal types can't be named portably in .d.mts).\n // 2. The admin plugin with custom ac/roles makes Auth<SpecificConfig>\n // structurally incompatible with Auth (better-auth#8855).\n // Widening erases plugin-specific API types at compile time, but plugin\n // endpoints (listUsers, etc.) are fully functional at runtime.\n return betterAuth(buildAuthOptions(config, app, auditLogger))\n}\n\n/** Type of the auth server instance. Aliased from better-auth's generic\n * `Auth` because the explicit annotation on `createAuthServer` means\n * `ReturnType<typeof createAuthServer>` is already `BetterAuthBase`. */\nexport type Auth = BetterAuthBase\n\n/**\n * Structural interface for the server-side admin API methods.\n *\n * The widened `Auth` type (BetterAuthBase) doesn't expose admin plugin\n * endpoints. This interface describes just the methods consumers need\n * so they can access them without `as any`.\n *\n * Usage: `(auth.api as AuthAdminApi).listUsers(...)`\n */\nexport interface AuthAdminApi {\n listUsers: (opts: {\n headers: Headers\n query: {\n limit: number\n sortBy: string\n sortDirection: 'asc' | 'desc'\n }\n }) => Promise<{ users: AdminUser[]; total: number }>\n}\n","/**\n * Auth runtime — CLI-safe subpath for the plugin factory and for code that\n * needs to read auth singleton state without pulling in the `server-only`\n * marker from the main `@murumets-ee/auth` entry. Imported by:\n *\n * - `@murumets-ee/auth-ui/plugin` (the factory — needs `createAuthServer`\n * + the state setters + the schema tables)\n * - `@murumets-ee/cli` `create-admin` command (needs `getAuth` under jiti\n * without triggering `server-only`)\n *\n * Everything re-exported here is `server-only`-clean by construction:\n * - `createAuthServer`, `Auth`, `AuthAdminApi` — from `./server.js`\n * - `AuthConfig`, `AdminUser` — from `./types.js`\n * - better-auth Drizzle tables — from `./schema.js`\n */\n\nexport {\n account,\n invitation,\n member,\n organization,\n session,\n user,\n verification,\n} from './schema.js'\nexport type { Auth, AuthAdminApi } from './server.js'\nexport { createAuthServer } from './server.js'\nexport type { AdminUser, AuthConfig } from './types.js'\n\nimport type { Auth, AuthAdminApi } from './server.js'\nimport type { AuthConfig } from './types.js'\n\n/** Auth with admin API — admin plugin is always loaded in createAuthServer */\nexport type AuthWithAdmin = Auth & { api: AuthAdminApi }\n\n/** The initialized auth server instance (set during plugin init) */\nlet _auth: AuthWithAdmin | null = null\n\n/** The auth plugin config — captured at plugin() call so server-side code\n * (page.tsx guards, admin UIs) can read runtime flags like `signup.enabled`\n * without threading config through every layer. */\nlet _authConfig: AuthConfig | null = null\n\n/**\n * Internal: store the resolved better-auth server instance. Called from the\n * `auth()` plugin factory's `server.init`; not part of the public API.\n *\n * @internal\n */\nexport function _setAuth(auth: AuthWithAdmin): void {\n _auth = auth\n}\n\n/**\n * Internal: capture the plugin config. Invoked by the `auth()` factory at\n * construction time (not init) so `isSignupEnabled()` works during RSC\n * render on cold boot, before async init completes.\n *\n * @internal\n */\nexport function _setAuthConfig(config: AuthConfig): void {\n _authConfig = config\n}\n\n/**\n * Get the auth server instance.\n * Throws if the auth plugin hasn't been initialized yet.\n *\n * @example\n * ```typescript\n * // app/api/auth/[...all]/route.ts\n * import { toNextJsHandler } from 'better-auth/next-js'\n * import { getAuth } from '@murumets-ee/auth'\n *\n * export const { GET, POST } = toNextJsHandler(getAuth())\n * ```\n */\nexport function getAuth(): AuthWithAdmin {\n if (!_auth) {\n throw new Error('@murumets-ee/auth not initialized. Add auth() to your plugins array.')\n }\n return _auth\n}\n\n/**\n * Is public sign-up allowed in this deployment?\n *\n * Defaults to `false` — first admin is bootstrapped via `lumi create-admin`.\n * Set `auth({ signup: { enabled: true } })` in lumi.config.ts to open.\n */\nexport function isSignupEnabled(): boolean {\n return _authConfig?.signup?.enabled === true\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"runtime-WUuhlRlH.mjs","names":["defaultAuthSchema"],"sources":["../src/audit-hooks.ts","../src/signup-gate.ts","../src/server.ts","../src/runtime.ts"],"sourcesContent":["/**\n * Audit hooks for better-auth events.\n *\n * Two layers:\n * 1. `buildDatabaseHooks` — fires on user CRUD via better-auth's `databaseHooks`.\n * Captures `auth.signup`, `auth.user.update`, `auth.user.delete`.\n * 2. `buildRequestHooks` — fires on HTTP request lifecycle via the `hooks.after`\n * middleware. Captures `auth.login`, `auth.login.failed`, `auth.logout`,\n * `auth.password.*`, and `auth.admin.*` operations.\n *\n * All audit writes are fire-and-forget — they never block auth operations,\n * but failures are logged via the application logger so silent loss of audit\n * trail does not pass unnoticed.\n */\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { AuditLogger } from '@murumets-ee/logging'\nimport { createAuthMiddleware } from 'better-auth/api'\n\n// ---------------------------------------------------------------------------\n// Structural types\n// ---------------------------------------------------------------------------\n//\n// We avoid importing `GenericEndpointContext` / `User` from better-auth\n// because their generic parameters and zod-internal references trip\n// tsdown's DTS emit (TS2742). Instead, we declare exactly the shape these\n// hooks read — narrower than `Record<string, unknown>`, fully typed.\n\n/** Subset of better-auth's `User` row we read in audit payloads. */\ninterface UserRow {\n id?: string\n name?: string\n email?: string\n}\n\n/** Subset of better-auth's `GenericEndpointContext` we read in audit hooks. */\nexport interface AuthHookCtx {\n path: string\n body?: unknown\n context: {\n session?: { user?: UserRow; userId?: string } | null\n returned?: { status?: number } | unknown\n newSession?: { user?: UserRow } | null\n }\n}\n\n/** Body fields the hooks inspect for audit metadata. */\ninterface AuthBody {\n email?: unknown\n userId?: unknown\n role?: unknown\n}\n\n/** Audit-log entry shape (mirrors AuditLogger.log signature). */\ntype AuditEntry = Parameters<AuditLogger['log']>[0]\n\n/** Fire-and-forget audit emitter. Logs swallow reasons via `logger.warn` so\n * audit-write failures aren't entirely silent (CLAUDE.md: empty `.catch`\n * needs justification, and \"logger.warn on swallow\" IS the justification). */\nexport function makeAuditFn(\n auditLogger: AuditLogger | undefined,\n logger: Logger,\n): (entry: AuditEntry) => void {\n return (entry) => {\n auditLogger?.log(entry).catch((err: unknown) => {\n logger.warn({ err, action: entry.action }, 'audit log write failed')\n })\n }\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** Read the actor (current session user) from a hook context. */\nexport function getActor(ctx: AuthHookCtx | null | undefined): {\n id: string | undefined\n name: string | undefined\n} {\n const session = ctx?.context?.session ?? undefined\n const user = session?.user\n return {\n id: user?.id ?? session?.userId,\n name: user?.name,\n }\n}\n\n/** Read the response status the route handler produced (if any). */\nfunction getReturnedStatus(ctx: AuthHookCtx): number | undefined {\n const returned = ctx.context.returned as { status?: number } | undefined\n return returned?.status\n}\n\n/** Read the request body as a typed-but-narrow `AuthBody`. */\nfunction getBody(ctx: AuthHookCtx): AuthBody | undefined {\n return (ctx.body ?? undefined) as AuthBody | undefined\n}\n\n/** Maximum length for user-supplied strings written to the audit log.\n * Caps the blast-radius of a malicious client sending megabyte-sized\n * inputs (`email`, etc.) into the audit table. RFC 5321 caps email at\n * 254 chars; we round to 256. */\nconst AUDIT_STRING_CAP = 256\n\n/** Cap a user-supplied string for audit-log emission. Returns undefined\n * for non-strings so callers can spread it into metadata conditionally. */\nfunction capForAudit(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined\n return value.length <= AUDIT_STRING_CAP ? value : value.slice(0, AUDIT_STRING_CAP)\n}\n\n/** Fields stripped from user-update audit payloads (noise). */\nconst SKIP_USER_UPDATE_FIELDS = new Set(['updatedAt', 'createdAt'])\n\n// ---------------------------------------------------------------------------\n// Database hooks (user CRUD)\n// ---------------------------------------------------------------------------\n//\n// HIGH-2 fix (#TBD): the previous implementation used a closure-scoped\n// `pendingFields` variable to bridge `before` → `after`. That bridge was\n// shared across ALL concurrent requests handled by the same Node process,\n// causing audit-log integrity bugs (request A's diff could be logged\n// against user B). Replaced with a `WeakMap` keyed by the per-request\n// context object — better-auth passes the same `GenericEndpointContext`\n// instance to `before` and `after` of the same operation.\n\ninterface DatabaseHookCallbacks {\n user: {\n create: { after: (user: UserRow, ctx: AuthHookCtx | null) => Promise<void> }\n update: {\n before: (userData: Record<string, unknown>, ctx: AuthHookCtx | null) => Promise<void>\n after: (user: UserRow, ctx: AuthHookCtx | null) => Promise<void>\n }\n delete: { after: (user: UserRow, ctx: AuthHookCtx | null) => Promise<void> }\n }\n}\n\nexport function buildDatabaseHooks(audit: (entry: AuditEntry) => void): DatabaseHookCallbacks {\n /** Per-request scratch space bridging `update.before` → `update.after`.\n * Keyed by the better-auth hook context object — concurrent requests get\n * isolated entries because better-auth passes the same context instance\n * to `before` and `after` of the same operation. */\n const pendingFieldsByCtx = new WeakMap<object, Record<string, unknown>>()\n\n return {\n user: {\n create: {\n after: async (user) => {\n // Signup — actor is the new user themselves.\n // NOTE: first-user auto-promotion was removed. Bootstrap the first\n // admin via `lumi create-admin` (requires shell access). Public\n // signup is closed by default; see AuthConfig.signup.enabled.\n audit({\n action: 'auth.signup',\n entityType: 'user',\n ...(user.id !== undefined && { entityId: user.id, userId: user.id }),\n ...(user.name !== undefined && { userName: user.name }),\n changes: {\n fields: { name: user.name, email: user.email },\n },\n })\n },\n },\n update: {\n // `before` receives only the changed fields — capture them.\n // When ctx is null (internal-adapter writes outside an HTTP request,\n // e.g. background jobs) we skip the bridge entirely and let `after`\n // fire without a diff. The previous fallback used a closure variable\n // shared across all ctx-less calls, which would race the same way\n // the WeakMap fix removed for HTTP requests.\n before: async (userData, ctx) => {\n if (!ctx) return\n const fields: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(userData)) {\n if (SKIP_USER_UPDATE_FIELDS.has(key) || key === 'id') continue\n fields[key] = value\n }\n if (Object.keys(fields).length > 0) {\n pendingFieldsByCtx.set(ctx, fields)\n }\n },\n // `after` has full user (name) + ctx (actor session) — log everything.\n after: async (user, ctx) => {\n const actor = getActor(ctx)\n let fields: Record<string, unknown> | null = null\n if (ctx) {\n fields = pendingFieldsByCtx.get(ctx) ?? null\n if (fields) pendingFieldsByCtx.delete(ctx)\n }\n audit({\n action: 'auth.user.update',\n entityType: 'user',\n ...(user.id !== undefined && { entityId: user.id }),\n ...(actor.id !== undefined && { userId: actor.id }),\n ...(actor.name !== undefined && { userName: actor.name }),\n ...(fields ? { changes: { fields } } : {}),\n metadata: {\n targetUser: user.name,\n },\n })\n },\n },\n delete: {\n after: async (user, ctx) => {\n const actor = getActor(ctx)\n audit({\n action: 'auth.user.delete',\n entityType: 'user',\n ...(user.id !== undefined && { entityId: user.id }),\n ...(actor.id !== undefined && { userId: actor.id }),\n ...(actor.name !== undefined && { userName: actor.name }),\n metadata: {\n targetUser: user.name,\n },\n })\n },\n },\n },\n }\n}\n\n// ---------------------------------------------------------------------------\n// Request hooks (auth lifecycle: login, logout, password, admin ops)\n// ---------------------------------------------------------------------------\n\n/** Auth paths where a non-2xx response means a failed attempt worth logging. */\nconst SIGN_IN_PATHS = ['/sign-in/email', '/sign-in/social'] as const\n\n/** Auth paths for session/password lifecycle events. */\nconst SIGN_OUT_PATH = '/sign-out'\nconst PASSWORD_PATHS: Record<string, string> = {\n '/change-password': 'auth.password.change',\n '/forget-password': 'auth.password.reset_request',\n '/reset-password': 'auth.password.reset',\n}\n\n/** Paths that should be audit-logged when successful. */\nconst AUDIT_ADMIN_PATHS: Record<string, string> = {\n '/admin/set-role': 'auth.admin.set_role',\n '/admin/ban-user': 'auth.admin.ban',\n '/admin/unban-user': 'auth.admin.unban',\n '/admin/create-user': 'auth.admin.create_user',\n '/admin/remove-user': 'auth.admin.remove_user',\n '/admin/impersonate-user': 'auth.admin.impersonate',\n '/admin/stop-impersonating': 'auth.admin.stop_impersonating',\n '/admin/revoke-session': 'auth.admin.revoke_session',\n '/admin/revoke-sessions': 'auth.admin.revoke_sessions',\n}\n\nexport function buildRequestHooks(audit: (entry: AuditEntry) => void): {\n after: ReturnType<typeof createAuthMiddleware>\n} {\n return {\n after: createAuthMiddleware(async (rawCtx) => {\n const ctx = rawCtx as unknown as AuthHookCtx\n\n // --- Login attempt logging ---\n if (SIGN_IN_PATHS.some((p) => ctx.path.startsWith(p))) {\n const status = getReturnedStatus(ctx)\n if (!status) return\n\n if (status >= 400) {\n const email = capForAudit(getBody(ctx)?.email)\n audit({\n action: 'auth.login.failed',\n metadata: {\n ...(email ? { email } : {}),\n status,\n path: ctx.path,\n },\n })\n } else {\n const newSession = ctx.context.newSession\n if (newSession?.user?.id) {\n audit({\n action: 'auth.login',\n entityType: 'user',\n entityId: newSession.user.id,\n userId: newSession.user.id,\n ...(newSession.user.name !== undefined && { userName: newSession.user.name }),\n })\n }\n }\n return\n }\n\n // --- Logout logging ---\n if (ctx.path === SIGN_OUT_PATH) {\n const status = getReturnedStatus(ctx)\n if (!status || status < 400) {\n const actor = getActor(ctx)\n if (actor.id) {\n audit({\n action: 'auth.logout',\n entityType: 'user',\n entityId: actor.id,\n userId: actor.id,\n ...(actor.name !== undefined && { userName: actor.name }),\n })\n }\n }\n return\n }\n\n // --- Password change/reset logging ---\n const passwordAction = PASSWORD_PATHS[ctx.path]\n if (passwordAction) {\n const status = getReturnedStatus(ctx)\n if (!status || status < 400) {\n const actor = getActor(ctx)\n const body = getBody(ctx)\n const email = capForAudit(body?.email)\n audit({\n action: passwordAction,\n entityType: 'user',\n ...(actor.id !== undefined && { userId: actor.id }),\n ...(actor.name !== undefined && { userName: actor.name }),\n metadata: {\n // For reset requests, log the email (not sensitive — it's the input)\n ...(email ? { email } : {}),\n path: ctx.path,\n },\n })\n }\n return\n }\n\n // --- Admin operation audit logging (impersonation, role changes, bans, etc.) ---\n const auditAction = AUDIT_ADMIN_PATHS[ctx.path]\n if (!auditAction) return\n\n const status = getReturnedStatus(ctx)\n if (status && status >= 400) return // failed — skip\n\n const actor = getActor(ctx)\n const body = getBody(ctx)\n audit({\n action: auditAction,\n entityType: 'user',\n ...(typeof body?.userId === 'string' && { entityId: body.userId }),\n ...(actor.id !== undefined && { userId: actor.id }),\n ...(actor.name !== undefined && { userName: actor.name }),\n metadata: {\n ...(typeof body?.userId === 'string' ? { targetUserId: body.userId } : {}),\n ...(typeof body?.role === 'string' ? { role: body.role } : {}),\n path: ctx.path,\n },\n })\n }),\n }\n}\n","/**\n * Signup gate — rejects public registration on `/sign-up/email` unless\n * explicitly enabled via `auth({ signup: { enabled: true } })`.\n *\n * Closed by default. The first admin is bootstrapped via `lumi create-admin`,\n * which writes through `auth.$context.internalAdapter` below the HTTP layer\n * so this gate never fires for CLI bootstrap.\n *\n * Note: this gate covers `/sign-up/email` only. Social-provider signup is\n * gated separately by passing `disableSignUp: true` per provider in\n * `createAuthServer` when `signup.enabled === false` (see SECURITY-HIGH-1).\n */\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { AuditLogger } from '@murumets-ee/logging'\nimport { APIError, createAuthMiddleware } from 'better-auth/api'\nimport type { AuthHookCtx } from './audit-hooks.js'\nimport { getActor, makeAuditFn } from './audit-hooks.js'\n\nconst SIGN_UP_EMAIL_PATH = '/sign-up/email'\n\nexport interface SignupGateOptions {\n enabled: boolean\n /** Optional audit logger — when present, blocked attempts are logged. */\n auditLogger?: AuditLogger | undefined\n /** Application logger used to swallow audit-write failures non-silently. */\n logger: Logger\n}\n\nexport function buildSignupGate(\n options: SignupGateOptions,\n): ReturnType<typeof createAuthMiddleware> {\n const { enabled, auditLogger, logger } = options\n const audit = makeAuditFn(auditLogger, logger)\n\n return createAuthMiddleware(async (rawCtx) => {\n const ctx = rawCtx as unknown as AuthHookCtx\n if (ctx.path !== SIGN_UP_EMAIL_PATH) return\n if (enabled) return\n\n // Log the blocked attempt before throwing — gives the SOC visibility\n // into pattern-of-attempts without relying solely on rate-limit counters.\n const body = (ctx.body ?? undefined) as { email?: unknown } | undefined\n const actor = getActor(ctx)\n // Cap the user-supplied email at 256 chars — caps blast radius of a\n // malicious client sending a megabyte-sized `email` field into our\n // audit table. RFC 5321 caps real emails at 254.\n const rawEmail = body?.email\n const email =\n typeof rawEmail === 'string' && rawEmail.length > 0 ? rawEmail.slice(0, 256) : undefined\n audit({\n action: 'auth.signup.blocked',\n metadata: {\n path: ctx.path,\n ...(email ? { email } : {}),\n ...(actor.id ? { actorId: actor.id } : {}),\n },\n })\n\n throw new APIError('FORBIDDEN', { message: 'Sign-up is closed' })\n })\n}\n","/**\n * Server-side auth instance factory.\n *\n * Creates a configured `betterAuth()` instance using the toolkit's database\n * connection and entity definitions. This is server-only code.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { AuditLogger } from '@murumets-ee/logging'\nimport type { Auth as BetterAuthBase, BetterAuthOptions } from 'better-auth'\nimport { betterAuth } from 'better-auth'\nimport { drizzleAdapter } from 'better-auth/adapters/drizzle'\nimport { nextCookies } from 'better-auth/next-js'\nimport { admin } from 'better-auth/plugins'\nimport type { Role } from 'better-auth/plugins/access'\nimport { createAccessControl } from 'better-auth/plugins/access'\nimport { organization } from 'better-auth/plugins/organization'\nimport { buildDatabaseHooks, buildRequestHooks, makeAuditFn } from './audit-hooks.js'\nimport { buildDefaultRoles, buildStatements } from './permissions.js'\nimport * as defaultAuthSchema from './schema.js'\nimport { buildSignupGate } from './signup-gate.js'\nimport type { AdminUser, AuthConfig } from './types.js'\n\n// ---------------------------------------------------------------------------\n// Provider config — wires the closed-signup contract through to OAuth\n// ---------------------------------------------------------------------------\n\nexport interface SocialProviderConfig {\n clientId: string\n clientSecret: string\n /** When `true`, OAuth login refuses to create an account for an unknown\n * email. Existing users sign in normally. SECURITY-HIGH-1: required when\n * `signup.enabled === false`, otherwise the closed-signup gate is bypassed. */\n disableSignUp?: boolean\n}\n\n/** @internal Exported for unit tests — not part of the public API. */\nexport function buildSocialProviders(\n config: AuthConfig,\n signupClosed: boolean,\n): Record<string, SocialProviderConfig> {\n const out: Record<string, SocialProviderConfig> = {}\n if (config.social?.google) {\n out.google = {\n ...config.social.google,\n ...(signupClosed ? { disableSignUp: true } : {}),\n }\n }\n if (config.social?.github) {\n out.github = {\n ...config.social.github,\n ...(signupClosed ? { disableSignUp: true } : {}),\n }\n }\n return out\n}\n\n// ---------------------------------------------------------------------------\n// Production env asserts (SECURITY-MED-1, SECURITY-MED-5)\n// ---------------------------------------------------------------------------\n\n/**\n * Throw if production *runtime* is missing the env vars better-auth needs\n * for stable cookies and CSRF anchoring.\n *\n * Called from `createAuthServer` at plugin init time so a misconfigured\n * deploy crashes loudly instead of silently degrading (random per-process\n * secret = sessions invalidate on every restart; missing baseURL = CSRF\n * Origin check anchored to whatever header arrives first).\n *\n * NOTE: skipped during `next build` (`NEXT_PHASE=phase-production-build`).\n * Build-time page-data collection initializes the toolkit and would\n * otherwise crash builds where env vars are injected at runtime only\n * (typical Docker setup). The assert still fires when the production\n * server actually starts (`phase-production-server`) — that's where a\n * misconfigured deploy actually matters. Same reasoning for static\n * export (`phase-export`).\n *\n * @internal Exported for unit tests — not part of the public API.\n */\nexport function assertProductionEnv(\n config: AuthConfig,\n env: NodeJS.ProcessEnv = process.env,\n): void {\n if (env.NODE_ENV !== 'production') return\n if (env.NEXT_PHASE === 'phase-production-build' || env.NEXT_PHASE === 'phase-export') return\n if (!env.BETTER_AUTH_SECRET) {\n throw new Error(\n '@murumets-ee/auth: BETTER_AUTH_SECRET is required in production. ' +\n 'Generate one with `openssl rand -base64 32` and set it in the environment.',\n )\n }\n if (!config.baseURL && !env.BETTER_AUTH_URL) {\n throw new Error(\n '@murumets-ee/auth: a public baseURL is required in production. ' +\n 'Set `auth({ baseURL: \"https://...\" })` or the `BETTER_AUTH_URL` env var.',\n )\n }\n}\n\n// ---------------------------------------------------------------------------\n// Pure option builder (so tests can exercise wiring without `betterAuth()`)\n// ---------------------------------------------------------------------------\n\n/**\n * Build the `BetterAuthOptions` that `createAuthServer` hands to `betterAuth()`.\n *\n * Pure, side-effect-free, no env-var reads except `BETTER_AUTH_URL` as a\n * fallback for `config.baseURL`. Extracted so unit tests can inspect the\n * resulting options object (rate-limit storage, social-provider gating,\n * email-verification default) without invoking better-auth's full setup.\n *\n * @internal Exported for unit tests — not part of the public API.\n */\nexport function buildAuthOptions(\n config: AuthConfig,\n app: ToolkitApp,\n auditLogger?: AuditLogger,\n env: NodeJS.ProcessEnv = process.env,\n): BetterAuthOptions {\n const entities = [...app.entities.values()]\n const statement = buildStatements(entities)\n const ac = createAccessControl(statement)\n const roles: Record<string, Role> = buildDefaultRoles(ac, entities)\n\n const baseURL = config.baseURL ?? env.BETTER_AUTH_URL\n const signupEnabled = config.signup?.enabled ?? false\n const socialProviders = buildSocialProviders(config, !signupEnabled)\n const schema = config.schema ?? defaultAuthSchema\n const audit = makeAuditFn(auditLogger, app.logger)\n const rateLimitStorage: 'memory' | 'database' | 'secondary-storage' =\n config.rateLimit?.storage ?? 'memory'\n\n return {\n database: drizzleAdapter(app.db.readWrite, {\n provider: 'pg',\n schema,\n }),\n\n ...(baseURL ? { baseURL } : {}),\n ...(config.trustedOrigins ? { trustedOrigins: config.trustedOrigins } : {}),\n\n emailAndPassword: {\n enabled: config.providers?.includes('email') ?? true,\n // SECURITY-MED-3: when public signup is enabled, default to\n // requiring email verification — without it, attackers can register\n // any email and immediately sign in.\n requireEmailVerification: config.signup?.requireEmailVerification ?? signupEnabled,\n },\n\n socialProviders,\n\n session: {\n expiresIn: config.session?.expiresIn ?? 60 * 60 * 2, // 2 hours (admin CMS — short-lived)\n updateAge: config.session?.updateAge ?? 60 * 60, // 1 hour\n },\n\n // Rate limiting — strict on sensitive paths, relaxed global default\n rateLimit: {\n enabled: true,\n window: 60, // 60s global window\n max: 100, // 100 req/min default\n storage: rateLimitStorage,\n customRules: {\n '/sign-in/email': { window: 60, max: 5 }, // 5 login attempts/min\n '/sign-in/social': { window: 60, max: 10 },\n '/sign-up/email': { window: 60, max: 3 }, // 3 signups/min\n '/forget-password': { window: 60, max: 3 }, // 3 resets/min\n '/reset-password': { window: 60, max: 5 },\n '/admin/*': { window: 60, max: 20 }, // admin ops capped\n },\n },\n\n databaseHooks: buildDatabaseHooks(audit),\n hooks: {\n before: buildSignupGate({ enabled: signupEnabled, auditLogger, logger: app.logger }),\n ...(auditLogger ? buildRequestHooks(audit) : {}),\n },\n\n plugins: [\n admin({ ac, roles, defaultRole: 'authenticated' }),\n ...(config.organizations ? [organization({ ac, roles })] : []),\n ...(config.betterAuthPlugins ?? []),\n // nextCookies() must be last — required for Next.js server actions.\n nextCookies(),\n ],\n }\n}\n\n// ---------------------------------------------------------------------------\n// Server factory\n// ---------------------------------------------------------------------------\n\n/**\n * Create a better-auth server instance wired to the toolkit.\n *\n * Called during plugin init — the returned instance powers:\n * - `auth.api.getSession()` for session resolution\n * - `auth.api.listUsers()` for admin user management\n * - Route handler via `toNextJsHandler(auth)`\n *\n * IMPORTANT: Plugins are inlined in the `betterAuth()` call so TypeScript\n * preserves the literal plugin types. Extracting them into a `BetterAuthPlugin[]`\n * variable erases specific endpoint types (admin, organization, etc.).\n *\n * The explicit `BetterAuthBase` return type annotation is required because\n * better-auth 1.6's internals use zod@4 types that tsdown's dts generator\n * cannot name portably across pnpm's `.pnpm/zod@4.x` symlink paths (TS2742).\n * The annotation widens to better-auth's generic `Auth` type — specific\n * plugin endpoint types (`auth.api.listUsers` etc.) are still accessible\n * because better-auth's `InferAPI` helper resolves them at the consumer's\n * compile time against their installed better-auth version.\n */\nexport function createAuthServer(\n config: AuthConfig,\n app: ToolkitApp,\n auditLogger?: AuditLogger,\n): BetterAuthBase {\n // Runtime belt-and-suspenders: `createAuthServer` is exposed via the\n // `/runtime` subpath (which has no `server-only` marker so the CLI / worker\n // can load `lumi.config.ts` under jiti / tsx). The marker-less export trades\n // a clean build-time error for a cryptic bundler error if a client\n // component ever tries to import it. This guard adds a runtime failure\n // with a clear message in case that reaches a browser somehow.\n if (typeof (globalThis as { window?: unknown }).window !== 'undefined') {\n throw new Error(\n '@murumets-ee/auth: createAuthServer must not run in a browser environment. ' +\n 'Construct the auth server from server code only (RSC, route handlers, CLI, worker).',\n )\n }\n\n // SECURITY-MED-1 + SECURITY-MED-5: refuse to start a production deploy\n // missing `BETTER_AUTH_SECRET` or a stable `baseURL`. Catches the\n // misconfig early instead of after the first session-invalidation\n // surprise.\n assertProductionEnv(config)\n\n // Widen to BetterAuthOptions so betterAuth() returns Auth<BetterAuthOptions>\n // (= BetterAuthBase). Required because:\n // 1. Without the return type annotation, tsdown fails with TS2742\n // (zod@4 internal types can't be named portably in .d.mts).\n // 2. The admin plugin with custom ac/roles makes Auth<SpecificConfig>\n // structurally incompatible with Auth (better-auth#8855).\n // Widening erases plugin-specific API types at compile time, but plugin\n // endpoints (listUsers, etc.) are fully functional at runtime.\n return betterAuth(buildAuthOptions(config, app, auditLogger))\n}\n\n/** Type of the auth server instance. Aliased from better-auth's generic\n * `Auth` because the explicit annotation on `createAuthServer` means\n * `ReturnType<typeof createAuthServer>` is already `BetterAuthBase`. */\nexport type Auth = BetterAuthBase\n\n/**\n * Structural interface for the server-side admin API methods.\n *\n * The widened `Auth` type (BetterAuthBase) doesn't expose admin plugin\n * endpoints. This interface describes just the methods consumers need\n * so they can access them without `as any`.\n *\n * Usage: `(auth.api as AuthAdminApi).listUsers(...)`\n */\nexport interface AuthAdminApi {\n listUsers: (opts: {\n headers: Headers\n query: {\n limit: number\n sortBy: string\n sortDirection: 'asc' | 'desc'\n }\n }) => Promise<{ users: AdminUser[]; total: number }>\n}\n","/**\n * Auth runtime — CLI-safe subpath for the plugin factory and for code that\n * needs to read auth singleton state without pulling in the `server-only`\n * marker from the main `@murumets-ee/auth` entry. Imported by:\n *\n * - `@murumets-ee/auth-ui/plugin` (the factory — needs `createAuthServer`\n * + the state setters + the schema tables)\n * - `@murumets-ee/cli` `create-admin` command (needs `getAuth` under jiti\n * without triggering `server-only`)\n *\n * Everything re-exported here is `server-only`-clean by construction:\n * - `createAuthServer`, `Auth`, `AuthAdminApi` — from `./server.js`\n * - `AuthConfig`, `AdminUser` — from `./types.js`\n * - better-auth Drizzle tables — from `./schema.js`\n */\n\nexport {\n account,\n invitation,\n member,\n organization,\n session,\n user,\n verification,\n} from './schema.js'\nexport type { Auth, AuthAdminApi } from './server.js'\nexport { createAuthServer } from './server.js'\nexport type { AdminUser, AuthConfig } from './types.js'\n\nimport type { Auth, AuthAdminApi } from './server.js'\nimport type { AuthConfig } from './types.js'\n\n/** Auth with admin API — admin plugin is always loaded in createAuthServer */\nexport type AuthWithAdmin = Auth & { api: AuthAdminApi }\n\n/** The initialized auth server instance (set during plugin init) */\nlet _auth: AuthWithAdmin | null = null\n\n/** The auth plugin config — captured at plugin() call so server-side code\n * (page.tsx guards, admin UIs) can read runtime flags like `signup.enabled`\n * without threading config through every layer. */\nlet _authConfig: AuthConfig | null = null\n\n/**\n * Internal: store the resolved better-auth server instance. Called from the\n * `auth()` plugin factory's `server.init`; not part of the public API.\n *\n * @internal\n */\nexport function _setAuth(auth: AuthWithAdmin): void {\n _auth = auth\n}\n\n/**\n * Internal: capture the plugin config. Invoked by the `auth()` factory at\n * construction time (not init) so `isSignupEnabled()` works during RSC\n * render on cold boot, before async init completes.\n *\n * @internal\n */\nexport function _setAuthConfig(config: AuthConfig): void {\n _authConfig = config\n}\n\n/**\n * Get the auth server instance.\n * Throws if the auth plugin hasn't been initialized yet.\n *\n * @example\n * ```typescript\n * // app/api/auth/[...all]/route.ts\n * import { toNextJsHandler } from 'better-auth/next-js'\n * import { getAuth } from '@murumets-ee/auth'\n *\n * export const { GET, POST } = toNextJsHandler(getAuth())\n * ```\n */\nexport function getAuth(): AuthWithAdmin {\n if (!_auth) {\n throw new Error('@murumets-ee/auth not initialized. Add auth() to your plugins array.')\n }\n return _auth\n}\n\n/**\n * Is public sign-up allowed in this deployment?\n *\n * Defaults to `false` — first admin is bootstrapped via `lumi create-admin`.\n * Set `auth({ signup: { enabled: true } })` in lumi.config.ts to open.\n */\nexport function isSignupEnabled(): boolean {\n return _authConfig?.signup?.enabled === true\n}\n"],"mappings":"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"}
package/dist/runtime.mjs CHANGED
@@ -1 +1 @@
1
- import{a as e,f as t,m as n,r,s as i,t as a,u as o}from"./schema-Je6e5yt2.mjs";import{a as s,i as c,n as l,r as u,t as d}from"./runtime-Cj8SEWhk.mjs";export{d as _setAuth,l as _setAuthConfig,a as account,s as createAuthServer,u as getAuth,r as invitation,c as isSignupEnabled,e as member,i as organization,o as session,t as user,n as verification};
1
+ import{a as e,f as t,m as n,r,s as i,t as a,u as o}from"./schema-Je6e5yt2.mjs";import{a as s,i as c,n as l,r as u,t as d}from"./runtime-WUuhlRlH.mjs";export{d as _setAuth,l as _setAuthConfig,a as account,s as createAuthServer,u as getAuth,r as invitation,c as isSignupEnabled,e as member,i as organization,o as session,t as user,n as verification};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/auth",
3
- "version": "0.16.3",
3
+ "version": "0.16.5",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -32,10 +32,10 @@
32
32
  "better-auth": "^1.6.10",
33
33
  "drizzle-orm": "^0.45.2",
34
34
  "server-only": "^0.0.1",
35
- "@murumets-ee/core": "0.16.3",
36
- "@murumets-ee/logging": "0.16.3",
37
- "@murumets-ee/entity": "0.16.3",
38
- "@murumets-ee/db": "0.16.3"
35
+ "@murumets-ee/core": "0.16.5",
36
+ "@murumets-ee/db": "0.16.5",
37
+ "@murumets-ee/entity": "0.16.5",
38
+ "@murumets-ee/logging": "0.16.5"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/node": "^20.19.40",
@@ -1 +0,0 @@
1
- {"version":3,"file":"permissions-BbVD2CmT.mjs","names":[],"sources":["../src/permissions.ts"],"sourcesContent":["/**\n * Permission system — bridges entity access definitions to better-auth's access control,\n * and provides the configurable permission checker for the admin API.\n *\n * Two layers:\n * 1. **better-auth integration**: `buildStatements` / `buildDefaultRoles` — feeds into\n * better-auth's `createAccessControl` for its internal permission system.\n * 2. **Admin API enforcement**: `buildPermissionChecker` / `buildInitialRoleDefinitions` —\n * the toolkit's own firewall-model permission system, persisted in settings.\n */\n\nimport type { PermissionChecker } from '@murumets-ee/core'\nimport type { Entity } from '@murumets-ee/entity'\nimport { createAccessControl } from 'better-auth/plugins/access'\n\nconst ACTIONS = ['view', 'create', 'update', 'delete'] as const\nconst ACTIONS_WITH_PUBLISH = ['view', 'create', 'update', 'delete', 'publish'] as const\n\nexport { ACTIONS, ACTIONS_WITH_PUBLISH }\n\n// ---------------------------------------------------------------------------\n// Built-in roles & constants\n// ---------------------------------------------------------------------------\n\n/** Built-in role names — cannot be deleted via the roles editor. */\nexport const BUILT_IN_ROLES = ['admin', 'public', 'authenticated', 'agent'] as const\n\n/**\n * Default permissions for the built-in `agent` role.\n *\n * Agents handle the ticketing inbox: they can view + update tickets,\n * messages, and attachments, and view the metadata that scopes them\n * (departments, tags). Delete actions and all email-template management\n * stay admin-only. Consumers can add more resources via the roles editor.\n *\n * SECURITY (#82): `ticket_message:create` is admin-only by design. Agents\n * authoring replies go through `/api/admin/ticketing/reply` (gated on\n * `ticket:update`), which uses `createSystemAdminClient` to perform the\n * write under elevated permissions while still recording the real agent\n * in the audit log. Allowing `ticket_message:create` for agents would\n * re-open the generic-CRUD impersonation path where an agent can spoof\n * another agent's `senderUserId` / `senderEmail` / `senderName`.\n */\nconst AGENT_DEFAULT_PERMISSIONS: Record<string, string[]> = {\n ticket: ['view', 'create', 'update'],\n ticket_message: ['view', 'update'],\n ticket_attachment: ['view', 'create'],\n department: ['view'],\n ticket_tag: ['view'],\n}\n\n/** Maps HTTP methods to permission action names. */\nexport const METHOD_TO_ACTION: Record<string, string> = {\n GET: 'view',\n POST: 'create',\n PATCH: 'update',\n DELETE: 'delete',\n}\n\n// ---------------------------------------------------------------------------\n// Admin API permission system (firewall model)\n// ---------------------------------------------------------------------------\n\n/**\n * Build initial role definitions for first run (no settings saved yet).\n *\n * All built-in non-admin roles start with ZERO permissions.\n * Admin is never stored — it's hardcoded in the checker.\n */\nexport function buildInitialRoleDefinitions(): Record<string, Record<string, string[]>> {\n return {\n public: {},\n authenticated: {},\n agent: { ...AGENT_DEFAULT_PERMISSIONS },\n }\n}\n\n/**\n * Build a synchronous permission checker from role definitions.\n *\n * Rules:\n * - `admin` role: ALWAYS returns `true` (hardcoded safety net, ignores settings)\n * - All other roles: exact match from `roleDefinitions` (deny-by-default)\n * - Unknown role / unknown resource / unknown action → `false`\n */\nexport function buildPermissionChecker(\n roleDefinitions: Record<string, Record<string, string[]>>,\n): PermissionChecker {\n // Pre-build lookup maps for O(1) checks\n const perms = new Map<string, Map<string, Set<string>>>()\n\n for (const [role, resources] of Object.entries(roleDefinitions)) {\n const resourceMap = new Map<string, Set<string>>()\n for (const [resource, actions] of Object.entries(resources)) {\n resourceMap.set(resource, new Set(actions))\n }\n perms.set(role, resourceMap)\n }\n\n return (role: string, resource: string, action: string): boolean => {\n if (role === 'admin') return true // Safety net — admin always passes\n return perms.get(role)?.get(resource)?.has(action) ?? false\n }\n}\n\n// ---------------------------------------------------------------------------\n// Resource catalog builder\n// ---------------------------------------------------------------------------\n\n/** Check if an entity has the publishable behavior. */\nfunction isPublishable(entity: { behaviors?: { name: string }[] }): boolean {\n return entity.behaviors?.some((b) => b.name === 'publishable') ?? false\n}\n\n/**\n * Build a complete resource catalog from entities and admin routes.\n *\n * Used by:\n * - `permissionRoutes()` config (`getStatements` callback)\n * - Server-side permission page data loaders\n *\n * Entities automatically get CRUD actions. Publishable entities also get\n * the `publish` action, which gates who can set status to 'published'.\n * Routes with `resource` and `actions` are added if not already present.\n */\nexport function buildResourceCatalog(\n entities: { name: string; behaviors?: { name: string }[] }[],\n routes?: { resource?: string; actions?: readonly string[] }[],\n): Record<string, string[]> {\n const catalog: Record<string, string[]> = {}\n\n for (const entity of entities) {\n catalog[entity.name] = isPublishable(entity)\n ? ['view', 'create', 'update', 'delete', 'publish']\n : ['view', 'create', 'update', 'delete']\n }\n\n if (routes) {\n for (const route of routes) {\n if (route.resource && route.actions && !(route.resource in catalog)) {\n catalog[route.resource] = [...route.actions]\n }\n }\n }\n\n return catalog\n}\n\n// ---------------------------------------------------------------------------\n// better-auth integration (unchanged, used by createAuthServer)\n// ---------------------------------------------------------------------------\n\n/** Admin plugin's built-in resources — must be included for listUsers, ban, etc. */\nconst ADMIN_STATEMENTS = {\n user: [\n 'create',\n 'list',\n 'set-role',\n 'ban',\n 'impersonate',\n 'delete',\n 'set-password',\n 'get',\n 'update',\n ] as const,\n session: ['list', 'revoke', 'delete'] as const,\n}\n\n/**\n * Build a permission statement object from all registered entities,\n * plus the admin plugin's built-in user/session resources.\n *\n * Publishable entities get the additional `publish` action.\n * Result shape: `{ user: [...], session: [...], article: ['view', ...], category: [...] }`\n */\nexport function buildStatements(entities: Entity[]) {\n const statement: Record<string, readonly string[]> = {\n ...ADMIN_STATEMENTS,\n }\n for (const entity of entities) {\n statement[entity.name] = isPublishable(entity) ? ACTIONS_WITH_PUBLISH : ACTIONS\n }\n return statement\n}\n\n/**\n * Build the default toolkit roles for better-auth's access control.\n *\n * - **admin**: full CRUD on all entities + user/session management\n * - **authenticated**: view only (better-auth's `defaultRole`)\n * - **agent**: view + create + update on the ticketing surface (see\n * `AGENT_DEFAULT_PERMISSIONS`). Non-ticketing entities get view only,\n * matching `authenticated`.\n */\nexport function buildDefaultRoles(ac: ReturnType<typeof createAccessControl>, entities: Entity[]) {\n const adminPerms: Record<string, string[]> = {}\n const authenticatedPerms: Record<string, string[]> = {}\n const agentPerms: Record<string, string[]> = {}\n\n // Admin gets full control of user/session management\n adminPerms.user = [...ADMIN_STATEMENTS.user]\n adminPerms.session = [...ADMIN_STATEMENTS.session]\n // Non-admin roles get no user/session management\n authenticatedPerms.user = []\n authenticatedPerms.session = []\n agentPerms.user = []\n agentPerms.session = []\n\n for (const entity of entities) {\n adminPerms[entity.name] = isPublishable(entity)\n ? ['view', 'create', 'update', 'delete', 'publish']\n : ['view', 'create', 'update', 'delete']\n authenticatedPerms[entity.name] = ['view']\n // Agents get the seeded ticketing actions where defined; everything else\n // defaults to view-only (same as authenticated).\n agentPerms[entity.name] = AGENT_DEFAULT_PERMISSIONS[entity.name] ?? ['view']\n }\n\n return {\n admin: ac.newRole(adminPerms),\n authenticated: ac.newRole(authenticatedPerms),\n agent: ac.newRole(agentPerms),\n }\n}\n\nexport { createAccessControl }\n"],"mappings":"mCAeA,MAAM,EAAU,CAAC,OAAQ,SAAU,SAAU,SAAS,CAChD,EAAuB,CAAC,OAAQ,SAAU,SAAU,SAAU,UAAU,CASjE,EAAiB,CAAC,QAAS,SAAU,gBAAiB,QAAQ,CAkBrE,EAAsD,CAC1D,OAAQ,CAAC,OAAQ,SAAU,SAAS,CACpC,eAAgB,CAAC,OAAQ,SAAS,CAClC,kBAAmB,CAAC,OAAQ,SAAS,CACrC,WAAY,CAAC,OAAO,CACpB,WAAY,CAAC,OAAO,CACrB,CAGY,EAA2C,CACtD,IAAK,OACL,KAAM,SACN,MAAO,SACP,OAAQ,SACT,CAYD,SAAgB,GAAwE,CACtF,MAAO,CACL,OAAQ,EAAE,CACV,cAAe,EAAE,CACjB,MAAO,CAAE,GAAG,EAA2B,CACxC,CAWH,SAAgB,EACd,EACmB,CAEnB,IAAM,EAAQ,IAAI,IAElB,IAAK,GAAM,CAAC,EAAM,KAAc,OAAO,QAAQ,EAAgB,CAAE,CAC/D,IAAM,EAAc,IAAI,IACxB,IAAK,GAAM,CAAC,EAAU,KAAY,OAAO,QAAQ,EAAU,CACzD,EAAY,IAAI,EAAU,IAAI,IAAI,EAAQ,CAAC,CAE7C,EAAM,IAAI,EAAM,EAAY,CAG9B,OAAQ,EAAc,EAAkB,IAClC,IAAS,QAAgB,GACtB,EAAM,IAAI,EAAK,EAAE,IAAI,EAAS,EAAE,IAAI,EAAO,EAAI,GAS1D,SAAS,EAAc,EAAqD,CAC1E,OAAO,EAAO,WAAW,KAAM,GAAM,EAAE,OAAS,cAAc,EAAI,GAcpE,SAAgB,EACd,EACA,EAC0B,CAC1B,IAAM,EAAoC,EAAE,CAE5C,IAAK,IAAM,KAAU,EACnB,EAAQ,EAAO,MAAQ,EAAc,EAAO,CACxC,CAAC,OAAQ,SAAU,SAAU,SAAU,UAAU,CACjD,CAAC,OAAQ,SAAU,SAAU,SAAS,CAG5C,GAAI,MACG,IAAM,KAAS,EACd,EAAM,UAAY,EAAM,SAAW,EAAE,EAAM,YAAY,KACzD,EAAQ,EAAM,UAAY,CAAC,GAAG,EAAM,QAAQ,EAKlD,OAAO,EAQT,MAAM,EAAmB,CACvB,KAAM,CACJ,SACA,OACA,WACA,MACA,cACA,SACA,eACA,MACA,SACD,CACD,QAAS,CAAC,OAAQ,SAAU,SAAS,CACtC,CASD,SAAgB,EAAgB,EAAoB,CAClD,IAAM,EAA+C,CACnD,GAAG,EACJ,CACD,IAAK,IAAM,KAAU,EACnB,EAAU,EAAO,MAAQ,EAAc,EAAO,CAAG,EAAuB,EAE1E,OAAO,EAYT,SAAgB,EAAkB,EAA4C,EAAoB,CAChG,IAAM,EAAuC,EAAE,CACzC,EAA+C,EAAE,CACjD,EAAuC,EAAE,CAG/C,EAAW,KAAO,CAAC,GAAG,EAAiB,KAAK,CAC5C,EAAW,QAAU,CAAC,GAAG,EAAiB,QAAQ,CAElD,EAAmB,KAAO,EAAE,CAC5B,EAAmB,QAAU,EAAE,CAC/B,EAAW,KAAO,EAAE,CACpB,EAAW,QAAU,EAAE,CAEvB,IAAK,IAAM,KAAU,EACnB,EAAW,EAAO,MAAQ,EAAc,EAAO,CAC3C,CAAC,OAAQ,SAAU,SAAU,SAAU,UAAU,CACjD,CAAC,OAAQ,SAAU,SAAU,SAAS,CAC1C,EAAmB,EAAO,MAAQ,CAAC,OAAO,CAG1C,EAAW,EAAO,MAAQ,EAA0B,EAAO,OAAS,CAAC,OAAO,CAG9E,MAAO,CACL,MAAO,EAAG,QAAQ,EAAW,CAC7B,cAAe,EAAG,QAAQ,EAAmB,CAC7C,MAAO,EAAG,QAAQ,EAAW,CAC9B"}