@acms-subir/core 0.3.12 → 0.3.13

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/cli/migrations.generated.mjs +16 -1
  3. package/integration.mjs +2 -0
  4. package/package.json +1 -1
  5. package/src/actions/index.ts +76 -59
  6. package/src/components/RolesManager.tsx +276 -0
  7. package/src/layouts/Admin.astro +8 -3
  8. package/src/lib/auth/accounts.ts +2 -2
  9. package/src/lib/auth/store.ts +4 -4
  10. package/src/lib/content-service.ts +14 -0
  11. package/src/lib/design-service.ts +11 -3
  12. package/src/lib/editor/http.ts +7 -0
  13. package/src/lib/editor/me.ts +12 -8
  14. package/src/lib/editor/roles.ts +14 -6
  15. package/src/lib/editor/schedule.ts +12 -12
  16. package/src/lib/editor/sections.ts +11 -4
  17. package/src/lib/editor/site-data.ts +4 -2
  18. package/src/lib/editor/translations.ts +2 -1
  19. package/src/lib/email/accounts.ts +2 -2
  20. package/src/lib/email/store.ts +3 -3
  21. package/src/lib/email/types.ts +1 -1
  22. package/src/lib/permissions/access.ts +73 -0
  23. package/src/lib/permissions/catalog.ts +147 -0
  24. package/src/lib/permissions/migration.ts +17 -0
  25. package/src/lib/permissions/service.ts +59 -0
  26. package/src/lib/permissions/store.ts +54 -0
  27. package/src/lib/repo/migrations.ts +2 -0
  28. package/src/lib/repo/sql.ts +3 -0
  29. package/src/lib/repo/types.ts +2 -0
  30. package/src/lib/roles.ts +9 -0
  31. package/src/pages/admin/c/[collection]/index.astro +6 -0
  32. package/src/pages/admin/calendar/bookings.astro +2 -0
  33. package/src/pages/admin/calendar/events/index.astro +11 -0
  34. package/src/pages/admin/calendar/resources/[id].astro +2 -0
  35. package/src/pages/admin/calendar/resources/index.astro +2 -0
  36. package/src/pages/admin/calendar/resources/link.astro +2 -0
  37. package/src/pages/admin/calendar/settings.astro +2 -0
  38. package/src/pages/admin/email.astro +2 -2
  39. package/src/pages/admin/forms/[id]/share.astro +2 -0
  40. package/src/pages/admin/forms/[id]/submissions.astro +2 -0
  41. package/src/pages/admin/forms/index.astro +2 -0
  42. package/src/pages/admin/import.astro +3 -6
  43. package/src/pages/admin/integrations.astro +10 -12
  44. package/src/pages/admin/live.astro +3 -3
  45. package/src/pages/admin/mobile.astro +3 -3
  46. package/src/pages/admin/pages/index.astro +8 -1
  47. package/src/pages/admin/roles.astro +80 -0
  48. package/src/pages/admin/settings.astro +31 -28
  49. package/src/pages/admin/tags.astro +2 -0
  50. package/src/pages/admin/users.astro +87 -41
  51. package/src/pages/api/ai/clean.ts +3 -2
  52. package/src/pages/api/ai/draft.ts +3 -2
  53. package/src/pages/api/ai/settings.ts +4 -3
  54. package/src/pages/api/ai/test.ts +3 -2
  55. package/src/pages/api/design/export.ts +2 -1
  56. package/src/pages/api/design/import.ts +2 -1
  57. package/src/pages/api/design/keys.ts +3 -2
  58. package/src/pages/api/email/settings.ts +4 -3
  59. package/src/pages/api/export.ts +3 -2
  60. package/src/pages/api/import/asset.ts +3 -2
  61. package/src/pages/api/import/index.ts +4 -3
  62. package/src/pages/api/import/media.ts +3 -2
  63. package/src/pages/api/live/oauth/youtube/callback.ts +4 -4
  64. package/src/pages/api/live/settings.ts +4 -3
  65. package/src/pages/api/media/upload.ts +4 -0
  66. package/src/pages/api/mobile/access.ts +4 -3
  67. package/src/pages/api/monitoring.ts +4 -3
  68. package/src/pages/api/push/admin.ts +4 -3
  69. package/src/pages/api/roles.ts +36 -0
  70. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +2 -1
  71. package/src/pages/api/v1/admin/bookings/index.ts +3 -2
  72. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +3 -2
  73. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +3 -2
  74. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +3 -2
  75. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +3 -2
  76. package/src/pages/api/v1/admin/documents/index.ts +2 -1
  77. package/src/pages/api/v1/admin/links.ts +2 -1
  78. package/src/pages/api/v1/admin/media/index.ts +2 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.13
