@fonderie/permissions 5.1.6 → 5.2.0

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.
@@ -58,6 +58,7 @@ new PermissionsModule(store: IStoreAdapter, config?: IPermissionsConfig): Permis
58
58
 
59
59
  new PermissionsEngine(store: IStoreAdapter, config?: IPermissionsConfig): PermissionsEngine
60
60
  .catalog: readonly IPermissionCatalogEntry[] | null
61
+ .systemGrants: Record<string, Record<string, Operation[]>>
61
62
  .isKnown(permissionKey: string): boolean
62
63
  .operationsOf(permissionKey: string): Operation[]
63
64
  .effective(userId: string, workspaceId: string): Promise<IEffectivePermissions | null>
package/dist/index.cjs CHANGED
@@ -222,10 +222,10 @@ var PermissionsEngine = class {
222
222
  this.store = store;
223
223
  this.superRole = config.superRole ?? "owner";
224
224
  this.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;
225
- this.systemGrants = config.systemGrants ?? {};
225
+ this.grantsByRole = config.systemGrants ?? {};
226
226
  if (this.catalog) {
227
227
  const known = new Set(this.catalog.map((e) => e.key));
228
- for (const [role, grants] of Object.entries(this.systemGrants)) {
228
+ for (const [role, grants] of Object.entries(this.grantsByRole)) {
229
229
  for (const key of Object.keys(grants)) {
230
230
  if (!known.has(key)) {
231
231
  throw new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);
@@ -236,9 +236,22 @@ var PermissionsEngine = class {
236
236
  }
237
237
  store;
238
238
  superRole;
239
- systemGrants;
239
+ grantsByRole;
240
240
  /** The app's declared resources, or null when it declared none. */
241
241
  catalog;
242
+ /**
243
+ * What each SYSTEM role is granted by config (`systemGrants`) — rights that
244
+ * are never stored as rows, so a role editor reads them here to show them.
245
+ * A copy: changing it changes nothing.
246
+ */
247
+ get systemGrants() {
248
+ return Object.fromEntries(
249
+ Object.entries(this.grantsByRole).map(([role, grants]) => [
250
+ role,
251
+ Object.fromEntries(Object.entries(grants).map(([key, ops]) => [key, [...ops]]))
252
+ ])
253
+ );
254
+ }
242
255
  /** Is this key in the catalog? Always true when the app declared no catalog. */
243
256
  isKnown(permissionKey) {
244
257
  return !this.catalog || this.catalog.some((e) => e.key === permissionKey);
@@ -248,9 +261,9 @@ var PermissionsEngine = class {
248
261
  return this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;
249
262
  }
250
263
  async allows(userId, workspaceId, permissionKey, operation) {
251
- if (Object.keys(this.systemGrants).length > 0) {
264
+ if (Object.keys(this.grantsByRole).length > 0) {
252
265
  const roles = await listSystemRoleNames(userId, workspaceId, this.store);
253
- if (roles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
266
+ if (roles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
254
267
  }
255
268
  return checkPermission(userId, workspaceId, permissionKey, operation, this.store);
256
269
  }
@@ -272,11 +285,11 @@ var PermissionsEngine = class {
272
285
  return { isSuper: true, permissions };
273
286
  }
274
287
  const [systemRoles, stored] = await Promise.all([
275
- Object.keys(this.systemGrants).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),
288
+ Object.keys(this.grantsByRole).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),
276
289
  listGrantedPermissions(userId, workspaceId, this.store)
277
290
  ]);
278
291
  for (const role of systemRoles) {
279
- for (const [key, ops] of Object.entries(this.systemGrants[role] ?? {})) {
292
+ for (const [key, ops] of Object.entries(this.grantsByRole[role] ?? {})) {
280
293
  for (const op of ops) entry(key)[op] = true;
281
294
  }
282
295
  }
@@ -299,7 +312,7 @@ var PermissionsEngine = class {
299
312
  const a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);
300
313
  if (!a.member) return false;
301
314
  if (a.isSuper) return true;
302
- if (a.systemRoles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
315
+ if (a.systemRoles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
303
316
  return a.granted;
304
317
  }
305
318
  async assert(userId, operation, permissionKey, workspaceId) {
@@ -342,7 +355,7 @@ var PermissionsModule = class {
342
355
  name = "@fonderie/permissions";
343
356
  // Baked in at build time by tsup.base, so the operator's Modules page can
344
357
  // say what is actually deployed rather than 'not reported'.
345
- version = "5.1.6";
358
+ version = "5.2.0";
346
359
  deps = ["@fonderie/auth"];
347
360
  constructor(store, config = {}) {
348
361
  this.engine = new PermissionsEngine(store, config);
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/constants.ts","../src/services/membership.ts","../src/services/permissions.ts","../src/engine.ts","../src/module.ts","../src/middlewares/require-role.ts","../src/middlewares/require-permission.ts","../src/services/grants.ts"],"sourcesContent":["export type {\n\tOperation,\n\tPermissionKey,\n\tIRole,\n\tIPermission,\n\tIMembership,\n\tIRoleWithPermissions,\n\tIPermissionCatalogEntry,\n\tIEffectivePermissions,\n} from './types';\nexport { PermissionsModule } from './module';\nexport { PermissionsEngine, PermissionDeniedError } from './engine';\nexport type { IPermissionsConfig } from './config';\n\nexport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nexport { requireRole } from './middlewares/require-role';\nexport { requirePermission } from './middlewares/require-permission';\n\n// Access-grant export for the SOC 2 access-review register.\nexport { listGrants } from './services/grants';\nexport type { IGrant } from './services/grants';\n","import type { Operation } from './types';\n\n// Canonical definition moved to @fonderie/core; re-exported here for back-compat.\nexport { OPERATIONS } from '@fonderie/core';\n\nexport const PERMISSION_COLUMN: Record<Operation, string> = {\n\tcreate: 'can_create',\n\tread: 'can_read',\n\tupdate: 'can_update',\n\tdelete: 'can_delete',\n};\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMembership } from '../types';\n\nexport async function getMembership(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMembership | null> {\n\tconst [row] = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\n\tif (!row) return null;\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tworkspaceId: row.workspace_id,\n\t\troleId: row.role_id,\n\t\troleName: row.role_name,\n\t};\n}\n\n/**\n * Does the user hold the SUPER-ROLE in this workspace? The engine uses this for\n * its total-access bypass, so the match is deliberately restricted to a\n * **system** role: `r.is_system = true`. Without that guard, a member could\n * create a workspace-local role whose name equals the configured super-role\n * (e.g. 'ADMIN') and self-assign it to gain full access — a privilege\n * escalation. System roles are seeded by the app, not member-creatable. Also\n * requires `active = true` so a deactivated role grants nothing.\n */\nexport async function hasRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleName: string,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $3\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleName],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * Does the user hold ANY of the named roles in this workspace (active, not\n * removed/suspended)? Used by requireRole — an EXISTS over ALL of the user's\n * role rows, so a member holding several roles isn't denied because an\n * arbitrary single row (LIMIT 1) happened to be the wrong one. Not restricted\n * to system roles: requireRole checks arbitrary named roles.\n */\nexport async function hasAnyRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleNames: string[],\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tif (roleNames.length === 0) return false;\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = ANY($3)\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleNames],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * The SYSTEM roles (shared across workspaces) the user holds here — the ones\n * whose rights come from config (systemGrants). System only, for the same\n * reason as hasRole: a member-created local role named 'GUEST' must not pick\n * up the system GUEST's rights.\n */\nexport async function listSystemRoleNames(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<string[]> {\n\tconst rows = await store.query<{ name: string }>(\n\t\t`SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n\treturn rows.map((r) => r.name);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from '../types';\nimport { PERMISSION_COLUMN } from '../constants';\n\nexport async function checkPermission(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst col = PERMISSION_COLUMN[operation];\n\n\tconst [row] = await store.query<{ has_permission: boolean }>(\n\t\t`SELECT BOOL_OR(rp.${col}) AS has_permission\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY ruw.user_id, ruw.workspace_id`,\n\t\t[userId, workspaceId, permissionKey],\n\t);\n\n\treturn row?.has_permission ?? false;\n}\n\n/**\n * Everything one permission check needs, in ONE round-trip — the same four\n * predicates as getMembership, hasRole (super-role), listSystemRoleNames and\n * checkPermission, each written exactly as there, read side by side:\n *\n * member — an active row joined to an existing role (getMembership)\n * isSuper — holds the super-role as an ACTIVE SYSTEM role (hasRole)\n * systemRoles — the active SYSTEM roles held (listSystemRoleNames)\n * granted — a stored grant for `operation` on `permissionKey` (checkPermission)\n *\n * The caller applies the same order of decisions the engine always has.\n */\nexport async function readAccess(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tsuperRole: string,\n\tstore: IStoreAdapter,\n): Promise<{ member: boolean; isSuper: boolean; systemRoles: string[]; granted: boolean }> {\n\tconst col = PERMISSION_COLUMN[operation];\n\tconst [row] = await store.query<{ member: boolean; is_super: boolean; system_roles: string[] | null; granted: boolean | null }>(\n\t\t`SELECT\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS member,\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $4\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS is_super,\n\t\t ARRAY (\n\t\t SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS system_roles,\n\t\t (\n\t\t SELECT BOOL_OR(rp.${col})\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t ) AS granted`,\n\t\t[userId, workspaceId, permissionKey, superRole],\n\t);\n\treturn {\n\t\tmember: row?.member === true,\n\t\tisSuper: row?.is_super === true,\n\t\tsystemRoles: row?.system_roles ?? [],\n\t\tgranted: row?.granted === true,\n\t};\n}\n\n/**\n * Every grant the member's STORED roles give in this workspace, folded across\n * roles (a member with two roles gets the union) — one query for the\n * effective-permissions read instead of one check per key.\n */\nexport async function listGrantedPermissions(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<Array<{ permissionKey: string; create: boolean; read: boolean; update: boolean; delete: boolean }>> {\n\treturn store.query(\n\t\t`SELECT rp.permission_key AS \"permissionKey\",\n\t\t BOOL_OR(rp.can_create) AS \"create\",\n\t\t BOOL_OR(rp.can_read) AS \"read\",\n\t\t BOOL_OR(rp.can_update) AS \"update\",\n\t\t BOOL_OR(rp.can_delete) AS \"delete\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY rp.permission_key`,\n\t\t[userId, workspaceId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation, IPermissionCatalogEntry, IEffectivePermissions } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nimport { getMembership, hasRole, listSystemRoleNames } from './services/membership';\nimport { checkPermission, listGrantedPermissions, readAccess } from './services/permissions';\n\nconst ALL_OPERATIONS = Object.values(OPERATIONS) as Operation[];\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\tprivate systemGrants: Record<string, Record<string, Operation[]>>;\n\t/** The app's declared resources, or null when it declared none. */\n\treadonly catalog: readonly IPermissionCatalogEntry[] | null;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\n\t\tthis.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;\n\t\tthis.systemGrants = config.systemGrants ?? {};\n\t\t// A misspelled resource in systemGrants would grant nothing, silently.\n\t\t// Refuse to start instead — a config mistake, caught at boot.\n\t\tif (this.catalog) {\n\t\t\tconst known = new Set(this.catalog.map((e) => e.key));\n\t\t\tfor (const [role, grants] of Object.entries(this.systemGrants)) {\n\t\t\t\tfor (const key of Object.keys(grants)) {\n\t\t\t\t\tif (!known.has(key)) {\n\t\t\t\t\t\tthrow new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Is this key in the catalog? Always true when the app declared no catalog. */\n\tisKnown(permissionKey: string): boolean {\n\t\treturn !this.catalog || this.catalog.some((e) => e.key === permissionKey);\n\t}\n\n\t/** The operations a resource supports (all four when not declared). */\n\toperationsOf(permissionKey: string): Operation[] {\n\t\treturn this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;\n\t}\n\n\tprivate async allows(\n\t\tuserId: string,\n\t\tworkspaceId: string,\n\t\tpermissionKey: string,\n\t\toperation: Operation,\n\t): Promise<boolean> {\n\t\tif (Object.keys(this.systemGrants).length > 0) {\n\t\t\tconst roles = await listSystemRoleNames(userId, workspaceId, this.store);\n\t\t\tif (roles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\t}\n\t\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\n\t}\n\n\t/**\n\t * Everything this member may do in this workspace, in one read — what a\n\t * client needs to show only the actions that will succeed. Null when the\n\t * user is not a member. Lists the catalog's resources (every operation\n\t * present, true or false) plus any granted key outside it.\n\t */\n\tasync effective(userId: string, workspaceId: string): Promise<IEffectivePermissions | null> {\n\t\tif (!(await getMembership(userId, workspaceId, this.store))) return null;\n\n\t\tconst permissions: Record<string, Record<Operation, boolean>> = {};\n\t\tconst entry = (key: string) =>\n\t\t\t(permissions[key] ??= { create: false, read: false, update: false, delete: false });\n\t\tfor (const e of this.catalog ?? []) entry(e.key);\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) {\n\t\t\tfor (const e of this.catalog ?? []) {\n\t\t\t\tfor (const op of e.operations ?? ALL_OPERATIONS) entry(e.key)[op] = true;\n\t\t\t}\n\t\t\treturn { isSuper: true, permissions };\n\t\t}\n\n\t\tconst [systemRoles, stored] = await Promise.all([\n\t\t\tObject.keys(this.systemGrants).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),\n\t\t\tlistGrantedPermissions(userId, workspaceId, this.store),\n\t\t]);\n\t\tfor (const role of systemRoles) {\n\t\t\tfor (const [key, ops] of Object.entries(this.systemGrants[role] ?? {})) {\n\t\t\t\tfor (const op of ops) entry(key)[op] = true;\n\t\t\t}\n\t\t}\n\t\tfor (const g of stored) {\n\t\t\tconst e = entry(g.permissionKey);\n\t\t\tfor (const op of ALL_OPERATIONS) e[op] ||= Boolean(g[op]);\n\t\t}\n\t\treturn { isSuper: false, permissions };\n\t}\n\n\tasync getMembership(userId: string, workspaceId: string) {\n\t\treturn getMembership(userId, workspaceId, this.store);\n\t}\n\n\tasync can(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\t// An operation outside the four has no grant column; keep the old,\n\t\t// step-by-step path for it rather than change what it answers.\n\t\tif (!Object.hasOwn(PERMISSION_COLUMN, operation)) {\n\t\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\t\tif (!isMember) return false;\n\t\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\t\t\treturn this.allows(userId, workspaceId, permissionKey, operation);\n\t\t}\n\n\t\t// Member → super-role → config system grants → stored grants: the same\n\t\t// decisions in the same order, read in one round-trip.\n\t\tconst a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);\n\t\tif (!a.member) return false;\n\t\tif (a.isSuper) return true;\n\t\tif (a.systemRoles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\treturn a.granted;\n\t}\n\n\tasync assert(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<void> {\n\t\tconst allowed = await this.can(userId, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\tthrow new PermissionDeniedError(operation, permissionKey);\n\t\t}\n\t}\n\n\tasync canAll(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.every(Boolean);\n\t}\n\n\tasync canAny(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.some(Boolean);\n\t}\n}\n\nexport class PermissionDeniedError extends Error {\n\treadonly status = 403;\n\n\tconstructor(operation: string, permissionKey: string) {\n\t\tsuper(`Permission denied: ${operation}:${permissionKey}`);\n\t\tthis.name = 'PermissionDeniedError';\n\t}\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IPermissionsConfig } from './config';\nimport { PermissionsEngine } from './engine';\n\nexport const PERMISSIONS_ENGINE_KEY = 'fonderie.permissions.engine';\n\nexport class PermissionsModule implements IFonderieModule {\n\treadonly engine: PermissionsEngine;\n\treadonly name = '@fonderie/permissions';\n\t// Baked in at build time by tsup.base, so the operator's Modules page can\n\t// say what is actually deployed rather than 'not reported'.\n\treadonly version = process.env['FONDERIE_PKG_VERSION'] ?? '0.0.0-dev';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(store: IStoreAdapter, config: IPermissionsConfig = {}) {\n\t\tthis.engine = new PermissionsEngine(store, config);\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst engine = this.engine;\n\n\t\t// Inject engine into every request context\n\t\t// requirePermission() reads it from here — no manual passing needed\n\t\tapp.use(async (ctx, next) => {\n\t\t\tctx.meta[PERMISSIONS_ENGINE_KEY] = engine;\n\t\t\treturn next();\n\t\t});\n\t}\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\nimport { hasAnyRole } from '../services/membership';\n\nfunction makeHandler(roleName: string | string[], store: IStoreAdapter): Middleware {\n\tconst allowed = Array.isArray(roleName) ? roleName : [roleName];\n\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId =\n\t\t\tctx.workspace?.id ??\n\t\t\t(ctx.meta['params'] as Record<string, string> | undefined)?.['workspaceId'];\n\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\t// EXISTS across ALL of the user's roles — a member holding several roles\n\t\t// must not be denied because a single arbitrary row was the wrong one.\n\t\tif (!(await hasAnyRole(ctx.user.id, workspaceId, allowed, store))) {\n\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Insufficient role');\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requireRole(roleName: string | string[], store: IStoreAdapter): Middleware;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(roleName, store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\n\nimport type { Operation } from '../types';\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\n\nfunction makeHandler(operation: Operation, permissionKey: string): Middleware {\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId = resolveWorkspaceId(ctx);\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\tconst allowed = await engine.can(ctx.user.id, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.FORBIDDEN,\n\t\t\t\t'FORBIDDEN',\n\t\t\t\t`Permission denied: ${operation}:${permissionKey}`,\n\t\t\t);\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requirePermission(operation: Operation, permissionKey: string): Middleware;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(operation, permissionKey);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n\nfunction resolveWorkspaceId(ctx: {\n\tworkspace?: { id: string } | null;\n\tmeta: Record<string, unknown>;\n}): string | null {\n\tif (ctx.workspace?.id) return ctx.workspace.id;\n\n\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\treturn params?.['workspaceId'] ?? null;\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\n// Access-grant export (SOC 2 CC6.2/6.3) — feeds the access-review register with\n// the current who-has-what: every active user→workspace→role grant. Run this on\n// a schedule (or during a review) and diff against the previous snapshot.\n\nexport interface IGrant {\n\tuserId: string;\n\tworkspaceId: string;\n\troleId: string;\n\troleName: string;\n\tsuspended: boolean;\n}\n\nexport async function listGrants(store: IStoreAdapter): Promise<IGrant[]> {\n\tconst rows = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t\tsuspended: boolean;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name, ruw.suspended\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.removed = false\n\t\t ORDER BY ruw.workspace_id, ruw.user_id`,\n\t);\n\treturn rows.map((r) => ({\n\t\tuserId: r.user_id,\n\t\tworkspaceId: r.workspace_id,\n\t\troleId: r.role_id,\n\t\troleName: r.role_name,\n\t\tsuspended: r.suspended,\n\t}));\n}\n"],"mappings":"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","names":["import_core","import_core","makeHandler"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/constants.ts","../src/services/membership.ts","../src/services/permissions.ts","../src/engine.ts","../src/module.ts","../src/middlewares/require-role.ts","../src/middlewares/require-permission.ts","../src/services/grants.ts"],"sourcesContent":["export type {\n\tOperation,\n\tPermissionKey,\n\tIRole,\n\tIPermission,\n\tIMembership,\n\tIRoleWithPermissions,\n\tIPermissionCatalogEntry,\n\tIEffectivePermissions,\n} from './types';\nexport { PermissionsModule } from './module';\nexport { PermissionsEngine, PermissionDeniedError } from './engine';\nexport type { IPermissionsConfig } from './config';\n\nexport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nexport { requireRole } from './middlewares/require-role';\nexport { requirePermission } from './middlewares/require-permission';\n\n// Access-grant export for the SOC 2 access-review register.\nexport { listGrants } from './services/grants';\nexport type { IGrant } from './services/grants';\n","import type { Operation } from './types';\n\n// Canonical definition moved to @fonderie/core; re-exported here for back-compat.\nexport { OPERATIONS } from '@fonderie/core';\n\nexport const PERMISSION_COLUMN: Record<Operation, string> = {\n\tcreate: 'can_create',\n\tread: 'can_read',\n\tupdate: 'can_update',\n\tdelete: 'can_delete',\n};\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMembership } from '../types';\n\nexport async function getMembership(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMembership | null> {\n\tconst [row] = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\n\tif (!row) return null;\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tworkspaceId: row.workspace_id,\n\t\troleId: row.role_id,\n\t\troleName: row.role_name,\n\t};\n}\n\n/**\n * Does the user hold the SUPER-ROLE in this workspace? The engine uses this for\n * its total-access bypass, so the match is deliberately restricted to a\n * **system** role: `r.is_system = true`. Without that guard, a member could\n * create a workspace-local role whose name equals the configured super-role\n * (e.g. 'ADMIN') and self-assign it to gain full access — a privilege\n * escalation. System roles are seeded by the app, not member-creatable. Also\n * requires `active = true` so a deactivated role grants nothing.\n */\nexport async function hasRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleName: string,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $3\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleName],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * Does the user hold ANY of the named roles in this workspace (active, not\n * removed/suspended)? Used by requireRole — an EXISTS over ALL of the user's\n * role rows, so a member holding several roles isn't denied because an\n * arbitrary single row (LIMIT 1) happened to be the wrong one. Not restricted\n * to system roles: requireRole checks arbitrary named roles.\n */\nexport async function hasAnyRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleNames: string[],\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tif (roleNames.length === 0) return false;\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = ANY($3)\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleNames],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * The SYSTEM roles (shared across workspaces) the user holds here — the ones\n * whose rights come from config (systemGrants). System only, for the same\n * reason as hasRole: a member-created local role named 'GUEST' must not pick\n * up the system GUEST's rights.\n */\nexport async function listSystemRoleNames(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<string[]> {\n\tconst rows = await store.query<{ name: string }>(\n\t\t`SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n\treturn rows.map((r) => r.name);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from '../types';\nimport { PERMISSION_COLUMN } from '../constants';\n\nexport async function checkPermission(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst col = PERMISSION_COLUMN[operation];\n\n\tconst [row] = await store.query<{ has_permission: boolean }>(\n\t\t`SELECT BOOL_OR(rp.${col}) AS has_permission\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY ruw.user_id, ruw.workspace_id`,\n\t\t[userId, workspaceId, permissionKey],\n\t);\n\n\treturn row?.has_permission ?? false;\n}\n\n/**\n * Everything one permission check needs, in ONE round-trip — the same four\n * predicates as getMembership, hasRole (super-role), listSystemRoleNames and\n * checkPermission, each written exactly as there, read side by side:\n *\n * member — an active row joined to an existing role (getMembership)\n * isSuper — holds the super-role as an ACTIVE SYSTEM role (hasRole)\n * systemRoles — the active SYSTEM roles held (listSystemRoleNames)\n * granted — a stored grant for `operation` on `permissionKey` (checkPermission)\n *\n * The caller applies the same order of decisions the engine always has.\n */\nexport async function readAccess(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tsuperRole: string,\n\tstore: IStoreAdapter,\n): Promise<{ member: boolean; isSuper: boolean; systemRoles: string[]; granted: boolean }> {\n\tconst col = PERMISSION_COLUMN[operation];\n\tconst [row] = await store.query<{ member: boolean; is_super: boolean; system_roles: string[] | null; granted: boolean | null }>(\n\t\t`SELECT\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS member,\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $4\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS is_super,\n\t\t ARRAY (\n\t\t SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS system_roles,\n\t\t (\n\t\t SELECT BOOL_OR(rp.${col})\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t ) AS granted`,\n\t\t[userId, workspaceId, permissionKey, superRole],\n\t);\n\treturn {\n\t\tmember: row?.member === true,\n\t\tisSuper: row?.is_super === true,\n\t\tsystemRoles: row?.system_roles ?? [],\n\t\tgranted: row?.granted === true,\n\t};\n}\n\n/**\n * Every grant the member's STORED roles give in this workspace, folded across\n * roles (a member with two roles gets the union) — one query for the\n * effective-permissions read instead of one check per key.\n */\nexport async function listGrantedPermissions(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<Array<{ permissionKey: string; create: boolean; read: boolean; update: boolean; delete: boolean }>> {\n\treturn store.query(\n\t\t`SELECT rp.permission_key AS \"permissionKey\",\n\t\t BOOL_OR(rp.can_create) AS \"create\",\n\t\t BOOL_OR(rp.can_read) AS \"read\",\n\t\t BOOL_OR(rp.can_update) AS \"update\",\n\t\t BOOL_OR(rp.can_delete) AS \"delete\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY rp.permission_key`,\n\t\t[userId, workspaceId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation, IPermissionCatalogEntry, IEffectivePermissions } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nimport { getMembership, hasRole, listSystemRoleNames } from './services/membership';\nimport { checkPermission, listGrantedPermissions, readAccess } from './services/permissions';\n\nconst ALL_OPERATIONS = Object.values(OPERATIONS) as Operation[];\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\tprivate grantsByRole: Record<string, Record<string, Operation[]>>;\n\t/** The app's declared resources, or null when it declared none. */\n\treadonly catalog: readonly IPermissionCatalogEntry[] | null;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\n\t\tthis.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;\n\t\tthis.grantsByRole = config.systemGrants ?? {};\n\t\t// A misspelled resource in systemGrants would grant nothing, silently.\n\t\t// Refuse to start instead — a config mistake, caught at boot.\n\t\tif (this.catalog) {\n\t\t\tconst known = new Set(this.catalog.map((e) => e.key));\n\t\t\tfor (const [role, grants] of Object.entries(this.grantsByRole)) {\n\t\t\t\tfor (const key of Object.keys(grants)) {\n\t\t\t\t\tif (!known.has(key)) {\n\t\t\t\t\t\tthrow new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * What each SYSTEM role is granted by config (`systemGrants`) — rights that\n\t * are never stored as rows, so a role editor reads them here to show them.\n\t * A copy: changing it changes nothing.\n\t */\n\tget systemGrants(): Record<string, Record<string, Operation[]>> {\n\t\treturn Object.fromEntries(\n\t\t\tObject.entries(this.grantsByRole).map(([role, grants]) => [\n\t\t\t\trole,\n\t\t\t\tObject.fromEntries(Object.entries(grants).map(([key, ops]) => [key, [...ops]])),\n\t\t\t]),\n\t\t);\n\t}\n\n\t/** Is this key in the catalog? Always true when the app declared no catalog. */\n\tisKnown(permissionKey: string): boolean {\n\t\treturn !this.catalog || this.catalog.some((e) => e.key === permissionKey);\n\t}\n\n\t/** The operations a resource supports (all four when not declared). */\n\toperationsOf(permissionKey: string): Operation[] {\n\t\treturn this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;\n\t}\n\n\tprivate async allows(\n\t\tuserId: string,\n\t\tworkspaceId: string,\n\t\tpermissionKey: string,\n\t\toperation: Operation,\n\t): Promise<boolean> {\n\t\tif (Object.keys(this.grantsByRole).length > 0) {\n\t\t\tconst roles = await listSystemRoleNames(userId, workspaceId, this.store);\n\t\t\tif (roles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\t}\n\t\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\n\t}\n\n\t/**\n\t * Everything this member may do in this workspace, in one read — what a\n\t * client needs to show only the actions that will succeed. Null when the\n\t * user is not a member. Lists the catalog's resources (every operation\n\t * present, true or false) plus any granted key outside it.\n\t */\n\tasync effective(userId: string, workspaceId: string): Promise<IEffectivePermissions | null> {\n\t\tif (!(await getMembership(userId, workspaceId, this.store))) return null;\n\n\t\tconst permissions: Record<string, Record<Operation, boolean>> = {};\n\t\tconst entry = (key: string) =>\n\t\t\t(permissions[key] ??= { create: false, read: false, update: false, delete: false });\n\t\tfor (const e of this.catalog ?? []) entry(e.key);\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) {\n\t\t\tfor (const e of this.catalog ?? []) {\n\t\t\t\tfor (const op of e.operations ?? ALL_OPERATIONS) entry(e.key)[op] = true;\n\t\t\t}\n\t\t\treturn { isSuper: true, permissions };\n\t\t}\n\n\t\tconst [systemRoles, stored] = await Promise.all([\n\t\t\tObject.keys(this.grantsByRole).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),\n\t\t\tlistGrantedPermissions(userId, workspaceId, this.store),\n\t\t]);\n\t\tfor (const role of systemRoles) {\n\t\t\tfor (const [key, ops] of Object.entries(this.grantsByRole[role] ?? {})) {\n\t\t\t\tfor (const op of ops) entry(key)[op] = true;\n\t\t\t}\n\t\t}\n\t\tfor (const g of stored) {\n\t\t\tconst e = entry(g.permissionKey);\n\t\t\tfor (const op of ALL_OPERATIONS) e[op] ||= Boolean(g[op]);\n\t\t}\n\t\treturn { isSuper: false, permissions };\n\t}\n\n\tasync getMembership(userId: string, workspaceId: string) {\n\t\treturn getMembership(userId, workspaceId, this.store);\n\t}\n\n\tasync can(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\t// An operation outside the four has no grant column; keep the old,\n\t\t// step-by-step path for it rather than change what it answers.\n\t\tif (!Object.hasOwn(PERMISSION_COLUMN, operation)) {\n\t\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\t\tif (!isMember) return false;\n\t\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\t\t\treturn this.allows(userId, workspaceId, permissionKey, operation);\n\t\t}\n\n\t\t// Member → super-role → config system grants → stored grants: the same\n\t\t// decisions in the same order, read in one round-trip.\n\t\tconst a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);\n\t\tif (!a.member) return false;\n\t\tif (a.isSuper) return true;\n\t\tif (a.systemRoles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\treturn a.granted;\n\t}\n\n\tasync assert(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<void> {\n\t\tconst allowed = await this.can(userId, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\tthrow new PermissionDeniedError(operation, permissionKey);\n\t\t}\n\t}\n\n\tasync canAll(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.every(Boolean);\n\t}\n\n\tasync canAny(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.some(Boolean);\n\t}\n}\n\nexport class PermissionDeniedError extends Error {\n\treadonly status = 403;\n\n\tconstructor(operation: string, permissionKey: string) {\n\t\tsuper(`Permission denied: ${operation}:${permissionKey}`);\n\t\tthis.name = 'PermissionDeniedError';\n\t}\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IPermissionsConfig } from './config';\nimport { PermissionsEngine } from './engine';\n\nexport const PERMISSIONS_ENGINE_KEY = 'fonderie.permissions.engine';\n\nexport class PermissionsModule implements IFonderieModule {\n\treadonly engine: PermissionsEngine;\n\treadonly name = '@fonderie/permissions';\n\t// Baked in at build time by tsup.base, so the operator's Modules page can\n\t// say what is actually deployed rather than 'not reported'.\n\treadonly version = process.env['FONDERIE_PKG_VERSION'] ?? '0.0.0-dev';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(store: IStoreAdapter, config: IPermissionsConfig = {}) {\n\t\tthis.engine = new PermissionsEngine(store, config);\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst engine = this.engine;\n\n\t\t// Inject engine into every request context\n\t\t// requirePermission() reads it from here — no manual passing needed\n\t\tapp.use(async (ctx, next) => {\n\t\t\tctx.meta[PERMISSIONS_ENGINE_KEY] = engine;\n\t\t\treturn next();\n\t\t});\n\t}\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\nimport { hasAnyRole } from '../services/membership';\n\nfunction makeHandler(roleName: string | string[], store: IStoreAdapter): Middleware {\n\tconst allowed = Array.isArray(roleName) ? roleName : [roleName];\n\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId =\n\t\t\tctx.workspace?.id ??\n\t\t\t(ctx.meta['params'] as Record<string, string> | undefined)?.['workspaceId'];\n\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\t// EXISTS across ALL of the user's roles — a member holding several roles\n\t\t// must not be denied because a single arbitrary row was the wrong one.\n\t\tif (!(await hasAnyRole(ctx.user.id, workspaceId, allowed, store))) {\n\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Insufficient role');\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requireRole(roleName: string | string[], store: IStoreAdapter): Middleware;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(roleName, store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\n\nimport type { Operation } from '../types';\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\n\nfunction makeHandler(operation: Operation, permissionKey: string): Middleware {\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId = resolveWorkspaceId(ctx);\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\tconst allowed = await engine.can(ctx.user.id, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.FORBIDDEN,\n\t\t\t\t'FORBIDDEN',\n\t\t\t\t`Permission denied: ${operation}:${permissionKey}`,\n\t\t\t);\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requirePermission(operation: Operation, permissionKey: string): Middleware;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(operation, permissionKey);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n\nfunction resolveWorkspaceId(ctx: {\n\tworkspace?: { id: string } | null;\n\tmeta: Record<string, unknown>;\n}): string | null {\n\tif (ctx.workspace?.id) return ctx.workspace.id;\n\n\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\treturn params?.['workspaceId'] ?? null;\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\n// Access-grant export (SOC 2 CC6.2/6.3) — feeds the access-review register with\n// the current who-has-what: every active user→workspace→role grant. Run this on\n// a schedule (or during a review) and diff against the previous snapshot.\n\nexport interface IGrant {\n\tuserId: string;\n\tworkspaceId: string;\n\troleId: string;\n\troleName: string;\n\tsuspended: boolean;\n}\n\nexport async function listGrants(store: IStoreAdapter): Promise<IGrant[]> {\n\tconst rows = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t\tsuspended: boolean;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name, ruw.suspended\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.removed = false\n\t\t ORDER BY ruw.workspace_id, ruw.user_id`,\n\t);\n\treturn rows.map((r) => ({\n\t\tuserId: r.user_id,\n\t\tworkspaceId: r.workspace_id,\n\t\troleId: r.role_id,\n\t\troleName: r.role_name,\n\t\tsuspended: r.suspended,\n\t}));\n}\n"],"mappings":"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","names":["import_core","import_core","makeHandler"]}
package/dist/index.d.cts CHANGED
@@ -9,10 +9,16 @@ export { requirePermission, requireRole } from './middlewares/index.cjs';
9
9
  declare class PermissionsEngine {
10
10
  private store;
11
11
  private superRole;
12
- private systemGrants;
12
+ private grantsByRole;
13
13
  /** The app's declared resources, or null when it declared none. */
14
14
  readonly catalog: readonly IPermissionCatalogEntry[] | null;
15
15
  constructor(store: IStoreAdapter, config?: IPermissionsConfig);
16
+ /**
17
+ * What each SYSTEM role is granted by config (`systemGrants`) — rights that
18
+ * are never stored as rows, so a role editor reads them here to show them.
19
+ * A copy: changing it changes nothing.
20
+ */
21
+ get systemGrants(): Record<string, Record<string, Operation[]>>;
16
22
  /** Is this key in the catalog? Always true when the app declared no catalog. */
17
23
  isKnown(permissionKey: string): boolean;
18
24
  /** The operations a resource supports (all four when not declared). */
package/dist/index.d.ts CHANGED
@@ -9,10 +9,16 @@ export { requirePermission, requireRole } from './middlewares/index.js';
9
9
  declare class PermissionsEngine {
10
10
  private store;
11
11
  private superRole;
12
- private systemGrants;
12
+ private grantsByRole;
13
13
  /** The app's declared resources, or null when it declared none. */
14
14
  readonly catalog: readonly IPermissionCatalogEntry[] | null;
15
15
  constructor(store: IStoreAdapter, config?: IPermissionsConfig);
16
+ /**
17
+ * What each SYSTEM role is granted by config (`systemGrants`) — rights that
18
+ * are never stored as rows, so a role editor reads them here to show them.
19
+ * A copy: changing it changes nothing.
20
+ */
21
+ get systemGrants(): Record<string, Record<string, Operation[]>>;
16
22
  /** Is this key in the catalog? Always true when the app declared no catalog. */
17
23
  isKnown(permissionKey: string): boolean;
18
24
  /** The operations a resource supports (all four when not declared). */
package/dist/index.js CHANGED
@@ -189,10 +189,10 @@ var PermissionsEngine = class {
189
189
  this.store = store;
190
190
  this.superRole = config.superRole ?? "owner";
191
191
  this.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;
192
- this.systemGrants = config.systemGrants ?? {};
192
+ this.grantsByRole = config.systemGrants ?? {};
193
193
  if (this.catalog) {
194
194
  const known = new Set(this.catalog.map((e) => e.key));
195
- for (const [role, grants] of Object.entries(this.systemGrants)) {
195
+ for (const [role, grants] of Object.entries(this.grantsByRole)) {
196
196
  for (const key of Object.keys(grants)) {
197
197
  if (!known.has(key)) {
198
198
  throw new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);
@@ -203,9 +203,22 @@ var PermissionsEngine = class {
203
203
  }
204
204
  store;
205
205
  superRole;
206
- systemGrants;
206
+ grantsByRole;
207
207
  /** The app's declared resources, or null when it declared none. */
208
208
  catalog;
209
+ /**
210
+ * What each SYSTEM role is granted by config (`systemGrants`) — rights that
211
+ * are never stored as rows, so a role editor reads them here to show them.
212
+ * A copy: changing it changes nothing.
213
+ */
214
+ get systemGrants() {
215
+ return Object.fromEntries(
216
+ Object.entries(this.grantsByRole).map(([role, grants]) => [
217
+ role,
218
+ Object.fromEntries(Object.entries(grants).map(([key, ops]) => [key, [...ops]]))
219
+ ])
220
+ );
221
+ }
209
222
  /** Is this key in the catalog? Always true when the app declared no catalog. */
210
223
  isKnown(permissionKey) {
211
224
  return !this.catalog || this.catalog.some((e) => e.key === permissionKey);
@@ -215,9 +228,9 @@ var PermissionsEngine = class {
215
228
  return this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;
216
229
  }
217
230
  async allows(userId, workspaceId, permissionKey, operation) {
218
- if (Object.keys(this.systemGrants).length > 0) {
231
+ if (Object.keys(this.grantsByRole).length > 0) {
219
232
  const roles = await listSystemRoleNames(userId, workspaceId, this.store);
220
- if (roles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
233
+ if (roles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
221
234
  }
222
235
  return checkPermission(userId, workspaceId, permissionKey, operation, this.store);
223
236
  }
@@ -239,11 +252,11 @@ var PermissionsEngine = class {
239
252
  return { isSuper: true, permissions };
240
253
  }
241
254
  const [systemRoles, stored] = await Promise.all([
242
- Object.keys(this.systemGrants).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),
255
+ Object.keys(this.grantsByRole).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),
243
256
  listGrantedPermissions(userId, workspaceId, this.store)
244
257
  ]);
245
258
  for (const role of systemRoles) {
246
- for (const [key, ops] of Object.entries(this.systemGrants[role] ?? {})) {
259
+ for (const [key, ops] of Object.entries(this.grantsByRole[role] ?? {})) {
247
260
  for (const op of ops) entry(key)[op] = true;
248
261
  }
249
262
  }
@@ -266,7 +279,7 @@ var PermissionsEngine = class {
266
279
  const a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);
267
280
  if (!a.member) return false;
268
281
  if (a.isSuper) return true;
269
- if (a.systemRoles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
282
+ if (a.systemRoles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
270
283
  return a.granted;
271
284
  }
272
285
  async assert(userId, operation, permissionKey, workspaceId) {
@@ -309,7 +322,7 @@ var PermissionsModule = class {
309
322
  name = "@fonderie/permissions";
310
323
  // Baked in at build time by tsup.base, so the operator's Modules page can
311
324
  // say what is actually deployed rather than 'not reported'.
312
- version = "5.1.6";
325
+ version = "5.2.0";
313
326
  deps = ["@fonderie/auth"];
314
327
  constructor(store, config = {}) {
315
328
  this.engine = new PermissionsEngine(store, config);
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/constants.ts","../src/services/membership.ts","../src/services/permissions.ts","../src/engine.ts","../src/module.ts","../src/middlewares/require-role.ts","../src/middlewares/require-permission.ts","../src/services/grants.ts"],"sourcesContent":["import type { Operation } from './types';\n\n// Canonical definition moved to @fonderie/core; re-exported here for back-compat.\nexport { OPERATIONS } from '@fonderie/core';\n\nexport const PERMISSION_COLUMN: Record<Operation, string> = {\n\tcreate: 'can_create',\n\tread: 'can_read',\n\tupdate: 'can_update',\n\tdelete: 'can_delete',\n};\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMembership } from '../types';\n\nexport async function getMembership(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMembership | null> {\n\tconst [row] = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\n\tif (!row) return null;\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tworkspaceId: row.workspace_id,\n\t\troleId: row.role_id,\n\t\troleName: row.role_name,\n\t};\n}\n\n/**\n * Does the user hold the SUPER-ROLE in this workspace? The engine uses this for\n * its total-access bypass, so the match is deliberately restricted to a\n * **system** role: `r.is_system = true`. Without that guard, a member could\n * create a workspace-local role whose name equals the configured super-role\n * (e.g. 'ADMIN') and self-assign it to gain full access — a privilege\n * escalation. System roles are seeded by the app, not member-creatable. Also\n * requires `active = true` so a deactivated role grants nothing.\n */\nexport async function hasRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleName: string,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $3\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleName],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * Does the user hold ANY of the named roles in this workspace (active, not\n * removed/suspended)? Used by requireRole — an EXISTS over ALL of the user's\n * role rows, so a member holding several roles isn't denied because an\n * arbitrary single row (LIMIT 1) happened to be the wrong one. Not restricted\n * to system roles: requireRole checks arbitrary named roles.\n */\nexport async function hasAnyRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleNames: string[],\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tif (roleNames.length === 0) return false;\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = ANY($3)\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleNames],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * The SYSTEM roles (shared across workspaces) the user holds here — the ones\n * whose rights come from config (systemGrants). System only, for the same\n * reason as hasRole: a member-created local role named 'GUEST' must not pick\n * up the system GUEST's rights.\n */\nexport async function listSystemRoleNames(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<string[]> {\n\tconst rows = await store.query<{ name: string }>(\n\t\t`SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n\treturn rows.map((r) => r.name);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from '../types';\nimport { PERMISSION_COLUMN } from '../constants';\n\nexport async function checkPermission(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst col = PERMISSION_COLUMN[operation];\n\n\tconst [row] = await store.query<{ has_permission: boolean }>(\n\t\t`SELECT BOOL_OR(rp.${col}) AS has_permission\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY ruw.user_id, ruw.workspace_id`,\n\t\t[userId, workspaceId, permissionKey],\n\t);\n\n\treturn row?.has_permission ?? false;\n}\n\n/**\n * Everything one permission check needs, in ONE round-trip — the same four\n * predicates as getMembership, hasRole (super-role), listSystemRoleNames and\n * checkPermission, each written exactly as there, read side by side:\n *\n * member — an active row joined to an existing role (getMembership)\n * isSuper — holds the super-role as an ACTIVE SYSTEM role (hasRole)\n * systemRoles — the active SYSTEM roles held (listSystemRoleNames)\n * granted — a stored grant for `operation` on `permissionKey` (checkPermission)\n *\n * The caller applies the same order of decisions the engine always has.\n */\nexport async function readAccess(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tsuperRole: string,\n\tstore: IStoreAdapter,\n): Promise<{ member: boolean; isSuper: boolean; systemRoles: string[]; granted: boolean }> {\n\tconst col = PERMISSION_COLUMN[operation];\n\tconst [row] = await store.query<{ member: boolean; is_super: boolean; system_roles: string[] | null; granted: boolean | null }>(\n\t\t`SELECT\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS member,\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $4\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS is_super,\n\t\t ARRAY (\n\t\t SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS system_roles,\n\t\t (\n\t\t SELECT BOOL_OR(rp.${col})\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t ) AS granted`,\n\t\t[userId, workspaceId, permissionKey, superRole],\n\t);\n\treturn {\n\t\tmember: row?.member === true,\n\t\tisSuper: row?.is_super === true,\n\t\tsystemRoles: row?.system_roles ?? [],\n\t\tgranted: row?.granted === true,\n\t};\n}\n\n/**\n * Every grant the member's STORED roles give in this workspace, folded across\n * roles (a member with two roles gets the union) — one query for the\n * effective-permissions read instead of one check per key.\n */\nexport async function listGrantedPermissions(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<Array<{ permissionKey: string; create: boolean; read: boolean; update: boolean; delete: boolean }>> {\n\treturn store.query(\n\t\t`SELECT rp.permission_key AS \"permissionKey\",\n\t\t BOOL_OR(rp.can_create) AS \"create\",\n\t\t BOOL_OR(rp.can_read) AS \"read\",\n\t\t BOOL_OR(rp.can_update) AS \"update\",\n\t\t BOOL_OR(rp.can_delete) AS \"delete\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY rp.permission_key`,\n\t\t[userId, workspaceId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation, IPermissionCatalogEntry, IEffectivePermissions } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nimport { getMembership, hasRole, listSystemRoleNames } from './services/membership';\nimport { checkPermission, listGrantedPermissions, readAccess } from './services/permissions';\n\nconst ALL_OPERATIONS = Object.values(OPERATIONS) as Operation[];\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\tprivate systemGrants: Record<string, Record<string, Operation[]>>;\n\t/** The app's declared resources, or null when it declared none. */\n\treadonly catalog: readonly IPermissionCatalogEntry[] | null;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\n\t\tthis.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;\n\t\tthis.systemGrants = config.systemGrants ?? {};\n\t\t// A misspelled resource in systemGrants would grant nothing, silently.\n\t\t// Refuse to start instead — a config mistake, caught at boot.\n\t\tif (this.catalog) {\n\t\t\tconst known = new Set(this.catalog.map((e) => e.key));\n\t\t\tfor (const [role, grants] of Object.entries(this.systemGrants)) {\n\t\t\t\tfor (const key of Object.keys(grants)) {\n\t\t\t\t\tif (!known.has(key)) {\n\t\t\t\t\t\tthrow new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Is this key in the catalog? Always true when the app declared no catalog. */\n\tisKnown(permissionKey: string): boolean {\n\t\treturn !this.catalog || this.catalog.some((e) => e.key === permissionKey);\n\t}\n\n\t/** The operations a resource supports (all four when not declared). */\n\toperationsOf(permissionKey: string): Operation[] {\n\t\treturn this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;\n\t}\n\n\tprivate async allows(\n\t\tuserId: string,\n\t\tworkspaceId: string,\n\t\tpermissionKey: string,\n\t\toperation: Operation,\n\t): Promise<boolean> {\n\t\tif (Object.keys(this.systemGrants).length > 0) {\n\t\t\tconst roles = await listSystemRoleNames(userId, workspaceId, this.store);\n\t\t\tif (roles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\t}\n\t\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\n\t}\n\n\t/**\n\t * Everything this member may do in this workspace, in one read — what a\n\t * client needs to show only the actions that will succeed. Null when the\n\t * user is not a member. Lists the catalog's resources (every operation\n\t * present, true or false) plus any granted key outside it.\n\t */\n\tasync effective(userId: string, workspaceId: string): Promise<IEffectivePermissions | null> {\n\t\tif (!(await getMembership(userId, workspaceId, this.store))) return null;\n\n\t\tconst permissions: Record<string, Record<Operation, boolean>> = {};\n\t\tconst entry = (key: string) =>\n\t\t\t(permissions[key] ??= { create: false, read: false, update: false, delete: false });\n\t\tfor (const e of this.catalog ?? []) entry(e.key);\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) {\n\t\t\tfor (const e of this.catalog ?? []) {\n\t\t\t\tfor (const op of e.operations ?? ALL_OPERATIONS) entry(e.key)[op] = true;\n\t\t\t}\n\t\t\treturn { isSuper: true, permissions };\n\t\t}\n\n\t\tconst [systemRoles, stored] = await Promise.all([\n\t\t\tObject.keys(this.systemGrants).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),\n\t\t\tlistGrantedPermissions(userId, workspaceId, this.store),\n\t\t]);\n\t\tfor (const role of systemRoles) {\n\t\t\tfor (const [key, ops] of Object.entries(this.systemGrants[role] ?? {})) {\n\t\t\t\tfor (const op of ops) entry(key)[op] = true;\n\t\t\t}\n\t\t}\n\t\tfor (const g of stored) {\n\t\t\tconst e = entry(g.permissionKey);\n\t\t\tfor (const op of ALL_OPERATIONS) e[op] ||= Boolean(g[op]);\n\t\t}\n\t\treturn { isSuper: false, permissions };\n\t}\n\n\tasync getMembership(userId: string, workspaceId: string) {\n\t\treturn getMembership(userId, workspaceId, this.store);\n\t}\n\n\tasync can(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\t// An operation outside the four has no grant column; keep the old,\n\t\t// step-by-step path for it rather than change what it answers.\n\t\tif (!Object.hasOwn(PERMISSION_COLUMN, operation)) {\n\t\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\t\tif (!isMember) return false;\n\t\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\t\t\treturn this.allows(userId, workspaceId, permissionKey, operation);\n\t\t}\n\n\t\t// Member → super-role → config system grants → stored grants: the same\n\t\t// decisions in the same order, read in one round-trip.\n\t\tconst a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);\n\t\tif (!a.member) return false;\n\t\tif (a.isSuper) return true;\n\t\tif (a.systemRoles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\treturn a.granted;\n\t}\n\n\tasync assert(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<void> {\n\t\tconst allowed = await this.can(userId, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\tthrow new PermissionDeniedError(operation, permissionKey);\n\t\t}\n\t}\n\n\tasync canAll(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.every(Boolean);\n\t}\n\n\tasync canAny(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.some(Boolean);\n\t}\n}\n\nexport class PermissionDeniedError extends Error {\n\treadonly status = 403;\n\n\tconstructor(operation: string, permissionKey: string) {\n\t\tsuper(`Permission denied: ${operation}:${permissionKey}`);\n\t\tthis.name = 'PermissionDeniedError';\n\t}\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IPermissionsConfig } from './config';\nimport { PermissionsEngine } from './engine';\n\nexport const PERMISSIONS_ENGINE_KEY = 'fonderie.permissions.engine';\n\nexport class PermissionsModule implements IFonderieModule {\n\treadonly engine: PermissionsEngine;\n\treadonly name = '@fonderie/permissions';\n\t// Baked in at build time by tsup.base, so the operator's Modules page can\n\t// say what is actually deployed rather than 'not reported'.\n\treadonly version = process.env['FONDERIE_PKG_VERSION'] ?? '0.0.0-dev';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(store: IStoreAdapter, config: IPermissionsConfig = {}) {\n\t\tthis.engine = new PermissionsEngine(store, config);\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst engine = this.engine;\n\n\t\t// Inject engine into every request context\n\t\t// requirePermission() reads it from here — no manual passing needed\n\t\tapp.use(async (ctx, next) => {\n\t\t\tctx.meta[PERMISSIONS_ENGINE_KEY] = engine;\n\t\t\treturn next();\n\t\t});\n\t}\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\nimport { hasAnyRole } from '../services/membership';\n\nfunction makeHandler(roleName: string | string[], store: IStoreAdapter): Middleware {\n\tconst allowed = Array.isArray(roleName) ? roleName : [roleName];\n\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId =\n\t\t\tctx.workspace?.id ??\n\t\t\t(ctx.meta['params'] as Record<string, string> | undefined)?.['workspaceId'];\n\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\t// EXISTS across ALL of the user's roles — a member holding several roles\n\t\t// must not be denied because a single arbitrary row was the wrong one.\n\t\tif (!(await hasAnyRole(ctx.user.id, workspaceId, allowed, store))) {\n\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Insufficient role');\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requireRole(roleName: string | string[], store: IStoreAdapter): Middleware;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(roleName, store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\n\nimport type { Operation } from '../types';\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\n\nfunction makeHandler(operation: Operation, permissionKey: string): Middleware {\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId = resolveWorkspaceId(ctx);\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\tconst allowed = await engine.can(ctx.user.id, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.FORBIDDEN,\n\t\t\t\t'FORBIDDEN',\n\t\t\t\t`Permission denied: ${operation}:${permissionKey}`,\n\t\t\t);\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requirePermission(operation: Operation, permissionKey: string): Middleware;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(operation, permissionKey);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n\nfunction resolveWorkspaceId(ctx: {\n\tworkspace?: { id: string } | null;\n\tmeta: Record<string, unknown>;\n}): string | null {\n\tif (ctx.workspace?.id) return ctx.workspace.id;\n\n\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\treturn params?.['workspaceId'] ?? null;\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\n// Access-grant export (SOC 2 CC6.2/6.3) — feeds the access-review register with\n// the current who-has-what: every active user→workspace→role grant. Run this on\n// a schedule (or during a review) and diff against the previous snapshot.\n\nexport interface IGrant {\n\tuserId: string;\n\tworkspaceId: string;\n\troleId: string;\n\troleName: string;\n\tsuspended: boolean;\n}\n\nexport async function listGrants(store: IStoreAdapter): Promise<IGrant[]> {\n\tconst rows = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t\tsuspended: boolean;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name, ruw.suspended\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.removed = false\n\t\t ORDER BY ruw.workspace_id, ruw.user_id`,\n\t);\n\treturn rows.map((r) => ({\n\t\tuserId: r.user_id,\n\t\tworkspaceId: r.workspace_id,\n\t\troleId: r.role_id,\n\t\troleName: r.role_name,\n\t\tsuspended: r.suspended,\n\t}));\n}\n"],"mappings":"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","names":["setApiResponse","HTTP","makeHandler","setApiResponse","HTTP"]}
1
+ {"version":3,"sources":["../src/constants.ts","../src/services/membership.ts","../src/services/permissions.ts","../src/engine.ts","../src/module.ts","../src/middlewares/require-role.ts","../src/middlewares/require-permission.ts","../src/services/grants.ts"],"sourcesContent":["import type { Operation } from './types';\n\n// Canonical definition moved to @fonderie/core; re-exported here for back-compat.\nexport { OPERATIONS } from '@fonderie/core';\n\nexport const PERMISSION_COLUMN: Record<Operation, string> = {\n\tcreate: 'can_create',\n\tread: 'can_read',\n\tupdate: 'can_update',\n\tdelete: 'can_delete',\n};\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMembership } from '../types';\n\nexport async function getMembership(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMembership | null> {\n\tconst [row] = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\n\tif (!row) return null;\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tworkspaceId: row.workspace_id,\n\t\troleId: row.role_id,\n\t\troleName: row.role_name,\n\t};\n}\n\n/**\n * Does the user hold the SUPER-ROLE in this workspace? The engine uses this for\n * its total-access bypass, so the match is deliberately restricted to a\n * **system** role: `r.is_system = true`. Without that guard, a member could\n * create a workspace-local role whose name equals the configured super-role\n * (e.g. 'ADMIN') and self-assign it to gain full access — a privilege\n * escalation. System roles are seeded by the app, not member-creatable. Also\n * requires `active = true` so a deactivated role grants nothing.\n */\nexport async function hasRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleName: string,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $3\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleName],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * Does the user hold ANY of the named roles in this workspace (active, not\n * removed/suspended)? Used by requireRole — an EXISTS over ALL of the user's\n * role rows, so a member holding several roles isn't denied because an\n * arbitrary single row (LIMIT 1) happened to be the wrong one. Not restricted\n * to system roles: requireRole checks arbitrary named roles.\n */\nexport async function hasAnyRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleNames: string[],\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tif (roleNames.length === 0) return false;\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = ANY($3)\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleNames],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * The SYSTEM roles (shared across workspaces) the user holds here — the ones\n * whose rights come from config (systemGrants). System only, for the same\n * reason as hasRole: a member-created local role named 'GUEST' must not pick\n * up the system GUEST's rights.\n */\nexport async function listSystemRoleNames(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<string[]> {\n\tconst rows = await store.query<{ name: string }>(\n\t\t`SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n\treturn rows.map((r) => r.name);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from '../types';\nimport { PERMISSION_COLUMN } from '../constants';\n\nexport async function checkPermission(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst col = PERMISSION_COLUMN[operation];\n\n\tconst [row] = await store.query<{ has_permission: boolean }>(\n\t\t`SELECT BOOL_OR(rp.${col}) AS has_permission\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY ruw.user_id, ruw.workspace_id`,\n\t\t[userId, workspaceId, permissionKey],\n\t);\n\n\treturn row?.has_permission ?? false;\n}\n\n/**\n * Everything one permission check needs, in ONE round-trip — the same four\n * predicates as getMembership, hasRole (super-role), listSystemRoleNames and\n * checkPermission, each written exactly as there, read side by side:\n *\n * member — an active row joined to an existing role (getMembership)\n * isSuper — holds the super-role as an ACTIVE SYSTEM role (hasRole)\n * systemRoles — the active SYSTEM roles held (listSystemRoleNames)\n * granted — a stored grant for `operation` on `permissionKey` (checkPermission)\n *\n * The caller applies the same order of decisions the engine always has.\n */\nexport async function readAccess(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tsuperRole: string,\n\tstore: IStoreAdapter,\n): Promise<{ member: boolean; isSuper: boolean; systemRoles: string[]; granted: boolean }> {\n\tconst col = PERMISSION_COLUMN[operation];\n\tconst [row] = await store.query<{ member: boolean; is_super: boolean; system_roles: string[] | null; granted: boolean | null }>(\n\t\t`SELECT\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS member,\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $4\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS is_super,\n\t\t ARRAY (\n\t\t SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS system_roles,\n\t\t (\n\t\t SELECT BOOL_OR(rp.${col})\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t ) AS granted`,\n\t\t[userId, workspaceId, permissionKey, superRole],\n\t);\n\treturn {\n\t\tmember: row?.member === true,\n\t\tisSuper: row?.is_super === true,\n\t\tsystemRoles: row?.system_roles ?? [],\n\t\tgranted: row?.granted === true,\n\t};\n}\n\n/**\n * Every grant the member's STORED roles give in this workspace, folded across\n * roles (a member with two roles gets the union) — one query for the\n * effective-permissions read instead of one check per key.\n */\nexport async function listGrantedPermissions(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<Array<{ permissionKey: string; create: boolean; read: boolean; update: boolean; delete: boolean }>> {\n\treturn store.query(\n\t\t`SELECT rp.permission_key AS \"permissionKey\",\n\t\t BOOL_OR(rp.can_create) AS \"create\",\n\t\t BOOL_OR(rp.can_read) AS \"read\",\n\t\t BOOL_OR(rp.can_update) AS \"update\",\n\t\t BOOL_OR(rp.can_delete) AS \"delete\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY rp.permission_key`,\n\t\t[userId, workspaceId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation, IPermissionCatalogEntry, IEffectivePermissions } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nimport { getMembership, hasRole, listSystemRoleNames } from './services/membership';\nimport { checkPermission, listGrantedPermissions, readAccess } from './services/permissions';\n\nconst ALL_OPERATIONS = Object.values(OPERATIONS) as Operation[];\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\tprivate grantsByRole: Record<string, Record<string, Operation[]>>;\n\t/** The app's declared resources, or null when it declared none. */\n\treadonly catalog: readonly IPermissionCatalogEntry[] | null;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\n\t\tthis.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;\n\t\tthis.grantsByRole = config.systemGrants ?? {};\n\t\t// A misspelled resource in systemGrants would grant nothing, silently.\n\t\t// Refuse to start instead — a config mistake, caught at boot.\n\t\tif (this.catalog) {\n\t\t\tconst known = new Set(this.catalog.map((e) => e.key));\n\t\t\tfor (const [role, grants] of Object.entries(this.grantsByRole)) {\n\t\t\t\tfor (const key of Object.keys(grants)) {\n\t\t\t\t\tif (!known.has(key)) {\n\t\t\t\t\t\tthrow new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * What each SYSTEM role is granted by config (`systemGrants`) — rights that\n\t * are never stored as rows, so a role editor reads them here to show them.\n\t * A copy: changing it changes nothing.\n\t */\n\tget systemGrants(): Record<string, Record<string, Operation[]>> {\n\t\treturn Object.fromEntries(\n\t\t\tObject.entries(this.grantsByRole).map(([role, grants]) => [\n\t\t\t\trole,\n\t\t\t\tObject.fromEntries(Object.entries(grants).map(([key, ops]) => [key, [...ops]])),\n\t\t\t]),\n\t\t);\n\t}\n\n\t/** Is this key in the catalog? Always true when the app declared no catalog. */\n\tisKnown(permissionKey: string): boolean {\n\t\treturn !this.catalog || this.catalog.some((e) => e.key === permissionKey);\n\t}\n\n\t/** The operations a resource supports (all four when not declared). */\n\toperationsOf(permissionKey: string): Operation[] {\n\t\treturn this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;\n\t}\n\n\tprivate async allows(\n\t\tuserId: string,\n\t\tworkspaceId: string,\n\t\tpermissionKey: string,\n\t\toperation: Operation,\n\t): Promise<boolean> {\n\t\tif (Object.keys(this.grantsByRole).length > 0) {\n\t\t\tconst roles = await listSystemRoleNames(userId, workspaceId, this.store);\n\t\t\tif (roles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\t}\n\t\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\n\t}\n\n\t/**\n\t * Everything this member may do in this workspace, in one read — what a\n\t * client needs to show only the actions that will succeed. Null when the\n\t * user is not a member. Lists the catalog's resources (every operation\n\t * present, true or false) plus any granted key outside it.\n\t */\n\tasync effective(userId: string, workspaceId: string): Promise<IEffectivePermissions | null> {\n\t\tif (!(await getMembership(userId, workspaceId, this.store))) return null;\n\n\t\tconst permissions: Record<string, Record<Operation, boolean>> = {};\n\t\tconst entry = (key: string) =>\n\t\t\t(permissions[key] ??= { create: false, read: false, update: false, delete: false });\n\t\tfor (const e of this.catalog ?? []) entry(e.key);\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) {\n\t\t\tfor (const e of this.catalog ?? []) {\n\t\t\t\tfor (const op of e.operations ?? ALL_OPERATIONS) entry(e.key)[op] = true;\n\t\t\t}\n\t\t\treturn { isSuper: true, permissions };\n\t\t}\n\n\t\tconst [systemRoles, stored] = await Promise.all([\n\t\t\tObject.keys(this.grantsByRole).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),\n\t\t\tlistGrantedPermissions(userId, workspaceId, this.store),\n\t\t]);\n\t\tfor (const role of systemRoles) {\n\t\t\tfor (const [key, ops] of Object.entries(this.grantsByRole[role] ?? {})) {\n\t\t\t\tfor (const op of ops) entry(key)[op] = true;\n\t\t\t}\n\t\t}\n\t\tfor (const g of stored) {\n\t\t\tconst e = entry(g.permissionKey);\n\t\t\tfor (const op of ALL_OPERATIONS) e[op] ||= Boolean(g[op]);\n\t\t}\n\t\treturn { isSuper: false, permissions };\n\t}\n\n\tasync getMembership(userId: string, workspaceId: string) {\n\t\treturn getMembership(userId, workspaceId, this.store);\n\t}\n\n\tasync can(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\t// An operation outside the four has no grant column; keep the old,\n\t\t// step-by-step path for it rather than change what it answers.\n\t\tif (!Object.hasOwn(PERMISSION_COLUMN, operation)) {\n\t\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\t\tif (!isMember) return false;\n\t\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\t\t\treturn this.allows(userId, workspaceId, permissionKey, operation);\n\t\t}\n\n\t\t// Member → super-role → config system grants → stored grants: the same\n\t\t// decisions in the same order, read in one round-trip.\n\t\tconst a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);\n\t\tif (!a.member) return false;\n\t\tif (a.isSuper) return true;\n\t\tif (a.systemRoles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\treturn a.granted;\n\t}\n\n\tasync assert(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<void> {\n\t\tconst allowed = await this.can(userId, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\tthrow new PermissionDeniedError(operation, permissionKey);\n\t\t}\n\t}\n\n\tasync canAll(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.every(Boolean);\n\t}\n\n\tasync canAny(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.some(Boolean);\n\t}\n}\n\nexport class PermissionDeniedError extends Error {\n\treadonly status = 403;\n\n\tconstructor(operation: string, permissionKey: string) {\n\t\tsuper(`Permission denied: ${operation}:${permissionKey}`);\n\t\tthis.name = 'PermissionDeniedError';\n\t}\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IPermissionsConfig } from './config';\nimport { PermissionsEngine } from './engine';\n\nexport const PERMISSIONS_ENGINE_KEY = 'fonderie.permissions.engine';\n\nexport class PermissionsModule implements IFonderieModule {\n\treadonly engine: PermissionsEngine;\n\treadonly name = '@fonderie/permissions';\n\t// Baked in at build time by tsup.base, so the operator's Modules page can\n\t// say what is actually deployed rather than 'not reported'.\n\treadonly version = process.env['FONDERIE_PKG_VERSION'] ?? '0.0.0-dev';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(store: IStoreAdapter, config: IPermissionsConfig = {}) {\n\t\tthis.engine = new PermissionsEngine(store, config);\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst engine = this.engine;\n\n\t\t// Inject engine into every request context\n\t\t// requirePermission() reads it from here — no manual passing needed\n\t\tapp.use(async (ctx, next) => {\n\t\t\tctx.meta[PERMISSIONS_ENGINE_KEY] = engine;\n\t\t\treturn next();\n\t\t});\n\t}\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\nimport { hasAnyRole } from '../services/membership';\n\nfunction makeHandler(roleName: string | string[], store: IStoreAdapter): Middleware {\n\tconst allowed = Array.isArray(roleName) ? roleName : [roleName];\n\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId =\n\t\t\tctx.workspace?.id ??\n\t\t\t(ctx.meta['params'] as Record<string, string> | undefined)?.['workspaceId'];\n\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\t// EXISTS across ALL of the user's roles — a member holding several roles\n\t\t// must not be denied because a single arbitrary row was the wrong one.\n\t\tif (!(await hasAnyRole(ctx.user.id, workspaceId, allowed, store))) {\n\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Insufficient role');\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requireRole(roleName: string | string[], store: IStoreAdapter): Middleware;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(roleName, store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\n\nimport type { Operation } from '../types';\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\n\nfunction makeHandler(operation: Operation, permissionKey: string): Middleware {\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId = resolveWorkspaceId(ctx);\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\tconst allowed = await engine.can(ctx.user.id, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.FORBIDDEN,\n\t\t\t\t'FORBIDDEN',\n\t\t\t\t`Permission denied: ${operation}:${permissionKey}`,\n\t\t\t);\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requirePermission(operation: Operation, permissionKey: string): Middleware;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(operation, permissionKey);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n\nfunction resolveWorkspaceId(ctx: {\n\tworkspace?: { id: string } | null;\n\tmeta: Record<string, unknown>;\n}): string | null {\n\tif (ctx.workspace?.id) return ctx.workspace.id;\n\n\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\treturn params?.['workspaceId'] ?? null;\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\n// Access-grant export (SOC 2 CC6.2/6.3) — feeds the access-review register with\n// the current who-has-what: every active user→workspace→role grant. Run this on\n// a schedule (or during a review) and diff against the previous snapshot.\n\nexport interface IGrant {\n\tuserId: string;\n\tworkspaceId: string;\n\troleId: string;\n\troleName: string;\n\tsuspended: boolean;\n}\n\nexport async function listGrants(store: IStoreAdapter): Promise<IGrant[]> {\n\tconst rows = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t\tsuspended: boolean;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name, ruw.suspended\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.removed = false\n\t\t ORDER BY ruw.workspace_id, ruw.user_id`,\n\t);\n\treturn rows.map((r) => ({\n\t\tuserId: r.user_id,\n\t\tworkspaceId: r.workspace_id,\n\t\troleId: r.role_id,\n\t\troleName: r.role_name,\n\t\tsuspended: r.suspended,\n\t}));\n}\n"],"mappings":"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","names":["setApiResponse","HTTP","makeHandler","setApiResponse","HTTP"]}
@@ -219,10 +219,10 @@ var PermissionsEngine = class {
219
219
  this.store = store;
220
220
  this.superRole = config.superRole ?? "owner";
221
221
  this.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;
222
- this.systemGrants = config.systemGrants ?? {};
222
+ this.grantsByRole = config.systemGrants ?? {};
223
223
  if (this.catalog) {
224
224
  const known = new Set(this.catalog.map((e) => e.key));
225
- for (const [role, grants] of Object.entries(this.systemGrants)) {
225
+ for (const [role, grants] of Object.entries(this.grantsByRole)) {
226
226
  for (const key of Object.keys(grants)) {
227
227
  if (!known.has(key)) {
228
228
  throw new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);
@@ -233,9 +233,22 @@ var PermissionsEngine = class {
233
233
  }
234
234
  store;
235
235
  superRole;
236
- systemGrants;
236
+ grantsByRole;
237
237
  /** The app's declared resources, or null when it declared none. */
238
238
  catalog;
239
+ /**
240
+ * What each SYSTEM role is granted by config (`systemGrants`) — rights that
241
+ * are never stored as rows, so a role editor reads them here to show them.
242
+ * A copy: changing it changes nothing.
243
+ */
244
+ get systemGrants() {
245
+ return Object.fromEntries(
246
+ Object.entries(this.grantsByRole).map(([role, grants]) => [
247
+ role,
248
+ Object.fromEntries(Object.entries(grants).map(([key, ops]) => [key, [...ops]]))
249
+ ])
250
+ );
251
+ }
239
252
  /** Is this key in the catalog? Always true when the app declared no catalog. */
240
253
  isKnown(permissionKey) {
241
254
  return !this.catalog || this.catalog.some((e) => e.key === permissionKey);
@@ -245,9 +258,9 @@ var PermissionsEngine = class {
245
258
  return this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;
246
259
  }
247
260
  async allows(userId, workspaceId, permissionKey, operation) {
248
- if (Object.keys(this.systemGrants).length > 0) {
261
+ if (Object.keys(this.grantsByRole).length > 0) {
249
262
  const roles = await listSystemRoleNames(userId, workspaceId, this.store);
250
- if (roles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
263
+ if (roles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
251
264
  }
252
265
  return checkPermission(userId, workspaceId, permissionKey, operation, this.store);
253
266
  }
@@ -269,11 +282,11 @@ var PermissionsEngine = class {
269
282
  return { isSuper: true, permissions };
270
283
  }
271
284
  const [systemRoles, stored] = await Promise.all([
272
- Object.keys(this.systemGrants).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),
285
+ Object.keys(this.grantsByRole).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),
273
286
  listGrantedPermissions(userId, workspaceId, this.store)
274
287
  ]);
275
288
  for (const role of systemRoles) {
276
- for (const [key, ops] of Object.entries(this.systemGrants[role] ?? {})) {
289
+ for (const [key, ops] of Object.entries(this.grantsByRole[role] ?? {})) {
277
290
  for (const op of ops) entry(key)[op] = true;
278
291
  }
279
292
  }
@@ -296,7 +309,7 @@ var PermissionsEngine = class {
296
309
  const a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);
297
310
  if (!a.member) return false;
298
311
  if (a.isSuper) return true;
299
- if (a.systemRoles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
312
+ if (a.systemRoles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
300
313
  return a.granted;
301
314
  }
302
315
  async assert(userId, operation, permissionKey, workspaceId) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/middlewares/index.ts","../../src/middlewares/require-role.ts","../../src/constants.ts","../../src/services/membership.ts","../../src/services/permissions.ts","../../src/engine.ts","../../src/module.ts","../../src/middlewares/require-permission.ts"],"sourcesContent":["export { requireRole } from './require-role';\nexport { requirePermission } from './require-permission';\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\nimport { hasAnyRole } from '../services/membership';\n\nfunction makeHandler(roleName: string | string[], store: IStoreAdapter): Middleware {\n\tconst allowed = Array.isArray(roleName) ? roleName : [roleName];\n\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId =\n\t\t\tctx.workspace?.id ??\n\t\t\t(ctx.meta['params'] as Record<string, string> | undefined)?.['workspaceId'];\n\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\t// EXISTS across ALL of the user's roles — a member holding several roles\n\t\t// must not be denied because a single arbitrary row was the wrong one.\n\t\tif (!(await hasAnyRole(ctx.user.id, workspaceId, allowed, store))) {\n\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Insufficient role');\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requireRole(roleName: string | string[], store: IStoreAdapter): Middleware;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(roleName, store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import type { Operation } from './types';\n\n// Canonical definition moved to @fonderie/core; re-exported here for back-compat.\nexport { OPERATIONS } from '@fonderie/core';\n\nexport const PERMISSION_COLUMN: Record<Operation, string> = {\n\tcreate: 'can_create',\n\tread: 'can_read',\n\tupdate: 'can_update',\n\tdelete: 'can_delete',\n};\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMembership } from '../types';\n\nexport async function getMembership(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMembership | null> {\n\tconst [row] = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\n\tif (!row) return null;\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tworkspaceId: row.workspace_id,\n\t\troleId: row.role_id,\n\t\troleName: row.role_name,\n\t};\n}\n\n/**\n * Does the user hold the SUPER-ROLE in this workspace? The engine uses this for\n * its total-access bypass, so the match is deliberately restricted to a\n * **system** role: `r.is_system = true`. Without that guard, a member could\n * create a workspace-local role whose name equals the configured super-role\n * (e.g. 'ADMIN') and self-assign it to gain full access — a privilege\n * escalation. System roles are seeded by the app, not member-creatable. Also\n * requires `active = true` so a deactivated role grants nothing.\n */\nexport async function hasRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleName: string,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $3\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleName],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * Does the user hold ANY of the named roles in this workspace (active, not\n * removed/suspended)? Used by requireRole — an EXISTS over ALL of the user's\n * role rows, so a member holding several roles isn't denied because an\n * arbitrary single row (LIMIT 1) happened to be the wrong one. Not restricted\n * to system roles: requireRole checks arbitrary named roles.\n */\nexport async function hasAnyRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleNames: string[],\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tif (roleNames.length === 0) return false;\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = ANY($3)\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleNames],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * The SYSTEM roles (shared across workspaces) the user holds here — the ones\n * whose rights come from config (systemGrants). System only, for the same\n * reason as hasRole: a member-created local role named 'GUEST' must not pick\n * up the system GUEST's rights.\n */\nexport async function listSystemRoleNames(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<string[]> {\n\tconst rows = await store.query<{ name: string }>(\n\t\t`SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n\treturn rows.map((r) => r.name);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from '../types';\nimport { PERMISSION_COLUMN } from '../constants';\n\nexport async function checkPermission(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst col = PERMISSION_COLUMN[operation];\n\n\tconst [row] = await store.query<{ has_permission: boolean }>(\n\t\t`SELECT BOOL_OR(rp.${col}) AS has_permission\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY ruw.user_id, ruw.workspace_id`,\n\t\t[userId, workspaceId, permissionKey],\n\t);\n\n\treturn row?.has_permission ?? false;\n}\n\n/**\n * Everything one permission check needs, in ONE round-trip — the same four\n * predicates as getMembership, hasRole (super-role), listSystemRoleNames and\n * checkPermission, each written exactly as there, read side by side:\n *\n * member — an active row joined to an existing role (getMembership)\n * isSuper — holds the super-role as an ACTIVE SYSTEM role (hasRole)\n * systemRoles — the active SYSTEM roles held (listSystemRoleNames)\n * granted — a stored grant for `operation` on `permissionKey` (checkPermission)\n *\n * The caller applies the same order of decisions the engine always has.\n */\nexport async function readAccess(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tsuperRole: string,\n\tstore: IStoreAdapter,\n): Promise<{ member: boolean; isSuper: boolean; systemRoles: string[]; granted: boolean }> {\n\tconst col = PERMISSION_COLUMN[operation];\n\tconst [row] = await store.query<{ member: boolean; is_super: boolean; system_roles: string[] | null; granted: boolean | null }>(\n\t\t`SELECT\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS member,\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $4\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS is_super,\n\t\t ARRAY (\n\t\t SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS system_roles,\n\t\t (\n\t\t SELECT BOOL_OR(rp.${col})\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t ) AS granted`,\n\t\t[userId, workspaceId, permissionKey, superRole],\n\t);\n\treturn {\n\t\tmember: row?.member === true,\n\t\tisSuper: row?.is_super === true,\n\t\tsystemRoles: row?.system_roles ?? [],\n\t\tgranted: row?.granted === true,\n\t};\n}\n\n/**\n * Every grant the member's STORED roles give in this workspace, folded across\n * roles (a member with two roles gets the union) — one query for the\n * effective-permissions read instead of one check per key.\n */\nexport async function listGrantedPermissions(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<Array<{ permissionKey: string; create: boolean; read: boolean; update: boolean; delete: boolean }>> {\n\treturn store.query(\n\t\t`SELECT rp.permission_key AS \"permissionKey\",\n\t\t BOOL_OR(rp.can_create) AS \"create\",\n\t\t BOOL_OR(rp.can_read) AS \"read\",\n\t\t BOOL_OR(rp.can_update) AS \"update\",\n\t\t BOOL_OR(rp.can_delete) AS \"delete\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY rp.permission_key`,\n\t\t[userId, workspaceId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation, IPermissionCatalogEntry, IEffectivePermissions } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nimport { getMembership, hasRole, listSystemRoleNames } from './services/membership';\nimport { checkPermission, listGrantedPermissions, readAccess } from './services/permissions';\n\nconst ALL_OPERATIONS = Object.values(OPERATIONS) as Operation[];\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\tprivate systemGrants: Record<string, Record<string, Operation[]>>;\n\t/** The app's declared resources, or null when it declared none. */\n\treadonly catalog: readonly IPermissionCatalogEntry[] | null;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\n\t\tthis.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;\n\t\tthis.systemGrants = config.systemGrants ?? {};\n\t\t// A misspelled resource in systemGrants would grant nothing, silently.\n\t\t// Refuse to start instead — a config mistake, caught at boot.\n\t\tif (this.catalog) {\n\t\t\tconst known = new Set(this.catalog.map((e) => e.key));\n\t\t\tfor (const [role, grants] of Object.entries(this.systemGrants)) {\n\t\t\t\tfor (const key of Object.keys(grants)) {\n\t\t\t\t\tif (!known.has(key)) {\n\t\t\t\t\t\tthrow new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Is this key in the catalog? Always true when the app declared no catalog. */\n\tisKnown(permissionKey: string): boolean {\n\t\treturn !this.catalog || this.catalog.some((e) => e.key === permissionKey);\n\t}\n\n\t/** The operations a resource supports (all four when not declared). */\n\toperationsOf(permissionKey: string): Operation[] {\n\t\treturn this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;\n\t}\n\n\tprivate async allows(\n\t\tuserId: string,\n\t\tworkspaceId: string,\n\t\tpermissionKey: string,\n\t\toperation: Operation,\n\t): Promise<boolean> {\n\t\tif (Object.keys(this.systemGrants).length > 0) {\n\t\t\tconst roles = await listSystemRoleNames(userId, workspaceId, this.store);\n\t\t\tif (roles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\t}\n\t\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\n\t}\n\n\t/**\n\t * Everything this member may do in this workspace, in one read — what a\n\t * client needs to show only the actions that will succeed. Null when the\n\t * user is not a member. Lists the catalog's resources (every operation\n\t * present, true or false) plus any granted key outside it.\n\t */\n\tasync effective(userId: string, workspaceId: string): Promise<IEffectivePermissions | null> {\n\t\tif (!(await getMembership(userId, workspaceId, this.store))) return null;\n\n\t\tconst permissions: Record<string, Record<Operation, boolean>> = {};\n\t\tconst entry = (key: string) =>\n\t\t\t(permissions[key] ??= { create: false, read: false, update: false, delete: false });\n\t\tfor (const e of this.catalog ?? []) entry(e.key);\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) {\n\t\t\tfor (const e of this.catalog ?? []) {\n\t\t\t\tfor (const op of e.operations ?? ALL_OPERATIONS) entry(e.key)[op] = true;\n\t\t\t}\n\t\t\treturn { isSuper: true, permissions };\n\t\t}\n\n\t\tconst [systemRoles, stored] = await Promise.all([\n\t\t\tObject.keys(this.systemGrants).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),\n\t\t\tlistGrantedPermissions(userId, workspaceId, this.store),\n\t\t]);\n\t\tfor (const role of systemRoles) {\n\t\t\tfor (const [key, ops] of Object.entries(this.systemGrants[role] ?? {})) {\n\t\t\t\tfor (const op of ops) entry(key)[op] = true;\n\t\t\t}\n\t\t}\n\t\tfor (const g of stored) {\n\t\t\tconst e = entry(g.permissionKey);\n\t\t\tfor (const op of ALL_OPERATIONS) e[op] ||= Boolean(g[op]);\n\t\t}\n\t\treturn { isSuper: false, permissions };\n\t}\n\n\tasync getMembership(userId: string, workspaceId: string) {\n\t\treturn getMembership(userId, workspaceId, this.store);\n\t}\n\n\tasync can(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\t// An operation outside the four has no grant column; keep the old,\n\t\t// step-by-step path for it rather than change what it answers.\n\t\tif (!Object.hasOwn(PERMISSION_COLUMN, operation)) {\n\t\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\t\tif (!isMember) return false;\n\t\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\t\t\treturn this.allows(userId, workspaceId, permissionKey, operation);\n\t\t}\n\n\t\t// Member → super-role → config system grants → stored grants: the same\n\t\t// decisions in the same order, read in one round-trip.\n\t\tconst a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);\n\t\tif (!a.member) return false;\n\t\tif (a.isSuper) return true;\n\t\tif (a.systemRoles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\treturn a.granted;\n\t}\n\n\tasync assert(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<void> {\n\t\tconst allowed = await this.can(userId, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\tthrow new PermissionDeniedError(operation, permissionKey);\n\t\t}\n\t}\n\n\tasync canAll(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.every(Boolean);\n\t}\n\n\tasync canAny(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.some(Boolean);\n\t}\n}\n\nexport class PermissionDeniedError extends Error {\n\treadonly status = 403;\n\n\tconstructor(operation: string, permissionKey: string) {\n\t\tsuper(`Permission denied: ${operation}:${permissionKey}`);\n\t\tthis.name = 'PermissionDeniedError';\n\t}\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IPermissionsConfig } from './config';\nimport { PermissionsEngine } from './engine';\n\nexport const PERMISSIONS_ENGINE_KEY = 'fonderie.permissions.engine';\n\nexport class PermissionsModule implements IFonderieModule {\n\treadonly engine: PermissionsEngine;\n\treadonly name = '@fonderie/permissions';\n\t// Baked in at build time by tsup.base, so the operator's Modules page can\n\t// say what is actually deployed rather than 'not reported'.\n\treadonly version = process.env['FONDERIE_PKG_VERSION'] ?? '0.0.0-dev';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(store: IStoreAdapter, config: IPermissionsConfig = {}) {\n\t\tthis.engine = new PermissionsEngine(store, config);\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst engine = this.engine;\n\n\t\t// Inject engine into every request context\n\t\t// requirePermission() reads it from here — no manual passing needed\n\t\tapp.use(async (ctx, next) => {\n\t\t\tctx.meta[PERMISSIONS_ENGINE_KEY] = engine;\n\t\t\treturn next();\n\t\t});\n\t}\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\n\nimport type { Operation } from '../types';\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\n\nfunction makeHandler(operation: Operation, permissionKey: string): Middleware {\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId = resolveWorkspaceId(ctx);\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\tconst allowed = await engine.can(ctx.user.id, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.FORBIDDEN,\n\t\t\t\t'FORBIDDEN',\n\t\t\t\t`Permission denied: ${operation}:${permissionKey}`,\n\t\t\t);\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requirePermission(operation: Operation, permissionKey: string): Middleware;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(operation, permissionKey);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n\nfunction resolveWorkspaceId(ctx: {\n\tworkspace?: { id: string } | null;\n\tmeta: Record<string, unknown>;\n}): string | null {\n\tif (ctx.workspace?.id) return ctx.workspace.id;\n\n\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\treturn params?.['workspaceId'] ?? null;\n}\n"],"mappings":"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","names":["import_core","import_core","makeHandler"]}
1
+ {"version":3,"sources":["../../src/middlewares/index.ts","../../src/middlewares/require-role.ts","../../src/constants.ts","../../src/services/membership.ts","../../src/services/permissions.ts","../../src/engine.ts","../../src/module.ts","../../src/middlewares/require-permission.ts"],"sourcesContent":["export { requireRole } from './require-role';\nexport { requirePermission } from './require-permission';\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\nimport { hasAnyRole } from '../services/membership';\n\nfunction makeHandler(roleName: string | string[], store: IStoreAdapter): Middleware {\n\tconst allowed = Array.isArray(roleName) ? roleName : [roleName];\n\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId =\n\t\t\tctx.workspace?.id ??\n\t\t\t(ctx.meta['params'] as Record<string, string> | undefined)?.['workspaceId'];\n\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\t// EXISTS across ALL of the user's roles — a member holding several roles\n\t\t// must not be denied because a single arbitrary row was the wrong one.\n\t\tif (!(await hasAnyRole(ctx.user.id, workspaceId, allowed, store))) {\n\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Insufficient role');\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requireRole(roleName: string | string[], store: IStoreAdapter): Middleware;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(roleName, store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import type { Operation } from './types';\n\n// Canonical definition moved to @fonderie/core; re-exported here for back-compat.\nexport { OPERATIONS } from '@fonderie/core';\n\nexport const PERMISSION_COLUMN: Record<Operation, string> = {\n\tcreate: 'can_create',\n\tread: 'can_read',\n\tupdate: 'can_update',\n\tdelete: 'can_delete',\n};\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMembership } from '../types';\n\nexport async function getMembership(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMembership | null> {\n\tconst [row] = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\n\tif (!row) return null;\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tworkspaceId: row.workspace_id,\n\t\troleId: row.role_id,\n\t\troleName: row.role_name,\n\t};\n}\n\n/**\n * Does the user hold the SUPER-ROLE in this workspace? The engine uses this for\n * its total-access bypass, so the match is deliberately restricted to a\n * **system** role: `r.is_system = true`. Without that guard, a member could\n * create a workspace-local role whose name equals the configured super-role\n * (e.g. 'ADMIN') and self-assign it to gain full access — a privilege\n * escalation. System roles are seeded by the app, not member-creatable. Also\n * requires `active = true` so a deactivated role grants nothing.\n */\nexport async function hasRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleName: string,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $3\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleName],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * Does the user hold ANY of the named roles in this workspace (active, not\n * removed/suspended)? Used by requireRole — an EXISTS over ALL of the user's\n * role rows, so a member holding several roles isn't denied because an\n * arbitrary single row (LIMIT 1) happened to be the wrong one. Not restricted\n * to system roles: requireRole checks arbitrary named roles.\n */\nexport async function hasAnyRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleNames: string[],\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tif (roleNames.length === 0) return false;\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = ANY($3)\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleNames],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * The SYSTEM roles (shared across workspaces) the user holds here — the ones\n * whose rights come from config (systemGrants). System only, for the same\n * reason as hasRole: a member-created local role named 'GUEST' must not pick\n * up the system GUEST's rights.\n */\nexport async function listSystemRoleNames(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<string[]> {\n\tconst rows = await store.query<{ name: string }>(\n\t\t`SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n\treturn rows.map((r) => r.name);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from '../types';\nimport { PERMISSION_COLUMN } from '../constants';\n\nexport async function checkPermission(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst col = PERMISSION_COLUMN[operation];\n\n\tconst [row] = await store.query<{ has_permission: boolean }>(\n\t\t`SELECT BOOL_OR(rp.${col}) AS has_permission\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY ruw.user_id, ruw.workspace_id`,\n\t\t[userId, workspaceId, permissionKey],\n\t);\n\n\treturn row?.has_permission ?? false;\n}\n\n/**\n * Everything one permission check needs, in ONE round-trip — the same four\n * predicates as getMembership, hasRole (super-role), listSystemRoleNames and\n * checkPermission, each written exactly as there, read side by side:\n *\n * member — an active row joined to an existing role (getMembership)\n * isSuper — holds the super-role as an ACTIVE SYSTEM role (hasRole)\n * systemRoles — the active SYSTEM roles held (listSystemRoleNames)\n * granted — a stored grant for `operation` on `permissionKey` (checkPermission)\n *\n * The caller applies the same order of decisions the engine always has.\n */\nexport async function readAccess(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tsuperRole: string,\n\tstore: IStoreAdapter,\n): Promise<{ member: boolean; isSuper: boolean; systemRoles: string[]; granted: boolean }> {\n\tconst col = PERMISSION_COLUMN[operation];\n\tconst [row] = await store.query<{ member: boolean; is_super: boolean; system_roles: string[] | null; granted: boolean | null }>(\n\t\t`SELECT\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS member,\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $4\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS is_super,\n\t\t ARRAY (\n\t\t SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS system_roles,\n\t\t (\n\t\t SELECT BOOL_OR(rp.${col})\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t ) AS granted`,\n\t\t[userId, workspaceId, permissionKey, superRole],\n\t);\n\treturn {\n\t\tmember: row?.member === true,\n\t\tisSuper: row?.is_super === true,\n\t\tsystemRoles: row?.system_roles ?? [],\n\t\tgranted: row?.granted === true,\n\t};\n}\n\n/**\n * Every grant the member's STORED roles give in this workspace, folded across\n * roles (a member with two roles gets the union) — one query for the\n * effective-permissions read instead of one check per key.\n */\nexport async function listGrantedPermissions(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<Array<{ permissionKey: string; create: boolean; read: boolean; update: boolean; delete: boolean }>> {\n\treturn store.query(\n\t\t`SELECT rp.permission_key AS \"permissionKey\",\n\t\t BOOL_OR(rp.can_create) AS \"create\",\n\t\t BOOL_OR(rp.can_read) AS \"read\",\n\t\t BOOL_OR(rp.can_update) AS \"update\",\n\t\t BOOL_OR(rp.can_delete) AS \"delete\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY rp.permission_key`,\n\t\t[userId, workspaceId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation, IPermissionCatalogEntry, IEffectivePermissions } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nimport { getMembership, hasRole, listSystemRoleNames } from './services/membership';\nimport { checkPermission, listGrantedPermissions, readAccess } from './services/permissions';\n\nconst ALL_OPERATIONS = Object.values(OPERATIONS) as Operation[];\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\tprivate grantsByRole: Record<string, Record<string, Operation[]>>;\n\t/** The app's declared resources, or null when it declared none. */\n\treadonly catalog: readonly IPermissionCatalogEntry[] | null;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\n\t\tthis.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;\n\t\tthis.grantsByRole = config.systemGrants ?? {};\n\t\t// A misspelled resource in systemGrants would grant nothing, silently.\n\t\t// Refuse to start instead — a config mistake, caught at boot.\n\t\tif (this.catalog) {\n\t\t\tconst known = new Set(this.catalog.map((e) => e.key));\n\t\t\tfor (const [role, grants] of Object.entries(this.grantsByRole)) {\n\t\t\t\tfor (const key of Object.keys(grants)) {\n\t\t\t\t\tif (!known.has(key)) {\n\t\t\t\t\t\tthrow new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * What each SYSTEM role is granted by config (`systemGrants`) — rights that\n\t * are never stored as rows, so a role editor reads them here to show them.\n\t * A copy: changing it changes nothing.\n\t */\n\tget systemGrants(): Record<string, Record<string, Operation[]>> {\n\t\treturn Object.fromEntries(\n\t\t\tObject.entries(this.grantsByRole).map(([role, grants]) => [\n\t\t\t\trole,\n\t\t\t\tObject.fromEntries(Object.entries(grants).map(([key, ops]) => [key, [...ops]])),\n\t\t\t]),\n\t\t);\n\t}\n\n\t/** Is this key in the catalog? Always true when the app declared no catalog. */\n\tisKnown(permissionKey: string): boolean {\n\t\treturn !this.catalog || this.catalog.some((e) => e.key === permissionKey);\n\t}\n\n\t/** The operations a resource supports (all four when not declared). */\n\toperationsOf(permissionKey: string): Operation[] {\n\t\treturn this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;\n\t}\n\n\tprivate async allows(\n\t\tuserId: string,\n\t\tworkspaceId: string,\n\t\tpermissionKey: string,\n\t\toperation: Operation,\n\t): Promise<boolean> {\n\t\tif (Object.keys(this.grantsByRole).length > 0) {\n\t\t\tconst roles = await listSystemRoleNames(userId, workspaceId, this.store);\n\t\t\tif (roles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\t}\n\t\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\n\t}\n\n\t/**\n\t * Everything this member may do in this workspace, in one read — what a\n\t * client needs to show only the actions that will succeed. Null when the\n\t * user is not a member. Lists the catalog's resources (every operation\n\t * present, true or false) plus any granted key outside it.\n\t */\n\tasync effective(userId: string, workspaceId: string): Promise<IEffectivePermissions | null> {\n\t\tif (!(await getMembership(userId, workspaceId, this.store))) return null;\n\n\t\tconst permissions: Record<string, Record<Operation, boolean>> = {};\n\t\tconst entry = (key: string) =>\n\t\t\t(permissions[key] ??= { create: false, read: false, update: false, delete: false });\n\t\tfor (const e of this.catalog ?? []) entry(e.key);\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) {\n\t\t\tfor (const e of this.catalog ?? []) {\n\t\t\t\tfor (const op of e.operations ?? ALL_OPERATIONS) entry(e.key)[op] = true;\n\t\t\t}\n\t\t\treturn { isSuper: true, permissions };\n\t\t}\n\n\t\tconst [systemRoles, stored] = await Promise.all([\n\t\t\tObject.keys(this.grantsByRole).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),\n\t\t\tlistGrantedPermissions(userId, workspaceId, this.store),\n\t\t]);\n\t\tfor (const role of systemRoles) {\n\t\t\tfor (const [key, ops] of Object.entries(this.grantsByRole[role] ?? {})) {\n\t\t\t\tfor (const op of ops) entry(key)[op] = true;\n\t\t\t}\n\t\t}\n\t\tfor (const g of stored) {\n\t\t\tconst e = entry(g.permissionKey);\n\t\t\tfor (const op of ALL_OPERATIONS) e[op] ||= Boolean(g[op]);\n\t\t}\n\t\treturn { isSuper: false, permissions };\n\t}\n\n\tasync getMembership(userId: string, workspaceId: string) {\n\t\treturn getMembership(userId, workspaceId, this.store);\n\t}\n\n\tasync can(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\t// An operation outside the four has no grant column; keep the old,\n\t\t// step-by-step path for it rather than change what it answers.\n\t\tif (!Object.hasOwn(PERMISSION_COLUMN, operation)) {\n\t\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\t\tif (!isMember) return false;\n\t\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\t\t\treturn this.allows(userId, workspaceId, permissionKey, operation);\n\t\t}\n\n\t\t// Member → super-role → config system grants → stored grants: the same\n\t\t// decisions in the same order, read in one round-trip.\n\t\tconst a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);\n\t\tif (!a.member) return false;\n\t\tif (a.isSuper) return true;\n\t\tif (a.systemRoles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\treturn a.granted;\n\t}\n\n\tasync assert(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<void> {\n\t\tconst allowed = await this.can(userId, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\tthrow new PermissionDeniedError(operation, permissionKey);\n\t\t}\n\t}\n\n\tasync canAll(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.every(Boolean);\n\t}\n\n\tasync canAny(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.some(Boolean);\n\t}\n}\n\nexport class PermissionDeniedError extends Error {\n\treadonly status = 403;\n\n\tconstructor(operation: string, permissionKey: string) {\n\t\tsuper(`Permission denied: ${operation}:${permissionKey}`);\n\t\tthis.name = 'PermissionDeniedError';\n\t}\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IPermissionsConfig } from './config';\nimport { PermissionsEngine } from './engine';\n\nexport const PERMISSIONS_ENGINE_KEY = 'fonderie.permissions.engine';\n\nexport class PermissionsModule implements IFonderieModule {\n\treadonly engine: PermissionsEngine;\n\treadonly name = '@fonderie/permissions';\n\t// Baked in at build time by tsup.base, so the operator's Modules page can\n\t// say what is actually deployed rather than 'not reported'.\n\treadonly version = process.env['FONDERIE_PKG_VERSION'] ?? '0.0.0-dev';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(store: IStoreAdapter, config: IPermissionsConfig = {}) {\n\t\tthis.engine = new PermissionsEngine(store, config);\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst engine = this.engine;\n\n\t\t// Inject engine into every request context\n\t\t// requirePermission() reads it from here — no manual passing needed\n\t\tapp.use(async (ctx, next) => {\n\t\t\tctx.meta[PERMISSIONS_ENGINE_KEY] = engine;\n\t\t\treturn next();\n\t\t});\n\t}\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\n\nimport type { Operation } from '../types';\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\n\nfunction makeHandler(operation: Operation, permissionKey: string): Middleware {\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId = resolveWorkspaceId(ctx);\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\tconst allowed = await engine.can(ctx.user.id, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.FORBIDDEN,\n\t\t\t\t'FORBIDDEN',\n\t\t\t\t`Permission denied: ${operation}:${permissionKey}`,\n\t\t\t);\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requirePermission(operation: Operation, permissionKey: string): Middleware;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(operation, permissionKey);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n\nfunction resolveWorkspaceId(ctx: {\n\tworkspace?: { id: string } | null;\n\tmeta: Record<string, unknown>;\n}): string | null {\n\tif (ctx.workspace?.id) return ctx.workspace.id;\n\n\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\treturn params?.['workspaceId'] ?? null;\n}\n"],"mappings":"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","names":["import_core","import_core","makeHandler"]}
@@ -192,10 +192,10 @@ var PermissionsEngine = class {
192
192
  this.store = store;
193
193
  this.superRole = config.superRole ?? "owner";
194
194
  this.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;
195
- this.systemGrants = config.systemGrants ?? {};
195
+ this.grantsByRole = config.systemGrants ?? {};
196
196
  if (this.catalog) {
197
197
  const known = new Set(this.catalog.map((e) => e.key));
198
- for (const [role, grants] of Object.entries(this.systemGrants)) {
198
+ for (const [role, grants] of Object.entries(this.grantsByRole)) {
199
199
  for (const key of Object.keys(grants)) {
200
200
  if (!known.has(key)) {
201
201
  throw new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);
@@ -206,9 +206,22 @@ var PermissionsEngine = class {
206
206
  }
207
207
  store;
208
208
  superRole;
209
- systemGrants;
209
+ grantsByRole;
210
210
  /** The app's declared resources, or null when it declared none. */
211
211
  catalog;
212
+ /**
213
+ * What each SYSTEM role is granted by config (`systemGrants`) — rights that
214
+ * are never stored as rows, so a role editor reads them here to show them.
215
+ * A copy: changing it changes nothing.
216
+ */
217
+ get systemGrants() {
218
+ return Object.fromEntries(
219
+ Object.entries(this.grantsByRole).map(([role, grants]) => [
220
+ role,
221
+ Object.fromEntries(Object.entries(grants).map(([key, ops]) => [key, [...ops]]))
222
+ ])
223
+ );
224
+ }
212
225
  /** Is this key in the catalog? Always true when the app declared no catalog. */
213
226
  isKnown(permissionKey) {
214
227
  return !this.catalog || this.catalog.some((e) => e.key === permissionKey);
@@ -218,9 +231,9 @@ var PermissionsEngine = class {
218
231
  return this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;
219
232
  }
220
233
  async allows(userId, workspaceId, permissionKey, operation) {
221
- if (Object.keys(this.systemGrants).length > 0) {
234
+ if (Object.keys(this.grantsByRole).length > 0) {
222
235
  const roles = await listSystemRoleNames(userId, workspaceId, this.store);
223
- if (roles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
236
+ if (roles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
224
237
  }
225
238
  return checkPermission(userId, workspaceId, permissionKey, operation, this.store);
226
239
  }
@@ -242,11 +255,11 @@ var PermissionsEngine = class {
242
255
  return { isSuper: true, permissions };
243
256
  }
244
257
  const [systemRoles, stored] = await Promise.all([
245
- Object.keys(this.systemGrants).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),
258
+ Object.keys(this.grantsByRole).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),
246
259
  listGrantedPermissions(userId, workspaceId, this.store)
247
260
  ]);
248
261
  for (const role of systemRoles) {
249
- for (const [key, ops] of Object.entries(this.systemGrants[role] ?? {})) {
262
+ for (const [key, ops] of Object.entries(this.grantsByRole[role] ?? {})) {
250
263
  for (const op of ops) entry(key)[op] = true;
251
264
  }
252
265
  }
@@ -269,7 +282,7 @@ var PermissionsEngine = class {
269
282
  const a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);
270
283
  if (!a.member) return false;
271
284
  if (a.isSuper) return true;
272
- if (a.systemRoles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
285
+ if (a.systemRoles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
273
286
  return a.granted;
274
287
  }
275
288
  async assert(userId, operation, permissionKey, workspaceId) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/middlewares/require-role.ts","../../src/constants.ts","../../src/services/membership.ts","../../src/services/permissions.ts","../../src/engine.ts","../../src/module.ts","../../src/middlewares/require-permission.ts"],"sourcesContent":["import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\nimport { hasAnyRole } from '../services/membership';\n\nfunction makeHandler(roleName: string | string[], store: IStoreAdapter): Middleware {\n\tconst allowed = Array.isArray(roleName) ? roleName : [roleName];\n\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId =\n\t\t\tctx.workspace?.id ??\n\t\t\t(ctx.meta['params'] as Record<string, string> | undefined)?.['workspaceId'];\n\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\t// EXISTS across ALL of the user's roles — a member holding several roles\n\t\t// must not be denied because a single arbitrary row was the wrong one.\n\t\tif (!(await hasAnyRole(ctx.user.id, workspaceId, allowed, store))) {\n\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Insufficient role');\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requireRole(roleName: string | string[], store: IStoreAdapter): Middleware;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(roleName, store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import type { Operation } from './types';\n\n// Canonical definition moved to @fonderie/core; re-exported here for back-compat.\nexport { OPERATIONS } from '@fonderie/core';\n\nexport const PERMISSION_COLUMN: Record<Operation, string> = {\n\tcreate: 'can_create',\n\tread: 'can_read',\n\tupdate: 'can_update',\n\tdelete: 'can_delete',\n};\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMembership } from '../types';\n\nexport async function getMembership(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMembership | null> {\n\tconst [row] = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\n\tif (!row) return null;\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tworkspaceId: row.workspace_id,\n\t\troleId: row.role_id,\n\t\troleName: row.role_name,\n\t};\n}\n\n/**\n * Does the user hold the SUPER-ROLE in this workspace? The engine uses this for\n * its total-access bypass, so the match is deliberately restricted to a\n * **system** role: `r.is_system = true`. Without that guard, a member could\n * create a workspace-local role whose name equals the configured super-role\n * (e.g. 'ADMIN') and self-assign it to gain full access — a privilege\n * escalation. System roles are seeded by the app, not member-creatable. Also\n * requires `active = true` so a deactivated role grants nothing.\n */\nexport async function hasRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleName: string,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $3\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleName],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * Does the user hold ANY of the named roles in this workspace (active, not\n * removed/suspended)? Used by requireRole — an EXISTS over ALL of the user's\n * role rows, so a member holding several roles isn't denied because an\n * arbitrary single row (LIMIT 1) happened to be the wrong one. Not restricted\n * to system roles: requireRole checks arbitrary named roles.\n */\nexport async function hasAnyRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleNames: string[],\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tif (roleNames.length === 0) return false;\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = ANY($3)\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleNames],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * The SYSTEM roles (shared across workspaces) the user holds here — the ones\n * whose rights come from config (systemGrants). System only, for the same\n * reason as hasRole: a member-created local role named 'GUEST' must not pick\n * up the system GUEST's rights.\n */\nexport async function listSystemRoleNames(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<string[]> {\n\tconst rows = await store.query<{ name: string }>(\n\t\t`SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n\treturn rows.map((r) => r.name);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from '../types';\nimport { PERMISSION_COLUMN } from '../constants';\n\nexport async function checkPermission(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst col = PERMISSION_COLUMN[operation];\n\n\tconst [row] = await store.query<{ has_permission: boolean }>(\n\t\t`SELECT BOOL_OR(rp.${col}) AS has_permission\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY ruw.user_id, ruw.workspace_id`,\n\t\t[userId, workspaceId, permissionKey],\n\t);\n\n\treturn row?.has_permission ?? false;\n}\n\n/**\n * Everything one permission check needs, in ONE round-trip — the same four\n * predicates as getMembership, hasRole (super-role), listSystemRoleNames and\n * checkPermission, each written exactly as there, read side by side:\n *\n * member — an active row joined to an existing role (getMembership)\n * isSuper — holds the super-role as an ACTIVE SYSTEM role (hasRole)\n * systemRoles — the active SYSTEM roles held (listSystemRoleNames)\n * granted — a stored grant for `operation` on `permissionKey` (checkPermission)\n *\n * The caller applies the same order of decisions the engine always has.\n */\nexport async function readAccess(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tsuperRole: string,\n\tstore: IStoreAdapter,\n): Promise<{ member: boolean; isSuper: boolean; systemRoles: string[]; granted: boolean }> {\n\tconst col = PERMISSION_COLUMN[operation];\n\tconst [row] = await store.query<{ member: boolean; is_super: boolean; system_roles: string[] | null; granted: boolean | null }>(\n\t\t`SELECT\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS member,\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $4\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS is_super,\n\t\t ARRAY (\n\t\t SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS system_roles,\n\t\t (\n\t\t SELECT BOOL_OR(rp.${col})\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t ) AS granted`,\n\t\t[userId, workspaceId, permissionKey, superRole],\n\t);\n\treturn {\n\t\tmember: row?.member === true,\n\t\tisSuper: row?.is_super === true,\n\t\tsystemRoles: row?.system_roles ?? [],\n\t\tgranted: row?.granted === true,\n\t};\n}\n\n/**\n * Every grant the member's STORED roles give in this workspace, folded across\n * roles (a member with two roles gets the union) — one query for the\n * effective-permissions read instead of one check per key.\n */\nexport async function listGrantedPermissions(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<Array<{ permissionKey: string; create: boolean; read: boolean; update: boolean; delete: boolean }>> {\n\treturn store.query(\n\t\t`SELECT rp.permission_key AS \"permissionKey\",\n\t\t BOOL_OR(rp.can_create) AS \"create\",\n\t\t BOOL_OR(rp.can_read) AS \"read\",\n\t\t BOOL_OR(rp.can_update) AS \"update\",\n\t\t BOOL_OR(rp.can_delete) AS \"delete\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY rp.permission_key`,\n\t\t[userId, workspaceId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation, IPermissionCatalogEntry, IEffectivePermissions } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nimport { getMembership, hasRole, listSystemRoleNames } from './services/membership';\nimport { checkPermission, listGrantedPermissions, readAccess } from './services/permissions';\n\nconst ALL_OPERATIONS = Object.values(OPERATIONS) as Operation[];\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\tprivate systemGrants: Record<string, Record<string, Operation[]>>;\n\t/** The app's declared resources, or null when it declared none. */\n\treadonly catalog: readonly IPermissionCatalogEntry[] | null;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\n\t\tthis.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;\n\t\tthis.systemGrants = config.systemGrants ?? {};\n\t\t// A misspelled resource in systemGrants would grant nothing, silently.\n\t\t// Refuse to start instead — a config mistake, caught at boot.\n\t\tif (this.catalog) {\n\t\t\tconst known = new Set(this.catalog.map((e) => e.key));\n\t\t\tfor (const [role, grants] of Object.entries(this.systemGrants)) {\n\t\t\t\tfor (const key of Object.keys(grants)) {\n\t\t\t\t\tif (!known.has(key)) {\n\t\t\t\t\t\tthrow new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Is this key in the catalog? Always true when the app declared no catalog. */\n\tisKnown(permissionKey: string): boolean {\n\t\treturn !this.catalog || this.catalog.some((e) => e.key === permissionKey);\n\t}\n\n\t/** The operations a resource supports (all four when not declared). */\n\toperationsOf(permissionKey: string): Operation[] {\n\t\treturn this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;\n\t}\n\n\tprivate async allows(\n\t\tuserId: string,\n\t\tworkspaceId: string,\n\t\tpermissionKey: string,\n\t\toperation: Operation,\n\t): Promise<boolean> {\n\t\tif (Object.keys(this.systemGrants).length > 0) {\n\t\t\tconst roles = await listSystemRoleNames(userId, workspaceId, this.store);\n\t\t\tif (roles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\t}\n\t\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\n\t}\n\n\t/**\n\t * Everything this member may do in this workspace, in one read — what a\n\t * client needs to show only the actions that will succeed. Null when the\n\t * user is not a member. Lists the catalog's resources (every operation\n\t * present, true or false) plus any granted key outside it.\n\t */\n\tasync effective(userId: string, workspaceId: string): Promise<IEffectivePermissions | null> {\n\t\tif (!(await getMembership(userId, workspaceId, this.store))) return null;\n\n\t\tconst permissions: Record<string, Record<Operation, boolean>> = {};\n\t\tconst entry = (key: string) =>\n\t\t\t(permissions[key] ??= { create: false, read: false, update: false, delete: false });\n\t\tfor (const e of this.catalog ?? []) entry(e.key);\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) {\n\t\t\tfor (const e of this.catalog ?? []) {\n\t\t\t\tfor (const op of e.operations ?? ALL_OPERATIONS) entry(e.key)[op] = true;\n\t\t\t}\n\t\t\treturn { isSuper: true, permissions };\n\t\t}\n\n\t\tconst [systemRoles, stored] = await Promise.all([\n\t\t\tObject.keys(this.systemGrants).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),\n\t\t\tlistGrantedPermissions(userId, workspaceId, this.store),\n\t\t]);\n\t\tfor (const role of systemRoles) {\n\t\t\tfor (const [key, ops] of Object.entries(this.systemGrants[role] ?? {})) {\n\t\t\t\tfor (const op of ops) entry(key)[op] = true;\n\t\t\t}\n\t\t}\n\t\tfor (const g of stored) {\n\t\t\tconst e = entry(g.permissionKey);\n\t\t\tfor (const op of ALL_OPERATIONS) e[op] ||= Boolean(g[op]);\n\t\t}\n\t\treturn { isSuper: false, permissions };\n\t}\n\n\tasync getMembership(userId: string, workspaceId: string) {\n\t\treturn getMembership(userId, workspaceId, this.store);\n\t}\n\n\tasync can(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\t// An operation outside the four has no grant column; keep the old,\n\t\t// step-by-step path for it rather than change what it answers.\n\t\tif (!Object.hasOwn(PERMISSION_COLUMN, operation)) {\n\t\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\t\tif (!isMember) return false;\n\t\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\t\t\treturn this.allows(userId, workspaceId, permissionKey, operation);\n\t\t}\n\n\t\t// Member → super-role → config system grants → stored grants: the same\n\t\t// decisions in the same order, read in one round-trip.\n\t\tconst a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);\n\t\tif (!a.member) return false;\n\t\tif (a.isSuper) return true;\n\t\tif (a.systemRoles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\treturn a.granted;\n\t}\n\n\tasync assert(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<void> {\n\t\tconst allowed = await this.can(userId, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\tthrow new PermissionDeniedError(operation, permissionKey);\n\t\t}\n\t}\n\n\tasync canAll(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.every(Boolean);\n\t}\n\n\tasync canAny(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.some(Boolean);\n\t}\n}\n\nexport class PermissionDeniedError extends Error {\n\treadonly status = 403;\n\n\tconstructor(operation: string, permissionKey: string) {\n\t\tsuper(`Permission denied: ${operation}:${permissionKey}`);\n\t\tthis.name = 'PermissionDeniedError';\n\t}\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IPermissionsConfig } from './config';\nimport { PermissionsEngine } from './engine';\n\nexport const PERMISSIONS_ENGINE_KEY = 'fonderie.permissions.engine';\n\nexport class PermissionsModule implements IFonderieModule {\n\treadonly engine: PermissionsEngine;\n\treadonly name = '@fonderie/permissions';\n\t// Baked in at build time by tsup.base, so the operator's Modules page can\n\t// say what is actually deployed rather than 'not reported'.\n\treadonly version = process.env['FONDERIE_PKG_VERSION'] ?? '0.0.0-dev';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(store: IStoreAdapter, config: IPermissionsConfig = {}) {\n\t\tthis.engine = new PermissionsEngine(store, config);\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst engine = this.engine;\n\n\t\t// Inject engine into every request context\n\t\t// requirePermission() reads it from here — no manual passing needed\n\t\tapp.use(async (ctx, next) => {\n\t\t\tctx.meta[PERMISSIONS_ENGINE_KEY] = engine;\n\t\t\treturn next();\n\t\t});\n\t}\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\n\nimport type { Operation } from '../types';\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\n\nfunction makeHandler(operation: Operation, permissionKey: string): Middleware {\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId = resolveWorkspaceId(ctx);\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\tconst allowed = await engine.can(ctx.user.id, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.FORBIDDEN,\n\t\t\t\t'FORBIDDEN',\n\t\t\t\t`Permission denied: ${operation}:${permissionKey}`,\n\t\t\t);\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requirePermission(operation: Operation, permissionKey: string): Middleware;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(operation, permissionKey);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n\nfunction resolveWorkspaceId(ctx: {\n\tworkspace?: { id: string } | null;\n\tmeta: Record<string, unknown>;\n}): string | null {\n\tif (ctx.workspace?.id) return ctx.workspace.id;\n\n\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\treturn params?.['workspaceId'] ?? null;\n}\n"],"mappings":"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","names":["setApiResponse","HTTP","makeHandler","setApiResponse","HTTP"]}
1
+ {"version":3,"sources":["../../src/middlewares/require-role.ts","../../src/constants.ts","../../src/services/membership.ts","../../src/services/permissions.ts","../../src/engine.ts","../../src/module.ts","../../src/middlewares/require-permission.ts"],"sourcesContent":["import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\nimport { hasAnyRole } from '../services/membership';\n\nfunction makeHandler(roleName: string | string[], store: IStoreAdapter): Middleware {\n\tconst allowed = Array.isArray(roleName) ? roleName : [roleName];\n\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId =\n\t\t\tctx.workspace?.id ??\n\t\t\t(ctx.meta['params'] as Record<string, string> | undefined)?.['workspaceId'];\n\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\t// EXISTS across ALL of the user's roles — a member holding several roles\n\t\t// must not be denied because a single arbitrary row was the wrong one.\n\t\tif (!(await hasAnyRole(ctx.user.id, workspaceId, allowed, store))) {\n\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Insufficient role');\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requireRole(roleName: string | string[], store: IStoreAdapter): Middleware;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(roleName, store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import type { Operation } from './types';\n\n// Canonical definition moved to @fonderie/core; re-exported here for back-compat.\nexport { OPERATIONS } from '@fonderie/core';\n\nexport const PERMISSION_COLUMN: Record<Operation, string> = {\n\tcreate: 'can_create',\n\tread: 'can_read',\n\tupdate: 'can_update',\n\tdelete: 'can_delete',\n};\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMembership } from '../types';\n\nexport async function getMembership(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMembership | null> {\n\tconst [row] = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\n\tif (!row) return null;\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tworkspaceId: row.workspace_id,\n\t\troleId: row.role_id,\n\t\troleName: row.role_name,\n\t};\n}\n\n/**\n * Does the user hold the SUPER-ROLE in this workspace? The engine uses this for\n * its total-access bypass, so the match is deliberately restricted to a\n * **system** role: `r.is_system = true`. Without that guard, a member could\n * create a workspace-local role whose name equals the configured super-role\n * (e.g. 'ADMIN') and self-assign it to gain full access — a privilege\n * escalation. System roles are seeded by the app, not member-creatable. Also\n * requires `active = true` so a deactivated role grants nothing.\n */\nexport async function hasRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleName: string,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $3\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleName],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * Does the user hold ANY of the named roles in this workspace (active, not\n * removed/suspended)? Used by requireRole — an EXISTS over ALL of the user's\n * role rows, so a member holding several roles isn't denied because an\n * arbitrary single row (LIMIT 1) happened to be the wrong one. Not restricted\n * to system roles: requireRole checks arbitrary named roles.\n */\nexport async function hasAnyRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleNames: string[],\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tif (roleNames.length === 0) return false;\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = ANY($3)\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleNames],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * The SYSTEM roles (shared across workspaces) the user holds here — the ones\n * whose rights come from config (systemGrants). System only, for the same\n * reason as hasRole: a member-created local role named 'GUEST' must not pick\n * up the system GUEST's rights.\n */\nexport async function listSystemRoleNames(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<string[]> {\n\tconst rows = await store.query<{ name: string }>(\n\t\t`SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n\treturn rows.map((r) => r.name);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from '../types';\nimport { PERMISSION_COLUMN } from '../constants';\n\nexport async function checkPermission(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst col = PERMISSION_COLUMN[operation];\n\n\tconst [row] = await store.query<{ has_permission: boolean }>(\n\t\t`SELECT BOOL_OR(rp.${col}) AS has_permission\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY ruw.user_id, ruw.workspace_id`,\n\t\t[userId, workspaceId, permissionKey],\n\t);\n\n\treturn row?.has_permission ?? false;\n}\n\n/**\n * Everything one permission check needs, in ONE round-trip — the same four\n * predicates as getMembership, hasRole (super-role), listSystemRoleNames and\n * checkPermission, each written exactly as there, read side by side:\n *\n * member — an active row joined to an existing role (getMembership)\n * isSuper — holds the super-role as an ACTIVE SYSTEM role (hasRole)\n * systemRoles — the active SYSTEM roles held (listSystemRoleNames)\n * granted — a stored grant for `operation` on `permissionKey` (checkPermission)\n *\n * The caller applies the same order of decisions the engine always has.\n */\nexport async function readAccess(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tsuperRole: string,\n\tstore: IStoreAdapter,\n): Promise<{ member: boolean; isSuper: boolean; systemRoles: string[]; granted: boolean }> {\n\tconst col = PERMISSION_COLUMN[operation];\n\tconst [row] = await store.query<{ member: boolean; is_super: boolean; system_roles: string[] | null; granted: boolean | null }>(\n\t\t`SELECT\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS member,\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $4\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS is_super,\n\t\t ARRAY (\n\t\t SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS system_roles,\n\t\t (\n\t\t SELECT BOOL_OR(rp.${col})\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t ) AS granted`,\n\t\t[userId, workspaceId, permissionKey, superRole],\n\t);\n\treturn {\n\t\tmember: row?.member === true,\n\t\tisSuper: row?.is_super === true,\n\t\tsystemRoles: row?.system_roles ?? [],\n\t\tgranted: row?.granted === true,\n\t};\n}\n\n/**\n * Every grant the member's STORED roles give in this workspace, folded across\n * roles (a member with two roles gets the union) — one query for the\n * effective-permissions read instead of one check per key.\n */\nexport async function listGrantedPermissions(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<Array<{ permissionKey: string; create: boolean; read: boolean; update: boolean; delete: boolean }>> {\n\treturn store.query(\n\t\t`SELECT rp.permission_key AS \"permissionKey\",\n\t\t BOOL_OR(rp.can_create) AS \"create\",\n\t\t BOOL_OR(rp.can_read) AS \"read\",\n\t\t BOOL_OR(rp.can_update) AS \"update\",\n\t\t BOOL_OR(rp.can_delete) AS \"delete\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY rp.permission_key`,\n\t\t[userId, workspaceId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation, IPermissionCatalogEntry, IEffectivePermissions } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nimport { getMembership, hasRole, listSystemRoleNames } from './services/membership';\nimport { checkPermission, listGrantedPermissions, readAccess } from './services/permissions';\n\nconst ALL_OPERATIONS = Object.values(OPERATIONS) as Operation[];\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\tprivate grantsByRole: Record<string, Record<string, Operation[]>>;\n\t/** The app's declared resources, or null when it declared none. */\n\treadonly catalog: readonly IPermissionCatalogEntry[] | null;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\n\t\tthis.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;\n\t\tthis.grantsByRole = config.systemGrants ?? {};\n\t\t// A misspelled resource in systemGrants would grant nothing, silently.\n\t\t// Refuse to start instead — a config mistake, caught at boot.\n\t\tif (this.catalog) {\n\t\t\tconst known = new Set(this.catalog.map((e) => e.key));\n\t\t\tfor (const [role, grants] of Object.entries(this.grantsByRole)) {\n\t\t\t\tfor (const key of Object.keys(grants)) {\n\t\t\t\t\tif (!known.has(key)) {\n\t\t\t\t\t\tthrow new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * What each SYSTEM role is granted by config (`systemGrants`) — rights that\n\t * are never stored as rows, so a role editor reads them here to show them.\n\t * A copy: changing it changes nothing.\n\t */\n\tget systemGrants(): Record<string, Record<string, Operation[]>> {\n\t\treturn Object.fromEntries(\n\t\t\tObject.entries(this.grantsByRole).map(([role, grants]) => [\n\t\t\t\trole,\n\t\t\t\tObject.fromEntries(Object.entries(grants).map(([key, ops]) => [key, [...ops]])),\n\t\t\t]),\n\t\t);\n\t}\n\n\t/** Is this key in the catalog? Always true when the app declared no catalog. */\n\tisKnown(permissionKey: string): boolean {\n\t\treturn !this.catalog || this.catalog.some((e) => e.key === permissionKey);\n\t}\n\n\t/** The operations a resource supports (all four when not declared). */\n\toperationsOf(permissionKey: string): Operation[] {\n\t\treturn this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;\n\t}\n\n\tprivate async allows(\n\t\tuserId: string,\n\t\tworkspaceId: string,\n\t\tpermissionKey: string,\n\t\toperation: Operation,\n\t): Promise<boolean> {\n\t\tif (Object.keys(this.grantsByRole).length > 0) {\n\t\t\tconst roles = await listSystemRoleNames(userId, workspaceId, this.store);\n\t\t\tif (roles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\t}\n\t\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\n\t}\n\n\t/**\n\t * Everything this member may do in this workspace, in one read — what a\n\t * client needs to show only the actions that will succeed. Null when the\n\t * user is not a member. Lists the catalog's resources (every operation\n\t * present, true or false) plus any granted key outside it.\n\t */\n\tasync effective(userId: string, workspaceId: string): Promise<IEffectivePermissions | null> {\n\t\tif (!(await getMembership(userId, workspaceId, this.store))) return null;\n\n\t\tconst permissions: Record<string, Record<Operation, boolean>> = {};\n\t\tconst entry = (key: string) =>\n\t\t\t(permissions[key] ??= { create: false, read: false, update: false, delete: false });\n\t\tfor (const e of this.catalog ?? []) entry(e.key);\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) {\n\t\t\tfor (const e of this.catalog ?? []) {\n\t\t\t\tfor (const op of e.operations ?? ALL_OPERATIONS) entry(e.key)[op] = true;\n\t\t\t}\n\t\t\treturn { isSuper: true, permissions };\n\t\t}\n\n\t\tconst [systemRoles, stored] = await Promise.all([\n\t\t\tObject.keys(this.grantsByRole).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),\n\t\t\tlistGrantedPermissions(userId, workspaceId, this.store),\n\t\t]);\n\t\tfor (const role of systemRoles) {\n\t\t\tfor (const [key, ops] of Object.entries(this.grantsByRole[role] ?? {})) {\n\t\t\t\tfor (const op of ops) entry(key)[op] = true;\n\t\t\t}\n\t\t}\n\t\tfor (const g of stored) {\n\t\t\tconst e = entry(g.permissionKey);\n\t\t\tfor (const op of ALL_OPERATIONS) e[op] ||= Boolean(g[op]);\n\t\t}\n\t\treturn { isSuper: false, permissions };\n\t}\n\n\tasync getMembership(userId: string, workspaceId: string) {\n\t\treturn getMembership(userId, workspaceId, this.store);\n\t}\n\n\tasync can(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\t// An operation outside the four has no grant column; keep the old,\n\t\t// step-by-step path for it rather than change what it answers.\n\t\tif (!Object.hasOwn(PERMISSION_COLUMN, operation)) {\n\t\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\t\tif (!isMember) return false;\n\t\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\t\t\treturn this.allows(userId, workspaceId, permissionKey, operation);\n\t\t}\n\n\t\t// Member → super-role → config system grants → stored grants: the same\n\t\t// decisions in the same order, read in one round-trip.\n\t\tconst a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);\n\t\tif (!a.member) return false;\n\t\tif (a.isSuper) return true;\n\t\tif (a.systemRoles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\treturn a.granted;\n\t}\n\n\tasync assert(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<void> {\n\t\tconst allowed = await this.can(userId, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\tthrow new PermissionDeniedError(operation, permissionKey);\n\t\t}\n\t}\n\n\tasync canAll(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.every(Boolean);\n\t}\n\n\tasync canAny(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.some(Boolean);\n\t}\n}\n\nexport class PermissionDeniedError extends Error {\n\treadonly status = 403;\n\n\tconstructor(operation: string, permissionKey: string) {\n\t\tsuper(`Permission denied: ${operation}:${permissionKey}`);\n\t\tthis.name = 'PermissionDeniedError';\n\t}\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IPermissionsConfig } from './config';\nimport { PermissionsEngine } from './engine';\n\nexport const PERMISSIONS_ENGINE_KEY = 'fonderie.permissions.engine';\n\nexport class PermissionsModule implements IFonderieModule {\n\treadonly engine: PermissionsEngine;\n\treadonly name = '@fonderie/permissions';\n\t// Baked in at build time by tsup.base, so the operator's Modules page can\n\t// say what is actually deployed rather than 'not reported'.\n\treadonly version = process.env['FONDERIE_PKG_VERSION'] ?? '0.0.0-dev';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(store: IStoreAdapter, config: IPermissionsConfig = {}) {\n\t\tthis.engine = new PermissionsEngine(store, config);\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst engine = this.engine;\n\n\t\t// Inject engine into every request context\n\t\t// requirePermission() reads it from here — no manual passing needed\n\t\tapp.use(async (ctx, next) => {\n\t\t\tctx.meta[PERMISSIONS_ENGINE_KEY] = engine;\n\t\t\treturn next();\n\t\t});\n\t}\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\n\nimport type { Operation } from '../types';\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\n\nfunction makeHandler(operation: Operation, permissionKey: string): Middleware {\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId = resolveWorkspaceId(ctx);\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\tconst allowed = await engine.can(ctx.user.id, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.FORBIDDEN,\n\t\t\t\t'FORBIDDEN',\n\t\t\t\t`Permission denied: ${operation}:${permissionKey}`,\n\t\t\t);\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requirePermission(operation: Operation, permissionKey: string): Middleware;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(operation, permissionKey);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n\nfunction resolveWorkspaceId(ctx: {\n\tworkspace?: { id: string } | null;\n\tmeta: Record<string, unknown>;\n}): string | null {\n\tif (ctx.workspace?.id) return ctx.workspace.id;\n\n\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\treturn params?.['workspaceId'] ?? null;\n}\n"],"mappings":";AAAA,SAAS,gBAAgB,YAAY;;;ACGrC,SAAS,kBAAkB;AAEpB,IAAM,oBAA+C;AAAA,EAC3D,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,QAAQ;AACT;;;ACNA,eAAsB,cACrB,QACA,aACA,OAC8B;AAC9B,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IAMzB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAQA,CAAC,QAAQ,WAAW;AAAA,EACrB;AAEA,MAAI,CAAC,IAAK,QAAO;AAEjB,SAAO;AAAA,IACN,QAAQ,IAAI;AAAA,IACZ,aAAa,IAAI;AAAA,IACjB,QAAQ,IAAI;AAAA,IACZ,UAAU,IAAI;AAAA,EACf;AACD;AAWA,eAAsB,QACrB,QACA,aACA,UACA,OACmB;AACnB,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAYA,CAAC,QAAQ,aAAa,QAAQ;AAAA,EAC/B;AAEA,SAAO,KAAK,UAAU;AACvB;AASA,eAAsB,WACrB,QACA,aACA,WACA,OACmB;AACnB,MAAI,UAAU,WAAW,EAAG,QAAO;AACnC,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAWA,CAAC,QAAQ,aAAa,SAAS;AAAA,EAChC;AAEA,SAAO,KAAK,UAAU;AACvB;AAQA,eAAsB,oBACrB,QACA,aACA,OACoB;AACpB,QAAM,OAAO,MAAM,MAAM;AAAA,IACxB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IASA,CAAC,QAAQ,WAAW;AAAA,EACrB;AACA,SAAO,KAAK,IAAI,CAAC,MAAM,EAAE,IAAI;AAC9B;;;ACzHA,eAAsB,gBACrB,QACA,aACA,eACA,WACA,OACmB;AACnB,QAAM,MAAM,kBAAkB,SAAS;AAEvC,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB,qBAAqB,GAAG;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAaxB,CAAC,QAAQ,aAAa,aAAa;AAAA,EACpC;AAEA,SAAO,KAAK,kBAAkB;AAC/B;AAcA,eAAsB,WACrB,QACA,aACA,eACA,WACA,WACA,OAC0F;AAC1F,QAAM,MAAM,kBAAkB,SAAS;AACvC,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,2BAkCyB,GAAG;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAa5B,CAAC,QAAQ,aAAa,eAAe,SAAS;AAAA,EAC/C;AACA,SAAO;AAAA,IACN,QAAQ,KAAK,WAAW;AAAA,IACxB,SAAS,KAAK,aAAa;AAAA,IAC3B,aAAa,KAAK,gBAAgB,CAAC;AAAA,IACnC,SAAS,KAAK,YAAY;AAAA,EAC3B;AACD;AAOA,eAAsB,uBACrB,QACA,aACA,OAC8G;AAC9G,SAAO,MAAM;AAAA,IACZ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAgBA,CAAC,QAAQ,WAAW;AAAA,EACrB;AACD;;;ACtIA,IAAM,iBAAiB,OAAO,OAAO,UAAU;AAExC,IAAM,oBAAN,MAAwB;AAAA,EAM9B,YACS,OACR,SAA6B,CAAC,GAC7B;AAFO;AAGR,SAAK,YAAY,OAAO,aAAa;AACrC,SAAK,UAAU,OAAO,UAAU,OAAO,OAAO,OAAO,QAAQ,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE,EAAE,CAAC,IAAI;AACvF,SAAK,eAAe,OAAO,gBAAgB,CAAC;AAG5C,QAAI,KAAK,SAAS;AACjB,YAAM,QAAQ,IAAI,IAAI,KAAK,QAAQ,IAAI,CAAC,MAAM,EAAE,GAAG,CAAC;AACpD,iBAAW,CAAC,MAAM,MAAM,KAAK,OAAO,QAAQ,KAAK,YAAY,GAAG;AAC/D,mBAAW,OAAO,OAAO,KAAK,MAAM,GAAG;AACtC,cAAI,CAAC,MAAM,IAAI,GAAG,GAAG;AACpB,kBAAM,IAAI,MAAM,8BAA8B,IAAI,IAAI,GAAG,MAAM,GAAG,yBAAyB;AAAA,UAC5F;AAAA,QACD;AAAA,MACD;AAAA,IACD;AAAA,EACD;AAAA,EAlBS;AAAA,EAND;AAAA,EACA;AAAA;AAAA,EAEC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EA4BT,IAAI,eAA4D;AAC/D,WAAO,OAAO;AAAA,MACb,OAAO,QAAQ,KAAK,YAAY,EAAE,IAAI,CAAC,CAAC,MAAM,MAAM,MAAM;AAAA,QACzD;AAAA,QACA,OAAO,YAAY,OAAO,QAAQ,MAAM,EAAE,IAAI,CAAC,CAAC,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC;AAAA,MAC/E,CAAC;AAAA,IACF;AAAA,EACD;AAAA;AAAA,EAGA,QAAQ,eAAgC;AACvC,WAAO,CAAC,KAAK,WAAW,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,QAAQ,aAAa;AAAA,EACzE;AAAA;AAAA,EAGA,aAAa,eAAoC;AAChD,WAAO,KAAK,SAAS,KAAK,CAAC,MAAM,EAAE,QAAQ,aAAa,GAAG,cAAc;AAAA,EAC1E;AAAA,EAEA,MAAc,OACb,QACA,aACA,eACA,WACmB;AACnB,QAAI,OAAO,KAAK,KAAK,YAAY,EAAE,SAAS,GAAG;AAC9C,YAAM,QAAQ,MAAM,oBAAoB,QAAQ,aAAa,KAAK,KAAK;AACvE,UAAI,MAAM,KAAK,CAAC,MAAM,KAAK,aAAa,CAAC,IAAI,aAAa,GAAG,SAAS,SAAS,KAAK,KAAK,EAAG,QAAO;AAAA,IACpG;AACA,WAAO,gBAAgB,QAAQ,aAAa,eAAe,WAAW,KAAK,KAAK;AAAA,EACjF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,UAAU,QAAgB,aAA4D;AAC3F,QAAI,CAAE,MAAM,cAAc,QAAQ,aAAa,KAAK,KAAK,EAAI,QAAO;AAEpE,UAAM,cAA0D,CAAC;AACjE,UAAM,QAAQ,CAAC,QACb,YAAY,GAAG,MAAM,EAAE,QAAQ,OAAO,MAAM,OAAO,QAAQ,OAAO,QAAQ,MAAM;AAClF,eAAW,KAAK,KAAK,WAAW,CAAC,EAAG,OAAM,EAAE,GAAG;AAE/C,QAAI,MAAM,QAAQ,QAAQ,aAAa,KAAK,WAAW,KAAK,KAAK,GAAG;AACnE,iBAAW,KAAK,KAAK,WAAW,CAAC,GAAG;AACnC,mBAAW,MAAM,EAAE,cAAc,eAAgB,OAAM,EAAE,GAAG,EAAE,EAAE,IAAI;AAAA,MACrE;AACA,aAAO,EAAE,SAAS,MAAM,YAAY;AAAA,IACrC;AAEA,UAAM,CAAC,aAAa,MAAM,IAAI,MAAM,QAAQ,IAAI;AAAA,MAC/C,OAAO,KAAK,KAAK,YAAY,EAAE,SAAS,oBAAoB,QAAQ,aAAa,KAAK,KAAK,IAAI,QAAQ,QAAQ,CAAC,CAAC;AAAA,MACjH,uBAAuB,QAAQ,aAAa,KAAK,KAAK;AAAA,IACvD,CAAC;AACD,eAAW,QAAQ,aAAa;AAC/B,iBAAW,CAAC,KAAK,GAAG,KAAK,OAAO,QAAQ,KAAK,aAAa,IAAI,KAAK,CAAC,CAAC,GAAG;AACvE,mBAAW,MAAM,IAAK,OAAM,GAAG,EAAE,EAAE,IAAI;AAAA,MACxC;AAAA,IACD;AACA,eAAW,KAAK,QAAQ;AACvB,YAAM,IAAI,MAAM,EAAE,aAAa;AAC/B,iBAAW,MAAM,eAAgB,GAAE,EAAE,MAAM,QAAQ,EAAE,EAAE,CAAC;AAAA,IACzD;AACA,WAAO,EAAE,SAAS,OAAO,YAAY;AAAA,EACtC;AAAA,EAEA,MAAM,cAAc,QAAgB,aAAqB;AACxD,WAAO,cAAc,QAAQ,aAAa,KAAK,KAAK;AAAA,EACrD;AAAA,EAEA,MAAM,IACL,QACA,WACA,eACA,aACmB;AAGnB,QAAI,CAAC,OAAO,OAAO,mBAAmB,SAAS,GAAG;AACjD,YAAM,WAAW,MAAM,cAAc,QAAQ,aAAa,KAAK,KAAK;AACpE,UAAI,CAAC,SAAU,QAAO;AACtB,UAAI,MAAM,QAAQ,QAAQ,aAAa,KAAK,WAAW,KAAK,KAAK,EAAG,QAAO;AAC3E,aAAO,KAAK,OAAO,QAAQ,aAAa,eAAe,SAAS;AAAA,IACjE;AAIA,UAAM,IAAI,MAAM,WAAW,QAAQ,aAAa,eAAe,WAAW,KAAK,WAAW,KAAK,KAAK;AACpG,QAAI,CAAC,EAAE,OAAQ,QAAO;AACtB,QAAI,EAAE,QAAS,QAAO;AACtB,QAAI,EAAE,YAAY,KAAK,CAAC,MAAM,KAAK,aAAa,CAAC,IAAI,aAAa,GAAG,SAAS,SAAS,KAAK,KAAK,EAAG,QAAO;AAC3G,WAAO,EAAE;AAAA,EACV;AAAA,EAEA,MAAM,OACL,QACA,WACA,eACA,aACgB;AAChB,UAAM,UAAU,MAAM,KAAK,IAAI,QAAQ,WAAW,eAAe,WAAW;AAC5E,QAAI,CAAC,SAAS;AACb,YAAM,IAAI,sBAAsB,WAAW,aAAa;AAAA,IACzD;AAAA,EACD;AAAA,EAEA,MAAM,OACL,QACA,QACA,aACmB;AACnB,UAAM,WAAW,MAAM,cAAc,QAAQ,aAAa,KAAK,KAAK;AACpE,QAAI,CAAC,SAAU,QAAO;AAEtB,QAAI,MAAM,QAAQ,QAAQ,aAAa,KAAK,WAAW,KAAK,KAAK,EAAG,QAAO;AAE3E,UAAM,UAAU,MAAM,QAAQ;AAAA,MAC7B,OAAO,IAAI,CAAC,MAAM,KAAK,OAAO,QAAQ,aAAa,EAAE,eAAe,EAAE,SAAS,CAAC;AAAA,IACjF;AACA,WAAO,QAAQ,MAAM,OAAO;AAAA,EAC7B;AAAA,EAEA,MAAM,OACL,QACA,QACA,aACmB;AACnB,UAAM,WAAW,MAAM,cAAc,QAAQ,aAAa,KAAK,KAAK;AACpE,QAAI,CAAC,SAAU,QAAO;AAEtB,QAAI,MAAM,QAAQ,QAAQ,aAAa,KAAK,WAAW,KAAK,KAAK,EAAG,QAAO;AAE3E,UAAM,UAAU,MAAM,QAAQ;AAAA,MAC7B,OAAO,IAAI,CAAC,MAAM,KAAK,OAAO,QAAQ,aAAa,EAAE,eAAe,EAAE,SAAS,CAAC;AAAA,IACjF;AACA,WAAO,QAAQ,KAAK,OAAO;AAAA,EAC5B;AACD;AAEO,IAAM,wBAAN,cAAoC,MAAM;AAAA,EACvC,SAAS;AAAA,EAElB,YAAY,WAAmB,eAAuB;AACrD,UAAM,sBAAsB,SAAS,IAAI,aAAa,EAAE;AACxD,SAAK,OAAO;AAAA,EACb;AACD;;;ACzLO,IAAM,yBAAyB;;;ALGtC,SAAS,YAAY,UAA6B,OAAkC;AACnF,QAAM,UAAU,MAAM,QAAQ,QAAQ,IAAI,WAAW,CAAC,QAAQ;AAE9D,SAAO,OAAO,KAAK,SAAS;AAC3B,QAAI,CAAC,IAAI,MAAM;AACd,aAAO,eAAe,KAAK,cAAc,gBAAgB,cAAc;AAAA,IACxE;AAEA,UAAM,SAAS,IAAI,KAAK,sBAAsB;AAC9C,QAAI,EAAE,kBAAkB,oBAAoB;AAC3C,aAAO,eAAe,KAAK,cAAc,gBAAgB,kCAAkC;AAAA,IAC5F;AAEA,UAAM,cACL,IAAI,WAAW,MACd,IAAI,KAAK,QAAQ,IAA2C,aAAa;AAE3E,QAAI,CAAC,aAAa;AACjB,aAAO,eAAe,KAAK,aAAa,sBAAsB,4BAA4B;AAAA,IAC3F;AAIA,QAAI,CAAE,MAAM,WAAW,IAAI,KAAK,IAAI,aAAa,SAAS,KAAK,GAAI;AAClE,aAAO,eAAe,KAAK,WAAW,aAAa,mBAAmB;AAAA,IACvE;AAEA,WAAO,KAAK;AAAA,EACb;AACD;AASO,SAAS,YACf,UACA,OACA,KACA,MACiC;AACjC,QAAM,UAAU,YAAY,UAAU,KAAK;AAC3C,MAAI,QAAQ,UAAa,SAAS,OAAW,QAAO,QAAQ,KAAK,IAAI;AACrE,SAAO;AACR;;;AMxDA,SAAS,kBAAAA,iBAAgB,QAAAC,aAAY;AAQrC,SAASC,aAAY,WAAsB,eAAmC;AAC7E,SAAO,OAAO,KAAK,SAAS;AAC3B,QAAI,CAAC,IAAI,MAAM;AACd,aAAOC,gBAAeC,MAAK,cAAc,gBAAgB,cAAc;AAAA,IACxE;AAEA,UAAM,SAAS,IAAI,KAAK,sBAAsB;AAC9C,QAAI,EAAE,kBAAkB,oBAAoB;AAC3C,aAAOD,gBAAeC,MAAK,cAAc,gBAAgB,kCAAkC;AAAA,IAC5F;AAEA,UAAM,cAAc,mBAAmB,GAAG;AAC1C,QAAI,CAAC,aAAa;AACjB,aAAOD,gBAAeC,MAAK,aAAa,sBAAsB,4BAA4B;AAAA,IAC3F;AAEA,UAAM,UAAU,MAAM,OAAO,IAAI,IAAI,KAAK,IAAI,WAAW,eAAe,WAAW;AACnF,QAAI,CAAC,SAAS;AACb,aAAOD;AAAA,QACNC,MAAK;AAAA,QACL;AAAA,QACA,sBAAsB,SAAS,IAAI,aAAa;AAAA,MACjD;AAAA,IACD;AAEA,WAAO,KAAK;AAAA,EACb;AACD;AASO,SAAS,kBACf,WACA,eACA,KACA,MACiC;AACjC,QAAM,UAAUF,aAAY,WAAW,aAAa;AACpD,MAAI,QAAQ,UAAa,SAAS,OAAW,QAAO,QAAQ,KAAK,IAAI;AACrE,SAAO;AACR;AAEA,SAAS,mBAAmB,KAGV;AACjB,MAAI,IAAI,WAAW,GAAI,QAAO,IAAI,UAAU;AAE5C,QAAM,SAAS,IAAI,KAAK,QAAQ;AAChC,SAAO,SAAS,aAAa,KAAK;AACnC;","names":["setApiResponse","HTTP","makeHandler","setApiResponse","HTTP"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonderie/permissions",
3
- "version": "5.1.6",
3
+ "version": "5.2.0",
4
4
  "fonderie": { "stability": "stable" },
5
5
  "description": "Role-based access control for SaaS — define roles, assign CRUD permissions per resource, and gate any route with requirePermission. Supports multiple roles per user via BOOL_OR aggregation.",
6
6
  "keywords": [