domma-cms 0.85.0 → 0.86.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.
@@ -8,15 +8,25 @@ import {catalogue, seedBlocks} from './lib/render.js';
8
8
  import {liveBase, liveSettings, loadEntries as loadAll, renderPostPage} from './lib/page.js';
9
9
  import {addNavLink, moveNavLink, removeNavLink} from './lib/nav-link.js';
10
10
  import {SAMPLE_CATEGORIES, samplePosts} from './lib/samples.js';
11
+ import {commentKey, commentNotice} from './lib/notices.js';
12
+ import {authorPatch, permissionChecker} from './lib/access.js';
13
+ import {getPermissionsFor, invalidate as invalidateRoles} from '../../server/services/roles.js';
11
14
 
12
15
  const POSTS_SLUG = 'blog-posts';
13
16
  const CATEGORIES_SLUG = 'blog-categories';
14
17
  const COMMENTS_SLUG = 'blog-comments';
15
18
 
16
- const PUBLISH_ROLES = ['blog-editor', 'admin', 'super-admin'];
19
+ // Who may do what is a PERMISSION (lib/access.js), set when the plugin
20
+ // registers - never a role name. An editor is whoever holds blog-posts.publish.
21
+ let can = () => false;
22
+
23
+ /** Point the checks at a permission lookup - the plugin does, on start; tests do, with their own. */
24
+ export function usePermissionLookup(permissionsFor) {
25
+ can = permissionChecker(permissionsFor);
26
+ }
17
27
 
18
28
  function canPublish(user) {
19
- return PUBLISH_ROLES.includes(user?.role);
29
+ return can(user, 'blog-posts.publish');
20
30
  }
21
31
 