4
+
5
+ ### Patch Changes
6
+
7
+ - 0cea2b7: Team → Roles: owners choose what each role can do, with Save, Discard, Review and Restore defaults. Defaults keep today's access; administrators can now invite people (to roles below their own). Permissions apply on everyone's next request in the web admin and the editor app, and every save is recorded. Adds migration 51.
8
+
3
9
  ## 0.3.12
4
10
 
5
11
  ### Patch Changes
@@ -229,6 +229,20 @@ var PLACES_MIGRATION = { version: 43, statements: [
229
229
  `ALTER TABLE resources ADD COLUMN place_id TEXT NOT NULL DEFAULT ''`
230
230
  ] };
231
231
 
232
+ // src/lib/permissions/migration.ts
233
+ var ROLES_MIGRATION = {
234
+ version: 51,
235
+ statements: [
236
+ `CREATE TABLE role_policy (id INTEGER PRIMARY KEY CHECK (id = 1), overrides TEXT NOT NULL DEFAULT '{}', revision INTEGER NOT NULL DEFAULT 0,
237
+ updated_by TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL DEFAULT 0)`,
238
+ `INSERT INTO role_policy (id) VALUES (1)`,
239
+ `CREATE TABLE role_policy_log (id TEXT PRIMARY KEY, revision INTEGER NOT NULL, actor TEXT NOT NULL, changes TEXT NOT NULL, created_at INTEGER NOT NULL)`,
240
+ `CREATE INDEX role_policy_log_created ON role_policy_log(created_at)`,
241
+ `ALTER TABLE auth_invitations ADD COLUMN invited_by TEXT NOT NULL DEFAULT ''`,
242
+ `ALTER TABLE email_tokens ADD COLUMN invited_by TEXT NOT NULL DEFAULT ''`
243
+ ]
244
+ };
245
+
232
246
  // src/lib/repo/migrations.ts
233
247
  var CONTENT_VERSION_COLUMNS = {
234
248
  settings: [
@@ -968,7 +982,8 @@ var MIGRATIONS = [
968
982
  `CREATE TABLE backup_state (id INTEGER PRIMARY KEY CHECK (id = 1), last_at INTEGER NOT NULL DEFAULT 0, lease_until INTEGER NOT NULL DEFAULT 0,
969
983
  last_key TEXT NOT NULL DEFAULT '', last_error TEXT NOT NULL DEFAULT '', failed_at INTEGER NOT NULL DEFAULT 0)`,
970
984
  `INSERT INTO backup_state (id) VALUES (1)`
971
- ] }
985
+ ] },
986
+ ROLES_MIGRATION
972
987
  ];
973
988
  var LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
974
989
 
