@murumets-ee/auth 0.4.0 → 0.4.6

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{r as e}from"../permissions-DREmJByu.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,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,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(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,userName:a.name,changes:{roleName:u,permissions:f}}),n({deleted:u})}}}}export{i as permissionRoutes};
1
+ import{r as e}from"../permissions-DYBlqpv3.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,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,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(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,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
@@ -31,7 +31,7 @@ declare function resolveAuthContext(auth: Auth, headers: Headers): Promise<Reque
31
31
  declare const ACTIONS: readonly ["view", "create", "update", "delete"];
32
32
  declare const ACTIONS_WITH_PUBLISH: readonly ["view", "create", "update", "delete", "publish"];
33
33
  /** Built-in role names — cannot be deleted via the roles editor. */
34
- declare const BUILT_IN_ROLES: readonly ["admin", "public", "authenticated"];
34
+ declare const BUILT_IN_ROLES: readonly ["admin", "public", "authenticated", "agent"];
35
35
  /** Maps HTTP methods to permission action names. */
36
36
  declare const METHOD_TO_ACTION: Record<string, string>;
37
37
  /**
@@ -83,6 +83,9 @@ declare function buildStatements(entities: Entity[]): Record<string, readonly st
83
83
  *
84
84
  * - **admin**: full CRUD on all entities + user/session management
85
85
  * - **authenticated**: view only (better-auth's `defaultRole`)
86
+ * - **agent**: view + create + update on the ticketing surface (see
87
+ * `AGENT_DEFAULT_PERMISSIONS`). Non-ticketing entities get view only,
88
+ * matching `authenticated`.
86
89
  */
87
90
  declare function buildDefaultRoles(ac: ReturnType<typeof createAccessControl>, entities: Entity[]): {
88
91
  admin: {
@@ -99,7 +102,58 @@ declare function buildDefaultRoles(ac: ReturnType<typeof createAccessControl>, e
99
102
  } | undefined } : never, connector?: "OR" | "AND"): _$better_auth_plugins0.AuthorizeResponse;
100
103
  statements: _$better_auth_plugins0.Subset<string, _$better_auth_plugins0.Statements>;
101
104
  };
105
+ agent: {
106
+ authorize<K_1 extends string>(request: K_1 extends infer T extends K ? { [key in T]?: _$better_auth_plugins0.Subset<string, _$better_auth_plugins0.Statements>[key] | {
107
+ actions: _$better_auth_plugins0.Subset<string, _$better_auth_plugins0.Statements>[key];
108
+ connector: "OR" | "AND";
109
+ } | undefined } : never, connector?: "OR" | "AND"): _$better_auth_plugins0.AuthorizeResponse;
110
+ statements: _$better_auth_plugins0.Subset<string, _$better_auth_plugins0.Statements>;
111
+ };
102
112
  };
103
113
  //#endregion
