@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
@@ -3,8 +3,8 @@ import Admin from '../../layouts/Admin.astro';
3
3
  import AiSettings from '../../components/AiSettings';
4
4
  import MonitoringSettings from '../../components/MonitoringSettings';
5
5
  import { getRepo } from '../../lib/repo';
6
- import { OWNER_ENV } from '../../lib/auth';
7
- import { validateRequestSession } from '../../lib/auth/request';
6
+ import { accessFor } from '../../lib/permissions/access';
7
+ import type { Permission } from '../../lib/permissions/catalog';
8
8
  import { AUTH } from '../../lib/auth/config';
9
9
  import { invalidateRedirectCache } from '../../middleware';
10
10
  import { CONTENT } from '../../lib/content';
@@ -12,9 +12,10 @@ import { UMAMI_ID_RE, META_PIXEL_ID_RE, TIKTOK_PIXEL_ID_RE, normalizeScriptUrl }
12
12
 
13
13
  const repo = await getRepo(Astro.locals);
14
14
 
15
- const sessionUserId = (await validateRequestSession(Astro.request, Astro.cookies, Astro.locals));
16
- const sessionUser = sessionUserId && sessionUserId !== OWNER_ENV ? await repo.getUser(sessionUserId) : null;
17
- const isOwner = sessionUserId === OWNER_ENV || sessionUser?.role === 'owner';
15
+ // each card follows its permission on Team → Roles
16
+ const access = await accessFor(Astro.request, Astro.cookies, repo);
17
+ const isOwner = access?.role === 'owner';
18
+ const can = (permission: Permission) => access?.can(permission) ?? false;
18
19
 
19
20
  const genKey = () =>
20
21
  'acms_sk_' + [...crypto.getRandomValues(new Uint8Array(24))].map((b) => b.toString(16).padStart(2, '0')).join('');