package/integration.mjs CHANGED
@@ -77,8 +77,10 @@ const ROUTES = [
77
77
  ['/admin/import', 'admin/import.astro'],
78
78
  ['/admin/tags', 'admin/tags.astro'],
79
79
  ['/admin/users', 'admin/users.astro'],
80
+ ['/admin/roles', 'admin/roles.astro'],
80
81
  // API
81
82
  ['/api/backup', 'api/backup.ts'],
83
+ ['/api/roles', 'api/roles.ts'],
82
84
  ['/api/drafts', 'api/drafts.ts'],
83
85
  ['/api/ai/settings', 'api/ai/settings.ts'],
84
86
  ['/api/ai/test', 'api/ai/test.ts'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.12",
3
+ "version": "0.3.13",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -8,6 +8,8 @@ import type { ActionAPIContext } from 'astro:actions';
8
8
  import { getRepo } from '../lib/repo';
9
9
  import { getMediaStore } from '../lib/media/store';
10
10
  import { validateRequestSession } from '../lib/auth/request';
11
+ import { accessFor } from '../lib/permissions/access';
12
+ import { deniedMessage, type Permission } from '../lib/permissions/catalog';
11
13
  import { DocTranslations, LinkKind, parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
12
14
  import { env } from '../lib/env';
13
15
  import { CONTENT, collectionUrl } from '../lib/content';
@@ -54,6 +56,13 @@ async function requireAdmin(ctx: ActionAPIContext) {
54
56
  throw new ActionError({ code: 'UNAUTHORIZED', message: 'Not signed in.' });
55
57
  }
56
58
  }
59
+ // …and that their role grants the permission (Team → Roles)
60
+ async function requireCan(ctx: ActionAPIContext, permission: Permission) {
61
+ const access = await accessFor(ctx.request, ctx.cookies, await getRepo(ctx.locals));
62
+ if (!access) throw new ActionError({ code: 'UNAUTHORIZED', message: 'Not signed in.' });
63
+ if (!access.can(permission)) throw new ActionError({ code: 'FORBIDDEN', message: deniedMessage(permission) });
64
+ return access;
65
+ }
57
66
 
58
67
  function decodeEntities(s: string): string {
59
68
  return s
@@ -101,6 +110,11 @@ function asActionError(error: unknown): never {
101
110
  throw error;
102
111
  }
103
112
  const hooksFor = (ctx: ActionAPIContext): SaveHooks => ({ url: ctx.url, locals: ctx.locals, onRedirect: invalidateRedirectCache });
113
+ // content saves also carry whether this person may change what's live
114
+ async function publishHooks(ctx: ActionAPIContext): Promise<SaveHooks> {
115
+ const access = await accessFor(ctx.request, ctx.cookies, await getRepo(ctx.locals));
116
+ return { ...hooksFor(ctx), canPublish: access?.can('content.publish') ?? false };
117
+ }
104
118
 
105
119
  async function importEvents(
106
120
  repo: Awaited<ReturnType<typeof getRepo>>,
@@ -146,7 +160,7 @@ export const server = {
146
160
  saveTemplate: defineAction({
147
161
  input: z.object({ name: z.string().trim().min(1).max(120), definition: z.any(), purpose: z.enum(['event', 'general']) }),
148
162
  handler: async (input, ctx) => {
149
- await requireAdmin(ctx);
163
+ await requireCan(ctx, 'forms.manage');
150
164
  const parsed = FormDefinition.safeParse(input.definition);
151
165
  if (!parsed.success) throw new ActionError({ code: 'BAD_REQUEST', message: parsed.error.issues[0]?.message || 'Invalid form.' });
152
166
  const repo = await getRepo(ctx.locals);
@@ -160,7 +174,7 @@ export const server = {
160
174
  save: defineAction({
161
175
  input: z.object({ id: z.string(), revision: z.number().int(), name: z.string().trim().min(1).max(120), definition: z.any(), op: z.enum(['save', 'publish', 'archive']) }),
162
176
  handler: async (input, ctx) => {
163
- await requireAdmin(ctx);
177
+ await requireCan(ctx, 'forms.manage');
164
178
  const parsed = FormDefinition.safeParse(input.definition);
165
179
  if (!parsed.success) throw new ActionError({ code: 'BAD_REQUEST', message: parsed.error.issues[0]?.message || 'Invalid form.' });
166
180
  const repo = await getRepo(ctx.locals);
@@ -179,7 +193,7 @@ export const server = {
179
193
  useTemplate: defineAction({
180
194
  input: z.object({ id: z.string(), name: z.string().trim().min(1).max(120).optional() }),
181
195
  handler: async (input, ctx) => {
182
- await requireAdmin(ctx);
196
+ await requireCan(ctx, 'forms.manage');
183
197
  const repo = await getRepo(ctx.locals);
184
198
  const source = await repo.forms.get(input.id);
185
199
  if (!source || source.kind !== 'template' || source.purpose !== 'general') throw new ActionError({ code: 'NOT_FOUND', message: 'Template not found.' });
@@ -215,9 +229,9 @@ export const server = {
215
229
  scheduledAt: nullableTs.optional(),
216
230
  }),
217
231
  handler: async ({ id, ...data }, ctx) => {
218
- await requireAdmin(ctx);
232
+ await requireCan(ctx, 'content.edit');
219
233
  const repo = await getRepo(ctx.locals);
220
- try { return { post: await savePost(repo, id, data, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
234
+ try { return { post: await savePost(repo, id, data, await publishHooks(ctx)) }; } catch (e) { asActionError(e); }
221
235
  },
222
236
  }),
223
237
  // Update button: copy the pending draft onto the live columns. The
@@ -226,15 +240,15 @@ export const server = {
226
240
  applyUpdate: defineAction({
227
241
  input: z.object({ id: z.string() }),
228
242
  handler: async ({ id }, ctx) => {
229
- await requireAdmin(ctx);
243
+ await requireCan(ctx, 'content.publish');
230
244
  const repo = await getRepo(ctx.locals);
231
- try { return { post: await applyPostUpdate(repo, id, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
245
+ try { return { post: await applyPostUpdate(repo, id, await publishHooks(ctx)) }; } catch (e) { asActionError(e); }
232
246
  },
233
247
  }),
234
248
  discardChanges: defineAction({
235
249
  input: z.object({ id: z.string() }),
236
250
  handler: async ({ id }, ctx) => {
237
- await requireAdmin(ctx);
251
+ await requireCan(ctx, 'content.edit');
238
252
  const repo = await getRepo(ctx.locals);
239
253
  await repo.discardPostDraft(id);
240
254
  const post = await repo.getPost(id);
@@ -245,7 +259,7 @@ export const server = {
245
259
  delete: defineAction({
246
260
  input: z.object({ id: z.string() }),
247
261
  handler: async ({ id }, ctx) => {
248
- await requireAdmin(ctx);
262
+ await requireCan(ctx, 'content.delete');
249
263
  const repo = await getRepo(ctx.locals);
250
264
  await repo.deletePost(id);
251
265
  return { ok: true };
@@ -271,7 +285,7 @@ export const server = {
271
285
  blocks: z.array(BlockInput).optional(),
272
286
  }),
273
287
  handler: async ({ id, blocks, ...data }, ctx) => {
274
- await requireAdmin(ctx);
288
+ await requireCan(ctx, 'content.edit');
275
289
  const repo = await getRepo(ctx.locals);
276
290
  const cleanBlocks =
277
291
  blocks === undefined
@@ -279,21 +293,21 @@ export const server = {
279
293
  : blocks
280
294
  .filter((b) => widgetTypes.includes(b.type as WidgetType))
281
295
  .map((b) => ({ ...b, type: b.type as WidgetType, config: parseConfig(b.type as WidgetType, b.config) as Record<string, unknown> }));
282
- try { return { page: await savePage(repo, id, data, cleanBlocks, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
296
+ try { return { page: await savePage(repo, id, data, cleanBlocks, await publishHooks(ctx)) }; } catch (e) { asActionError(e); }
283
297
  },
284
298
  }),
285
299
  applyUpdate: defineAction({
286
300
  input: z.object({ id: z.string() }),
287
301
  handler: async ({ id }, ctx) => {
288
- await requireAdmin(ctx);
302
+ await requireCan(ctx, 'content.publish');
289
303
  const repo = await getRepo(ctx.locals);
290
- try { return { page: await applyPageUpdate(repo, id, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
304
+ try { return { page: await applyPageUpdate(repo, id, await publishHooks(ctx)) }; } catch (e) { asActionError(e); }
291
305
  },
292
306
  }),
293
307
  discardChanges: defineAction({
294
308
  input: z.object({ id: z.string() }),
295
309
  handler: async ({ id }, ctx) => {
296
- await requireAdmin(ctx);
310
+ await requireCan(ctx, 'content.edit');
297
311
  const repo = await getRepo(ctx.locals);
298
312
  await repo.discardPageDraft(id);
299
313
  const page = await repo.getPage(id);
@@ -304,7 +318,7 @@ export const server = {
304
318
  delete: defineAction({
305
319
  input: z.object({ id: z.string() }),
306
320
  handler: async ({ id }, ctx) => {
307
- await requireAdmin(ctx);
321
+ await requireCan(ctx, 'content.delete');
308
322
  const repo = await getRepo(ctx.locals);
309
323
  await repo.deletePage(id);
310
324
  return { ok: true };
@@ -324,7 +338,7 @@ export const server = {
324
338
  updateAlt: defineAction({
325
339
  input: z.object({ id: z.string(), alt: z.string() }),
326
340
  handler: async ({ id, alt }, ctx) => {
327
- await requireAdmin(ctx);
341
+ await requireCan(ctx, 'media.upload');
328
342
  const repo = await getRepo(ctx.locals);
329
343
  await repo.updateMedia(id, { alt });
330
344
  return { ok: true };
@@ -333,7 +347,7 @@ export const server = {
333
347
  delete: defineAction({
334
348
  input: z.object({ id: z.string() }),
335
349
  handler: async ({ id }, ctx) => {
336
- await requireAdmin(ctx);
350
+ await requireCan(ctx, 'media.delete');
337
351
  const repo = await getRepo(ctx.locals);
338
352
  const media = await repo.getMedia(id);
339
353
  if (media) {
@@ -358,7 +372,7 @@ export const server = {
358
372
  meta: z.record(z.any()).default({}),
359
373
  }),
360
374
  handler: async (data, ctx) => {
361
- await requireAdmin(ctx);
375
+ await requireCan(ctx, 'site.links');
362
376
  const repo = await getRepo(ctx.locals);
363
377
  return { link: await repo.createLink(data) };
364
378
  },
@@ -373,7 +387,7 @@ export const server = {
373
387
  meta: z.record(z.any()).optional(),
374
388
  }),
375
389
  handler: async ({ id, ...data }, ctx) => {
376
- await requireAdmin(ctx);
390
+ await requireCan(ctx, 'site.links');
377
391
  const repo = await getRepo(ctx.locals);
378
392
  await repo.updateLink(id, data);
379
393
  return { ok: true };
@@ -382,7 +396,7 @@ export const server = {
382
396
  reorder: defineAction({
383
397
  input: z.object({ ids: z.array(z.string()) }),
384
398
  handler: async ({ ids }, ctx) => {
385
- await requireAdmin(ctx);
399
+ await requireCan(ctx, 'site.links');
386
400
  const repo = await getRepo(ctx.locals);
387
401
  await repo.reorderLinks(ids);
388
402
  return { ok: true };
@@ -391,7 +405,7 @@ export const server = {
391
405
  delete: defineAction({
392
406
  input: z.object({ id: z.string() }),
393
407
  handler: async ({ id }, ctx) => {
394
- await requireAdmin(ctx);
408
+ await requireCan(ctx, 'site.links');
395
409
  const repo = await getRepo(ctx.locals);
396
410
  await repo.deleteLink(id);
397
411
  return { ok: true };
@@ -412,7 +426,7 @@ export const server = {
412
426
  })),
413
427
  }),
414
428
  handler: async ({ items }, ctx) => {
415
- await requireAdmin(ctx);
429
+ await requireCan(ctx, 'site.navigation');
416
430
  const repo = await getRepo(ctx.locals);
417
431
  await repo.saveDesignDraft({ navItems: items });
418
432
  const changes = await designChanges(repo);
@@ -427,7 +441,7 @@ export const server = {
427
441
  area: z.enum(['header', 'footer', 'social']).default('header'),
428
442
  }),
429
443
  handler: async (data, ctx) => {
430
- await requireAdmin(ctx);
444
+ await requireCan(ctx, 'site.navigation');
431
445
  const repo = await getRepo(ctx.locals);
432
446
  return { item: await repo.createNavItem(data) };
433
447
  },
@@ -440,7 +454,7 @@ export const server = {
440
454
  area: z.enum(['header', 'footer', 'social']).optional(),
441
455
  }),
442
456
  handler: async ({ id, ...data }, ctx) => {
443
- await requireAdmin(ctx);
457
+ await requireCan(ctx, 'site.navigation');
444
458
  const repo = await getRepo(ctx.locals);
445
459
  await repo.updateNavItem(id, data);
446
460
  return { ok: true };
@@ -449,7 +463,7 @@ export const server = {
449
463
  reorder: defineAction({
450
464
  input: z.object({ ids: z.array(z.string()) }),
451
465
  handler: async ({ ids }, ctx) => {
452
- await requireAdmin(ctx);
466
+ await requireCan(ctx, 'site.navigation');
453
467
  const repo = await getRepo(ctx.locals);
454
468
  await repo.reorderNavItems(ids);
455
469
  return { ok: true };
@@ -458,7 +472,7 @@ export const server = {
458
472
  delete: defineAction({
459
473
  input: z.object({ id: z.string() }),
460
474
  handler: async ({ id }, ctx) => {
461
- await requireAdmin(ctx);
475
+ await requireCan(ctx, 'site.navigation');
462
476
  const repo = await getRepo(ctx.locals);
463
477
  await repo.deleteNavItem(id);
464
478
  return { ok: true };
@@ -487,7 +501,11 @@ export const server = {
487
501
  // unknown keys are an error, not silently dropped
488
502
  }).strict(),
489
503
  handler: async (data, ctx) => {
490
- await requireAdmin(ctx);
504
+ // each field belongs to the screen that edits it
505
+ const needs = new Set<Permission>(Object.keys(data).map((k): Permission =>
506
+ k.startsWith('links') ? 'site.links' : ['siteTitle', 'tagline', 'footerText', 'timezone', 'locale'].includes(k) ? 'site.settings' : 'site.design'));
507
+ if (!needs.size) await requireAdmin(ctx);
508
+ for (const permission of needs) await requireCan(ctx, permission);
491
509
  const repo = await getRepo(ctx.locals);
492
510
  await repo.updateSettings({ ...data, ...(data.locale ? { locale: Intl.getCanonicalLocales(data.locale)[0] } : {}) });
493
511
  return { ok: true };
@@ -506,7 +524,7 @@ export const server = {
506
524
  siteCover: z.string().optional(),
507
525
  }),
508
526
  handler: async (data, ctx) => {
509
- await requireAdmin(ctx);
527
+ await requireCan(ctx, 'site.design');
510
528
  const repo = await getRepo(ctx.locals);
511
529
  await repo.saveDesignDraft(data);
512
530
  // edits that land back on the live design leave nothing to publish
@@ -526,7 +544,7 @@ export const server = {
526
544
  revertDesignChange: defineAction({
527
545
  input: z.object({ key: z.string() }),
528
546
  handler: async ({ key }, ctx) => {
529
- await requireAdmin(ctx);
547
+ await requireCan(ctx, 'site.design');
530
548
  const repo = await getRepo(ctx.locals);
531
549
  const [settings, nav] = await Promise.all([repo.getSettings(), repo.listNavItems()]);
532
550
  if (settings.draftDesign) await repo.setDesignDraft(revertDesignChange(settings, nav, settings.draftDesign, key));
@@ -537,8 +555,7 @@ export const server = {
537
555
  applyDesign: defineAction({
538
556
  input: z.object({}),
539
557
  handler: async (_input, ctx) => {
540
- const userId = await validateRequestSession(ctx.request, ctx.cookies, ctx.locals);
541
- if (!userId) throw new ActionError({ code: 'UNAUTHORIZED', message: 'Not signed in.' });
558
+ const { userId } = await requireCan(ctx, 'site.design');
542
559
  const repo = await getRepo(ctx.locals);
543
560
  const changes = await designChanges(repo);
544
561
  const author = (await repo.listUsers()).find((u) => u.id === userId)?.name ?? 'Admin';
@@ -557,7 +574,7 @@ export const server = {
557
574
  discardDesign: defineAction({
558
575
  input: z.object({}),
559
576
  handler: async (_input, ctx) => {
560
- await requireAdmin(ctx);
577
+ await requireCan(ctx, 'site.design');
561
578
  const repo = await getRepo(ctx.locals);
562
579
  await repo.discardDesignDraft();
563
580
  return { ok: true };
@@ -582,7 +599,7 @@ export const server = {
582
599
  restoreDesignVersion: defineAction({
583
600
  input: z.object({ id: z.string() }),
584
601
  handler: async ({ id }, ctx) => {
585
- await requireAdmin(ctx);
602
+ await requireCan(ctx, 'site.design');
586
603
  const repo = await getRepo(ctx.locals);
587
604
  const v = await repo.getDesignVersion(id);
588
605
  if (!v) throw new ActionError({ code: 'NOT_FOUND', message: 'That version no longer exists.' });
@@ -597,7 +614,7 @@ export const server = {
597
614
  createShareLink: defineAction({
598
615
  input: z.object({ days: z.number().int().min(1).max(30).default(7) }),
599
616
  handler: async ({ days }, ctx) => {
600
- await requireAdmin(ctx);
617
+ await requireCan(ctx, 'site.design');
601
618
  const repo = await getRepo(ctx.locals);
602
619
  const settings = await repo.getSettings();
603
620
  if (!settings.draftDesign) throw new ActionError({ code: 'BAD_REQUEST', message: 'There are no draft changes to share.' });
@@ -612,7 +629,7 @@ export const server = {
612
629
  revokeShareLinks: defineAction({
613
630
  input: z.object({}),
614
631
  handler: async (_input, ctx) => {
615
- await requireAdmin(ctx);
632
+ await requireCan(ctx, 'site.design');
616
633
  const repo = await getRepo(ctx.locals);
617
634
  const settings = await repo.getSettings();
618
635
  if (settings.draftDesign) await repo.saveDesignDraft({ shareEpoch: crypto.randomUUID() });
@@ -699,7 +716,7 @@ export const server = {
699
716
  create: defineAction({
700
717
  input: z.object({ name: z.string().min(1).max(80), contentJson: z.string().min(2) }),
701
718
  handler: async ({ name, contentJson }, ctx) => {
702
- await requireAdmin(ctx);
719
+ await requireCan(ctx, 'content.edit');
703
720
  // must be a JSON array of ProseMirror nodes — reject anything else
704
721
  // before it can break the editor on re-insert
705
722
  let parsed: unknown;
@@ -714,7 +731,7 @@ export const server = {
714
731
  delete: defineAction({
715
732
  input: z.object({ id: z.string() }),
716
733
  handler: async ({ id }, ctx) => {
717
- await requireAdmin(ctx);
734
+ await requireCan(ctx, 'content.edit');
718
735
  const repo = await getRepo(ctx.locals);
719
736
  await repo.deleteSnippet(id);
720
737
  return { ok: true };
@@ -920,7 +937,7 @@ export const server = {
920
937
  applyToLinks: z.boolean().optional(),
921
938
  }),
922
939
  handler: async ({ applyToLinks, ...patch }, ctx) => {
923
- await requireAdmin(ctx);
940
+ await requireCan(ctx, 'schedule.manage');
924
941
  const repo = await getRepo(ctx.locals);
925
942
  const settings = await repo.getSettings();
926
943
  if (patch.defaultCalendarId) {
@@ -976,23 +993,23 @@ export const server = {
976
993
  scheduledAt: nullableTs.optional(),
977
994
  }),
978
995
  handler: async ({ id, ...data }, ctx) => {
979
- await requireAdmin(ctx);
996
+ await requireCan(ctx, 'content.edit');
980
997
  const repo = await getRepo(ctx.locals);
981
- try { return { event: await saveEvent(repo, id, data, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
998
+ try { return { event: await saveEvent(repo, id, data, await publishHooks(ctx)) }; } catch (e) { asActionError(e); }
982
999
  },
983
1000
  }),
984
1001
  applyUpdate: defineAction({
985
1002
  input: z.object({ id: z.string() }),
986
1003
  handler: async ({ id }, ctx) => {
987
- await requireAdmin(ctx);
1004
+ await requireCan(ctx, 'content.publish');
988
1005
  const repo = await getRepo(ctx.locals);
989
- try { return { event: await applyEventUpdate(repo, id, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
1006
+ try { return { event: await applyEventUpdate(repo, id, await publishHooks(ctx)) }; } catch (e) { asActionError(e); }
990
1007
  },
991
1008
  }),
992
1009
  discardChanges: defineAction({
993
1010
  input: z.object({ id: z.string() }),
994
1011
  handler: async ({ id }, ctx) => {
995
- await requireAdmin(ctx);
1012
+ await requireCan(ctx, 'content.edit');
996
1013
  const repo = await getRepo(ctx.locals);
997
1014
  await repo.discardEventDraft(id);
998
1015
  return { event: await repo.getEvent(id) };
@@ -1001,7 +1018,7 @@ export const server = {
1001
1018
  delete: defineAction({
1002
1019
  input: z.object({ id: z.string() }),
1003
1020
  handler: async ({ id }, ctx) => {
1004
- await requireAdmin(ctx);
1021
+ await requireCan(ctx, 'content.delete');
1005
1022
  const repo = await getRepo(ctx.locals);
1006
1023
  const before = await repo.getEvent(id);
1007
1024
  await repo.deleteEvent(id);
@@ -1070,7 +1087,7 @@ export const server = {
1070
1087
  cancelBookingsForEvent: defineAction({
1071
1088
  input: z.object({ ids: z.array(z.string()).min(1).max(100), message: z.string().max(2000).default('') }),
1072
1089
  handler: async ({ ids, message }, ctx) => {
1073
- await requireAdmin(ctx);
1090
+ await requireCan(ctx, 'schedule.bookings');
1074
1091
  const repo = await getRepo(ctx.locals);
1075
1092
  const settings = await repo.getSettings();
1076
1093
  const opts = { origin: siteOrigin(settings, ctx.url), locals: ctx.locals, waitUntil: waitUntilFrom(ctx.locals) };
@@ -1097,9 +1114,9 @@ export const server = {
1097
1114
  roomIds: z.array(z.string()).max(50).default([]),
1098
1115
  }),
1099
1116
  handler: async (input, ctx) => {
1100
- await requireAdmin(ctx);
1117
+ await requireCan(ctx, 'content.edit');
1101
1118
  const repo = await getRepo(ctx.locals);
1102
- try { const e = await quickCreate(repo, input, hooksFor(ctx)); return { id: e.id, status: e.status }; } catch (e) { asActionError(e); }
1119
+ try { const e = await quickCreate(repo, input, await publishHooks(ctx)); return { id: e.id, status: e.status }; } catch (e) { asActionError(e); }
1103
1120
  },
1104
1121
  }),
1105
1122
  // Schedule: drag to another day. `occurrence` + mode 'one' moves a single
@@ -1107,10 +1124,10 @@ export const server = {
1107
1124
  move: defineAction({
1108
1125
  input: z.object({ id: z.string(), days: z.number().int(), mode: z.enum(['series', 'one']).default('series'), occurrence: z.string().max(20).default('') }),
1109
1126
  handler: async ({ id, days, mode, occurrence }, ctx) => {
1110
- await requireAdmin(ctx);
1127
+ await requireCan(ctx, 'content.edit');
1111
1128
  const repo = await getRepo(ctx.locals);
1112
1129
  try {
1113
- const r = mode === 'one' ? await moveOccurrence(repo, id, occurrence, days, hooksFor(ctx)) : await moveSeries(repo, id, days, hooksFor(ctx));
1130
+ const r = mode === 'one' ? await moveOccurrence(repo, id, occurrence, days, await publishHooks(ctx)) : await moveSeries(repo, id, days, await publishHooks(ctx));
1114
1131
  return { id: r.event.id, undo: r.undo };
1115
1132
  } catch (e) { asActionError(e); }
1116
1133
  },
@@ -1121,15 +1138,15 @@ export const server = {
1121
1138
  z.object({ kind: z.literal('split'), id: z.string(), seriesId: z.string(), occurrence: z.string().max(20) }),
1122
1139
  ]),
1123
1140
  handler: async (undo, ctx) => {
1124
- await requireAdmin(ctx);
1141
+ await requireCan(ctx, 'content.edit');
1125
1142
  const repo = await getRepo(ctx.locals);
1126
- try { await undoMove(repo, undo, hooksFor(ctx)); return { ok: true }; } catch (e) { asActionError(e); }
1143
+ try { await undoMove(repo, undo, await publishHooks(ctx)); return { ok: true }; } catch (e) { asActionError(e); }
1127
1144
  },
1128
1145
  }),
1129
1146
  duplicate: defineAction({
1130
1147
  input: z.object({ id: z.string() }),
1131
1148
  handler: async ({ id }, ctx) => {
1132
- await requireAdmin(ctx);
1149
+ await requireCan(ctx, 'content.edit');
1133
1150
  const repo = await getRepo(ctx.locals);
1134
1151
  try { return { id: (await duplicateEvent(repo, id)).id }; } catch (e) { asActionError(e); }
1135
1152
  },
@@ -1163,7 +1180,7 @@ export const server = {
1163
1180
  calendarId: z.string().optional(),
1164
1181
  }),
1165
1182
  handler: async ({ format, text, url, calendarId }, ctx) => {
1166
- await requireAdmin(ctx);
1183
+ await requireCan(ctx, 'content.edit');
1167
1184
  const repo = await getRepo(ctx.locals);
1168
1185
  const settings = await repo.getSettings();
1169
1186
  if (calendarId && !(await repo.getCalendar(calendarId))) throw new ActionError({ code: 'BAD_REQUEST', message: 'Unknown calendar.' });
@@ -1211,7 +1228,7 @@ export const server = {
1211
1228
  visibility: z.enum(['public', 'unlisted', 'private']).optional(),
1212
1229
  }),
1213
1230
  handler: async ({ id, ...data }, ctx) => {
1214
- await requireAdmin(ctx);
1231
+ await requireCan(ctx, 'schedule.manage');
1215
1232
  const repo = await getRepo(ctx.locals);
1216
1233
  try { return { calendar: await saveCalendar(repo, id, data) }; }
1217
1234
  catch (e) {
@@ -1223,7 +1240,7 @@ export const server = {
1223
1240
  reorder: defineAction({
1224
1241
  input: z.object({ ids: z.array(z.string()) }),
1225
1242
  handler: async ({ ids }, ctx) => {
1226
- await requireAdmin(ctx);
1243
+ await requireCan(ctx, 'schedule.manage');
1227
1244
  await (await getRepo(ctx.locals)).reorderCalendars(ids);
1228
1245
  return { ok: true };
1229
1246
  },
@@ -1231,7 +1248,7 @@ export const server = {
1231
1248
  rotateToken: defineAction({
1232
1249
  input: z.object({ id: z.string() }),
1233
1250
  handler: async ({ id }, ctx) => {
1234
- await requireAdmin(ctx);
1251
+ await requireCan(ctx, 'schedule.manage');
1235
1252
  const repo = await getRepo(ctx.locals);
1236
1253
  const bytes = crypto.getRandomValues(new Uint8Array(18));
1237
1254
  const token = btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
@@ -1242,7 +1259,7 @@ export const server = {
1242
1259
  delete: defineAction({
1243
1260
  input: z.object({ id: z.string() }),
1244
1261
  handler: async ({ id }, ctx) => {
1245
- await requireAdmin(ctx);
1262
+ await requireCan(ctx, 'schedule.manage');
1246
1263
  await (await getRepo(ctx.locals)).deleteCalendar(id);
1247
1264
  return { ok: true };
1248
1265
  },
@@ -1295,7 +1312,7 @@ export const server = {
1295
1312
  isDefault: z.boolean().default(false),
1296
1313
  }),
1297
1314
  handler: async (input, ctx) => {
1298
- await requireAdmin(ctx);
1315
+ await requireCan(ctx, 'schedule.manage');
1299
1316
  const repo = await getRepo(ctx.locals);
1300
1317
  try { return await savePlace(repo, input, mapsCtx(ctx)); } catch (e) { asActionError(e); }
1301
1318
  },
@@ -1303,7 +1320,7 @@ export const server = {
1303
1320
  delete: defineAction({
1304
1321
  input: z.object({ id: z.string().max(100) }),
1305
1322
  handler: async ({ id }, ctx) => {
1306
- await requireAdmin(ctx);
1323
+ await requireCan(ctx, 'schedule.manage');
1307
1324
  await (await getRepo(ctx.locals)).deletePlace(id);
1308
1325
  return { ok: true };
1309
1326
  },