@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/LICENSE +21 -0
- package/README.md +171 -0
- package/dist/index.d.ts +689 -0
- package/dist/index.js +864 -0
- package/package.json +48 -0
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 };
|