@@ -41,7 +42,9 @@ if (Astro.request.method === 'POST') {
41
42
  const form = await Astro.request.formData();
42
43
  const action = String(form.get('action') || 'site');
43
44
  postedAction = action;
44
- if (action === 'site') {
45
+ if (['site', 'localization', 'announcement', 'addRedirect', 'removeRedirect'].includes(action) && !can('site.settings')) {
46
+ flashError = 'Your role doesn’t include “General settings”. Ask an owner if you need it.';
47
+ } else if (action === 'site') {
45
48
  // site URL: normalize to a bare origin; empty is fine (falls back to the
46
49
  // request origin until configured)
47
50
  const rawUrl = String(form.get('siteUrl') ?? '').trim();
@@ -75,8 +78,8 @@ if (Astro.request.method === 'POST') {
75
78
  flash = 'Timezone & language saved.';
76
79
  }
77
80
  } else if (action === 'codeInjection') {
78
- if (!isOwner) {
79
- flashError = 'Only owners can edit injected code.';
81
+ if (!can('site.code')) {
82
+ flashError = 'Your role doesn’t include “Code injection and analytics”.';
80
83
  } else {
81
84
  await repo.updateSettings({
82
85
  headCode: String(form.get('headCode') ?? ''),
@@ -85,8 +88,8 @@ if (Astro.request.method === 'POST') {
85
88
  flash = 'Code injection saved.';
86
89
  }
87
90
  } else if (action === 'analytics') {
88
- if (!isOwner) {
89
- flashError = 'Only owners can edit analytics.';
91
+ if (!can('site.code')) {
92
+ flashError = 'Your role doesn’t include “Code injection and analytics”.';
90
93
  } else {
91
94
  // replay sample rate is edited as a percent, stored as a fraction
92
95
  const replayPct = Number(String(form.get('umamiReplaySampleRate') ?? '15').trim());
@@ -167,8 +170,8 @@ if (Astro.request.method === 'POST') {
167
170
  await repo.deleteRedirect(String(form.get('id') ?? ''));
168
171
  invalidateRedirectCache();
169
172
  flash = 'Redirect removed.';
170
- } else if (!isOwner) {
171
- flashError = 'Only owners can manage the drafts API.';
173
+ } else if (!can('integrations.manage')) {
174
+ flashError = 'Your role doesn’t include “API keys and webhooks”.';
172
175
  } else {
173
176
  const current = await repo.getSettings();
174
177
  if (action === 'generateKey') {
@@ -222,18 +225,18 @@ const groups = [
222
225
  { id: 'announcement', label: 'Announcement bar', terms: 'announcement banner bar' },
223
226
  ] },
224
227
  { label: 'Publishing', items: [
225
- ...(isOwner ? [{ id: 'drafts-api', label: 'External drafts API', terms: 'api key drafts cors origins' }] : []),
228
+ ...(can('integrations.manage') ? [{ id: 'drafts-api', label: 'External drafts API', terms: 'api key drafts cors origins' }] : []),
226
229
  { id: 'redirects', label: 'Redirects', terms: 'redirects 301 302 urls' },
227
230
  ] },
228
- ...(isOwner ? [{ label: 'Integrations', items: [
229
- { id: 'email', label: 'Email', terms: 'email transactional resend mailgun amazon ses aws cloudflare invitation invite password reset booking calendar notifications reminders' },
230
- { id: 'ai-agents', label: 'AI & agents', terms: 'ai agents openai custom kimi deepseek cli proxy cpamc tailscale mcp' },
231
- { id: 'analytics', label: 'Analytics', terms: 'analytics umami replay meta pixel tiktok tracking' },
232
- { id: 'code', label: 'Code injection', terms: 'code injection head footer scripts' },
233
- { id: 'monitoring', label: 'Monitoring', terms: 'monitoring uptime kuma health status down alerts heartbeat scheduler' },
231
+ ...(can('services.manage') || can('site.code') ? [{ label: 'Integrations', items: [
232
+ ...(can('services.manage') ? [{ id: 'email', label: 'Email', terms: 'email transactional resend mailgun amazon ses aws cloudflare invitation invite password reset booking calendar notifications reminders' },
233
+ { id: 'ai-agents', label: 'AI & agents', terms: 'ai agents openai custom kimi deepseek cli proxy cpamc tailscale mcp' }] : []),
234
+ ...(can('site.code') ? [{ id: 'analytics', label: 'Analytics', terms: 'analytics umami replay meta pixel tiktok tracking' },
235
+ { id: 'code', label: 'Code injection', terms: 'code injection head footer scripts' }] : []),
236
+ ...(can('services.manage') ? [{ id: 'monitoring', label: 'Monitoring', terms: 'monitoring uptime kuma health status down alerts heartbeat scheduler' }] : []),
234
237
  ] }] : []),
235
238
  { label: 'Advanced', items: [
236
- ...(isOwner ? [{ id: 'import', label: 'Import & export', terms: 'import export ghost wordpress migrate migration move content' }] : []),
239
+ ...(can('data.import') ? [{ id: 'import', label: 'Import & export', terms: 'import export ghost wordpress migrate migration move content' }] : []),
237
240
  { id: 'backup', label: 'Backup', terms: 'backup restore export zip' },
238
241
  { id: 'backend', label: 'Backend', terms: 'backend database d1 sqlite' },
239
242
  ] },
@@ -370,7 +373,7 @@ const groups = [
370
373
 
371
374
  <div class="set-group-title">Publishing</div>
372
375
 
373
- {isOwner && (
376
+ {can('integrations.manage') && (
374
377
  <section class="scard" id="drafts-api">
375
378
  <header class="scard-head">
376
379
  <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
@@ -470,9 +473,9 @@ const groups = [
470
473
  </form>
471
474
  </section>
472
475
 
473
- {isOwner && <div class="set-group-title">Integrations</div>}
476
+ {(can('services.manage') || can('site.code')) && <div class="set-group-title">Integrations</div>}
474
477
 
475
- {isOwner && (
478
+ {can('services.manage') && (
476
479
  <section class="scard" id="email">
477
480
  <header class="scard-head">
478
481
  <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
@@ -483,7 +486,7 @@ const groups = [
483
486
  </section>
484
487
  )}
485
488
 
486
- {isOwner && (
489
+ {can('site.code') && (
487
490
  <section class={`scard ${openCard === 'analytics' ? 'editing' : ''}`} id="analytics" data-editable>
488
491
  <header class="scard-head">
489
492
  <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
@@ -545,7 +548,7 @@ const groups = [
545
548
  </section>
546
549
  )}
547
550
 
548
- {isOwner && (
551
+ {can('site.code') && (
549
552
  <section class={`scard ${openCard === 'codeInjection' ? 'editing' : ''}`} id="code" data-editable>
550
553
  <header class="scard-head">
551
554
  <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
@@ -572,9 +575,9 @@ const groups = [
572
575
  </section>
573
576
  )}
574
577
 
575
- {isOwner && <section class="scard" id="ai-agents"><header class="scard-head"><a class="scard-back" href="#" aria-label="All settings">←</a><div class="grow"><h2>AI &amp; agents</h2></div><a class="btn" href="/admin/ai">Import document</a></header><AiSettings client:load /></section>}
578
+ {can('services.manage') && <section class="scard" id="ai-agents"><header class="scard-head"><a class="scard-back" href="#" aria-label="All settings">←</a><div class="grow"><h2>AI &amp; agents</h2></div><a class="btn" href="/admin/ai">Import document</a></header><AiSettings client:load /></section>}
576
579
 
577
- {isOwner && (
580
+ {can('services.manage') && (
578
581
  <section class="scard" id="monitoring">
579
582
  <header class="scard-head">
580
583
  <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
@@ -586,7 +589,7 @@ const groups = [
586
589
 
587
590
  <div class="set-group-title">Advanced</div>
588
591
 
589
- {isOwner && (
592
+ {can('data.import') && (
590
593
  <section class="scard" id="import">
591
594
  <header class="scard-head">
592
595
  <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
@@ -4,8 +4,10 @@ import Modal from '../../components/Modal.astro';
4
4
  import { getRepo } from '../../lib/repo';
5
5
  import { slugify } from '../../lib/schema';
6
6
  import { CONTENT, tagUrl } from '../../lib/content';
7
+ import { forbiddenUnless } from '../../lib/permissions/access';
7
8
 
8
9
  const repo = await getRepo(Astro.locals);
10
+ if (Astro.request.method === 'POST') { const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'content.edit'); if (denied) return denied; }
9
11
 
10
12
  if (Astro.request.method === 'POST') {
11
13
  const form = await Astro.request.formData();
@@ -13,12 +13,16 @@ import { kickEmail } from '../../lib/email/service';
13
13
  import { waitUntilFrom } from '../../lib/webhooks';
14
14
  import { requireSession } from '../../lib/api';
15
15
  import { slugify, authorSlug, Role } from '../../lib/schema';
16
- import { roleLabels } from '../../lib/roles';
16
+ import { roleLabels, roleSummaries } from '../../lib/roles';
17
+ import { accessFor } from '../../lib/permissions/access';
18
+ import { ROLE_RANK, assignableRoles } from '../../lib/permissions/catalog';
17
19
  import { EDITOR } from '../../lib/editor/config';
18
20
 
19
21
  const repo = await getRepo(Astro.locals);
20
22
 
21
- // Only owners manage users. The env-password session is always owner.
23
+ // Owners manage everyone. Team → Roles can let other roles invite people
24
+ // (Invite people) or manage members (Manage members), always limited to roles
25
+ // below their own. The env-password session is always owner.
22
26
  const auth = await getRequestAuth(Astro.request, Astro.cookies, Astro.locals);
23
27
  const sessionUserId = auth.userId;
24
28
  // Clerk mode: credentials live in Clerk; ACMS keeps accounts, roles and the
@@ -29,8 +33,14 @@ let clerkReady = !clerkMode;
29
33
  if (clerkMode) {
30
34
  try { issuer = clerkConfig(Astro.locals).issuer; clerkReady = true; } catch { clerkReady = false; }
31
35
  }
32
- const sessionUser = sessionUserId && sessionUserId !== OWNER_ENV ? await repo.getUser(sessionUserId) : null;
33
- const isOwner = sessionUserId === OWNER_ENV || sessionUser?.role === 'owner';
36
+ const access = await accessFor(Astro.request, Astro.cookies, repo);
37
+ const isOwner = access?.role === 'owner';
38
+ const canInvite = access?.can('team.invite') ?? false;
39
+ const canManage = access?.can('team.manage') ?? false;
40
+ // roles this person may give out (owners: all; everyone else: below their own)
41
+ const assignable = access ? assignableRoles(access.role) : [];
42
+ const below = (role: Role) => isOwner || (!!access && ROLE_RANK[role] < ROLE_RANK[access.role]);
43
+ const manages = (u: { id: string; role: Role }) => canManage && below(u.role) && (isOwner || u.id !== sessionUserId);
34
44
 
35
45
  let error = '';
36
46
  let failedOp = '';
@@ -67,14 +77,30 @@ if (Astro.url.searchParams.has('signedOut')) notice = 'Signed out. The app has t
67
77
  if (Astro.url.searchParams.has('linked')) notice = 'Account linked. They can now sign in with Clerk; their apps were signed out.';
68
78
  if (Astro.url.searchParams.has('emailChanged')) notice = clerkMode ? 'Email updated.' : 'Email updated. They were signed out everywhere and sign in with the new address.';
69
79
  if (Astro.url.searchParams.has('invited')) notice = clerkMode ? 'Invitation sent. Clerk emails the sign-up link; it expires in seven days.' : 'Invitation queued.';
70
- if (Astro.request.method === 'POST' && isOwner && !error) {
80
+ if (Astro.request.method === 'POST' && (canInvite || canManage) && !error) {
71
81
  const form = await Astro.request.formData();
72
82
  const op = String(form.get('op'));
73
83
  failedOp = op;
74
84
  failedId = String(form.get('id') ?? '');
75
85
  try {
76
- await requireSession(Astro.request,Astro.cookies,repo,true);
86
+ await requireSession(Astro.request,Astro.cookies,repo);
77
87
  if(Astro.request.headers.get('origin')!==Astro.url.origin)throw new Error('Cross-origin requests are not allowed.');
88
+ // what this person may do here, checked again on the server for every change
89
+ const inviteOp = ['create', 'resend', 'revoke'].includes(op);
90
+ if (inviteOp && !canInvite) throw new Error('Your role doesn’t include “Invite people”. Ask an owner if you need it.');
91
+ if (!inviteOp && !canManage) throw new Error('Your role doesn’t include “Manage members”. Ask an owner if you need it.');
92
+ if (op === 'link' && !isOwner) throw new Error('Only owners can link sign-in accounts.');
93
+ if (op === 'create' && !assignable.includes(Role.parse(form.get('role')))) throw new Error('You can only invite people to roles below your own.');
94
+ if (op === 'resend' || op === 'revoke') {
95
+ const target = clerkMode ? await repo.auth.invitation(issuer, String(form.get('id'))) : await repo.email.token(String(form.get('id')));
96
+ if (target && !below(Role.parse(target.role))) throw new Error('Only an owner can change invitations for this role.');
97
+ }
98
+ if (['update', 'delete', 'email'].includes(op)) {
99
+ const target = await repo.getUser(String(form.get('id')));
100
+ if (!target) throw new Error('The account no longer exists.');
101
+ if (!manages(target)) throw new Error('You can only manage people in roles below your own.');
102
+ if (op === 'update' && !assignable.includes(Role.parse(form.get('role')))) throw new Error('You can only assign roles below your own.');
103
+ }
78
104
  if (clerkMode) {
79
105
  if (!clerkReady) throw new Error('Clerk sign-in isn’t fully configured, so accounts can’t be changed right now.');
80
106
  if (SENSITIVE.has(op) && !auth.reverified) {
@@ -91,7 +117,7 @@ if (Astro.request.method === 'POST' && isOwner && !error) {
91
117
  if (previous.state === 'pending' || previous.state === 'sending') await revokeClerkInvitation(repo, previous.id, Astro.locals);
92
118
  data = { email: previous.email, name: previous.name, role: previous.role };
93
119
  }
94
- await inviteClerkUser(repo, data, Astro.locals);
120
+ await inviteClerkUser(repo, { ...data, invitedBy: sessionUserId }, Astro.locals);
95
121
  return Astro.redirect('/admin/users?invited=1');
96
122
  }
97
123
  if (op === 'revoke') await revokeClerkInvitation(repo, String(form.get('id')), Astro.locals);
@@ -109,7 +135,7 @@ if (Astro.request.method === 'POST' && isOwner && !error) {
109
135
  const invitedRole = Role.parse(previous ? previous.role : form.get('role'));
110
136
  await issueAccountToken(repo,{purpose:'invite',email:previous?String(previous.email):String(form.get('email')||''),
111
137
  name:previous?String(previous.name):String(form.get('name')||''),
112
- role:invitedRole},Astro.locals);
138
+ role:invitedRole,invitedBy:sessionUserId},Astro.locals);
113
139
  await kickEmail(repo,Astro.locals,waitUntilFrom(Astro.locals));
114
140
  }
115
141
  if(!clerkMode&&op==='revoke')await repo.email.revoke(String(form.get('id')));
@@ -148,8 +174,8 @@ if (Astro.request.method === 'GET' && isOwner && clerkMode && clerkReady) {
148
174
  try { await syncClerkEmails(repo, Astro.locals); } catch { /* Clerk unavailable: keep stored emails */ }
149
175
  }
150
176
  const users = await repo.listUsers();
151
- type Row = { id: string; email: string; role: AuthInvitation['role']; state: string; expiresAt: number; name?: string };
152
- const invitations: Row[] = !isOwner ? [] : clerkMode ? (clerkReady ? await repo.auth.invitations(issuer) : []) : await repo.email.invitations();
177
+ type Row = { id: string; email: string; role: AuthInvitation['role']; state: string; expiresAt: number; name?: string; invitedBy?: string; createdAt?: number };
178
+ const invitations: Row[] = !canInvite ? [] : clerkMode ? (clerkReady ? await repo.auth.invitations(issuer) : []) : await repo.email.invitations();
153
179
  // Clerk identity linked to each local account (owners see who can sign in)
154
180
  const linked = new Map<string, string>(clerkMode && clerkReady
155
181
  ? (await repo.auth.identities(issuer)).map((r) => [String(r.userId), String(r.subject)]) : []);
@@ -184,11 +210,13 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
184
210
  <h1>Users</h1>
185
211
  <p class="sub">{users.length} {users.length === 1 ? 'person' : 'people'}{clerkMode && isOwner && clerkReady && <> · {linked.size} connected to Clerk</>}</p>
186
212
  </div>
187
- {isOwner && clerkReady && <button class="btn primary" type="button" data-modal="invite-user">Invite user</button>}
213
+ {canInvite && clerkReady && <button class="btn primary" type="button" data-modal="invite-user">Invite people</button>}
188
214
  </div>
189
215
 
190
- {!isOwner && (
191
- <div class="flash error">{clerkMode ? 'Only owners can manage users. Ask an owner to make changes.' : 'Only owners can manage users. Ask an owner (or sign in with the owner password) to make changes.'}</div>
216
+ {!isOwner && !canManage && (
217
+ canInvite
218
+ ? <div class="flash" role="status">You can invite people to roles below yours. Ask an owner to change existing accounts.</div>
219
+ : <div class="flash error">{clerkMode ? 'Only owners can manage users. Ask an owner to make changes.' : 'Only owners can manage users. Ask an owner (or sign in with the owner password) to make changes.'}</div>
192
220
  )}
193
221
  {error && (
194
222
  <div class="flash error users-flash" role="alert">
@@ -215,7 +243,7 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
215
243
  {clerkMode
216
244
  ? 'Clerk handles passwords, verification and recovery. ACMS roles decide what each person can do; only invited or linked accounts get in.'
217
245
  : 'People sign in with their email and a password they choose from their invitation. Password resets use your email settings.'}
218
- {clerkMode && isOwner && clerkReady && ' Changes here ask you to verify it’s you.'}
246
+ {clerkMode && (canInvite || canManage) && clerkReady && ' Changes here ask you to verify it’s you.'}
219
247
  </p>
220
248
  {clerkMode && !clerkReady && (
221
249
  <p class="hint">Set <code>PUBLIC_CLERK_PUBLISHABLE_KEY</code>, <code>CLERK_SECRET_KEY</code> and <code>CLERK_AUTHORIZED_PARTIES</code> in the deployment environment.</p>
@@ -224,26 +252,30 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
224
252
  <p class="hint auth-where">Set in the deployment configuration</p>
225
253
  </section>
226
254
 
227
- {isOwner && clerkReady && (
228
- <Modal id="invite-user" title="Invite user" open={!!error && failedOp === 'create'}>
255
+ {canInvite && clerkReady && (
256
+ <Modal id="invite-user" title="Invite people" open={!!error && failedOp === 'create'}>
229
257
  <form method="post" data-reverify>
230
258
  <input type="hidden" name="op" value="create" />
231
- <div class="field-row">
232
- <label class="field">
233
- <span>Name</span>
234
- <input type="text" name="name" placeholder="Name" required autofocus />
235
- </label>
236
- <label class="field">
237
- <span>Role</span>
238
- <select name="role">
239
- {Role.options.map(role => <option value={role} selected={role === 'editor'}>{roleLabels[role]}</option>)}
240
- </select>
241
- </label>
242
- </div>
243
259
  <label class="field">
244
260
  <span>Email</span>
245
- <input type="email" name="email" placeholder="email@example.com" required />
261
+ <input type="email" name="email" placeholder="email@example.com" required autofocus />
246
262
  </label>
263
+ <label class="field">
264
+ <span>Name</span>
265
+ <input type="text" name="name" placeholder="Name" required />
266
+ </label>
267
+ <fieldset class="invite-roles">
268
+ <legend>Role</legend>
269
+ {Role.options.slice().reverse().map((role) => {
270
+ const allowed = assignable.includes(role);
271
+ return (
272
+ <label class:list={['invite-role', { 'is-off': !allowed }]}>
273
+ <input type="radio" name="role" value={role} checked={role === (assignable.includes('editor') ? 'editor' : assignable[assignable.length - 1])} disabled={!allowed} />
274
+ <span><b>{roleLabels[role]}</b>{!allowed && <span class="pill archived">Owner only</span>}<small>{roleSummaries[role]}</small></span>
275
+ </label>
276
+ );
277
+ })}
278
+ </fieldset>
247
279
  <p class="hint">{clerkMode
248
280
  ? 'Clerk emails them a sign-up link that’s valid for seven days. Their account gets this role once they accept.'
249
281
  : 'They’ll receive a single-use invitation valid for seven days and choose their own password.'}</p>
@@ -253,15 +285,15 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
253
285
  </div>
254
286
  <p class="hint modal-note">{clerkMode
255
287
  ? <>Already have an ACMS account? Open it in the list and link its Clerk user ID instead, so posts and roles stay attached.</>
256
- : <>They sign in at <code>/admin/login</code> with email + password. Choose a default role for their account. Owners also manage users.</>}</p>
288
+ : <>They sign in at <code>/admin/login</code> with email + password. {isOwner ? <a href="/admin/roles">Roles</a> : 'Roles'} decide what each role can do.</>}</p>
257
289
  </form>
258
290
  </Modal>
259
291
  )}
260
292
 
261
- {(isOwner && clerkReady || showDevices) && (
293
+ {(canInvite && clerkReady || showDevices) && (
262
294
  <div class="seg users-tabs" role="tablist" aria-label="Users sections">
263
295
  <button type="button" role="tab" id="tab-members" aria-controls="panel-members" aria-selected={tab === 'members' ? 'true' : 'false'} data-users-tab="members">Members <span class="count">{users.length}</span></button>
264
- {isOwner && clerkReady && <button type="button" role="tab" id="tab-invitations" aria-controls="panel-invitations" aria-selected={tab === 'invitations' ? 'true' : 'false'} data-users-tab="invitations">Invitations{pendingInvites > 0 && <span class="count">{pendingInvites}</span>}</button>}
296
+ {canInvite && clerkReady && <button type="button" role="tab" id="tab-invitations" aria-controls="panel-invitations" aria-selected={tab === 'invitations' ? 'true' : 'false'} data-users-tab="invitations">Invitations{pendingInvites > 0 && <span class="count">{pendingInvites}</span>}</button>}
265
297
  {showDevices && <button type="button" role="tab" id="tab-devices" aria-controls="panel-devices" aria-selected={tab === 'devices' ? 'true' : 'false'} data-users-tab="devices">Signed-in apps{devices.length > 0 && <span class="count">{devices.length}</span>}</button>}
266
298
  </div>
267
299
  )}
@@ -271,7 +303,7 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
271
303
  <thead><tr><th>Name</th><th>Role</th>{clerkMode && isOwner && clerkReady && <th>Sign-in</th>}<th><span class="sr-only">Actions</span></th></tr></thead>
272
304
  <tbody>
273
305
  {users.map((u) => (
274
- <tr data-user-row={isOwner ? `user-${u.id}` : undefined}>
306
+ <tr data-user-row={manages(u) ? `user-${u.id}` : undefined}>
275
307
  <td>
276
308
  <div class="user-cell">
277
309
  <span class="avatar user-av">{u.avatar ? <img src={u.avatar} alt="" loading="lazy" /> : initials(u.name)}</span>
@@ -288,7 +320,7 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
288
320
  : <span class="pill archived">Not linked</span>}</td>
289
321
  )}
290
322
  <td class="td-actions">
291
- {isOwner && <button class="btn sm ghost" type="button" data-modal={`user-${u.id}`} aria-label={`Edit ${u.name}`}>Edit</button>}
323
+ {manages(u) && <button class="btn sm ghost" type="button" data-modal={`user-${u.id}`} aria-label={`Edit ${u.name}`}>Edit</button>}
292
324
  </td>
293
325
  </tr>
294
326
  ))}
@@ -299,7 +331,7 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
299
331
  </table>
300
332
  </section>
301
333
 
302
- {isOwner && users.map((u) => {
334
+ {users.filter(manages).map((u) => {
303
335
  const isLinked = linked.has(u.id);
304
336
  const self = clerkMode && u.id === sessionUserId;
305
337
  const formId = `upd-${u.id}`;
@@ -334,7 +366,7 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
334
366
  <label class="field">
335
367
  <span>Role</span>
336
368
  <select name="role" form={formId}>
337
- {Role.options.map(role => <option value={role} selected={u.role === role}>{roleLabels[role]}</option>)}
369
+ {Role.options.filter((role) => assignable.includes(role)).map(role => <option value={role} selected={u.role === role}>{roleLabels[role]}</option>)}
338
370
  </select>
339
371
  </label>
340
372
  {!clerkMode && (
@@ -354,7 +386,7 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
354
386
  ? <span class="pill published" title={linked.get(u.id)}>Connected</span>
355
387
  : <span class="pill archived">Not linked</span>}</span>
356
388
  </div>
357
- {!isLinked && (
389
+ {!isLinked && isOwner && (
358
390
  <form method="post" class="drawer-link" data-reverify>
359
391
  <input type="hidden" name="op" value="link" />
360
392
  <input type="hidden" name="id" value={u.id} />
@@ -431,18 +463,19 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
431
463
  );
432
464
  })}
433
465
 
434
- {isOwner && clerkReady && <section class="card users-panel" id="panel-invitations" role="tabpanel" aria-labelledby="tab-invitations" data-users-panel="invitations" hidden={tab !== 'invitations'}>
466
+ {canInvite && clerkReady && <section class="card users-panel" id="panel-invitations" role="tabpanel" aria-labelledby="tab-invitations" data-users-panel="invitations" hidden={tab !== 'invitations'}>
435
467
  <p class="hint users-panel-hint">{clerkMode
436
468
  ? 'Clerk delivers these emails; your email settings aren’t needed for them. Resending cancels the previous link.'
437
469
  : <>Manage delivery in <a href="/admin/email">Email settings</a>. Resending revokes the previous link.</>}</p>
438
470
  <table class="list"><thead><tr><th>Email</th><th>Role</th><th>Status</th><th><span class="sr-only">Actions</span></th></tr></thead><tbody>
439
471
  {invitations.map(i=>{
440
472
  const [label, tone] = clerkMode ? (stateLabel[i.state] ?? [i.state, 'archived']) : [i.state, ''];
441
- return <tr><td>{i.email}{clerkMode && i.name && i.name !== i.email && <div class="t-sub">{i.name}</div>}</td><td>{roleLabels[i.role] ?? i.role}</td>
473
+ const inviter = i.invitedBy ? (userById.get(i.invitedBy)?.name ?? (i.invitedBy === OWNER_ENV ? 'Owner' : 'Removed user')) : '';
474
+ return <tr><td>{i.email}{clerkMode && i.name && i.name !== i.email && <div class="t-sub">{i.name}</div>}{(inviter || i.createdAt) && <div class="t-sub">{inviter && <>Invited by {inviter}</>}{inviter && i.createdAt ? ' · ' : ''}{i.createdAt ? day(i.createdAt) : ''}</div>}</td><td>{roleLabels[i.role] ?? i.role}</td>
442
475
  <td>{clerkMode ? <span class={`pill ${tone}`}>{label}</span> : i.state}{clerkMode && i.state === 'pending' && <div class="t-sub">Expires {day(i.expiresAt)}</div>}</td>
443
- <td class="td-actions">{i.state!=='accepted'&&<form method="post" class="inline-form" data-reverify><input type="hidden" name="id" value={i.id}/><button class="btn sm" name="op" value="resend">Resend</button>{(i.state==='pending'||(clerkMode&&i.state==='sending'))&&<button class="btn sm ghost" name="op" value="revoke">Revoke</button>}</form>}</td></tr>;
476
+ <td class="td-actions">{i.state!=='accepted'&&!below(i.role)&&<span class="hint">Owner only</span>}{i.state!=='accepted'&&below(i.role)&&<form method="post" class="inline-form" data-reverify><input type="hidden" name="id" value={i.id}/><button class="btn sm" name="op" value="resend">Resend</button>{(i.state==='pending'||(clerkMode&&i.state==='sending'))&&<button class="btn sm ghost" name="op" value="revoke">Revoke</button>}</form>}</td></tr>;
444
477
  })}
445
- {!invitations.length&&<tr><td colspan="4" class="hint">No invitations yet. Use <b>Invite user</b> to add someone.</td></tr>}
478
+ {!invitations.length&&<tr><td colspan="4" class="hint">No invitations yet. Use <b>Invite people</b> to add someone.</td></tr>}
446
479
  </tbody></table></section>}
447
480
 
448
481
  {showDevices && (
@@ -543,4 +576,17 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
543
576
  });
544
577
  }
545
578
  </script>
579
+ <style>
580
+ /* invite dialog: each role with what it's for; roles above yours stay visible but off */
581
+ .invite-roles { border: 0; padding: 0; margin: 4px 0 12px; display: grid; gap: 6px; }
582
+ .invite-roles legend { font-size: 13px; font-weight: 600; margin-bottom: 6px; }
583
+ .invite-role { display: flex; gap: 10px; align-items: flex-start; padding: 9px 12px; border: 1px solid var(--line-strong); border-radius: var(--r-md); cursor: pointer; }
584
+ .invite-role:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
585
+ .invite-role input { margin-top: 3px; accent-color: var(--accent); }
586
+ .invite-role > span { display: grid; gap: 1px; }
587
+ .invite-role b { font-size: 14px; }
588
+ .invite-role small { color: var(--muted); font-size: 12.5px; }
589
+ .invite-role .pill { margin-left: 6px; }
590
+ .invite-role.is-off { opacity: .55; cursor: not-allowed; }
591
+ </style>
546
592
  </Admin>
@@ -1,11 +1,12 @@
1
1
  import type { APIRoute } from 'astro';
2
2
  import { getRepo } from '../../../lib/repo';
3
- import { apiError, json, readJson, requireSession } from '../../../lib/api';
3
+ import { apiError, json, readJson } from '../../../lib/api';
4
4
  import { cleanDocument } from '../../../lib/ai';
5
+ import { requirePermission } from '../../../lib/permissions/access';
5
6
  export const POST: APIRoute = async ({ request, cookies, locals }) => {
6
7
  try {
7
8
  const repo = await getRepo(locals);
8
- await requireSession(request, cookies, repo);
9
+ await requirePermission(request, cookies, repo, 'content.edit');
9
10
  const input = await readJson(request);
10
11
  const { ai } = await repo.getSettings();
11
12
  return json(await cleanDocument(ai, input));
@@ -1,13 +1,14 @@
1
1
  import type { APIRoute } from 'astro';
2
2
  import { z } from 'zod';
3
3
  import { getRepo } from '../../../lib/repo';
4
- import { apiError, json, readJson, requireSession } from '../../../lib/api';
4
+ import { apiError, json, readJson } from '../../../lib/api';
5
5
  import { runAgentTool } from '../../../lib/agent';
6
+ import { requirePermission } from '../../../lib/permissions/access';
6
7
  const Input = z.object({ kind: z.enum(['posts', 'pages']), collection: z.string().optional(), title: z.string().trim().min(1).max(300), content: z.string().trim().min(1).max(100_000) }).strict();
7
8
  export const POST: APIRoute = async ({ request, cookies, locals }) => {
8
9
  try {
9
10
  const repo = await getRepo(locals);
10
- await requireSession(request, cookies, repo);
11
+ await requirePermission(request, cookies, repo, 'content.edit');
11
12
  const input = Input.parse(await readJson(request));
12
13
  return json(await runAgentTool(repo, { tool: 'create_draft', arguments: input }, false), 201);
13
14
  } catch (error) { return apiError(error); }
@@ -1,10 +1,11 @@
1
1
  import type { APIRoute } from 'astro';
2
2
  import { z } from 'zod';
3
3
  import { getRepo } from '../../../lib/repo';
4
- import { ApiError, apiError, json, readJson, requireSession } from '../../../lib/api';
4
+ import { ApiError, apiError, json, readJson } from '../../../lib/api';
5
5
  import { encryptApiKey, generateAgentKey, normalizeAiUrl, publicAiConfig } from '../../../lib/ai-config';
6
6
  import { AiProvider } from '../../../lib/ai-schema';
7
7
  import type { Settings } from '../../../lib/schema';
8
+ import { requirePermission } from '../../../lib/permissions/access';
8
9
 
9
10
  function publicSettings(settings: Settings, url: URL) {
10
11
  const { keyHash, ...agent } = settings.agent;
@@ -20,14 +21,14 @@ const Input = z.discriminatedUnion('action', [
20
21
  export const GET: APIRoute = async ({ request, cookies, locals, url }) => {
21
22
  try {
22
23
  const repo = await getRepo(locals);
23
- await requireSession(request, cookies, repo, true);
24
+ await requirePermission(request, cookies, repo, 'services.manage');
24
25
  return json(publicSettings(await repo.getSettings(), url));
25
26
  } catch (error) { return apiError(error); }
26
27
  };
27
28
  export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
28
29
  try {
29
30
  const repo = await getRepo(locals);
30
- await requireSession(request, cookies, repo, true);
31
+ await requirePermission(request, cookies, repo, 'services.manage');
31
32
  const input = Input.parse(await readJson(request, 16_000));
32
33
  const current = await repo.getSettings();
33
34
  let token: string | undefined;
@@ -1,11 +1,12 @@
1
1
  import type { APIRoute } from 'astro';
2
2
  import { getRepo } from '../../../lib/repo';
3
- import { apiError, json, requireSession } from '../../../lib/api';
3
+ import { apiError, json } from '../../../lib/api';
4
4
  import { listAiModels } from '../../../lib/ai';
5
+ import { requirePermission } from '../../../lib/permissions/access';
5
6
  export const POST: APIRoute = async ({ request, cookies, locals }) => {
6
7
  try {
7
8
  const repo = await getRepo(locals);
8
- await requireSession(request, cookies, repo, true);
9
+ await requirePermission(request, cookies, repo, 'services.manage');
9
10
  const { ai } = await repo.getSettings();
10
11
  return json({ models: await listAiModels(ai), baseUrl: ai.baseUrl });
11
12
  } catch (error) { return apiError(error); }
@@ -4,6 +4,7 @@ import { getRepo } from '../../../lib/repo';
4
4
  import { getMediaStore } from '../../../lib/media/store';
5
5
  import { apiError, ApiError } from '../../../lib/api';
6
6
  import { designIdentity } from '../../../lib/design-service';
7
+ import { deniedMessage } from '../../../lib/permissions/catalog';
7
8
  import { draftNavItems, mergeDesignDraft } from '../../../lib/drafts';
8
9
  import { sectionTargets } from '../../../lib/section-targets';
9
10
  import { getTheme, isProjectTheme } from '../../../lib/theme';
@@ -20,7 +21,7 @@ export const GET: APIRoute = async ({ request, cookies, locals }) => {
20
21
  const url = new URL(request.url);
21
22
  const includeMedia = url.searchParams.get('media') !== '0', includeNav = url.searchParams.get('nav') === '1';
22
23
  const who = await designIdentity(request, cookies, repo, 'design:read');
23
- if ('role' in who && who.role === 'editor') throw new ApiError(403, 'Only an owner can export the theme.');
24
+ if ('role' in who && who.role === 'editor') throw new ApiError(403, deniedMessage('site.layout'));
24
25
  if (includeMedia && !who.scopes.includes('media:read')) throw new ApiError(403, 'Required scope: media:read (or export with media=0).');
25
26
  const live = await repo.getSettings();
26
27
  const settings = mergeDesignDraft(live);
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { getRepo } from '../../../lib/repo';
4
4
  import { apiError, ApiError, json, readJson } from '../../../lib/api';
5
5
  import { DesignPatch, designIdentity, runDesignOperation } from '../../../lib/design-service';
6
+ import { deniedMessage } from '../../../lib/permissions/catalog';
6
7
  import { diffDesign } from '../../../lib/designDiff';
7
8
  import { sectionTargets } from '../../../lib/section-targets';
8
9
  import { getTheme, listThemes } from '../../../lib/theme';
@@ -23,7 +24,7 @@ export const POST: APIRoute = async ({ request, cookies, locals }) => {
23
24
  const repo = await getRepo(locals);
24
25
  const input = Body.parse(await readJson(request, 2_000_000));
25
26
  const who = await designIdentity(request, cookies, repo, 'design:write-draft');
26
- if ('role' in who && who.role === 'editor') throw new ApiError(403, 'Only an owner can import a theme.');
27
+ if ('role' in who && who.role === 'editor') throw new ApiError(403, deniedMessage('site.layout'));
27
28
  const current = await repo.getSettings();
28
29
  if (current.designRevision !== input.revision) throw new ApiError(409, 'Design changed. Reload and try again.');
29
30
  const [pages, targets, liveNav] = await Promise.all([repo.listPages(), sectionTargets(repo, locals), repo.listNavItems()]);
@@ -1,12 +1,13 @@
1
1
  import type { APIRoute } from 'astro';
2
2
  import { z } from 'zod';
3
3
  import { getRepo } from '../../../lib/repo';
4
- import { apiError, json, readJson, requireSession, ApiError } from '../../../lib/api';
4
+ import { apiError, json, readJson, ApiError } from '../../../lib/api';
5
5
  import { DESIGN_SCOPES } from '../../../lib/design-service';
6
6
  import { generateAgentKey } from '../../../lib/ai-config';
7
+ import { requirePermission } from '../../../lib/permissions/access';
7
8
  export const ALL:APIRoute=async({request,cookies,locals})=>{
8
9
  try {
9
- const repo=await getRepo(locals);await requireSession(request,cookies,repo,true);
10
+ const repo=await getRepo(locals);await requirePermission(request, cookies, repo, 'integrations.manage');
10
11
  if(request.method==='GET')return json({keys:(await repo.listApiKeys()).filter(k=>k.scopes.some(s=>DESIGN_SCOPES.includes(s as typeof DESIGN_SCOPES[number])))});
11
12
  if(request.method==='POST') {
12
13
  const input=z.object({name:z.string().min(1).max(100),scopes:z.array(z.enum(DESIGN_SCOPES)).min(1)}).strict().parse(await readJson(request,10000));
@@ -1,7 +1,7 @@
1
1
  import type { APIRoute } from 'astro';
2
2
  import { z } from 'zod';
3
3
  import { getRepo } from '../../../lib/repo';
4
- import { ApiError, apiError, json, readJson, requireSession } from '../../../lib/api';
4
+ import { ApiError, apiError, json, readJson } from '../../../lib/api';
5
5
  import { validateRequestSession } from '../../../lib/auth/request';
6
6
  import { reauthenticate } from '../../../lib/email/accounts';
7
7
  import { AUTH } from '../../../lib/auth/config';
@@ -11,6 +11,7 @@ import { dispatchEmail } from '../../../lib/email/service';
11
11
  import { emailReadiness } from '../../../lib/email/readiness';
12
12
  import type { ContentRepository } from '../../../lib/repo/types';
13
13
  import type { Credentials, PublicEmailSettings } from '../../../lib/email/types';
14
+ import { requirePermission } from '../../../lib/permissions/access';
14
15
  const Config = z.object({provider:z.enum(['auto','none','resend','mailgun','ses','cloudflare']),source:z.enum(['env','encrypted']),from:z.string().trim().max(320),replyTo:z.string().trim().max(320),domain:z.string().trim().max(253),region:z.string().trim().max(40),configurationSet:z.string().trim().max(200),snsTopicArn:z.string().trim().max(300),enabled:z.boolean(),reminders:z.boolean(),staffNotifications:z.boolean()}).strict();
15
16
  const Secrets = z.object({apiKey:z.string().max(4096).optional(),webhookSecret:z.string().max(4096).optional(),accessKeyId:z.string().max(256).optional(),secretAccessKey:z.string().max(4096).optional(),sessionToken:z.string().max(8192).optional()}).strict();
16
17
  const Input = z.discriminatedUnion('action',[
@@ -23,11 +24,11 @@ export async function publicSettings(repo:ContentRepository,locals?:unknown):Pro
23
24
  return {authProvider:AUTH.provider,version:c.version,config:c.config,provider:c.provider,...emailReadiness(c,(await repo.getSettings()).siteUrl,locals),environmentLocked:!!runtimeValue('EMAIL_PROVIDER',locals),credentialFields:Object.entries(c.credentials).filter(([,v])=>!!v).map(([k])=>k),activity:(await repo.email.list()).map(({payload,lease,fingerprint,rendered,...safe})=>safe),heartbeat:await repo.email.heartbeat()};
24
25
  }
25
26
  export const GET:APIRoute=async({request,cookies,locals})=>{
26
- try{const repo=await getRepo(locals);await requireSession(request,cookies,repo,true);return json(await publicSettings(repo,locals));}catch(e){return apiError(e);}
27
+ try{const repo=await getRepo(locals);await requirePermission(request, cookies, repo, 'services.manage');return json(await publicSettings(repo,locals));}catch(e){return apiError(e);}
27
28
  };
28
29
  export const POST:APIRoute=async({request,cookies,locals})=>{
29
30
  try{
30
- const repo=await getRepo(locals);await requireSession(request,cookies,repo,true);
31
+ const repo=await getRepo(locals);await requirePermission(request, cookies, repo, 'services.manage');
31
32
  const userId=(await validateRequestSession(request, cookies, repo))!;
32
33
  const input=Input.parse(await readJson(request,20000));
33
34
  if(input.action==='save'){