22
32
  function isEditorOrAdmin(user) {
@@ -242,8 +252,41 @@ function registerEmbedShortcodes(registerShortcode, baseOf) {
242
252
 
243
253
  export default async function blogPlugin(fastify, options) {
244
254
  const { authenticate, requireAdmin } = options.auth;
255
+
256
+ // Permissions (tests pass their own lookup). An editor held as an additional
257
+ // role counts, and a custom role given blog-posts.publish under Roles is one.
258
+ usePermissionLookup(options.getPermissionsFor || getPermissionsFor);
259
+ const need = (key) => async (request, reply) => {
260
+ if (!can(request.user, key)) return reply.code(403).send({ error: 'Forbidden' });
261
+ };
262
+ const uidOf = (user) => user?.id ?? user?.sub ?? null;
263
+ // A Blog Author made before this release holds a bare 'blog-posts', which a
264
+ // permission check reads as every action - publish and delete too. Once only.
265
+ try {
266
+ let patched = false;
267
+ for (const e of (await listEntries('roles', { limit: 500 })).entries || []) {
268
+ const patch = authorPatch(e.data);
269
+ if (!patch) continue;
270
+ await updateEntry('roles', e.id, { ...e.data, ...patch });
271
+ patched = true;
272
+ }
273
+ if (patched) { await invalidateRoles(); fastify.log.info('[blog] Blog Author: posts read/create/update and categories read (no publish or delete)'); }
274
+ } catch (err) {
275
+ fastify.log.warn(`[blog] could not update the Blog Author role: ${err.message}`);
276
+ }
245
277
  const settings = { ...defaultConfig, ...(options.settings || {}) };
246
278
 
279
+ // Notifications (core 0.80+; feature-detected): a comment held for approval
280
+ // tells the site's staff (the source's audience); it goes when moderated.
281
+ const notify = (p) => { try { options.hooks?.notify?.(p); } catch { /* a notice never breaks a request */ } };
282
+ const resolveNotice = (key) => { try { options.hooks?.resolveNotification?.(key, 'comments'); } catch { /* ditto */ } };
283
+ if (typeof options.hooks?.registerNotificationSource === 'function') {
284
+ try {
285
+ options.hooks.registerNotificationSource({id: 'comments', label: 'Comments awaiting moderation', severity: 'info',
286
+ description: 'A reader left a comment that is waiting for approval. It goes once the comment is approved, rejected or deleted.'});
287
+ } catch { /* older engine */ }
288
+ }
289
+
247
290
  if (options.hooks?.registerShortcode) {
248
291
  registerEmbedShortcodes(options.hooks.registerShortcode, () => liveBase(settings));
249
292
  }
@@ -262,7 +305,7 @@ export default async function blogPlugin(fastify, options) {
262
305
  // -------------------------------------------------------------------------
263
306
 
264
307
  /** GET /api/plugins/blog/layouts - every post layout and listing display on offer */
265
- fastify.get('/layouts', { preHandler: [authenticate] }, async () => {
308
+ fastify.get('/layouts', { preHandler: [authenticate, need('blog-posts.read')] }, async () => {
266
309
  const s = liveSettings(settings);
267
310
  return { ...(await catalogue()), postLayout: s.postLayout, listDisplay: s.listDisplay };
268
311
  });
@@ -272,7 +315,7 @@ export default async function blogPlugin(fastify, options) {
272
315
  * the editor's UNSAVED fields and in any layout, for the preview panel.
273
316
  * The whole page, chrome and all, so what the author sees is what readers get.
274
317
  */
275
- fastify.post('/preview', { preHandler: [authenticate] }, async (request, reply) => {
318
+ fastify.post('/preview', { preHandler: [authenticate, need('blog-posts.read')] }, async (request, reply) => {
276
319
  const body = request.body ?? {};
277
320
  const user = request.user;
278
321
  let stored = null;
@@ -368,13 +411,16 @@ export default async function blogPlugin(fastify, options) {
368
411
  // -------------------------------------------------------------------------
369
412
 
370
413
  /** GET /api/plugins/blog/posts */
371
- fastify.get('/posts', { preHandler: [authenticate] }, async (request, reply) => {
414
+ fastify.get('/posts', { preHandler: [authenticate, need('blog-posts.read')] }, async (request, reply) => {
372
415
  const user = request.user;
373
416
  const { entries } = await listEntries(POSTS_SLUG, { limit: 100000, sort: 'createdAt', order: 'desc' });
374
417
  let posts = entries.map(toPost);
375
418
 
376
- if (!isEditorOrAdmin(user) && user?.role === 'blog-author') {
377
- const uid = user?.id ?? user?.sub ?? null;
419
+ // A writer who is not an editor sees their own posts only - drafts are
420
+ // nobody else's business. (Until 1.8 only the role named blog-author
421
+ // was filtered: every other signed-in account saw every draft.)
422
+ if (!isEditorOrAdmin(user)) {
423
+ const uid = uidOf(user);
378
424
  posts = posts.filter(p => p.authorId === uid);
379
425
  }
380
426
 
@@ -382,7 +428,7 @@ export default async function blogPlugin(fastify, options) {
382
428
  });
383
429
 
384
430
  /** POST /api/plugins/blog/posts */
385
- fastify.post('/posts', { preHandler: [authenticate] }, async (request, reply) => {
431
+ fastify.post('/posts', { preHandler: [authenticate, need('blog-posts.create')] }, async (request, reply) => {
386
432
  const user = request.user;
387
433
  const uid = user?.id ?? user?.sub ?? null;
388
434
  const body = request.body ?? {};
@@ -419,14 +465,16 @@ export default async function blogPlugin(fastify, options) {
419
465
  });
420
466
 
421
467
  /** GET /api/plugins/blog/posts/:id */
422
- fastify.get('/posts/:id', { preHandler: [authenticate] }, async (request, reply) => {
468
+ fastify.get('/posts/:id', { preHandler: [authenticate, need('blog-posts.read')] }, async (request, reply) => {
423
469
  const entry = await getEntry(POSTS_SLUG, request.params.id);
424
- if (!entry) return reply.code(404).send({ error: 'Post not found' });
470
+ if (!entry || (!isEditorOrAdmin(request.user) && entry.data.authorId !== uidOf(request.user))) {
471
+ return reply.code(404).send({ error: 'Post not found' });
472
+ }
425
473
  return reply.send(toPost(entry));
426
474
  });
427
475
 
428
476
  /** PUT /api/plugins/blog/posts/:id */
429
- fastify.put('/posts/:id', { preHandler: [authenticate] }, async (request, reply) => {
477
+ fastify.put('/posts/:id', { preHandler: [authenticate, need('blog-posts.update')] }, async (request, reply) => {
430
478
  const user = request.user;
431
479
  const uid = user?.id ?? user?.sub ?? null;
432
480
  const { id } = request.params;
@@ -445,7 +493,7 @@ export default async function blogPlugin(fastify, options) {
445
493
  });
446
494
 
447
495
  /** DELETE /api/plugins/blog/posts/:id */
448
- fastify.delete('/posts/:id', { preHandler: [authenticate] }, async (request, reply) => {
496
+ fastify.delete('/posts/:id', { preHandler: [authenticate, need('blog-posts.delete')] }, async (request, reply) => {
449
497
  const user = request.user;
450
498
  if (!isEditorOrAdmin(user)) {
451
499
  return reply.code(403).send({ error: 'Forbidden' });
@@ -459,7 +507,7 @@ export default async function blogPlugin(fastify, options) {
459
507
  });
460
508
 
461
509
  /** POST /api/plugins/blog/posts/:id/publish */
462
- fastify.post('/posts/:id/publish', { preHandler: [authenticate] }, async (request, reply) => {
510
+ fastify.post('/posts/:id/publish', { preHandler: [authenticate, need('blog-posts.publish')] }, async (request, reply) => {
463
511
  const user = request.user;
464
512
  if (!canPublish(user)) {
465
513
  return reply.code(403).send({ error: 'Forbidden' });
@@ -478,7 +526,7 @@ export default async function blogPlugin(fastify, options) {
478
526
  });
479
527
 
480
528
  /** POST /api/plugins/blog/posts/:id/schedule */
481
- fastify.post('/posts/:id/schedule', { preHandler: [authenticate] }, async (request, reply) => {
529
+ fastify.post('/posts/:id/schedule', { preHandler: [authenticate, need('blog-posts.publish')] }, async (request, reply) => {
482
530
  const user = request.user;
483
531
  if (!canPublish(user)) {
484
532
  return reply.code(403).send({ error: 'Forbidden' });
@@ -506,13 +554,13 @@ export default async function blogPlugin(fastify, options) {
506
554
  // -------------------------------------------------------------------------
507
555
 
508
556
  /** GET /api/plugins/blog/categories */
509
- fastify.get('/categories', { preHandler: [authenticate] }, async (request, reply) => {
557
+ fastify.get('/categories', { preHandler: [authenticate, need('blog-categories.read')] }, async (request, reply) => {
510
558
  const { entries } = await listEntries(CATEGORIES_SLUG, { limit: 10000, sort: 'createdAt', order: 'asc' });
511
559
  return reply.send(entries.map(toCategory));
512
560
  });
513
561
 
514
562
  /** POST /api/plugins/blog/categories */
515
- fastify.post('/categories', { preHandler: [authenticate] }, async (request, reply) => {
563
+ fastify.post('/categories', { preHandler: [authenticate, need('blog-categories.create')] }, async (request, reply) => {
516
564
  if (!isEditorOrAdmin(request.user)) {
517
565
  return reply.code(403).send({ error: 'Forbidden' });
518
566
  }
@@ -534,7 +582,7 @@ export default async function blogPlugin(fastify, options) {
534
582
  });
535
583
 
536
584
  /** PUT /api/plugins/blog/categories/:id */
537
- fastify.put('/categories/:id', { preHandler: [authenticate] }, async (request, reply) => {
585
+ fastify.put('/categories/:id', { preHandler: [authenticate, need('blog-categories.update')] }, async (request, reply) => {
538
586
  if (!isEditorOrAdmin(request.user)) {
539
587
  return reply.code(403).send({ error: 'Forbidden' });
540
588
  }
@@ -542,15 +590,19 @@ export default async function blogPlugin(fastify, options) {
542
590
  const entry = await getEntry(CATEGORIES_SLUG, request.params.id);
543
591
  if (!entry) return reply.code(404).send({ error: 'Category not found' });
544
592
 
545
- const updates = request.body ?? {};
546
- const merged = { ...entry.data, ...updates };
593
+ // Its own fields only - the body used to be merged in whole.
594
+ const b = request.body ?? {};
595
+ const merged = { ...entry.data };
596
+ if (typeof b.name === 'string' && b.name.trim()) merged.name = b.name.trim();
597
+ if (typeof b.slug === 'string' && b.slug.trim()) merged.slug = slugify(b.slug);
598
+ if (typeof b.description === 'string') merged.description = b.description.trim();
547
599
 
548
600
  const updated = await updateEntry(CATEGORIES_SLUG, request.params.id, merged);
549
601
  return reply.send(toCategory(updated));
550
602
  });
551
603
 
552
604
  /** DELETE /api/plugins/blog/categories/:id */
553
- fastify.delete('/categories/:id', { preHandler: [authenticate] }, async (request, reply) => {
605
+ fastify.delete('/categories/:id', { preHandler: [authenticate, need('blog-categories.delete')] }, async (request, reply) => {
554
606
  if (!isEditorOrAdmin(request.user)) {
555
607
  return reply.code(403).send({ error: 'Forbidden' });
556
608
  }
@@ -567,7 +619,7 @@ export default async function blogPlugin(fastify, options) {
567
619
  // -------------------------------------------------------------------------
568
620
 
569
621
  /** GET /api/plugins/blog/comments */
570
- fastify.get('/comments', { preHandler: [authenticate] }, async (request, reply) => {
622
+ fastify.get('/comments', { preHandler: [authenticate, need('blog-comments.read')] }, async (request, reply) => {
571
623
  const { status: statusFilter, postId } = request.query;
572
624
  const { entries } = await listEntries(COMMENTS_SLUG, { limit: 100000, sort: 'createdAt', order: 'desc' });
573
625
  let comments = entries.map(toComment);
@@ -583,7 +635,7 @@ export default async function blogPlugin(fastify, options) {
583
635
  });
584
636
 
585
637
  /** GET /api/plugins/blog/comments/pending-count - the sidebar badge on Blog: comments waiting for approval */
586
- fastify.get('/comments/pending-count', { preHandler: [authenticate] }, async () => {
638
+ fastify.get('/comments/pending-count', { preHandler: [authenticate, need('blog-comments.read')] }, async () => {
587
639
  const [comments, posts] = await Promise.all([loadAll(COMMENTS_SLUG), loadAll(POSTS_SLUG)]);
588
640
  const pending = comments.filter(c => c.status === 'pending')
589
641
  .sort((a, b) => String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')));
@@ -649,11 +701,15 @@ export default async function blogPlugin(fastify, options) {
649
701
  createdAt: new Date().toISOString()
650
702
  }, { source: 'public' });
651
703
 
652
- return reply.code(201).send(toComment(entry));
704
+ const made = toComment(entry);
705
+ const post = await getEntry(POSTS_SLUG, String(postId)).catch(() => null);
706
+ const notice = commentNotice(made, post?.data?.title, '#/plugins/blog/comments');
707
+ if (notice) notify(notice);
708
+ return reply.code(201).send(made);
653
709
  });
654
710
 
655
711
  /** PUT /api/plugins/blog/comments/:id */
656
- fastify.put('/comments/:id', { preHandler: [authenticate] }, async (request, reply) => {
712
+ fastify.put('/comments/:id', { preHandler: [authenticate, need('blog-comments.update')] }, async (request, reply) => {
657
713
  const entry = await getEntry(COMMENTS_SLUG, request.params.id);
658
714
  if (!entry) return reply.code(404).send({ error: 'Comment not found' });
659
715
 
@@ -669,15 +725,17 @@ export default async function blogPlugin(fastify, options) {
669
725
  }
670
726
 
671
727
  const updated = await updateEntry(COMMENTS_SLUG, request.params.id, merged);
728
+ if (merged.status !== 'pending') resolveNotice(commentKey(request.params.id));
672
729
  return reply.send(toComment(updated));
673
730
  });
674
731
 
675
732
  /** DELETE /api/plugins/blog/comments/:id */
676
- fastify.delete('/comments/:id', { preHandler: [authenticate] }, async (request, reply) => {
733
+ fastify.delete('/comments/:id', { preHandler: [authenticate, need('blog-comments.delete')] }, async (request, reply) => {
677
734
  const entry = await getEntry(COMMENTS_SLUG, request.params.id);
678
735
  if (!entry) return reply.code(404).send({ error: 'Comment not found' });
679
736
 
680
737
  await deleteEntry(COMMENTS_SLUG, request.params.id);
738
+ resolveNotice(commentKey(request.params.id));
681
739
  return reply.send({ ok: true });
682
740
  });
683
741
 
@@ -693,7 +751,7 @@ export default async function blogPlugin(fastify, options) {
693
751
  // whole config object as the name.
694
752
 
695
753
  /** GET /api/plugins/blog/settings */
696
- fastify.get('/settings', { preHandler: [authenticate] }, async (request, reply) => {
754
+ fastify.get('/settings', { preHandler: [authenticate, need('blog-posts.read')] }, async (request, reply) => {
697
755
  const stored = getPluginStates()?.blog?.settings ?? {};
698
756
  return reply.send({ ...defaultConfig, ...DISPLAY_DEFAULTS, ...stored });
699
757
  });
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "blog",
3
3
  "displayName": "Blog",
4
- "version": "1.6.3",
4
+ "version": "1.8.0",
5
5
  "tier": "free",
6
6
  "description": "Public-facing blog with posts, categories and comments - layouts and listings built from editable blocks.",
7
7
  "author": "Domma CMS",
8
- "date": "2026-09-23",
8
+ "date": "2026-09-26",
9
9
  "icon": "file-text",
10
10
  "admin": {
11
- "css": ["admin/css/index.css"],
11
+ "css": [
12
+ "admin/css/index.css"
13
+ ],
12
14
  "sidebar": [
13
15
  {
14
16
  "id": "blog",
@@ -18,27 +20,178 @@
18
20
  "section": "#/plugins/blog",
19
21
  "countUrl": "/api/plugins/blog/comments/pending-count",
20
22
  "children": [
21
- { "id": "blog-posts", "text": "Posts", "url": "#/plugins/blog/posts" },
22
- { "id": "blog-categories", "text": "Categories", "url": "#/plugins/blog/categories" },
23
- { "id": "blog-comments", "text": "Comments", "url": "#/plugins/blog/comments" }
24
- ]
23
+ {
24
+ "id": "blog-posts",
25
+ "text": "Posts",
26
+ "url": "#/plugins/blog/posts"
27
+ },
28
+ {
29
+ "id": "blog-categories",
30
+ "text": "Categories",
31
+ "url": "#/plugins/blog/categories"
32
+ },
33
+ {
34
+ "id": "blog-comments",
35
+ "text": "Comments",
36
+ "url": "#/plugins/blog/comments"
37
+ }
38
+ ],
39
+ "permission": "blog-posts"
25
40
  }
26
41
  ],
27
42
  "routes": [
28
- { "path": "/plugins/blog", "view": "plugin-blog-posts", "title": "Blog - Domma CMS" },
29
- { "path": "/plugins/blog/posts", "view": "plugin-blog-posts", "title": "Blog Posts - Domma CMS" },
30
- { "path": "/plugins/blog/posts/new", "view": "plugin-blog-post-editor", "title": "New Post - Domma CMS" },
31
- { "path": "/plugins/blog/posts/:id", "view": "plugin-blog-post-editor", "title": "Edit Post - Domma CMS" },
32
- { "path": "/plugins/blog/categories", "view": "plugin-blog-categories", "title": "Blog Categories - Domma CMS" },
33
- { "path": "/plugins/blog/comments", "view": "plugin-blog-comments", "title": "Blog Comments - Domma CMS" },
34
- { "path": "/plugins/blog/settings", "view": "plugin-blog-settings", "title": "Blog Settings - Domma CMS" }
43
+ {
44
+ "path": "/plugins/blog",
45
+ "view": "plugin-blog-posts",
46
+ "title": "Blog - Domma CMS"
47
+ },
48
+ {
49
+ "path": "/plugins/blog/posts",
50
+ "view": "plugin-blog-posts",
51
+ "title": "Blog Posts - Domma CMS"
52
+ },
53
+ {
54
+ "path": "/plugins/blog/posts/new",
55
+ "view": "plugin-blog-post-editor",
56
+ "title": "New Post - Domma CMS"
57
+ },
58
+ {
59
+ "path": "/plugins/blog/posts/:id",
60
+ "view": "plugin-blog-post-editor",
61
+ "title": "Edit Post - Domma CMS"
62
+ },
63
+ {
64
+ "path": "/plugins/blog/categories",
65
+ "view": "plugin-blog-categories",
66
+ "title": "Blog Categories - Domma CMS"
67
+ },
68
+ {
69
+ "path": "/plugins/blog/comments",
70
+ "view": "plugin-blog-comments",
71
+ "title": "Blog Comments - Domma CMS"
72
+ },
73
+ {
74
+ "path": "/plugins/blog/settings",
75
+ "view": "plugin-blog-settings",
76
+ "title": "Blog Settings - Domma CMS"
77
+ }
35
78
  ],
36
79
  "views": {
37
- "plugin-blog-posts": { "entry": "blog/admin/views/blog.js?v=9", "exportName": "blogView" },
38
- "plugin-blog-post-editor": { "entry": "blog/admin/views/post-editor.js?v=9", "exportName": "postEditorView" },
39
- "plugin-blog-categories": { "entry": "blog/admin/views/categories.js?v=9", "exportName": "categoriesView" },
40
- "plugin-blog-comments": { "entry": "blog/admin/views/comments.js?v=9", "exportName": "commentsView" },
41
- "plugin-blog-settings": { "entry": "blog/admin/views/settings.js?v=9", "exportName": "settingsView" }
80
+ "plugin-blog-posts": {
81
+ "entry": "blog/admin/views/blog.js?v=9",
82
+ "exportName": "blogView"
83
+ },
84
+ "plugin-blog-post-editor": {
85
+ "entry": "blog/admin/views/post-editor.js?v=9",
86
+ "exportName": "postEditorView"
87
+ },
88
+ "plugin-blog-categories": {
89
+ "entry": "blog/admin/views/categories.js?v=9",
90
+ "exportName": "categoriesView"
91
+ },
92
+ "plugin-blog-comments": {
93
+ "entry": "blog/admin/views/comments.js?v=9",
94
+ "exportName": "commentsView"
95
+ },
96
+ "plugin-blog-settings": {
97
+ "entry": "blog/admin/views/settings.js?v=9",
98
+ "exportName": "settingsView"
99
+ }
100
+ }
101
+ },
102
+ "permissions": [
103
+ {
104
+ "key": "blog-posts",
105
+ "label": "Blog posts",
106
+ "group": "Blog",
107
+ "description": "Write, edit and publish blog posts.",
108
+ "actions": [
109
+ {
110
+ "key": "read",
111
+ "label": "View",
112
+ "description": "See the posts screens (a writer sees their own posts)"
113
+ },
114
+ {
115
+ "key": "create",
116
+ "label": "Write",
117
+ "description": "Write new posts"
118
+ },
119
+ {
120
+ "key": "update",
121
+ "label": "Edit",
122
+ "description": "Edit posts (a writer: their own)"
123
+ },
124
+ {
125
+ "key": "delete",
126
+ "label": "Delete",
127
+ "description": "Delete any post"
128
+ },
129
+ {
130
+ "key": "publish",
131
+ "label": "Publish",
132
+ "description": "Publish and schedule; see and edit everyone's posts"
133
+ }
134
+ ],
135
+ "grant": [
136
+ "admin"
137
+ ]
138
+ },
139
+ {
140
+ "key": "blog-categories",
141
+ "label": "Blog categories",
142
+ "group": "Blog",
143
+ "description": "The blog's categories.",
144
+ "actions": [
145
+ {
146
+ "key": "read",
147
+ "label": "View",
148
+ "description": "See and pick categories"
149
+ },
150
+ {
151
+ "key": "create",
152
+ "label": "Create",
153
+ "description": "Add categories"
154
+ },
155
+ {
156
+ "key": "update",
157
+ "label": "Edit",
158
+ "description": "Rename categories"
159
+ },
160
+ {
161
+ "key": "delete",
162
+ "label": "Delete",
163
+ "description": "Remove categories"
164
+ }
165
+ ],
166
+ "grant": [
167
+ "admin"
168
+ ]
169
+ },
170
+ {
171
+ "key": "blog-comments",
172
+ "label": "Blog comments",
173
+ "group": "Blog",
174
+ "description": "Readers' comments and moderation.",
175
+ "actions": [
176
+ {
177
+ "key": "read",
178
+ "label": "View",
179
+ "description": "See comments, with the commenter's email"
180
+ },
181
+ {
182
+ "key": "update",
183
+ "label": "Moderate",
184
+ "description": "Approve, reject, mark as spam"
185
+ },
186
+ {
187
+ "key": "delete",
188
+ "label": "Delete",
189
+ "description": "Delete comments"
190
+ }
191
+ ],
192
+ "grant": [
193
+ "admin"
194
+ ]
42
195
  }
43
- }
196
+ ]
44
197
  }
@@ -2,9 +2,22 @@
2
2
  "name": "blog-author",
3
3
  "label": "Blog Author",
4
4
  "level": 2,
5
- "permissions": ["blog-posts"],
5
+ "permissions": [
6
+ "blog-posts.read",
7
+ "blog-posts.create",
8
+ "blog-posts.update",
9
+ "blog-categories.read"
10
+ ],
6
11
  "badgeClass": "badge-info",
7
12
  "resources": [
8
- { "key": "blog-posts", "label": "Blog Posts", "actions": ["read", "create", "update"] }
13
+ {
14
+ "key": "blog-posts",
15
+ "label": "Blog Posts",
16
+ "actions": [
17
+ "read",
18
+ "create",
19
+ "update"
20
+ ]
21
+ }
9
22
  ]
10
23
  }
@@ -0,0 +1,32 @@
1
+ /** Blog access by permission (lib/access.js). */
2
+ import {test} from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import {AUTHOR_MARKER, AUTHOR_PERMISSIONS, authorPatch, permissionChecker, rolesOf} from '../lib/access.js';
5
+
6
+ const HOLDERS = {
7
+ 'blog-posts.read': ['super-admin', 'admin', 'blog-editor', 'blog-author'],
8
+ 'blog-posts.create': ['super-admin', 'admin', 'blog-editor', 'blog-author'],
9
+ 'blog-posts.publish': ['super-admin', 'admin', 'blog-editor'],
10
+ 'blog-comments.read': ['super-admin', 'admin', 'blog-editor']
11
+ };
12
+ const can = permissionChecker((k) => HOLDERS[k] || []);
13
+
14
+ test('by permission, counting additional roles - never by role name alone', () => {
15
+ assert.equal(can({role: 'user', additionalRoles: ['blog-editor']}, 'blog-posts.publish'), true, 'an editor held as an additional role');
16
+ assert.equal(can({role: 'blog-author'}, 'blog-posts.publish'), false);
17
+ assert.equal(can({role: 'blog-author'}, 'blog-posts.create'), true);
18
+ assert.equal(can({role: 'user'}, 'blog-posts.read'), false, 'a plain account reads no drafts');
19
+ assert.equal(can({role: 'user'}, 'blog-comments.read'), false, 'nor commenters');
20
+ assert.equal(can(null, 'blog-posts.read'), false);
21
+ assert.deepEqual(rolesOf({role: 'a', additionalRoles: ['b', 'a']}), ['a', 'b']);
22
+ });
23
+
24
+ test('authorPatch makes a bare blog-posts explicit, once, and leaves edited roles alone', () => {
25
+ const shipped = {name: 'blog-author', plugin: 'blog', permissions: ['blog-posts']};
26
+ assert.deepEqual(authorPatch(shipped), {grantedBy: [AUTHOR_MARKER], permissions: [...AUTHOR_PERMISSIONS]});
27
+ assert.equal(authorPatch({...shipped, grantedBy: [AUTHOR_MARKER]}), null, 'once');
28
+ assert.deepEqual(authorPatch({...shipped, permissions: ['blog-posts.read']}), {grantedBy: [AUTHOR_MARKER]}, 'an admin\'s own list is kept');
29
+ assert.equal(authorPatch({...shipped, plugin: 'other'}), null);
30
+ assert.equal(authorPatch({name: 'blog-editor', plugin: 'blog', permissions: ['blog-posts']}), null);
31
+ assert.ok(!AUTHOR_PERMISSIONS.some(p => /publish|delete/.test(p)));
32
+ });