@plakboek/permissions 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,864 @@
1
+ //#region src/catalogue.ts
2
+ /**
3
+ * The frozen v1 permission catalogue (D-07). Every catalogue entry uses
4
+ * `resource:action` strings with no ownership split (D-05); publishing is
5
+ * its own permission, separate from editing (D-06).
6
+ */
7
+ const PERMISSIONS = {
8
+ "pages:read": {
9
+ group: "pages",
10
+ label: {
11
+ id: "permissions.pages.read.label",
12
+ message: "View pages"
13
+ },
14
+ description: {
15
+ id: "permissions.pages.read.description",
16
+ message: "See pages and their published content in the admin"
17
+ }
18
+ },
19
+ "pages:read-drafts": {
20
+ group: "pages",
21
+ label: {
22
+ id: "permissions.pages.readDrafts.label",
23
+ message: "View page drafts"
24
+ },
25
+ description: {
26
+ id: "permissions.pages.readDrafts.description",
27
+ message: "See unpublished drafts, previews and revision history of pages"
28
+ }
29
+ },
30
+ "pages:create": {
31
+ group: "pages",
32
+ label: {
33
+ id: "permissions.pages.create.label",
34
+ message: "Create pages"
35
+ },
36
+ description: {
37
+ id: "permissions.pages.create.description",
38
+ message: "Add new pages to the site"
39
+ }
40
+ },
41
+ "pages:edit": {
42
+ group: "pages",
43
+ label: {
44
+ id: "permissions.pages.edit.label",
45
+ message: "Edit pages"
46
+ },
47
+ description: {
48
+ id: "permissions.pages.edit.description",
49
+ message: "Change page content, blocks, layout, SEO fields and translations as drafts"
50
+ }
51
+ },
52
+ "pages:publish": {
53
+ group: "pages",
54
+ label: {
55
+ id: "permissions.pages.publish.label",
56
+ message: "Publish pages"
57
+ },
58
+ description: {
59
+ id: "permissions.pages.publish.description",
60
+ message: "Publish or schedule page drafts so visitors receive them"
61
+ }
62
+ },
63
+ "pages:delete": {
64
+ group: "pages",
65
+ label: {
66
+ id: "permissions.pages.delete.label",
67
+ message: "Delete pages"
68
+ },
69
+ description: {
70
+ id: "permissions.pages.delete.description",
71
+ message: "Move pages to the trash and restore them"
72
+ }
73
+ },
74
+ "pages:delete-permanent": {
75
+ group: "pages",
76
+ label: {
77
+ id: "permissions.pages.deletePermanent.label",
78
+ message: "Permanently delete pages"
79
+ },
80
+ description: {
81
+ id: "permissions.pages.deletePermanent.description",
82
+ message: "Permanently remove trashed pages"
83
+ }
84
+ },
85
+ "entries:read": {
86
+ group: "entries",
87
+ label: {
88
+ id: "permissions.entries.read.label",
89
+ message: "View entries"
90
+ },
91
+ description: {
92
+ id: "permissions.entries.read.description",
93
+ message: "See content entries and their published content in the admin"
94
+ }
95
+ },
96
+ "entries:read-drafts": {
97
+ group: "entries",
98
+ label: {
99
+ id: "permissions.entries.readDrafts.label",
100
+ message: "View entry drafts"
101
+ },
102
+ description: {
103
+ id: "permissions.entries.readDrafts.description",
104
+ message: "See unpublished drafts, previews and revision history of entries"
105
+ }
106
+ },
107
+ "entries:create": {
108
+ group: "entries",
109
+ label: {
110
+ id: "permissions.entries.create.label",
111
+ message: "Create entries"
112
+ },
113
+ description: {
114
+ id: "permissions.entries.create.description",
115
+ message: "Add new content entries"
116
+ }
117
+ },
118
+ "entries:edit": {
119
+ group: "entries",
120
+ label: {
121
+ id: "permissions.entries.edit.label",
122
+ message: "Edit entries"
123
+ },
124
+ description: {
125
+ id: "permissions.entries.edit.description",
126
+ message: "Change entry fields and translations as drafts"
127
+ }
128
+ },
129
+ "entries:publish": {
130
+ group: "entries",
131
+ label: {
132
+ id: "permissions.entries.publish.label",
133
+ message: "Publish entries"
134
+ },
135
+ description: {
136
+ id: "permissions.entries.publish.description",
137
+ message: "Publish or schedule entry drafts so visitors receive them"
138
+ }
139
+ },
140
+ "entries:delete": {
141
+ group: "entries",
142
+ label: {
143
+ id: "permissions.entries.delete.label",
144
+ message: "Delete entries"
145
+ },
146
+ description: {
147
+ id: "permissions.entries.delete.description",
148
+ message: "Move entries to the trash and restore them"
149
+ }
150
+ },
151
+ "entries:delete-permanent": {
152
+ group: "entries",
153
+ label: {
154
+ id: "permissions.entries.deletePermanent.label",
155
+ message: "Permanently delete entries"
156
+ },
157
+ description: {
158
+ id: "permissions.entries.deletePermanent.description",
159
+ message: "Permanently remove trashed entries"
160
+ }
161
+ },
162
+ "templates:edit": {
163
+ group: "templates",
164
+ label: {
165
+ id: "permissions.templates.edit.label",
166
+ message: "Edit detail templates"
167
+ },
168
+ description: {
169
+ id: "permissions.templates.edit.description",
170
+ message: "Change the shared block tree used to render a content type's detail pages"
171
+ }
172
+ },
173
+ "templates:publish": {
174
+ group: "templates",
175
+ label: {
176
+ id: "permissions.templates.publish.label",
177
+ message: "Publish detail templates"
178
+ },
179
+ description: {
180
+ id: "permissions.templates.publish.description",
181
+ message: "Publish changes to shared detail templates"
182
+ }
183
+ },
184
+ "content-types:read": {
185
+ group: "content-types",
186
+ label: {
187
+ id: "permissions.contentTypes.read.label",
188
+ message: "View content types"
189
+ },
190
+ description: {
191
+ id: "permissions.contentTypes.read.description",
192
+ message: "See content types and the fields attached to them"
193
+ }
194
+ },
195
+ "content-types:create": {
196
+ group: "content-types",
197
+ label: {
198
+ id: "permissions.contentTypes.create.label",
199
+ message: "Create content types"
200
+ },
201
+ description: {
202
+ id: "permissions.contentTypes.create.description",
203
+ message: "Add new content types"
204
+ }
205
+ },
206
+ "content-types:edit": {
207
+ group: "content-types",
208
+ label: {
209
+ id: "permissions.contentTypes.edit.label",
210
+ message: "Edit content types"
211
+ },
212
+ description: {
213
+ id: "permissions.contentTypes.edit.description",
214
+ message: "Change content type settings and add, change or remove fields"
215
+ }
216
+ },
217
+ "content-types:delete": {
218
+ group: "content-types",
219
+ label: {
220
+ id: "permissions.contentTypes.delete.label",
221
+ message: "Delete content types"
222
+ },
223
+ description: {
224
+ id: "permissions.contentTypes.delete.description",
225
+ message: "Remove content types"
226
+ }
227
+ },
228
+ "media:read": {
229
+ group: "media",
230
+ label: {
231
+ id: "permissions.media.read.label",
232
+ message: "View media"
233
+ },
234
+ description: {
235
+ id: "permissions.media.read.description",
236
+ message: "Browse and search the media library"
237
+ }
238
+ },
239
+ "media:upload": {
240
+ group: "media",
241
+ label: {
242
+ id: "permissions.media.upload.label",
243
+ message: "Upload media"
244
+ },
245
+ description: {
246
+ id: "permissions.media.upload.description",
247
+ message: "Upload images and files"
248
+ }
249
+ },
250
+ "media:edit": {
251
+ group: "media",
252
+ label: {
253
+ id: "permissions.media.edit.label",
254
+ message: "Edit media"
255
+ },
256
+ description: {
257
+ id: "permissions.media.edit.description",
258
+ message: "Change alt text, copyright labels and replace assets in place"
259
+ }
260
+ },
261
+ "media:delete": {
262
+ group: "media",
263
+ label: {
264
+ id: "permissions.media.delete.label",
265
+ message: "Delete media"
266
+ },
267
+ description: {
268
+ id: "permissions.media.delete.description",
269
+ message: "Delete assets, including ones still in use after a warning"
270
+ }
271
+ },
272
+ "menus:read": {
273
+ group: "menus",
274
+ label: {
275
+ id: "permissions.menus.read.label",
276
+ message: "View menus"
277
+ },
278
+ description: {
279
+ id: "permissions.menus.read.description",
280
+ message: "See menus declared by the site and their links"
281
+ }
282
+ },
283
+ "menus:edit": {
284
+ group: "menus",
285
+ label: {
286
+ id: "permissions.menus.edit.label",
287
+ message: "Edit menus"
288
+ },
289
+ description: {
290
+ id: "permissions.menus.edit.description",
291
+ message: "Add, remove and re-arrange menu links"
292
+ }
293
+ },
294
+ "redirects:read": {
295
+ group: "seo",
296
+ label: {
297
+ id: "permissions.redirects.read.label",
298
+ message: "View redirects"
299
+ },
300
+ description: {
301
+ id: "permissions.redirects.read.description",
302
+ message: "See redirects and how long each has been active"
303
+ }
304
+ },
305
+ "redirects:manage": {
306
+ group: "seo",
307
+ label: {
308
+ id: "permissions.redirects.manage.label",
309
+ message: "Manage redirects"
310
+ },
311
+ description: {
312
+ id: "permissions.redirects.manage.description",
313
+ message: "Create, change and remove redirects"
314
+ }
315
+ },
316
+ "users:read": {
317
+ group: "users",
318
+ label: {
319
+ id: "permissions.users.read.label",
320
+ message: "View users"
321
+ },
322
+ description: {
323
+ id: "permissions.users.read.description",
324
+ message: "See users and their roles"
325
+ }
326
+ },
327
+ "users:create": {
328
+ group: "users",
329
+ label: {
330
+ id: "permissions.users.create.label",
331
+ message: "Invite users"
332
+ },
333
+ description: {
334
+ id: "permissions.users.create.description",
335
+ message: "Add users and send or resend set-password emails"
336
+ }
337
+ },
338
+ "users:edit": {
339
+ group: "users",
340
+ label: {
341
+ id: "permissions.users.edit.label",
342
+ message: "Edit users"
343
+ },
344
+ description: {
345
+ id: "permissions.users.edit.description",
346
+ message: "Change other users' profile details"
347
+ }
348
+ },
349
+ "users:deactivate": {
350
+ group: "users",
351
+ label: {
352
+ id: "permissions.users.deactivate.label",
353
+ message: "Deactivate users"
354
+ },
355
+ description: {
356
+ id: "permissions.users.deactivate.description",
357
+ message: "Deactivate and reactivate users"
358
+ }
359
+ },
360
+ "users:assign-roles": {
361
+ group: "users",
362
+ label: {
363
+ id: "permissions.users.assignRoles.label",
364
+ message: "Assign roles"
365
+ },
366
+ description: {
367
+ id: "permissions.users.assignRoles.description",
368
+ message: "Assign a code-defined role to a user"
369
+ }
370
+ },
371
+ "users:reset-password": {
372
+ group: "users",
373
+ label: {
374
+ id: "permissions.users.resetPassword.label",
375
+ message: "Reset passwords"
376
+ },
377
+ description: {
378
+ id: "permissions.users.resetPassword.description",
379
+ message: "Set or reset another user's password"
380
+ }
381
+ },
382
+ "users:impersonate": {
383
+ group: "users",
384
+ label: {
385
+ id: "permissions.users.impersonate.label",
386
+ message: "Impersonate users"
387
+ },
388
+ description: {
389
+ id: "permissions.users.impersonate.description",
390
+ message: "Act as another user and return to your own session"
391
+ }
392
+ },
393
+ "audit-log:read": {
394
+ group: "audit",
395
+ label: {
396
+ id: "permissions.auditLog.read.label",
397
+ message: "View audit log"
398
+ },
399
+ description: {
400
+ id: "permissions.auditLog.read.description",
401
+ message: "See the audit log of data-changing actions"
402
+ }
403
+ },
404
+ "settings:read": {
405
+ group: "settings",
406
+ label: {
407
+ id: "permissions.settings.read.label",
408
+ message: "View settings"
409
+ },
410
+ description: {
411
+ id: "permissions.settings.read.description",
412
+ message: "See installation settings"
413
+ }
414
+ },
415
+ "settings:manage": {
416
+ group: "settings",
417
+ label: {
418
+ id: "permissions.settings.manage.label",
419
+ message: "Manage settings"
420
+ },
421
+ description: {
422
+ id: "permissions.settings.manage.description",
423
+ message: "Change core configuration, styles and rules"
424
+ }
425
+ },
426
+ "modules:manage": {
427
+ group: "settings",
428
+ label: {
429
+ id: "permissions.modules.manage.label",
430
+ message: "Manage modules"
431
+ },
432
+ description: {
433
+ id: "permissions.modules.manage.description",
434
+ message: "Turn installation modules on or off"
435
+ }
436
+ },
437
+ "blocks:manage": {
438
+ group: "settings",
439
+ label: {
440
+ id: "permissions.blocks.manage.label",
441
+ message: "Manage built-in blocks"
442
+ },
443
+ description: {
444
+ id: "permissions.blocks.manage.description",
445
+ message: "Turn individual built-in sections and blocks on or off"
446
+ }
447
+ },
448
+ "bootstrap:run": {
449
+ group: "settings",
450
+ label: {
451
+ id: "permissions.bootstrap.run.label",
452
+ message: "Run bootstrap"
453
+ },
454
+ description: {
455
+ id: "permissions.bootstrap.run.description",
456
+ message: "Run installation bootstrap steps"
457
+ }
458
+ },
459
+ "api-tokens:create": {
460
+ group: "api",
461
+ label: {
462
+ id: "permissions.apiTokens.create.label",
463
+ message: "Create API tokens"
464
+ },
465
+ description: {
466
+ id: "permissions.apiTokens.create.description",
467
+ message: "Create and revoke your own API tokens for MCP clients"
468
+ }
469
+ },
470
+ "api-tokens:manage": {
471
+ group: "api",
472
+ label: {
473
+ id: "permissions.apiTokens.manage.label",
474
+ message: "Manage API tokens"
475
+ },
476
+ description: {
477
+ id: "permissions.apiTokens.manage.description",
478
+ message: "See and revoke any user's API tokens"
479
+ }
480
+ },
481
+ "backups:manage": {
482
+ group: "backups",
483
+ label: {
484
+ id: "permissions.backups.manage.label",
485
+ message: "Manage backups"
486
+ },
487
+ description: {
488
+ id: "permissions.backups.manage.description",
489
+ message: "Configure backup targets and run backups"
490
+ }
491
+ },
492
+ "backups:restore": {
493
+ group: "backups",
494
+ label: {
495
+ id: "permissions.backups.restore.label",
496
+ message: "Restore backups"
497
+ },
498
+ description: {
499
+ id: "permissions.backups.restore.description",
500
+ message: "Restore the installation from a backup"
501
+ }
502
+ }
503
+ };
504
+ for (const meta of Object.values(PERMISSIONS)) {
505
+ Object.freeze(meta.label);
506
+ Object.freeze(meta.description);
507
+ Object.freeze(meta);
508
+ }
509
+ Object.freeze(PERMISSIONS);
510
+ /** Runtime type guard for an arbitrary value being a known permission string.
511
+ * Uses `Object.hasOwn` (not `in` or bracket access) so inherited
512
+ * `Object.prototype` keys (`constructor`, `toString`, `__proto__`, ...) are
513
+ * never classified as permissions (T-01-08). */
514
+ function isPermission(value) {
515
+ return typeof value === "string" && Object.hasOwn(PERMISSIONS, value);
516
+ }
517
+ /** Every permission string, in catalogue declaration order, frozen. */
518
+ const ALL_PERMISSIONS = Object.freeze(Object.keys(PERMISSIONS).filter(isPermission));
519
+ /** The 12 v1 permission groups, in catalogue table order, frozen. */
520
+ const PERMISSION_GROUPS = Object.freeze([
521
+ "pages",
522
+ "entries",
523
+ "templates",
524
+ "content-types",
525
+ "media",
526
+ "menus",
527
+ "seo",
528
+ "users",
529
+ "audit",
530
+ "settings",
531
+ "api",
532
+ "backups"
533
+ ]);
534
+ //#endregion
535
+ //#region src/deprecated.ts
536
+ /**
537
+ * D-09 breaking-change policy for permission strings:
538
+ *
539
+ * - Adding a permission is a minor version bump.
540
+ * - Renaming or removing a permission is a major version bump (while
541
+ * pre-1.0, per D-17, it is a minor bump instead) -- and the old name
542
+ * stays in this table, pointing at its replacement, for one major
543
+ * version after the rename/removal ships. Host configs that still
544
+ * reference the old name keep booting, with a warning (D-10); they are
545
+ * never rejected outright during that window.
546
+ * - An alias key must never equal a canonical key in `PERMISSIONS`
547
+ * (catalogue.ts) -- a key that renames back onto itself is not a
548
+ * deprecation, it is a bug in this table.
549
+ *
550
+ * No permission has been renamed or removed since the v1 catalogue shipped
551
+ * at 0.1.0, so this table starts empty. `DeprecatedPermission` (`keyof
552
+ * typeof DEPRECATED_PERMISSION_ALIASES`) is therefore `never` today -- that
553
+ * is intentional and still a literal type, not `string`: the first entry
554
+ * added here narrows it to a real literal union, it never widens.
555
+ */
556
+ const DEPRECATED_PERMISSION_ALIASES = {};
557
+ Object.freeze(DEPRECATED_PERMISSION_ALIASES);
558
+ /**
559
+ * Classify an arbitrary string as a canonical permission, a deprecated
560
+ * alias (per `aliases`, defaulting to the shipped
561
+ * `DEPRECATED_PERMISSION_ALIASES`), or unknown.
562
+ *
563
+ * Checks `isPermission` first so a canonical name is never misclassified
564
+ * as deprecated even if it were accidentally also present as an alias key.
565
+ * Alias lookup uses `Object.hasOwn` (not `in` or bracket access), so
566
+ * inherited `Object.prototype` keys (`__proto__`, `constructor`, ...) are
567
+ * always classified as unknown, never as a hit (T-01-08).
568
+ */
569
+ function resolvePermissionName(value, aliases = DEPRECATED_PERMISSION_ALIASES) {
570
+ if (isPermission(value)) return {
571
+ kind: "canonical",
572
+ permission: value
573
+ };
574
+ if (Object.hasOwn(aliases, value)) {
575
+ const permission = aliases[value];
576
+ if (permission !== void 0) return {
577
+ kind: "deprecated",
578
+ alias: value,
579
+ permission
580
+ };
581
+ }
582
+ return {
583
+ kind: "unknown",
584
+ value
585
+ };
586
+ }
587
+ //#endregion
588
+ //#region src/roles.ts
589
+ /**
590
+ * Code-defined roles on top of the fixed permission catalogue (USER-03,
591
+ * USER-04, INST-06). `defineRoles` boot-fails a host's role config the
592
+ * moment it references an unknown permission string, is missing the
593
+ * reserved `superadmin` role, or narrows `superadmin` below
594
+ * `ALL_PERMISSIONS` (D-10, D-11, D-12). Hosts compose roles by spreading
595
+ * `defaultRoles` and overriding/adding keys (D-14) -- there is no
596
+ * inheritance, `extends`, or wildcard: a role's permissions are exactly
597
+ * what its list contains.
598
+ */
599
+ /** Thrown by `defineRoles`/`validateRoleConfig` with every problem found in
600
+ * the config, collected before throwing once (never one-issue-at-a-time). */
601
+ var RoleConfigError = class extends Error {
602
+ issues;
603
+ constructor(issues) {
604
+ super(["[@plakboek/permissions] invalid role config:", ...issues.map((issue) => issue.message)].join("\n"));
605
+ this.name = "RoleConfigError";
606
+ this.issues = issues;
607
+ }
608
+ };
609
+ /** A role key must be lowercase-kebab: starts with a letter, then letters,
610
+ * digits or hyphens. Rejects case variants ("Editor"), whitespace
611
+ * (" editor"), and inherited-property-name lookalikes ("__proto__",
612
+ * "constructor", "toString") without any special-casing -- none of those
613
+ * match the pattern. */
614
+ const ROLE_KEY_PATTERN = /^[a-z][a-z0-9-]*$/;
615
+ function defaultOnDeprecatedPermission(event) {
616
+ console.warn(`[@plakboek/permissions] role "${event.roleKey}" uses deprecated permission "${event.alias}"; rename it to "${event.permission}"`);
617
+ }
618
+ /** Narrows `unknown` to a plain, non-array object without an `as` cast. */
619
+ function isPlainObject(value) {
620
+ return typeof value === "object" && value !== null && !Array.isArray(value);
621
+ }
622
+ /** Narrows `unknown` to `string[]` without an `as` cast. */
623
+ function isStringArray(value) {
624
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
625
+ }
626
+ /**
627
+ * Module-level validator used directly by tests (not re-exported from
628
+ * `index.ts`). `defineRoles` calls this with the shipped deprecated-alias
629
+ * table; tests call it directly to inject a custom alias table.
630
+ *
631
+ * Collects every problem before throwing once. On success, every role's
632
+ * permissions are deduped and reordered to catalogue declaration order
633
+ * (`ALL_PERMISSIONS` filtered by membership), then frozen; the returned
634
+ * container object is frozen too (T-01-20).
635
+ */
636
+ function validateRoleConfig(input, options) {
637
+ const issues = [];
638
+ const aliases = options?.aliases ?? DEPRECATED_PERMISSION_ALIASES;
639
+ const onDeprecatedPermission = options?.onDeprecatedPermission ?? defaultOnDeprecatedPermission;
640
+ if (!isPlainObject(input)) {
641
+ issues.push({
642
+ code: "INVALID_ROLE_CONFIG",
643
+ message: "role config must be a plain object mapping role keys to permission lists"
644
+ });
645
+ throw new RoleConfigError(issues);
646
+ }
647
+ const roleKeys = Object.keys(input);
648
+ const normalized = /* @__PURE__ */ new Map();
649
+ for (const roleKey of roleKeys) {
650
+ if (!ROLE_KEY_PATTERN.test(roleKey)) {
651
+ issues.push({
652
+ code: "INVALID_ROLE_KEY",
653
+ roleKey,
654
+ message: `role key "${roleKey}" must match ${ROLE_KEY_PATTERN.source}`
655
+ });
656
+ continue;
657
+ }
658
+ const rawList = input[roleKey];
659
+ if (!isStringArray(rawList)) {
660
+ issues.push({
661
+ code: "INVALID_PERMISSION_LIST",
662
+ roleKey,
663
+ message: `role "${roleKey}" must be an array of permission strings`
664
+ });
665
+ continue;
666
+ }
667
+ const resolvedPermissions = [];
668
+ for (const value of rawList) {
669
+ const resolution = resolvePermissionName(value, aliases);
670
+ if (resolution.kind === "canonical") resolvedPermissions.push(resolution.permission);
671
+ else if (resolution.kind === "deprecated") {
672
+ resolvedPermissions.push(resolution.permission);
673
+ onDeprecatedPermission({
674
+ roleKey,
675
+ alias: resolution.alias,
676
+ permission: resolution.permission
677
+ });
678
+ } else issues.push({
679
+ code: "UNKNOWN_PERMISSION",
680
+ roleKey,
681
+ value,
682
+ message: `role "${roleKey}" references unknown permission "${value}"`
683
+ });
684
+ }
685
+ normalized.set(roleKey, resolvedPermissions);
686
+ }
687
+ if (!Object.hasOwn(input, "superadmin")) issues.push({
688
+ code: "MISSING_SUPERADMIN",
689
+ message: "role config must define a \"superadmin\" role holding ALL_PERMISSIONS"
690
+ });
691
+ else {
692
+ const superadminPermissions = normalized.get("superadmin");
693
+ if (superadminPermissions !== void 0) {
694
+ const superadminSet = new Set(superadminPermissions);
695
+ if (!(superadminSet.size === ALL_PERMISSIONS.length && ALL_PERMISSIONS.every((permission) => superadminSet.has(permission)))) issues.push({
696
+ code: "SUPERADMIN_NOT_ALL_PERMISSIONS",
697
+ roleKey: "superadmin",
698
+ message: "role \"superadmin\" must hold exactly ALL_PERMISSIONS -- use `superadmin: ALL_PERMISSIONS`"
699
+ });
700
+ }
701
+ }
702
+ if (issues.length > 0) throw new RoleConfigError(issues);
703
+ const result = {};
704
+ for (const roleKey of roleKeys) {
705
+ const resolved = normalized.get(roleKey);
706
+ if (resolved === void 0) continue;
707
+ const memberSet = new Set(resolved);
708
+ result[roleKey] = Object.freeze(ALL_PERMISSIONS.filter((permission) => memberSet.has(permission)));
709
+ }
710
+ return Object.freeze(result);
711
+ }
712
+ /**
713
+ * Validate and normalize a host's role config. Throws a single
714
+ * `RoleConfigError` listing every problem found; on success, returns a
715
+ * frozen, deduped, catalogue-ordered permission map per role.
716
+ */
717
+ function defineRoles(roles, options) {
718
+ return validateRoleConfig(roles, options);
719
+ }
720
+ /**
721
+ * The shipped `superadmin`/`admin`/`editor` role set (D-13). Works with
722
+ * zero customisation (USER-04); hosts compose via
723
+ * `defineRoles({ ...defaultRoles, client: [...] })` (D-14).
724
+ */
725
+ const defaultRoles = {
726
+ superadmin: ALL_PERMISSIONS,
727
+ admin: [
728
+ "pages:read",
729
+ "pages:read-drafts",
730
+ "pages:create",
731
+ "pages:edit",
732
+ "pages:publish",
733
+ "pages:delete",
734
+ "entries:read",
735
+ "entries:read-drafts",
736
+ "entries:create",
737
+ "entries:edit",
738
+ "entries:publish",
739
+ "entries:delete",
740
+ "templates:edit",
741
+ "templates:publish",
742
+ "content-types:read",
743
+ "content-types:create",
744
+ "content-types:edit",
745
+ "content-types:delete",
746
+ "media:read",
747
+ "media:upload",
748
+ "media:edit",
749
+ "media:delete",
750
+ "menus:read",
751
+ "menus:edit",
752
+ "redirects:read",
753
+ "redirects:manage",
754
+ "users:read",
755
+ "users:create",
756
+ "users:edit",
757
+ "users:deactivate",
758
+ "users:assign-roles",
759
+ "users:reset-password",
760
+ "audit-log:read",
761
+ "settings:read",
762
+ "settings:manage",
763
+ "api-tokens:create",
764
+ "api-tokens:manage"
765
+ ],
766
+ editor: [
767
+ "pages:read",
768
+ "pages:read-drafts",
769
+ "pages:create",
770
+ "pages:edit",
771
+ "pages:publish",
772
+ "pages:delete",
773
+ "entries:read",
774
+ "entries:read-drafts",
775
+ "entries:create",
776
+ "entries:edit",
777
+ "entries:publish",
778
+ "entries:delete",
779
+ "templates:edit",
780
+ "templates:publish",
781
+ "content-types:read",
782
+ "media:read",
783
+ "media:upload",
784
+ "media:edit",
785
+ "menus:read",
786
+ "menus:edit",
787
+ "redirects:read",
788
+ "api-tokens:create"
789
+ ]
790
+ };
791
+ Object.freeze(defaultRoles.admin);
792
+ Object.freeze(defaultRoles.editor);
793
+ Object.freeze(defaultRoles);
794
+ //#endregion
795
+ //#region src/resolve.ts
796
+ const DEFAULT_RATE_LIMIT_MS = 6e4;
797
+ /** Cap on distinct orphaned role keys tracked for rate limiting (T-01-22) --
798
+ * without it, an attacker (or a persistently misconfigured client) sending
799
+ * arbitrarily many distinct bogus role keys could grow this state without
800
+ * bound. The oldest-inserted key is evicted to make room for a new one. */
801
+ const MAX_TRACKED_KEYS = 1e3;
802
+ function defaultOnOrphanedRole(event) {
803
+ const userPart = event.userId !== void 0 ? ` (user ${event.userId})` : "";
804
+ console.warn(`[@plakboek/permissions] orphaned role key "${event.roleKey}"${userPart} -- denying all permissions`);
805
+ }
806
+ /**
807
+ * Build a resolver over a validated role map (the output of `defineRoles`).
808
+ * `resolve` never throws: an unknown role key returns an empty set and
809
+ * reports the orphan through `onOrphanedRole` (default `console.warn`),
810
+ * rate-limited independently per key.
811
+ */
812
+ function createPermissionResolver(roles, options) {
813
+ const roleMap = new Map(Object.entries(roles));
814
+ const rateLimitMs = options?.rateLimitMs ?? DEFAULT_RATE_LIMIT_MS;
815
+ const now = options?.now ?? Date.now;
816
+ const hook = options?.onOrphanedRole ?? defaultOnOrphanedRole;
817
+ const orphanState = /* @__PURE__ */ new Map();
818
+ function isKnownRole(roleKey) {
819
+ return roleMap.has(roleKey);
820
+ }
821
+ function emitOrphanedRoleEvent(roleKey, userId) {
822
+ const nowMs = now();
823
+ const state = orphanState.get(roleKey);
824
+ if (state !== void 0 && nowMs - state.lastEmittedAt < rateLimitMs) {
825
+ state.suppressed += 1;
826
+ return;
827
+ }
828
+ const suppressedCount = state?.suppressed ?? 0;
829
+ if (state === void 0 && orphanState.size >= MAX_TRACKED_KEYS) {
830
+ const oldestKey = orphanState.keys().next().value;
831
+ if (oldestKey !== void 0) orphanState.delete(oldestKey);
832
+ }
833
+ orphanState.set(roleKey, {
834
+ lastEmittedAt: nowMs,
835
+ suppressed: 0
836
+ });
837
+ const event = {
838
+ roleKey,
839
+ ...userId !== void 0 ? { userId } : {},
840
+ occurredAt: new Date(nowMs),
841
+ suppressedCount
842
+ };
843
+ try {
844
+ hook(event);
845
+ } catch (error) {
846
+ try {
847
+ console.error(`[@plakboek/permissions] onOrphanedRole hook threw while handling role "${roleKey}"`, error);
848
+ } catch {}
849
+ }
850
+ }
851
+ function resolve(roleKeyInput, context) {
852
+ const roleKey = String(roleKeyInput);
853
+ const known = roleMap.get(roleKey);
854
+ if (known !== void 0) return new Set(known);
855
+ emitOrphanedRoleEvent(roleKey, context?.userId);
856
+ return /* @__PURE__ */ new Set();
857
+ }
858
+ return {
859
+ resolve,
860
+ isKnownRole
861
+ };
862
+ }
863
+ //#endregion
864
+ export { ALL_PERMISSIONS, DEPRECATED_PERMISSION_ALIASES, PERMISSIONS, PERMISSION_GROUPS, RoleConfigError, createPermissionResolver, defaultRoles, defineRoles, isPermission, resolvePermissionName };