104
- export { ACTIONS, ACTIONS_WITH_PUBLISH, type AdminUser, type Auth, type AuthAdminApi, type AuthConfig, BUILT_IN_ROLES, METHOD_TO_ACTION, auth, buildDefaultRoles, buildInitialRoleDefinitions, buildPermissionChecker, buildResourceCatalog, buildStatements, createAccessControl, getAuth, isSignupEnabled, resolveAuthContext };
114
+ //#region src/seed-roles.d.ts
115
+ /**
116
+ * Idempotent seeder that adds missing built-in roles (e.g. `agent`) to an
117
+ * existing deployment's permission settings.
118
+ *
119
+ * `buildInitialRoleDefinitions` only runs on first-time admin-api-init —
120
+ * deployments created before a new built-in role was added (like the
121
+ * ticketing `agent` role shipped 2026-04-20) will never get it automatically.
122
+ * This helper lets consumers wire a one-shot seeder on the Seed page that
123
+ * merges any missing roles into their saved settings without overwriting
124
+ * customizations.
125
+ *
126
+ * Usage (app-side seeder):
127
+ *
128
+ * ```ts
129
+ * import { upsertBuiltInRoles } from '@murumets-ee/auth'
130
+ * import { createSettingsClient } from '@murumets-ee/settings'
131
+ * import { getApp } from '@murumets-ee/core'
132
+ * import { permissionSettings } from '@/settings'
133
+ *
134
+ * export async function seedRoles(): Promise<{ created: number }> {
135
+ * const client = createSettingsClient(permissionSettings, { app: getApp() })
136
+ * const { created } = await upsertBuiltInRoles(
137
+ * () => client.get('roles'),
138
+ * (roles) => client.set('roles', roles),
139
+ * )
140
+ * return { created }
141
+ * }
142
+ * ```
143
+ */
144
+ type RoleMap = Record<string, Record<string, string[]>>;
145
+ interface UpsertResult {
146
+ /** Number of role keys added (0 if all built-ins were already present). */
147
+ created: number;
148
+ /** Names of roles that were added. */
149
+ roles: string[];
150
+ }
151
+ /**
152
+ * Merge the default built-in role definitions into the caller's persisted
153
+ * role map, adding any missing ones. Existing entries are left untouched —
154
+ * the point is to bootstrap new built-ins, not to reset customizations.
155
+ */
156
+ declare function upsertBuiltInRoles(read: () => Promise<RoleMap | null | undefined>, write: (roles: RoleMap) => Promise<void>): Promise<UpsertResult>;
157
+ //#endregion
158
+ export { ACTIONS, ACTIONS_WITH_PUBLISH, type AdminUser, type Auth, type AuthAdminApi, type AuthConfig, BUILT_IN_ROLES, METHOD_TO_ACTION, type RoleMap, type UpsertResult, auth, buildDefaultRoles, buildInitialRoleDefinitions, buildPermissionChecker, buildResourceCatalog, buildStatements, createAccessControl, getAuth, isSignupEnabled, resolveAuthContext, upsertBuiltInRoles };
105
159
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/context.ts","../src/permissions.ts"],"mappings":";;;;;;;;AA8BA;;;;;;;;;;;;;;;;;;;AAAA,iBAAsB,kBAAA,CAAmB,IAAA,EAAM,IAAA,EAAM,OAAA,EAAS,OAAA,GAAU,OAAA,CAAQ,cAAA;;;cCf1E,OAAA;AAAA,cACA,oBAAA;;cASO,cAAA;;cAGA,gBAAA,EAAkB,MAAA;;;;;;;iBAiBf,2BAAA,CAAA,GAA+B,MAAA,SAAe,MAAA;;;AAhCE;;;;;AAED;iBA6C/C,sBAAA,CACd,eAAA,EAAiB,MAAA,SAAe,MAAA,sBAC/B,iBAAA;;;;AArCH;;;;;AAGA;;;iBAwEgB,oBAAA,CACd,QAAA;EAAY,IAAA;EAAc,SAAA;IAAc,IAAA;EAAA;AAAA,KACxC,MAAA;EAAW,QAAA;EAAmB,OAAA;AAAA,MAC7B,MAAA;;;;;;;;iBA+Ca,eAAA,CAAgB,QAAA,EAAU,MAAA,KAAQ,MAAA;;;;;AAlDlD;;iBAkEgB,iBAAA,CAAkB,EAAA,EAAI,UAAA,QAAkB,mBAAA,GAAsB,QAAA,EAAU,MAAA"}
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;;;cCf1E,OAAA;AAAA,cACA,oBAAA;;cASO,cAAA;;cAmBA,gBAAA,EAAkB,MAAA;;;;;;;iBAiBf,2BAAA,CAAA,GAA+B,MAAA,SAAe,MAAA;;;AAhDE;;;;;AAED;iBA8D/C,sBAAA,CACd,eAAA,EAAiB,MAAA,SAAe,MAAA,sBAC/B,iBAAA;;;;AAtDH;;;;;AAmBA;;;iBAyEgB,oBAAA,CACd,QAAA;EAAY,IAAA;EAAc,SAAA;IAAc,IAAA;EAAA;AAAA,KACxC,MAAA;EAAW,QAAA;EAAmB,OAAA;AAAA,MAC7B,MAAA;;;;;;;;iBA+Ca,eAAA,CAAgB,QAAA,EAAU,MAAA,KAAQ,MAAA;;;;;AAlDlD;;;;;iBAqEgB,iBAAA,CAAkB,EAAA,EAAI,UAAA,QAAkB,mBAAA,GAAsB,QAAA,EAAU,MAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AD5JxF;;;;;;;;;;;;;;;;;;;;KEEY,OAAA,GAAU,MAAA,SAAe,MAAA;AAAA,UAEpB,YAAA;;EAEf,OAAA;EDrB6D;ECuB7D,KAAA;AAAA;;;;ADbF;;iBCqBsB,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,c as t,i as n,l as r,n as i,o as a,r as o,s,t as c,u as l}from"./permissions-DREmJByu.mjs";import{auth as u,getAuth as d,isSignupEnabled as f}from"./plugin.mjs";import"server-only";async function p(e,t){let n=await e.api.getSession({headers:t});return n?{user:{id:n.user.id,groups:[n.user.role??`viewer`],name:n.user.name??void 0,email:n.user.email??void 0},requestId:crypto.randomUUID()}:{requestId:crypto.randomUUID()}}export{c as ACTIONS,i as ACTIONS_WITH_PUBLISH,o as BUILT_IN_ROLES,n as METHOD_TO_ACTION,u as auth,e as buildDefaultRoles,a as buildInitialRoleDefinitions,s as buildPermissionChecker,t as buildResourceCatalog,r as buildStatements,l as createAccessControl,d as getAuth,f as isSignupEnabled,p as resolveAuthContext};
1
+ import{a as e,c as t,i as n,l as r,n as i,o as a,r as o,s,t as c,u as l}from"./permissions-DYBlqpv3.mjs";import{auth as u,getAuth as d,isSignupEnabled as f}from"./plugin.mjs";import"server-only";async function p(e,t){let n=await e.api.getSession({headers:t});return n?{user:{id:n.user.id,groups:[n.user.role??`viewer`],name:n.user.name??void 0,email:n.user.email??void 0},requestId:crypto.randomUUID()}:{requestId:crypto.randomUUID()}}async function m(e,t){let n=await e()??{},r=a(),i={...n},o=[];for(let[e,t]of Object.entries(r))e in i||(i[e]=t,o.push(e));return o.length>0&&await t(i),{created:o.length,roles:o}}export{c as ACTIONS,i as ACTIONS_WITH_PUBLISH,o as BUILT_IN_ROLES,n as METHOD_TO_ACTION,u as auth,e as buildDefaultRoles,a as buildInitialRoleDefinitions,s as buildPermissionChecker,t as buildResourceCatalog,r as buildStatements,l as createAccessControl,d as getAuth,f as isSignupEnabled,p as resolveAuthContext,m as upsertBuiltInRoles};
2
2
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/context.ts"],"sourcesContent":["/**\n * Bridge between better-auth sessions and the toolkit's RequestContext.\n *\n * This is the critical integration point: it resolves a better-auth session\n * from request headers and returns a toolkit RequestContext that the entity\n * system's AdminClient, QueryClient, and auditable behavior can read.\n */\n\nimport type { RequestContext } from '@murumets-ee/core'\nimport type { Auth } from './server.js'\n\n/**\n * Resolve a better-auth session into a toolkit RequestContext.\n *\n * Call this from your Next.js middleware or server component to populate\n * the toolkit's AsyncLocalStorage context.\n *\n * @example\n * ```typescript\n * // middleware.ts (user writes this — documented copy-paste)\n * import { getAuth, resolveAuthContext } from '@murumets-ee/auth'\n * import { runWithContext } from '@murumets-ee/core'\n *\n * export async function middleware(request: NextRequest) {\n * const auth = getAuth()\n * const ctx = await resolveAuthContext(auth, request.headers)\n * return runWithContext(ctx, () => NextResponse.next())\n * }\n * ```\n */\nexport async function resolveAuthContext(auth: Auth, headers: Headers): Promise<RequestContext> {\n const session = await auth.api.getSession({ headers })\n\n if (!session) {\n return { requestId: crypto.randomUUID() }\n }\n\n return {\n user: {\n id: session.user.id,\n // The admin plugin stores role on the user object\n groups: [((session.user as Record<string, unknown>).role as string) ?? 'viewer'],\n name: session.user.name ?? undefined,\n email: session.user.email ?? undefined,\n },\n requestId: crypto.randomUUID(),\n }\n}\n"],"mappings":"mMA8BA,eAAsB,EAAmB,EAAY,EAA2C,CAC9F,IAAM,EAAU,MAAM,EAAK,IAAI,WAAW,CAAE,UAAS,CAAC,CAMtD,OAJK,EAIE,CACL,KAAM,CACJ,GAAI,EAAQ,KAAK,GAEjB,OAAQ,CAAG,EAAQ,KAAiC,MAAmB,SAAS,CAChF,KAAM,EAAQ,KAAK,MAAQ,IAAA,GAC3B,MAAO,EAAQ,KAAK,OAAS,IAAA,GAC9B,CACD,UAAW,OAAO,YAAY,CAC/B,CAZQ,CAAE,UAAW,OAAO,YAAY,CAAE"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/context.ts","../src/seed-roles.ts"],"sourcesContent":["/**\n * Bridge between better-auth sessions and the toolkit's RequestContext.\n *\n * This is the critical integration point: it resolves a better-auth session\n * from request headers and returns a toolkit RequestContext that the entity\n * system's AdminClient, QueryClient, and auditable behavior can read.\n */\n\nimport type { RequestContext } from '@murumets-ee/core'\nimport type { Auth } from './server.js'\n\n/**\n * Resolve a better-auth session into a toolkit RequestContext.\n *\n * Call this from your Next.js middleware or server component to populate\n * the toolkit's AsyncLocalStorage context.\n *\n * @example\n * ```typescript\n * // middleware.ts (user writes this — documented copy-paste)\n * import { getAuth, resolveAuthContext } from '@murumets-ee/auth'\n * import { runWithContext } from '@murumets-ee/core'\n *\n * export async function middleware(request: NextRequest) {\n * const auth = getAuth()\n * const ctx = await resolveAuthContext(auth, request.headers)\n * return runWithContext(ctx, () => NextResponse.next())\n * }\n * ```\n */\nexport async function resolveAuthContext(auth: Auth, headers: Headers): Promise<RequestContext> {\n const session = await auth.api.getSession({ headers })\n\n if (!session) {\n return { requestId: crypto.randomUUID() }\n }\n\n return {\n user: {\n id: session.user.id,\n // The admin plugin stores role on the user object\n groups: [((session.user as Record<string, unknown>).role as string) ?? 'viewer'],\n name: session.user.name ?? undefined,\n email: session.user.email ?? undefined,\n },\n requestId: crypto.randomUUID(),\n }\n}\n","/**\n * Idempotent seeder that adds missing built-in roles (e.g. `agent`) to an\n * existing deployment's permission settings.\n *\n * `buildInitialRoleDefinitions` only runs on first-time admin-api-init —\n * deployments created before a new built-in role was added (like the\n * ticketing `agent` role shipped 2026-04-20) will never get it automatically.\n * This helper lets consumers wire a one-shot seeder on the Seed page that\n * merges any missing roles into their saved settings without overwriting\n * customizations.\n *\n * Usage (app-side seeder):\n *\n * ```ts\n * import { upsertBuiltInRoles } from '@murumets-ee/auth'\n * import { createSettingsClient } from '@murumets-ee/settings'\n * import { getApp } from '@murumets-ee/core'\n * import { permissionSettings } from '@/settings'\n *\n * export async function seedRoles(): Promise<{ created: number }> {\n * const client = createSettingsClient(permissionSettings, { app: getApp() })\n * const { created } = await upsertBuiltInRoles(\n * () => client.get('roles'),\n * (roles) => client.set('roles', roles),\n * )\n * return { created }\n * }\n * ```\n */\n\nimport { buildInitialRoleDefinitions } from './permissions.js'\n\nexport type RoleMap = Record<string, Record<string, string[]>>\n\nexport interface UpsertResult {\n /** Number of role keys added (0 if all built-ins were already present). */\n created: number\n /** Names of roles that were added. */\n roles: string[]\n}\n\n/**\n * Merge the default built-in role definitions into the caller's persisted\n * role map, adding any missing ones. Existing entries are left untouched —\n * the point is to bootstrap new built-ins, not to reset customizations.\n */\nexport async function upsertBuiltInRoles(\n read: () => Promise<RoleMap | null | undefined>,\n write: (roles: RoleMap) => Promise<void>,\n): Promise<UpsertResult> {\n const saved = (await read()) ?? {}\n const defaults = buildInitialRoleDefinitions()\n const next: RoleMap = { ...saved }\n const added: string[] = []\n for (const [name, perms] of Object.entries(defaults)) {\n if (!(name in next)) {\n next[name] = perms\n added.push(name)\n }\n }\n if (added.length > 0) {\n await write(next)\n }\n return { created: added.length, roles: added }\n}\n"],"mappings":"mMA8BA,eAAsB,EAAmB,EAAY,EAA2C,CAC9F,IAAM,EAAU,MAAM,EAAK,IAAI,WAAW,CAAE,UAAS,CAAC,CAMtD,OAJK,EAIE,CACL,KAAM,CACJ,GAAI,EAAQ,KAAK,GAEjB,OAAQ,CAAG,EAAQ,KAAiC,MAAmB,SAAS,CAChF,KAAM,EAAQ,KAAK,MAAQ,IAAA,GAC3B,MAAO,EAAQ,KAAK,OAAS,IAAA,GAC9B,CACD,UAAW,OAAO,YAAY,CAC/B,CAZQ,CAAE,UAAW,OAAO,YAAY,CAAE,CCY7C,eAAsB,EACpB,EACA,EACuB,CACvB,IAAM,EAAS,MAAM,GAAM,EAAK,EAAE,CAC5B,EAAW,GAA6B,CACxC,EAAgB,CAAE,GAAG,EAAO,CAC5B,EAAkB,EAAE,CAC1B,IAAK,GAAM,CAAC,EAAM,KAAU,OAAO,QAAQ,EAAS,CAC5C,KAAQ,IACZ,EAAK,GAAQ,EACb,EAAM,KAAK,EAAK,EAMpB,OAHI,EAAM,OAAS,GACjB,MAAM,EAAM,EAAK,CAEZ,CAAE,QAAS,EAAM,OAAQ,MAAO,EAAO"}
@@ -0,0 +1,2 @@
1
+ import{createAccessControl as e}from"better-auth/plugins/access";const t=[`view`,`create`,`update`,`delete`],n=[`view`,`create`,`update`,`delete`,`publish`],r=[`admin`,`public`,`authenticated`,`agent`],i={ticket:[`view`,`create`,`update`],ticket_message:[`view`,`create`,`update`],ticket_attachment:[`view`,`create`],department:[`view`],ticket_tag:[`view`]},a={GET:`view`,POST:`create`,PATCH:`update`,DELETE:`delete`};function o(){return{public:{},authenticated:{},agent:{...i}}}function s(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 c(e){return e.behaviors?.some(e=>e.name===`publishable`)??!1}function l(e,t){let n={};for(let t of e)n[t.name]=c(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 u={user:[`create`,`list`,`set-role`,`ban`,`impersonate`,`delete`,`set-password`,`get`,`update`],session:[`list`,`revoke`,`delete`]};function d(e){let r={...u};for(let i of e)r[i.name]=c(i)?n:t;return r}function f(e,t){let n={},r={},a={};n.user=[...u.user],n.session=[...u.session],r.user=[],r.session=[],a.user=[],a.session=[];for(let e of t)n[e.name]=c(e)?[`view`,`create`,`update`,`delete`,`publish`]:[`view`,`create`,`update`,`delete`],r[e.name]=[`view`],a[e.name]=i[e.name]??[`view`];return{admin:e.newRole(n),authenticated:e.newRole(r),agent:e.newRole(a)}}export{f as a,l as c,a as i,d as l,n,o,r,s,t,e as u};
2
+ //# sourceMappingURL=permissions-DYBlqpv3.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permissions-DYBlqpv3.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 + create + update tickets,\n * messages, and attachments, and view the metadata that scopes them\n * (departments, tags). Delete actions and all email-template management stay\n * admin-only. Consumers can add more resources via the roles editor.\n */\nconst AGENT_DEFAULT_PERMISSIONS: Record<string, string[]> = {\n ticket: ['view', 'create', 'update'],\n ticket_message: ['view', 'create', '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":"iEAeA,MAAM,EAAU,CAAC,OAAQ,SAAU,SAAU,SAAS,CAChD,EAAuB,CAAC,OAAQ,SAAU,SAAU,SAAU,UAAU,CASjE,EAAiB,CAAC,QAAS,SAAU,gBAAiB,QAAQ,CAUrE,EAAsD,CAC1D,OAAQ,CAAC,OAAQ,SAAU,SAAS,CACpC,eAAgB,CAAC,OAAQ,SAAU,SAAS,CAC5C,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"}
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
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";let s=null,c=null;function l(){if(!s)throw Error(`@murumets-ee/auth not initialized. Add auth() to your toolkit config plugins.`);return s}function u(){return c?.signup?.enabled===!0}function d(l={}){return c=l,{name:`@murumets-ee/auth`,tables:{user:t,session:o,account:a,verification:n,organization:i,member:e,invitation:r},init:async e=>{let{createAuthServer:t}=await import(`./server-BKbmzuzv.mjs`),n;if(l.audit!==!1){let{createAuditLogger:t,createAuditDbWriter:r,createLogger:i}=await import(`@murumets-ee/logging`);n=t({logger:i({name:`auth-audit`}),dbWriter:r(e.db.readWrite)})}s=t(l,e,n),e.logger.info(`Auth plugin initialized`)}}}export{d as auth,l as getAuth,u as isSignupEnabled};
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";let s=null,c=null;function l(){if(!s)throw Error(`@murumets-ee/auth not initialized. Add auth() to your toolkit config plugins.`);return s}function u(){return c?.signup?.enabled===!0}function d(l={}){return c=l,{name:`@murumets-ee/auth`,tables:{user:t,session:o,account:a,verification:n,organization:i,member:e,invitation:r},init:async e=>{let{createAuthServer:t}=await import(`./server-juwZw1e-.mjs`),n;if(l.audit!==!1){let{createAuditLogger:t,createAuditDbWriter:r,createLogger:i}=await import(`@murumets-ee/logging`);n=t({logger:i({name:`auth-audit`}),dbWriter:r(e.db.readWrite)})}s=t(l,e,n),e.logger.info(`Auth plugin initialized`)}}}export{d as auth,l as getAuth,u as isSignupEnabled};
2
2
  //# sourceMappingURL=plugin.mjs.map
@@ -1,2 +1,2 @@
1
- import{l as e}from"./schema-Je6e5yt2.mjs";import{a as t,l as n}from"./permissions-DREmJByu.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{APIError as o,createAuthMiddleware as s}from"better-auth/api";import{nextCookies as c}from"better-auth/next-js";import{admin as l}from"better-auth/plugins";import{organization as u}from"better-auth/plugins/organization";function d(e){let t=e?.context?.session,n=t?.user;return{id:n?.id??t?.userId,name:n?.name}}const f=new Set([`updatedAt`,`createdAt`]);function p(e){function t(t){e?.log(t).catch(()=>{})}let n=null;return{user:{create:{after:async e=>{t({action:`auth.signup`,entityType:`user`,entityId:e.id,userId:e.id,userName:e.name,changes:{fields:{name:e.name,email:e.email}}})}},update:{before:async e=>{let t={};for(let[n,r]of Object.entries(e))f.has(n)||n===`id`||(t[n]=r);n=Object.keys(t).length>0?t:null},after:async(e,r)=>{let i=d(r),a=n;n=null,t({action:`auth.user.update`,entityType:`user`,entityId:e.id,userId:i.id,userName:i.name,changes:a?{fields:a}:void 0,metadata:{targetUser:e.name}})}},delete:{after:async(e,n)=>{let r=d(n);t({action:`auth.user.delete`,entityType:`user`,entityId:e.id,userId:r.id,userName:r.name,metadata:{targetUser:e.name}})}}}}}const m=[`/sign-in/email`,`/sign-in/social`],h={"/change-password":`auth.password.change`,"/forget-password":`auth.password.reset_request`,"/reset-password":`auth.password.reset`},g={"/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 _(e){function t(t){e.log(t).catch(()=>{})}return{after:s(async e=>{if(m.some(t=>e.path.startsWith(t))){let n=e.context.returned?.status;if(!n)return;if(n>=400){let r=e.body?.email;t({action:`auth.login.failed`,metadata:{...typeof r==`string`?{email:r}:{},status:n,path:e.path}})}else{let n=e.context.newSession;n?.user?.id&&t({action:`auth.login`,entityType:`user`,entityId:n.user.id,userId:n.user.id,userName:n.user.name})}return}if(e.path===`/sign-out`){let n=e.context.returned;if(!n?.status||n.status<400){let n=d(e);n.id&&t({action:`auth.logout`,entityType:`user`,entityId:n.id,userId:n.id,userName:n.name})}return}let n=h[e.path];if(n){let r=e.context.returned;if(!r?.status||r.status<400){let r=d(e),i=e.body;t({action:n,entityType:`user`,userId:r.id,userName:r.name,metadata:{...typeof i?.email==`string`?{email:i.email}:{},path:e.path}})}return}let r=g[e.path];if(!r)return;let i=e.context.returned;if(i?.status&&i.status>=400)return;let a=d(e),o=e.body;t({action:r,entityType:`user`,entityId:o?.userId??void 0,userId:a.id,userName:a.name,metadata:{targetUserId:o?.userId,...o?.role?{role:o.role}:{},path:e.path}})})}}function v(e){return s(async t=>{if(t.path===`/sign-up/email`&&!e)throw new o(`FORBIDDEN`,{message:`Sign-up is closed`})})}function y(o,s,d){let f=[...s.entities.values()],m=r(n(f)),h=t(m,f),g={};o.social?.google&&(g.google=o.social.google),o.social?.github&&(g.github=o.social.github);let y=o.schema??e;return i({database:a(s.db.readWrite,{provider:`pg`,schema:y}),emailAndPassword:{enabled:o.providers?.includes(`email`)??!0},socialProviders:g,session:{expiresIn:o.session?.expiresIn??3600*2,updateAge:o.session?.updateAge??3600},rateLimit:{enabled:!0,window:60,max:100,storage:`memory`,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:p(d),hooks:{before:v(o.signup?.enabled??!1),...d?_(d):{}},plugins:[l({ac:m,roles:h,defaultRole:`authenticated`}),...o.organizations?[u({ac:m,roles:h})]:[],...o.betterAuthPlugins??[],c()]})}export{y as createAuthServer};
2
- //# sourceMappingURL=server-BKbmzuzv.mjs.map
1
+ import{l as e}from"./schema-Je6e5yt2.mjs";import{a as t,l as n}from"./permissions-DYBlqpv3.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{APIError as o,createAuthMiddleware as s}from"better-auth/api";import{nextCookies as c}from"better-auth/next-js";import{admin as l}from"better-auth/plugins";import{organization as u}from"better-auth/plugins/organization";function d(e){let t=e?.context?.session,n=t?.user;return{id:n?.id??t?.userId,name:n?.name}}const f=new Set([`updatedAt`,`createdAt`]);function p(e){function t(t){e?.log(t).catch(()=>{})}let n=null;return{user:{create:{after:async e=>{t({action:`auth.signup`,entityType:`user`,entityId:e.id,userId:e.id,userName:e.name,changes:{fields:{name:e.name,email:e.email}}})}},update:{before:async e=>{let t={};for(let[n,r]of Object.entries(e))f.has(n)||n===`id`||(t[n]=r);n=Object.keys(t).length>0?t:null},after:async(e,r)=>{let i=d(r),a=n;n=null,t({action:`auth.user.update`,entityType:`user`,entityId:e.id,userId:i.id,userName:i.name,changes:a?{fields:a}:void 0,metadata:{targetUser:e.name}})}},delete:{after:async(e,n)=>{let r=d(n);t({action:`auth.user.delete`,entityType:`user`,entityId:e.id,userId:r.id,userName:r.name,metadata:{targetUser:e.name}})}}}}}const m=[`/sign-in/email`,`/sign-in/social`],h={"/change-password":`auth.password.change`,"/forget-password":`auth.password.reset_request`,"/reset-password":`auth.password.reset`},g={"/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 _(e){function t(t){e.log(t).catch(()=>{})}return{after:s(async e=>{if(m.some(t=>e.path.startsWith(t))){let n=e.context.returned?.status;if(!n)return;if(n>=400){let r=e.body?.email;t({action:`auth.login.failed`,metadata:{...typeof r==`string`?{email:r}:{},status:n,path:e.path}})}else{let n=e.context.newSession;n?.user?.id&&t({action:`auth.login`,entityType:`user`,entityId:n.user.id,userId:n.user.id,userName:n.user.name})}return}if(e.path===`/sign-out`){let n=e.context.returned;if(!n?.status||n.status<400){let n=d(e);n.id&&t({action:`auth.logout`,entityType:`user`,entityId:n.id,userId:n.id,userName:n.name})}return}let n=h[e.path];if(n){let r=e.context.returned;if(!r?.status||r.status<400){let r=d(e),i=e.body;t({action:n,entityType:`user`,userId:r.id,userName:r.name,metadata:{...typeof i?.email==`string`?{email:i.email}:{},path:e.path}})}return}let r=g[e.path];if(!r)return;let i=e.context.returned;if(i?.status&&i.status>=400)return;let a=d(e),o=e.body;t({action:r,entityType:`user`,entityId:o?.userId??void 0,userId:a.id,userName:a.name,metadata:{targetUserId:o?.userId,...o?.role?{role:o.role}:{},path:e.path}})})}}function v(e){return s(async t=>{if(t.path===`/sign-up/email`&&!e)throw new o(`FORBIDDEN`,{message:`Sign-up is closed`})})}function y(o,s,d){let f=[...s.entities.values()],m=r(n(f)),h=t(m,f),g={};o.social?.google&&(g.google=o.social.google),o.social?.github&&(g.github=o.social.github);let y=o.schema??e;return i({database:a(s.db.readWrite,{provider:`pg`,schema:y}),emailAndPassword:{enabled:o.providers?.includes(`email`)??!0},socialProviders:g,session:{expiresIn:o.session?.expiresIn??3600*2,updateAge:o.session?.updateAge??3600},rateLimit:{enabled:!0,window:60,max:100,storage:`memory`,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:p(d),hooks:{before:v(o.signup?.enabled??!1),...d?_(d):{}},plugins:[l({ac:m,roles:h,defaultRole:`authenticated`}),...o.organizations?[u({ac:m,roles:h})]:[],...o.betterAuthPlugins??[],c()]})}export{y as createAuthServer};
2
+ //# sourceMappingURL=server-juwZw1e-.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"server-BKbmzuzv.mjs","names":["defaultAuthSchema"],"sources":["../src/server.ts"],"sourcesContent":["/**\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 { APIError, createAuthMiddleware } from 'better-auth/api'\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 { buildDefaultRoles, buildStatements } from './permissions.js'\nimport * as defaultAuthSchema from './schema.js'\nimport type { AdminUser, AuthConfig } from './types.js'\n\n// ---------------------------------------------------------------------------\n// Audit hooks — wired into better-auth's databaseHooks\n// ---------------------------------------------------------------------------\n\n/** Extract acting admin's id and name from better-auth hook context.\n * ctx is GenericEndpointContext | null — we access session safely via optional chaining. */\nfunction getActor(ctx: unknown) {\n const session = (ctx as { context?: { session?: Record<string, unknown> } } | null)?.context\n ?.session\n const user = session?.user as { id?: string; name?: string } | undefined\n return {\n id: user?.id ?? (session?.userId as string | undefined),\n name: user?.name,\n }\n}\n\n/** Fields to strip from user update audit payloads */\nconst SKIP_FIELDS = new Set(['updatedAt', 'createdAt'])\n\nfunction buildDatabaseHooks(auditLogger?: AuditLogger) {\n /** Fire-and-forget audit — never block auth operations */\n function audit(entry: Parameters<AuditLogger['log']>[0]) {\n auditLogger?.log(entry).catch(() => {})\n }\n\n // `before` captures changed fields, `after` has full user + actor ctx.\n // Bridge with a simple variable — updates are sequential per request.\n let pendingFields: Record<string, unknown> | null = null\n\n return {\n user: {\n create: {\n after: async (user: Record<string, unknown>) => {\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 entityId: user.id as string,\n userId: user.id as string,\n userName: user.name as string,\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 before: async (userData: Record<string, unknown>) => {\n const fields: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(userData)) {\n if (SKIP_FIELDS.has(key) || key === 'id') continue\n fields[key] = value\n }\n pendingFields = Object.keys(fields).length > 0 ? fields : null\n },\n // `after` has full user (name) + ctx (actor session) — log everything\n after: async (user: Record<string, unknown>, ctx: unknown) => {\n const actor = getActor(ctx)\n const fields = pendingFields\n pendingFields = null\n audit({\n action: 'auth.user.update',\n entityType: 'user',\n entityId: user.id as string,\n userId: actor.id,\n userName: actor.name,\n changes: fields ? { fields } : undefined,\n metadata: {\n targetUser: user.name as string | undefined,\n },\n })\n },\n },\n delete: {\n after: async (user: Record<string, unknown>, ctx: unknown) => {\n const actor = getActor(ctx)\n audit({\n action: 'auth.user.delete',\n entityType: 'user',\n entityId: user.id as string,\n userId: actor.id,\n userName: actor.name,\n metadata: {\n targetUser: user.name as string | undefined,\n },\n })\n },\n },\n },\n }\n}\n\n// ---------------------------------------------------------------------------\n// Request-level hooks — catches failed logins, password changes, etc.\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']\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\nfunction buildRequestHooks(auditLogger: AuditLogger) {\n function audit(entry: Parameters<AuditLogger['log']>[0]) {\n auditLogger.log(entry).catch(() => {})\n }\n\n return {\n after: createAuthMiddleware(async (ctx) => {\n // --- Login attempt logging ---\n const isSignIn = SIGN_IN_PATHS.some((p) => ctx.path.startsWith(p))\n if (isSignIn) {\n const returned = ctx.context.returned as { status?: number } | undefined\n const status = returned?.status\n if (!status) return\n\n if (status >= 400) {\n const email = (ctx.body as Record<string, unknown> | undefined)?.email\n audit({\n action: 'auth.login.failed',\n metadata: {\n ...(typeof email === 'string' ? { email } : {}),\n status,\n path: ctx.path,\n },\n })\n } else {\n const newSession = ctx.context.newSession as\n | { user?: { id?: string; name?: string } }\n | undefined\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 userName: newSession.user.name,\n })\n }\n }\n return\n }\n\n // --- Logout logging ---\n if (ctx.path === SIGN_OUT_PATH) {\n const returned = ctx.context.returned as { status?: number } | undefined\n if (!returned?.status || returned.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 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 returned = ctx.context.returned as { status?: number } | undefined\n if (!returned?.status || returned.status < 400) {\n const actor = getActor(ctx)\n const body = ctx.body as Record<string, unknown> | undefined\n audit({\n action: passwordAction,\n entityType: 'user',\n userId: actor.id,\n userName: actor.name,\n metadata: {\n // For reset requests, log the email (not sensitive — it's the input)\n ...(typeof body?.email === 'string' ? { email: body.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 returned = ctx.context.returned as { status?: number } | undefined\n if (returned?.status && returned.status >= 400) return // failed — skip\n\n const actor = getActor(ctx)\n const body = ctx.body as Record<string, unknown> | undefined\n audit({\n action: auditAction,\n entityType: 'user',\n entityId: (body?.userId as string) ?? undefined,\n userId: actor.id,\n userName: actor.name,\n metadata: {\n targetUserId: body?.userId as string | undefined,\n ...(body?.role ? { role: body.role as string } : {}),\n path: ctx.path,\n },\n })\n }),\n }\n}\n\n// ---------------------------------------------------------------------------\n// Signup gate — rejects public registration unless explicitly enabled.\n//\n// Closed by default. The first admin is created via `lumi create-admin`\n// (runs below the HTTP/middleware layer via auth.$context.internalAdapter,\n// so this gate never fires for CLI bootstrap). To open public registration\n// later, set `auth({ signup: { enabled: true } })` in the toolkit config.\n// ---------------------------------------------------------------------------\n\nfunction buildSignupGate(signupEnabled: boolean) {\n return createAuthMiddleware(async (ctx) => {\n if (ctx.path !== '/sign-up/email') return\n if (!signupEnabled) {\n throw new APIError('FORBIDDEN', { message: 'Sign-up is closed' })\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 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 // Build social provider config\n const socialProviders: Record<string, { clientId: string; clientSecret: string }> = {}\n if (config.social?.google) {\n socialProviders.google = config.social.google\n }\n if (config.social?.github) {\n socialProviders.github = config.social.github\n }\n\n // Plugins are inlined so TS infers the literal tuple type.\n // DO NOT extract into a typed variable — `BetterAuthPlugin[]` erases endpoint types.\n // nextCookies() must be last — required for Next.js server actions.\n // Default to the pre-baked schema from `@murumets-ee/auth/schema` so\n // consumers don't need to generate one with `@better-auth/cli`. They\n // can still override via `auth({ schema: customSchema })` if they've\n // extended better-auth with plugins that add tables.\n const schema = config.schema ?? defaultAuthSchema\n\n // Widen the config to BetterAuthOptions so betterAuth() returns\n // Auth<BetterAuthOptions> (= BetterAuthBase). This is required because:\n // 1. Without a 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 the config erases plugin-specific API types at compile time,\n // but plugin endpoints (listUsers, etc.) are fully functional at runtime.\n // Consumers already access them via typed wrappers in AdminPagesConfig.\n const authOptions: BetterAuthOptions = {\n database: drizzleAdapter(app.db.readWrite, {\n provider: 'pg',\n schema,\n }),\n\n emailAndPassword: {\n enabled: config.providers?.includes('email') ?? true,\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: 'memory',\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(auditLogger),\n hooks: {\n before: buildSignupGate(config.signup?.enabled ?? false),\n ...(auditLogger ? buildRequestHooks(auditLogger) : {}),\n },\n\n plugins: [\n admin({ ac, roles, defaultRole: 'authenticated' }),\n ...(config.organizations ? [organization({ ac, roles })] : []),\n ...(config.betterAuthPlugins ?? []),\n nextCookies(),\n ],\n }\n\n return betterAuth(authOptions)\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"],"mappings":"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"}
1
+ {"version":3,"file":"server-juwZw1e-.mjs","names":["defaultAuthSchema"],"sources":["../src/server.ts"],"sourcesContent":["/**\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 { APIError, createAuthMiddleware } from 'better-auth/api'\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 { buildDefaultRoles, buildStatements } from './permissions.js'\nimport * as defaultAuthSchema from './schema.js'\nimport type { AdminUser, AuthConfig } from './types.js'\n\n// ---------------------------------------------------------------------------\n// Audit hooks — wired into better-auth's databaseHooks\n// ---------------------------------------------------------------------------\n\n/** Extract acting admin's id and name from better-auth hook context.\n * ctx is GenericEndpointContext | null — we access session safely via optional chaining. */\nfunction getActor(ctx: unknown) {\n const session = (ctx as { context?: { session?: Record<string, unknown> } } | null)?.context\n ?.session\n const user = session?.user as { id?: string; name?: string } | undefined\n return {\n id: user?.id ?? (session?.userId as string | undefined),\n name: user?.name,\n }\n}\n\n/** Fields to strip from user update audit payloads */\nconst SKIP_FIELDS = new Set(['updatedAt', 'createdAt'])\n\nfunction buildDatabaseHooks(auditLogger?: AuditLogger) {\n /** Fire-and-forget audit — never block auth operations */\n function audit(entry: Parameters<AuditLogger['log']>[0]) {\n auditLogger?.log(entry).catch(() => {})\n }\n\n // `before` captures changed fields, `after` has full user + actor ctx.\n // Bridge with a simple variable — updates are sequential per request.\n let pendingFields: Record<string, unknown> | null = null\n\n return {\n user: {\n create: {\n after: async (user: Record<string, unknown>) => {\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 entityId: user.id as string,\n userId: user.id as string,\n userName: user.name as string,\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 before: async (userData: Record<string, unknown>) => {\n const fields: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(userData)) {\n if (SKIP_FIELDS.has(key) || key === 'id') continue\n fields[key] = value\n }\n pendingFields = Object.keys(fields).length > 0 ? fields : null\n },\n // `after` has full user (name) + ctx (actor session) — log everything\n after: async (user: Record<string, unknown>, ctx: unknown) => {\n const actor = getActor(ctx)\n const fields = pendingFields\n pendingFields = null\n audit({\n action: 'auth.user.update',\n entityType: 'user',\n entityId: user.id as string,\n userId: actor.id,\n userName: actor.name,\n changes: fields ? { fields } : undefined,\n metadata: {\n targetUser: user.name as string | undefined,\n },\n })\n },\n },\n delete: {\n after: async (user: Record<string, unknown>, ctx: unknown) => {\n const actor = getActor(ctx)\n audit({\n action: 'auth.user.delete',\n entityType: 'user',\n entityId: user.id as string,\n userId: actor.id,\n userName: actor.name,\n metadata: {\n targetUser: user.name as string | undefined,\n },\n })\n },\n },\n },\n }\n}\n\n// ---------------------------------------------------------------------------\n// Request-level hooks — catches failed logins, password changes, etc.\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']\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\nfunction buildRequestHooks(auditLogger: AuditLogger) {\n function audit(entry: Parameters<AuditLogger['log']>[0]) {\n auditLogger.log(entry).catch(() => {})\n }\n\n return {\n after: createAuthMiddleware(async (ctx) => {\n // --- Login attempt logging ---\n const isSignIn = SIGN_IN_PATHS.some((p) => ctx.path.startsWith(p))\n if (isSignIn) {\n const returned = ctx.context.returned as { status?: number } | undefined\n const status = returned?.status\n if (!status) return\n\n if (status >= 400) {\n const email = (ctx.body as Record<string, unknown> | undefined)?.email\n audit({\n action: 'auth.login.failed',\n metadata: {\n ...(typeof email === 'string' ? { email } : {}),\n status,\n path: ctx.path,\n },\n })\n } else {\n const newSession = ctx.context.newSession as\n | { user?: { id?: string; name?: string } }\n | undefined\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 userName: newSession.user.name,\n })\n }\n }\n return\n }\n\n // --- Logout logging ---\n if (ctx.path === SIGN_OUT_PATH) {\n const returned = ctx.context.returned as { status?: number } | undefined\n if (!returned?.status || returned.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 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 returned = ctx.context.returned as { status?: number } | undefined\n if (!returned?.status || returned.status < 400) {\n const actor = getActor(ctx)\n const body = ctx.body as Record<string, unknown> | undefined\n audit({\n action: passwordAction,\n entityType: 'user',\n userId: actor.id,\n userName: actor.name,\n metadata: {\n // For reset requests, log the email (not sensitive — it's the input)\n ...(typeof body?.email === 'string' ? { email: body.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 returned = ctx.context.returned as { status?: number } | undefined\n if (returned?.status && returned.status >= 400) return // failed — skip\n\n const actor = getActor(ctx)\n const body = ctx.body as Record<string, unknown> | undefined\n audit({\n action: auditAction,\n entityType: 'user',\n entityId: (body?.userId as string) ?? undefined,\n userId: actor.id,\n userName: actor.name,\n metadata: {\n targetUserId: body?.userId as string | undefined,\n ...(body?.role ? { role: body.role as string } : {}),\n path: ctx.path,\n },\n })\n }),\n }\n}\n\n// ---------------------------------------------------------------------------\n// Signup gate — rejects public registration unless explicitly enabled.\n//\n// Closed by default. The first admin is created via `lumi create-admin`\n// (runs below the HTTP/middleware layer via auth.$context.internalAdapter,\n// so this gate never fires for CLI bootstrap). To open public registration\n// later, set `auth({ signup: { enabled: true } })` in the toolkit config.\n// ---------------------------------------------------------------------------\n\nfunction buildSignupGate(signupEnabled: boolean) {\n return createAuthMiddleware(async (ctx) => {\n if (ctx.path !== '/sign-up/email') return\n if (!signupEnabled) {\n throw new APIError('FORBIDDEN', { message: 'Sign-up is closed' })\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 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 // Build social provider config\n const socialProviders: Record<string, { clientId: string; clientSecret: string }> = {}\n if (config.social?.google) {\n socialProviders.google = config.social.google\n }\n if (config.social?.github) {\n socialProviders.github = config.social.github\n }\n\n // Plugins are inlined so TS infers the literal tuple type.\n // DO NOT extract into a typed variable — `BetterAuthPlugin[]` erases endpoint types.\n // nextCookies() must be last — required for Next.js server actions.\n // Default to the pre-baked schema from `@murumets-ee/auth/schema` so\n // consumers don't need to generate one with `@better-auth/cli`. They\n // can still override via `auth({ schema: customSchema })` if they've\n // extended better-auth with plugins that add tables.\n const schema = config.schema ?? defaultAuthSchema\n\n // Widen the config to BetterAuthOptions so betterAuth() returns\n // Auth<BetterAuthOptions> (= BetterAuthBase). This is required because:\n // 1. Without a 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 the config erases plugin-specific API types at compile time,\n // but plugin endpoints (listUsers, etc.) are fully functional at runtime.\n // Consumers already access them via typed wrappers in AdminPagesConfig.\n const authOptions: BetterAuthOptions = {\n database: drizzleAdapter(app.db.readWrite, {\n provider: 'pg',\n schema,\n }),\n\n emailAndPassword: {\n enabled: config.providers?.includes('email') ?? true,\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: 'memory',\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(auditLogger),\n hooks: {\n before: buildSignupGate(config.signup?.enabled ?? false),\n ...(auditLogger ? buildRequestHooks(auditLogger) : {}),\n },\n\n plugins: [\n admin({ ac, roles, defaultRole: 'authenticated' }),\n ...(config.organizations ? [organization({ ac, roles })] : []),\n ...(config.betterAuthPlugins ?? []),\n nextCookies(),\n ],\n }\n\n return betterAuth(authOptions)\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"],"mappings":"2eA4BA,SAAS,EAAS,EAAc,CAC9B,IAAM,EAAW,GAAoE,SACjF,QACE,EAAO,GAAS,KACtB,MAAO,CACL,GAAI,GAAM,IAAO,GAAS,OAC1B,KAAM,GAAM,KACb,CAIH,MAAM,EAAc,IAAI,IAAI,CAAC,YAAa,YAAY,CAAC,CAEvD,SAAS,EAAmB,EAA2B,CAErD,SAAS,EAAM,EAA0C,CACvD,GAAa,IAAI,EAAM,CAAC,UAAY,GAAG,CAKzC,IAAI,EAAgD,KAEpD,MAAO,CACL,KAAM,CACJ,OAAQ,CACN,MAAO,KAAO,IAAkC,CAK9C,EAAM,CACJ,OAAQ,cACR,WAAY,OACZ,SAAU,EAAK,GACf,OAAQ,EAAK,GACb,SAAU,EAAK,KACf,QAAS,CACP,OAAQ,CAAE,KAAM,EAAK,KAAM,MAAO,EAAK,MAAO,CAC/C,CACF,CAAC,EAEL,CACD,OAAQ,CAEN,OAAQ,KAAO,IAAsC,CACnD,IAAM,EAAkC,EAAE,CAC1C,IAAK,GAAM,CAAC,EAAK,KAAU,OAAO,QAAQ,EAAS,CAC7C,EAAY,IAAI,EAAI,EAAI,IAAQ,OACpC,EAAO,GAAO,GAEhB,EAAgB,OAAO,KAAK,EAAO,CAAC,OAAS,EAAI,EAAS,MAG5D,MAAO,MAAO,EAA+B,IAAiB,CAC5D,IAAM,EAAQ,EAAS,EAAI,CACrB,EAAS,EACf,EAAgB,KAChB,EAAM,CACJ,OAAQ,mBACR,WAAY,OACZ,SAAU,EAAK,GACf,OAAQ,EAAM,GACd,SAAU,EAAM,KAChB,QAAS,EAAS,CAAE,SAAQ,CAAG,IAAA,GAC/B,SAAU,CACR,WAAY,EAAK,KAClB,CACF,CAAC,EAEL,CACD,OAAQ,CACN,MAAO,MAAO,EAA+B,IAAiB,CAC5D,IAAM,EAAQ,EAAS,EAAI,CAC3B,EAAM,CACJ,OAAQ,mBACR,WAAY,OACZ,SAAU,EAAK,GACf,OAAQ,EAAM,GACd,SAAU,EAAM,KAChB,SAAU,CACR,WAAY,EAAK,KAClB,CACF,CAAC,EAEL,CACF,CACF,CAQH,MAAM,EAAgB,CAAC,iBAAkB,kBAAkB,CAIrD,EAAyC,CAC7C,mBAAoB,uBACpB,mBAAoB,8BACpB,kBAAmB,sBACpB,CAGK,EAA4C,CAChD,kBAAmB,sBACnB,kBAAmB,iBACnB,oBAAqB,mBACrB,qBAAsB,yBACtB,qBAAsB,yBACtB,0BAA2B,yBAC3B,4BAA6B,gCAC7B,wBAAyB,4BACzB,yBAA0B,6BAC3B,CAED,SAAS,EAAkB,EAA0B,CACnD,SAAS,EAAM,EAA0C,CACvD,EAAY,IAAI,EAAM,CAAC,UAAY,GAAG,CAGxC,MAAO,CACL,MAAO,EAAqB,KAAO,IAAQ,CAGzC,GADiB,EAAc,KAAM,GAAM,EAAI,KAAK,WAAW,EAAE,CAAC,CACpD,CAEZ,IAAM,EADW,EAAI,QAAQ,UACJ,OACzB,GAAI,CAAC,EAAQ,OAEb,GAAI,GAAU,IAAK,CACjB,IAAM,EAAS,EAAI,MAA8C,MACjE,EAAM,CACJ,OAAQ,oBACR,SAAU,CACR,GAAI,OAAO,GAAU,SAAW,CAAE,QAAO,CAAG,EAAE,CAC9C,SACA,KAAM,EAAI,KACX,CACF,CAAC,KACG,CACL,IAAM,EAAa,EAAI,QAAQ,WAG3B,GAAY,MAAM,IACpB,EAAM,CACJ,OAAQ,aACR,WAAY,OACZ,SAAU,EAAW,KAAK,GAC1B,OAAQ,EAAW,KAAK,GACxB,SAAU,EAAW,KAAK,KAC3B,CAAC,CAGN,OAIF,GAAI,EAAI,OAAS,YAAe,CAC9B,IAAM,EAAW,EAAI,QAAQ,SAC7B,GAAI,CAAC,GAAU,QAAU,EAAS,OAAS,IAAK,CAC9C,IAAM,EAAQ,EAAS,EAAI,CACvB,EAAM,IACR,EAAM,CACJ,OAAQ,cACR,WAAY,OACZ,SAAU,EAAM,GAChB,OAAQ,EAAM,GACd,SAAU,EAAM,KACjB,CAAC,CAGN,OAIF,IAAM,EAAiB,EAAe,EAAI,MAC1C,GAAI,EAAgB,CAClB,IAAM,EAAW,EAAI,QAAQ,SAC7B,GAAI,CAAC,GAAU,QAAU,EAAS,OAAS,IAAK,CAC9C,IAAM,EAAQ,EAAS,EAAI,CACrB,EAAO,EAAI,KACjB,EAAM,CACJ,OAAQ,EACR,WAAY,OACZ,OAAQ,EAAM,GACd,SAAU,EAAM,KAChB,SAAU,CAER,GAAI,OAAO,GAAM,OAAU,SAAW,CAAE,MAAO,EAAK,MAAO,CAAG,EAAE,CAChE,KAAM,EAAI,KACX,CACF,CAAC,CAEJ,OAIF,IAAM,EAAc,EAAkB,EAAI,MAC1C,GAAI,CAAC,EAAa,OAElB,IAAM,EAAW,EAAI,QAAQ,SAC7B,GAAI,GAAU,QAAU,EAAS,QAAU,IAAK,OAEhD,IAAM,EAAQ,EAAS,EAAI,CACrB,EAAO,EAAI,KACjB,EAAM,CACJ,OAAQ,EACR,WAAY,OACZ,SAAW,GAAM,QAAqB,IAAA,GACtC,OAAQ,EAAM,GACd,SAAU,EAAM,KAChB,SAAU,CACR,aAAc,GAAM,OACpB,GAAI,GAAM,KAAO,CAAE,KAAM,EAAK,KAAgB,CAAG,EAAE,CACnD,KAAM,EAAI,KACX,CACF,CAAC,EACF,CACH,CAYH,SAAS,EAAgB,EAAwB,CAC/C,OAAO,EAAqB,KAAO,IAAQ,CACrC,KAAI,OAAS,kBACb,CAAC,EACH,MAAM,IAAI,EAAS,YAAa,CAAE,QAAS,oBAAqB,CAAC,EAEnE,CA2BJ,SAAgB,EACd,EACA,EACA,EACgB,CAChB,IAAM,EAAW,CAAC,GAAG,EAAI,SAAS,QAAQ,CAAC,CAErC,EAAK,EADO,EAAgB,EAAS,CACF,CACnC,EAA8B,EAAkB,EAAI,EAAS,CAG7D,EAA8E,EAAE,CAClF,EAAO,QAAQ,SACjB,EAAgB,OAAS,EAAO,OAAO,QAErC,EAAO,QAAQ,SACjB,EAAgB,OAAS,EAAO,OAAO,QAUzC,IAAM,EAAS,EAAO,QAAUA,EA0DhC,OAAO,EA/CgC,CACrC,SAAU,EAAe,EAAI,GAAG,UAAW,CACzC,SAAU,KACV,SACD,CAAC,CAEF,iBAAkB,CAChB,QAAS,EAAO,WAAW,SAAS,QAAQ,EAAI,GACjD,CAED,kBAEA,QAAS,CACP,UAAW,EAAO,SAAS,WAAa,KAAU,EAClD,UAAW,EAAO,SAAS,WAAa,KACzC,CAGD,UAAW,CACT,QAAS,GACT,OAAQ,GACR,IAAK,IACL,QAAS,SACT,YAAa,CACX,iBAAkB,CAAE,OAAQ,GAAI,IAAK,EAAG,CACxC,kBAAmB,CAAE,OAAQ,GAAI,IAAK,GAAI,CAC1C,iBAAkB,CAAE,OAAQ,GAAI,IAAK,EAAG,CACxC,mBAAoB,CAAE,OAAQ,GAAI,IAAK,EAAG,CAC1C,kBAAmB,CAAE,OAAQ,GAAI,IAAK,EAAG,CACzC,WAAY,CAAE,OAAQ,GAAI,IAAK,GAAI,CACpC,CACF,CAED,cAAe,EAAmB,EAAY,CAC9C,MAAO,CACL,OAAQ,EAAgB,EAAO,QAAQ,SAAW,GAAM,CACxD,GAAI,EAAc,EAAkB,EAAY,CAAG,EAAE,CACtD,CAED,QAAS,CACP,EAAM,CAAE,KAAI,QAAO,YAAa,gBAAiB,CAAC,CAClD,GAAI,EAAO,cAAgB,CAAC,EAAa,CAAE,KAAI,QAAO,CAAC,CAAC,CAAG,EAAE,CAC7D,GAAI,EAAO,mBAAqB,EAAE,CAClC,GAAa,CACd,CACF,CAE6B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/auth",
3
- "version": "0.4.0",
3
+ "version": "0.4.6",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,13 +29,13 @@
29
29
  "dist"
30
30
  ],
31
31
  "dependencies": {
32
- "better-auth": "^1.6.2",
33
- "drizzle-orm": "^0.45.0",
32
+ "better-auth": "^1.6.5",
33
+ "drizzle-orm": "^0.45.1",
34
34
  "server-only": "^0.0.1",
35
- "@murumets-ee/core": "0.4.0",
36
- "@murumets-ee/db": "0.4.0",
37
- "@murumets-ee/entity": "0.4.0",
38
- "@murumets-ee/logging": "0.4.0"
35
+ "@murumets-ee/entity": "0.4.6",
36
+ "@murumets-ee/db": "0.4.6",
37
+ "@murumets-ee/core": "0.4.6",
38
+ "@murumets-ee/logging": "0.4.6"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/node": "^22.10.5",
@@ -1,2 +0,0 @@
1
- import{createAccessControl as e}from"better-auth/plugins/access";const t=[`view`,`create`,`update`,`delete`],n=[`view`,`create`,`update`,`delete`,`publish`],r=[`admin`,`public`,`authenticated`],i={GET:`view`,POST:`create`,PATCH:`update`,DELETE:`delete`};function a(){return{public:{},authenticated:{}}}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(e){let r={...l};for(let i of e)r[i.name]=s(i)?n:t;return r}function d(e,t){let n={},r={};n.user=[...l.user],n.session=[...l.session],r.user=[],r.session=[];for(let e of t)n[e.name]=s(e)?[`view`,`create`,`update`,`delete`,`publish`]:[`view`,`create`,`update`,`delete`],r[e.name]=[`view`];return{admin:e.newRole(n),authenticated:e.newRole(r)}}export{d as a,c,i,u as l,n,a as o,r,o as s,t,e as u};
2
- //# sourceMappingURL=permissions-DREmJByu.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"permissions-DREmJByu.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'] as const\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 }\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 */\nexport function buildDefaultRoles(ac: ReturnType<typeof createAccessControl>, entities: Entity[]) {\n const adminPerms: Record<string, string[]> = {}\n const authenticatedPerms: 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 // Authenticated gets no user/session management\n authenticatedPerms.user = []\n authenticatedPerms.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 }\n\n return {\n admin: ac.newRole(adminPerms),\n authenticated: ac.newRole(authenticatedPerms),\n }\n}\n\nexport { createAccessControl }\n"],"mappings":"iEAeA,MAAM,EAAU,CAAC,OAAQ,SAAU,SAAU,SAAS,CAChD,EAAuB,CAAC,OAAQ,SAAU,SAAU,SAAU,UAAU,CASjE,EAAiB,CAAC,QAAS,SAAU,gBAAgB,CAGrD,EAA2C,CACtD,IAAK,OACL,KAAM,SACN,MAAO,SACP,OAAQ,SACT,CAYD,SAAgB,GAAwE,CACtF,MAAO,CACL,OAAQ,EAAE,CACV,cAAe,EAAE,CAClB,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,EAST,SAAgB,EAAkB,EAA4C,EAAoB,CAChG,IAAM,EAAuC,EAAE,CACzC,EAA+C,EAAE,CAGvD,EAAW,KAAO,CAAC,GAAG,EAAiB,KAAK,CAC5C,EAAW,QAAU,CAAC,GAAG,EAAiB,QAAQ,CAElD,EAAmB,KAAO,EAAE,CAC5B,EAAmB,QAAU,EAAE,CAE/B,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,CAG5C,MAAO,CACL,MAAO,EAAG,QAAQ,EAAW,CAC7B,cAAe,EAAG,QAAQ,EAAmB,CAC9C"}