@acms-subir/core 0.3.21 → 0.3.23

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 (62) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/cli/migrations.generated.mjs +6 -0
  3. package/docs/mobile.md +1 -1
  4. package/docs/sections.md +2 -2
  5. package/docs/server.md +210 -29
  6. package/docs/theme-authoring.md +1 -1
  7. package/integration.mjs +134 -1
  8. package/package.json +1 -1
  9. package/src/actions/index.ts +3 -1
  10. package/src/components/EmailSettings.tsx +1 -1
  11. package/src/components/RolesManager.tsx +15 -7
  12. package/src/components/calendar/CalendarBoard.tsx +5 -3
  13. package/src/components/sections/SectionFields.tsx +31 -10
  14. package/src/layouts/Admin.astro +2 -2
  15. package/src/lib/auth/accounts.ts +2 -2
  16. package/src/lib/auth/request.ts +19 -7
  17. package/src/lib/auth/return.ts +32 -0
  18. package/src/lib/auth.ts +17 -4
  19. package/src/lib/calendar/board.ts +12 -1
  20. package/src/lib/calendar/cards.ts +6 -4
  21. package/src/lib/calendar/member-form.ts +152 -0
  22. package/src/lib/calendar/requests.ts +284 -0
  23. package/src/lib/calendar/schedule.ts +1 -1
  24. package/src/lib/editor/auth.ts +5 -3
  25. package/src/lib/editor/schemas.ts +2 -1
  26. package/src/lib/editor/store.ts +5 -3
  27. package/src/lib/email/service.ts +16 -3
  28. package/src/lib/email/store.ts +3 -1
  29. package/src/lib/email/templates.ts +12 -0
  30. package/src/lib/mobile/calendar.ts +6 -2
  31. package/src/lib/permissions/catalog.ts +84 -36
  32. package/src/lib/permissions/service.ts +11 -8
  33. package/src/lib/repo/migrations.ts +6 -0
  34. package/src/lib/repo/sql.ts +23 -5
  35. package/src/lib/repo/types.ts +5 -1
  36. package/src/lib/roles.ts +46 -4
  37. package/src/lib/schema.ts +43 -2
  38. package/src/lib/section-targets.ts +10 -0
  39. package/src/lib/sections.ts +7 -5
  40. package/src/lib/sync/catalog.ts +3 -0
  41. package/src/middleware.ts +8 -1
  42. package/src/pages/admin/app-auth.astro +5 -3
  43. package/src/pages/admin/calendar/events/[id].astro +249 -2
  44. package/src/pages/admin/calendar/events/index.astro +165 -12
  45. package/src/pages/admin/index.astro +22 -0
  46. package/src/pages/admin/login.astro +37 -16
  47. package/src/pages/admin/roles.astro +3 -0
  48. package/src/pages/admin/users.astro +15 -14
  49. package/src/pages/api/calendar/events/index.ts +8 -1
  50. package/src/pages/api/search.ts +3 -1
  51. package/src/pages/author/[slug].astro +3 -1
  52. package/src/pages/members/_EventForm.astro +89 -0
  53. package/src/pages/members/_Layout.astro +76 -0
  54. package/src/pages/members/_http.ts +25 -0
  55. package/src/pages/members/events/[id].astro +86 -0
  56. package/src/pages/members/events/index.astro +218 -0
  57. package/src/pages/members/events/new.astro +48 -0
  58. package/src/pages/members/index.ts +6 -0
  59. package/src/server/index.ts +111 -20
  60. package/src/theme-sdk/calendar.ts +6 -2
  61. package/src/theme-sdk/fields.mjs +7 -2
  62. package/src/virtual.d.ts +4 -0
@@ -1,4 +1,6 @@
1
+ import { userConfig } from 'virtual:acms-config';
1
2
  import type { Role } from '../schema';
3
+ import { SITE_ROLES, isStaffRole, roleRank, rolesByRank } from '../roles';
2
4
 
3
5
  // What each role may do. The catalog and its defaults live in code; the
4
6
  // owner's choices on Team → Roles are stored as differences from these
@@ -12,8 +14,13 @@ import type { Role } from '../schema';
12
14
  //
13
15
  // Owners always have every permission. Locked permissions stay owner-only and
14
16
  // can't be granted, so an owner can never lock themselves out of recovery.
17
+ //
18
+ // Sites add their own roles (lib/roles.ts) and permissions (acms({
19
+ // permissions }), shown under "This site"). A role without admin access can
20
+ // only hold site permissions and the few core ones marked `members`; every
21
+ // other core permission is forced off for it, whatever is stored.
15
22
 
16
- /** roles whose permissions an owner can change */
23
+ /** the core roles whose permissions an owner can change (site roles are editable too, see editableRoles) */
17
24
  export const EDITABLE_ROLES = ['administrator', 'editor', 'author', 'contributor'] as const;
18
25
  export type EditableRole = typeof EDITABLE_ROLES[number];
19
26
 
@@ -25,11 +32,17 @@ export interface PermissionDef {
25
32
  label: string;
26
33
  description: string;
27
34
  /** default holders (owners hold everything) */
28
- defaults: readonly EditableRole[];
35
+ defaults: readonly string[];
29
36
  /** owner-only, never grantable */
30
37
  locked?: boolean;
31
38
  /** grants code, credentials or people: the page asks owners to grant with care */
32
39
  sensitive?: boolean;
40
+ /** roles without admin access can hold it too */
41
+ members?: boolean;
42
+ /** on by default for every site role (with `members`) */
43
+ siteRoleDefault?: boolean;
44
+ /** defined by the site (acms({ permissions })) */
45
+ site?: boolean;
33
46
  }
34
47
  export interface PermissionArea { id: string; label: string; permissions: readonly PermissionDef[] }
35
48
 
@@ -39,6 +52,10 @@ export const PERMISSION_AREAS = [
39
52
  { key: 'content.publish', label: 'Publish content', description: 'Publish, schedule and unpublish, and put staged edits live.', defaults: STAFF },
40
53
  { key: 'content.delete', label: 'Delete content', description: 'Delete posts, pages and events.', defaults: STAFF },
41
54
  ] },
55
+ { id: 'requests', label: 'Event requests', permissions: [
56
+ // for the site's own roles (members): staff edit events directly, and approve requests with Publish content
57
+ { key: 'events.request', label: 'Request events', description: 'Ask for new events and suggest changes to events they host, from My events on the site. Staff approve them. Cancelling a date of an event they host is always allowed.', defaults: NONE, members: true, siteRoleDefault: true },
58
+ ] },
42
59
  { id: 'media', label: 'Media', permissions: [
43
60
  { key: 'media.upload', label: 'Upload media', description: 'Add images and files, and edit their descriptions.', defaults: STAFF },
44
61
  { key: 'media.delete', label: 'Delete media', description: 'Remove files from the media library.', defaults: STAFF },
@@ -75,29 +92,58 @@ export const PERMISSION_AREAS = [
75
92
  ] },
76
93
  ] as const satisfies readonly PermissionArea[];
77
94
 
95
+ /** a core permission key (site keys are plain strings) */
78
96
  export type Permission = typeof PERMISSION_AREAS[number]['permissions'][number]['key'];
97
+ /** the core permissions */
79
98
  export const PERMISSIONS: readonly PermissionDef[] = (PERMISSION_AREAS as readonly PermissionArea[]).flatMap((a) => [...a.permissions]);
80
- const BY_KEY = new Map<string, PermissionDef>(PERMISSIONS.map((p) => [p.key, p]));
81
- export const permissionDef = (key: string) => BY_KEY.get(key);
82
- export const isPermission = (key: string): key is Permission => BY_KEY.has(key);
99
+
100
+ /** the site's permissions; mutable so tests can add and remove some */
101
+ export const SITE_PERMISSIONS: PermissionDef[] = (userConfig.permissions ?? []).map((p) => ({ ...p, site: true, members: true }));
102
+ export const SITE_AREA_ID = 'site-defined';
103
+
104
+ /** core areas, then "This site" (when the site defines permissions), then "Owner only" */
105
+ export function permissionAreas(): PermissionArea[] {
106
+ const core = PERMISSION_AREAS as readonly PermissionArea[];
107
+ if (!SITE_PERMISSIONS.length) return [...core];
108
+ const owner = core.filter((a) => a.id === 'owner');
109
+ return [...core.filter((a) => a.id !== 'owner'), { id: SITE_AREA_ID, label: 'This site', permissions: SITE_PERMISSIONS }, ...owner];
110
+ }
111
+ export const allPermissions = (): PermissionDef[] => [...PERMISSIONS, ...SITE_PERMISSIONS];
112
+ const CORE_BY_KEY = new Map<string, PermissionDef>(PERMISSIONS.map((p) => [p.key, p]));
113
+ export const permissionDef = (key: string) => CORE_BY_KEY.get(key) ?? SITE_PERMISSIONS.find((p) => p.key === key);
114
+ /** a core or site permission */
115
+ export const isPermission = (key: string): boolean => !!permissionDef(key);
116
+ export const isCorePermission = (key: string): key is Permission => CORE_BY_KEY.has(key);
83
117
 
84
118
  /** grants that differ from the defaults: { editor: { 'site.design': true } } */
85
- export type PolicyOverrides = Partial<Record<EditableRole, Partial<Record<Permission, boolean>>>>;
119
+ export type PolicyOverrides = Partial<Record<string, Partial<Record<string, boolean>>>>;
86
120
  /** every editable role's full grant set */
87
- export type Grants = Record<EditableRole, Record<Permission, boolean>>;
121
+ export type Grants = Record<string, Record<string, boolean>>;
88
122
 
89
- export const isEditableRole = (role: string): role is EditableRole => (EDITABLE_ROLES as readonly string[]).includes(role);
90
- export const defaultGrant = (role: EditableRole, key: Permission) => (BY_KEY.get(key)!.defaults as readonly string[]).includes(role);
123
+ /** roles whose permissions an owner can change: the core four, then the site's */
124
+ export const editableRoles = (): string[] => [...EDITABLE_ROLES, ...SITE_ROLES.map((r) => r.key)];
125
+ export const isEditableRole = (role: string): boolean => editableRoles().includes(role);
126
+ /** whether a role can hold a permission at all (locked ones are owner-only) */
127
+ export function applies(role: string, key: string): boolean {
128
+ const p = permissionDef(key);
129
+ if (!p || p.locked) return false;
130
+ return isStaffRole(role) || p.members === true;
131
+ }
132
+ export function defaultGrant(role: string, key: string): boolean {
133
+ const p = permissionDef(key);
134
+ if (!p || !applies(role, key)) return false;
135
+ if (p.defaults.includes(role)) return true;
136
+ return p.siteRoleDefault === true && SITE_ROLES.some((r) => r.key === role);
137
+ }
91
138
 
92
- /** the effective grants: defaults, then overrides (unknown keys and locked ones ignored) */
139
+ /** the effective grants: defaults, then overrides (unknown, locked and non-applicable keys ignored) */
93
140
  export function resolveGrants(overrides: PolicyOverrides = {}): Grants {
94
- const out = {} as Grants;
95
- for (const role of EDITABLE_ROLES) {
96
- out[role] = {} as Record<Permission, boolean>;
97
- for (const p of PERMISSIONS) {
98
- const key = p.key as Permission;
99
- const chosen = overrides[role]?.[key];
100
- out[role][key] = p.locked ? false : typeof chosen === 'boolean' ? chosen : defaultGrant(role, key);
141
+ const out: Grants = {};
142
+ for (const role of editableRoles()) {
143
+ out[role] = {};
144
+ for (const p of allPermissions()) {
145
+ const chosen = overrides[role]?.[p.key];
146
+ out[role][p.key] = !applies(role, p.key) ? false : typeof chosen === 'boolean' ? chosen : defaultGrant(role, p.key);
101
147
  }
102
148
  }
103
149
  return out;
@@ -106,41 +152,43 @@ export function resolveGrants(overrides: PolicyOverrides = {}): Grants {
106
152
  /** a full grant set back to the minimal difference from defaults */
107
153
  export function diffFromDefaults(grants: Partial<Record<string, Partial<Record<string, unknown>>>>): PolicyOverrides {
108
154
  const out: PolicyOverrides = {};
109
- for (const role of EDITABLE_ROLES) {
110
- for (const p of PERMISSIONS) {
111
- if (p.locked) continue;
155
+ for (const role of editableRoles()) {
156
+ for (const p of allPermissions()) {
157
+ if (!applies(role, p.key)) continue;
112
158
  const value = grants[role]?.[p.key];
113
- if (typeof value !== 'boolean' || value === defaultGrant(role, p.key as Permission)) continue;
114
- (out[role] ??= {})[p.key as Permission] = value;
159
+ if (typeof value !== 'boolean' || value === defaultGrant(role, p.key)) continue;
160
+ (out[role] ??= {})[p.key] = value;
115
161
  }
116
162
  }
117
163
  return out;
118
164
  }
119
165
 
120
166
  export function deniedMessage(permission: string) {
121
- return `Your role doesn’t include “${BY_KEY.get(permission)?.label ?? permission}”. Ask an owner if you need it.`;
167
+ return `Your role doesn’t include “${permissionDef(permission)?.label ?? permission}”. Ask an owner if you need it.`;
122
168
  }
123
169
 
124
- export function allows(grants: Grants, role: Role, key: Permission): boolean {
170
+ /** owners hold every permission; everyone else needs an explicit grant that applies to their role */
171
+ export function allows(grants: Grants, role: Role, key: string): boolean {
172
+ if (!isPermission(key)) return false;
125
173
  if (role === 'owner') return true;
126
- return isEditableRole(role) ? grants[role][key] === true : false;
174
+ return applies(role, key) && grants[role]?.[key] === true;
127
175
  }
128
176
 
129
- /** highest first; people only invite or manage roles below their own */
130
- export const ROLE_RANK: Record<Role, number> = { owner: 4, administrator: 3, editor: 2, author: 1, contributor: 0 };
131
- export const ROLES_BY_RANK: readonly Role[] = ['owner', 'administrator', 'editor', 'author', 'contributor'];
177
+ /** people only invite or manage roles below their own (rolesByRank is highest first) */
132
178
  export function assignableRoles(role: Role): Role[] {
133
- if (role === 'owner') return [...ROLES_BY_RANK];
134
- return ROLES_BY_RANK.filter((r) => ROLE_RANK[r] < ROLE_RANK[role]);
179
+ if (role === 'owner') return rolesByRank();
180
+ return rolesByRank().filter((r) => roleRank(r) > roleRank(role));
135
181
  }
182
+ /** whether `role` ranks below `than` (unknown roles rank lowest) */
183
+ export const ranksBelow = (role: string, than: string) => roleRank(role) > roleRank(than);
136
184
 
137
185
  /** human summary of what changed between two grant sets (audit + review dialog) */
138
- export function describeChanges(before: Grants, after: Grants): Array<{ role: EditableRole; permission: Permission; granted: boolean }> {
139
- const out: Array<{ role: EditableRole; permission: Permission; granted: boolean }> = [];
140
- for (const role of EDITABLE_ROLES) {
141
- for (const p of PERMISSIONS) {
142
- const key = p.key as Permission;
143
- if (before[role][key] !== after[role][key]) out.push({ role, permission: key, granted: after[role][key] });
186
+ export function describeChanges(before: Grants, after: Grants): Array<{ role: string; permission: string; granted: boolean }> {
187
+ const out: Array<{ role: string; permission: string; granted: boolean }> = [];
188
+ for (const role of editableRoles()) {
189
+ for (const p of allPermissions()) {
190
+ const was = before[role]?.[p.key] === true, now = after[role]?.[p.key] === true;
191
+ if (was !== now) out.push({ role, permission: p.key, granted: now });
144
192
  }
145
193
  }
146
194
  return out;
@@ -2,11 +2,10 @@ import { z } from 'zod';
2
2
  import type { ContentRepository } from '../repo/types';
3
3
  import { OWNER_ENV } from '../auth';
4
4
  import { ApiError } from '../api';
5
- import { roleLabels, roleSummaries } from '../roles';
5
+ import { CORE_ROLES, isStaffRole, roleLabel, roleSummary, rolesByRank } from '../roles';
6
6
  import type { Role } from '../schema';
7
7
  import {
8
- PERMISSION_AREAS, ROLES_BY_RANK, describeChanges, diffFromDefaults, isEditableRole, isPermission, resolveGrants,
9
- type Grants,
8
+ applies, describeChanges, diffFromDefaults, isEditableRole, isPermission, permissionAreas, resolveGrants,
10
9
  } from './catalog';
11
10
  import type { Access } from './access';
12
11
 
@@ -30,10 +29,10 @@ export async function rolesState(repo: ContentRepository, access: Access) {
30
29
  canEdit: access.role === 'owner',
31
30
  updatedAt: policy.updatedAt || null,
32
31
  updatedBy: policy.updatedBy ? actorName(policy.updatedBy, names) : null,
33
- roles: ROLES_BY_RANK.map((role) => ({ role, label: roleLabels[role], summary: roleSummaries[role], members: members(role), editable: isEditableRole(role) })),
34
- areas: PERMISSION_AREAS.map((a) => ({
32
+ roles: rolesByRank().map((role) => ({ role, label: roleLabel(role), summary: roleSummary(role), members: members(role), editable: isEditableRole(role), admin: isStaffRole(role), site: !(CORE_ROLES as readonly string[]).includes(role) })),
33
+ areas: permissionAreas().map((a) => ({
35
34
  id: a.id, label: a.label,
36
- permissions: a.permissions.map((p) => ({ key: p.key, label: p.label, description: p.description, locked: 'locked' in p && p.locked === true, sensitive: 'sensitive' in p && p.sensitive === true })),
35
+ permissions: a.permissions.map((p) => ({ key: p.key, label: p.label, description: p.description, locked: p.locked === true, sensitive: p.sensitive === true, members: p.members === true, site: p.site === true })),
37
36
  })),
38
37
  grants: resolveGrants(policy.overrides),
39
38
  defaults: resolveGrants({}),
@@ -47,12 +46,16 @@ export async function saveRoles(repo: ContentRepository, access: Access, input:
47
46
  if (access.role !== 'owner') throw new ApiError(403, 'Only owners can change role permissions.');
48
47
  for (const [role, grants] of Object.entries(input.grants)) {
49
48
  if (!isEditableRole(role)) throw new ApiError(400, `Unknown or fixed role “${role}”.`);
50
- for (const key of Object.keys(grants)) if (!isPermission(key)) throw new ApiError(400, `Unknown permission “${key}”.`);
49
+ for (const [key, value] of Object.entries(grants)) {
50
+ if (!isPermission(key)) throw new ApiError(400, `Unknown permission “${key}”.`);
51
+ // a core permission for a role without admin access (or a locked one) can't be granted
52
+ if (value && !applies(role, key)) throw new ApiError(400, `“${roleLabel(role)}” can’t be given “${key}”: that role has no admin access.`);
53
+ }
51
54
  }
52
55
  const current = await repo.permissions.policy();
53
56
  if (current.revision !== input.revision) throw new ApiError(409, 'Permissions were changed by someone else since you opened this page.');
54
57
  const overrides = diffFromDefaults(input.grants);
55
- const changes = describeChanges(resolveGrants(current.overrides), resolveGrants(overrides) as Grants);
58
+ const changes = describeChanges(resolveGrants(current.overrides), resolveGrants(overrides));
56
59
  if (!changes.length) return current.revision;
57
60
  return repo.permissions.save(overrides, input.revision, access.userId, changes);
58
61
  }
@@ -662,6 +662,12 @@ export const MIGRATIONS: Migration[] = [
662
662
  // [insert, delete, update]: only the update trigger names columns
663
663
  contentVersionTriggers('events', [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates'])[2],
664
664
  ] },
665
+ // event hosts and members' event requests (CalEvent.hostIds / .request);
666
+ // neither is public, so the content-version trigger stays as it is
667
+ { version: 56, statements: [
668
+ `ALTER TABLE events ADD COLUMN host_ids TEXT NOT NULL DEFAULT '[]'`,
669
+ `ALTER TABLE events ADD COLUMN request TEXT`,
670
+ ] },
665
671
  ];
666
672
 
667
673
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
@@ -12,7 +12,7 @@ import { LiveConfig } from '../live/types';
12
12
  import { ApiError } from '../api';
13
13
  import { EmailStore, bookingJobs } from '../email/store';
14
14
  import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
15
- import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
15
+ import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
16
16
  import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
17
17
  import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
18
18
  import { cleanRrule } from '../calendar/rrule';
@@ -142,6 +142,11 @@ function rowToCalendar(r: Row): Calendar {
142
142
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
143
143
  });
144
144
  }
145
+ // a corrupt request snapshot reads as "no request" rather than breaking the event
146
+ function parseRequest(raw: unknown): EventRequest | null {
147
+ if (raw === null || raw === undefined || raw === '') return null;
148
+ try { const r = EventRequest.safeParse(JSON.parse(String(raw))); return r.success ? r.data : null; } catch { return null; }
149
+ }
145
150
  function rowToEvent(r: Row): CalEvent {
146
151
  return CalEvent.parse({
147
152
  id: r.id, slug: r.slug, title: r.title, bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
@@ -152,6 +157,7 @@ function rowToEvent(r: Row): CalEvent {
152
157
  rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled), cancelledDates: json(r.cancelled_dates, []),
153
158
  location: json(r.location, {}), cta: json(r.cta, {}), calendarIds: json(r.calendar_ids, []),
154
159
  fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}), roomHold: json(r.room_hold, {}),
160
+ hostIds: json(r.host_ids, []), request: parseRequest(r.request),
155
161
  startAt: Number(r.start_at), endAt: Number(r.end_at), draft: parseDraft(r.draft), translations: parseTranslations(r.translations),
156
162
  publishedAt: r.published_at === null || r.published_at === undefined ? null : Number(r.published_at),
157
163
  scheduledAt: r.scheduled_at === null || r.scheduled_at === undefined ? null : Number(r.scheduled_at),
@@ -833,8 +839,8 @@ export class SqlRepository implements ContentRepository {
833
839
  await this.driver.run(
834
840
  `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
835
841
  starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
836
- published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates)
837
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
842
+ published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request)
843
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
838
844
  [
839
845
  id, data.slug, data.title, data.bodyJson ?? '', data.bodyHtml ?? '', data.excerpt ?? '', data.cover ?? '', data.coverAlt ?? '',
840
846
  data.seoTitle ?? '', data.seoDesc ?? '', data.status ?? 'draft', data.featured ? 1 : 0, JSON.stringify(data.tags ?? []), data.template ?? '',
@@ -843,6 +849,7 @@ export class SqlRepository implements ContentRepository {
843
849
  JSON.stringify(data.fields ?? {}), data.sourceUid ?? '', startAt, endAt,
844
850
  data.publishedAt ?? (data.status === 'published' ? now : null), data.scheduledAt ?? null, now, now,
845
851
  JSON.stringify(data.rsvp ?? {}), JSON.stringify(data.roomHold ?? {}), JSON.stringify(data.cancelledDates ?? []),
852
+ JSON.stringify(data.hostIds ?? []), data.request ? JSON.stringify(data.request) : null,
846
853
  ]
847
854
  );
848
855
  await this.syncEventIndex(id);
@@ -936,6 +943,16 @@ export class SqlRepository implements ContentRepository {
936
943
  await this.ready;
937
944
  await this.driver.run('UPDATE events SET draft = NULL WHERE id = ?', [id]);
938
945
  }
946
+ // hosts and requests never change what the public sees, so they skip
947
+ // updateEvent (which would also drop a staged draft)
948
+ async setEventHosts(id: string, hostIds: string[]) {
949
+ await this.ready;
950
+ await this.driver.run('UPDATE events SET host_ids = ?, updated_at = ? WHERE id = ?', [JSON.stringify([...new Set(hostIds)]), Date.now(), id]);
951
+ }
952
+ async setEventRequest(id: string, request: EventRequest | null) {
953
+ await this.ready;
954
+ await this.driver.run('UPDATE events SET request = ?, updated_at = ? WHERE id = ?', [request ? JSON.stringify(request) : null, Date.now(), id]);
955
+ }
939
956
  async deleteEvent(id: string) {
940
957
  await this.ready;
941
958
  const mutation = crypto.randomUUID();
@@ -1784,14 +1801,15 @@ export class SqlRepository implements ContentRepository {
1784
1801
  ...(content.events ?? []).map((e) => ({
1785
1802
  sql: `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
1786
1803
  starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
1787
- draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates)
1788
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1804
+ draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request)
1805
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1789
1806
  args: [
1790
1807
  e.id, e.slug, e.title, e.bodyJson, e.bodyHtml, e.excerpt, e.cover, e.coverAlt, e.seoTitle, e.seoDesc, e.status, e.featured ? 1 : 0,
1791
1808
  JSON.stringify(e.tags), e.template, e.start, e.end, e.allDay ? 1 : 0, e.timezone, e.rrule, JSON.stringify(e.exdates),
1792
1809
  e.cancelled ? 1 : 0, JSON.stringify(e.location), JSON.stringify(e.cta), JSON.stringify(e.calendarIds), JSON.stringify(e.fields),
1793
1810
  e.sourceUid, e.startAt, e.endAt, e.draft ? JSON.stringify(e.draft) : null, e.publishedAt, e.scheduledAt, e.createdAt, e.updatedAt,
1794
1811
  JSON.stringify(e.rsvp ?? {}), JSON.stringify(e.roomHold ?? {}), JSON.stringify(e.cancelledDates ?? []),
1812
+ JSON.stringify(e.hostIds ?? []), e.request ? JSON.stringify(e.request) : null,
1795
1813
  ],
1796
1814
  })),
1797
1815
  ...(content.forms ?? []).map(formInsert),
@@ -15,7 +15,7 @@ export type ImportRecordKind = 'post' | 'page' | 'user' | 'tag' | 'media' | 'red
15
15
  export interface ImportRecord { runId: string; kind: ImportRecordKind; sourceId: string; acmsId: string; action: 'created' | 'updated'; stamp: number }
16
16
  export interface DesignVersion { id: string; createdAt: number; author: string; summary: string; snapshot: Record<string, unknown> }
17
17
 
18
- import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus, Place, EventLocation } from '../schema';
18
+ import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus, Place, EventLocation, EventRequest } from '../schema';
19
19
 
20
20
  export interface Stats {
21
21
  publishedPages: number;
@@ -141,6 +141,10 @@ export interface ContentRepository {
141
141
  createEvent(data: Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt'>> & { title: string; slug: string; start: string }): Promise<CalEvent>;
142
142
  updateEvent(id: string, data: EventUpdate): Promise<void>;
143
143
  saveEventDraft(id: string, data: Omit<EventUpdate, 'status' | 'scheduledAt' | 'publishedAt'>): Promise<void>;
144
+ /** the Team members who host an event (lib/calendar/requests.ts) */
145
+ setEventHosts(id: string, hostIds: string[]): Promise<void>;
146
+ /** a member's pending or declined request on an event; null clears it */
147
+ setEventRequest(id: string, request: EventRequest | null): Promise<void>;
144
148
  applyEventDraft(id: string): Promise<void>;
145
149
  discardEventDraft(id: string): Promise<void>;
146
150
  /** replace an event's cancelled occurrence keys at once, keeping any staged draft */
package/src/lib/roles.ts CHANGED
@@ -1,14 +1,56 @@
1
- import type { Role } from './schema';
1
+ import { userConfig } from 'virtual:acms-config';
2
+ import type { CoreRole, Role } from './schema';
2
3
 
3
- export const roleLabels: Record<Role, string> = {
4
+ // Every role ACMS knows: the five core roles, then the site's own from
5
+ // acms({ roles }), ranked below Contributor in the order listed. A site role
6
+ // with admin: false signs people in for the site's own pages only; it never
7
+ // counts as staff (see lib/auth.ts and lib/auth/request.ts). A stored role
8
+ // that isn't defined any more (the site removed it) is treated as non-staff
9
+ // with no permissions, so it fails closed instead of breaking pages.
10
+
11
+ export interface SiteRole { key: string; label: string; summary: string; admin: boolean; home: string }
12
+
13
+ /** the site's roles; mutable so tests can add and remove some */
14
+ export const SITE_ROLES: SiteRole[] = [...(userConfig.roles ?? [])];
15
+
16
+ export const CORE_ROLES: readonly CoreRole[] = ['owner', 'administrator', 'editor', 'author', 'contributor'];
17
+ const isCoreRole = (role: string): role is CoreRole => (CORE_ROLES as readonly string[]).includes(role);
18
+ const siteRole = (role: string) => SITE_ROLES.find((r) => r.key === role);
19
+
20
+ const CORE_LABELS: Record<CoreRole, string> = {
4
21
  contributor: 'Contributor', author: 'Author', editor: 'Editor', administrator: 'Administrator', owner: 'Owner',
5
22
  };
6
-
7
23
  /** one line per role for pickers (the defaults; owners can change what each role does on Team → Roles) */
8
- export const roleSummaries: Record<Role, string> = {
24
+ const CORE_SUMMARIES: Record<CoreRole, string> = {
9
25
  owner: 'Everything, including roles, backups and sign-in settings.',
10
26
  administrator: 'Runs the site day to day and invites people.',
11
27
  editor: 'Writes, edits and publishes content.',
12
28
  author: 'Writes and publishes content.',
13
29
  contributor: 'Writes and edits content.',
14
30
  };
31
+
32
+ /** highest first: the core roles, then the site's */
33
+ export const rolesByRank = (): Role[] => [...CORE_ROLES, ...SITE_ROLES.map((r) => r.key)];
34
+ /** a configured role (core or site) */
35
+ export const isRole = (role: unknown): role is Role => typeof role === 'string' && (isCoreRole(role) || !!siteRole(role));
36
+ /** may use the admin: core roles and site roles with admin: true */
37
+ export const isStaffRole = (role: string | null | undefined): boolean => !!role && (isCoreRole(role) || siteRole(role)?.admin === true);
38
+ /** owner 0 … contributor 4, then site roles; unknown roles rank lowest */
39
+ export function roleRank(role: string): number {
40
+ const i = rolesByRank().indexOf(role);
41
+ return i < 0 ? Number.MAX_SAFE_INTEGER : i;
42
+ }
43
+ export const roleLabel = (role: string): string => (isCoreRole(role) ? CORE_LABELS[role] : siteRole(role)?.label ?? role);
44
+ export function roleSummary(role: string): string {
45
+ if (isCoreRole(role)) return CORE_SUMMARIES[role];
46
+ const r = siteRole(role);
47
+ if (!r) return 'No longer defined by this site. People with it can’t use the admin.';
48
+ return r.summary || (r.admin ? 'Uses the admin with only what you grant it on Roles.' : 'Signs in to this site. No admin access.');
49
+ }
50
+ /** where someone with this role lands after signing in when nothing else is asked for */
51
+ export const roleHome = (role: string): string => (isStaffRole(role) ? '/admin' : siteRole(role)?.home ?? '/');
52
+ /** a role read from a form: must be configured */
53
+ export function parseRole(value: unknown): Role {
54
+ if (!isRole(value)) throw new Error('Choose a role from the list.');
55
+ return value;
56
+ }
package/src/lib/schema.ts CHANGED
@@ -155,6 +155,18 @@ export type Post = z.infer<typeof Post>;
155
155
  // and for post-list blocks.
156
156
  export const isInternalTag = (name: string) => name.startsWith('#');
157
157
  export const publicTags = (names: string[]) => names.filter((n) => !isInternalTag(n));
158
+ // Filters name a tag by its name (any case) or its slug: "Bible Study",
159
+ // "bible study" and "bible-study" all match a "Bible Study" tag. Internal
160
+ // tags match by name only, so "class" never picks up "#class".
161
+ export function hasTag(names: string[], wanted: string): boolean {
162
+ const w = wanted.trim().toLowerCase();
163
+ if (!w) return false;
164
+ const slug = /^[a-z0-9-]+$/.test(w);
165
+ return names.some((n) => n.trim().toLowerCase() === w || (slug && !isInternalTag(n.trim()) && slugify(n) === w));
166
+ }
167
+ /** only `tag` (when set), never `excludeTag` (when set) */
168
+ export const tagFilter = (tag?: string, excludeTag?: string) => (names: string[]) =>
169
+ (!tag?.trim() || hasTag(names, tag)) && (!excludeTag?.trim() || !hasTag(names, excludeTag));
158
170
 
159
171
  export const MediaVariant = z.object({ w: z.number(), key: z.string() });
160
172
  export type MediaVariant = z.infer<typeof MediaVariant>;
@@ -196,8 +208,13 @@ export const Snippet = z.object({
196
208
  });
197
209
  export type Snippet = z.infer<typeof Snippet>;
198
210
 
199
- export const Role = z.enum(['contributor', 'author', 'editor', 'administrator', 'owner']);
200
- export type Role = z.infer<typeof Role>;
211
+ export const CoreRole = z.enum(['contributor', 'author', 'editor', 'administrator', 'owner']);
212
+ export type CoreRole = z.infer<typeof CoreRole>;
213
+ // A core role or one the site defines (acms({ roles })). Stored values are
214
+ // never rejected here, so a role a site later removes can't break a page:
215
+ // lib/roles.ts decides what any role may do, and an unknown one gets nothing.
216
+ export const Role = z.string().min(1).max(64);
217
+ export type Role = CoreRole | (string & {});
201
218
 
202
219
  // Password hashes never leave the repo layer — User is the public shape.
203
220
  export const User = z.object({
@@ -339,6 +356,26 @@ export const RoomHold = z.object({
339
356
  });
340
357
  export type RoomHold = z.infer<typeof RoomHold>;
341
358
 
359
+ // A member's event request (lib/calendar/requests.ts): a new event waiting
360
+ // for approval (kind 'new', the event stays a draft), or changes to a live
361
+ // event staged in its `draft` (kind 'changes'). Staff approve or decline;
362
+ // approving clears it. `note` is the requester's message to staff, `reply`
363
+ // staff's answer when declining.
364
+ export const EventRequest = z.object({
365
+ kind: z.enum(['new', 'changes']),
366
+ state: z.enum(['pending', 'declined']),
367
+ by: z.string(),
368
+ at: z.number(),
369
+ note: z.string().default(''),
370
+ reply: z.string().default(''),
371
+ reviewedBy: z.string().default(''),
372
+ reviewedAt: z.number().default(0),
373
+ // the event was made by this request (withdrawing it may delete it); false
374
+ // when a host asked to publish a draft staff wrote
375
+ created: z.boolean().default(false),
376
+ });
377
+ export type EventRequest = z.infer<typeof EventRequest>;
378
+
342
379
  export const CalEvent = z.object({
343
380
  id: z.string(),
344
381
  slug: z.string(),
@@ -389,6 +426,10 @@ export const CalEvent = z.object({
389
426
  // all day). Computed into availability, never stored as bookings. Drafts
390
427
  // only hold when whileDraft is on; cancelled events never hold.
391
428
  roomHold: RoomHold.default({}),
429
+ // Team members who host it: they see it on My events, can cancel its dates
430
+ // and suggest changes (lib/calendar/requests.ts)
431
+ hostIds: z.array(z.string()).default([]),
432
+ request: EventRequest.nullable().default(null),
392
433
  startAt: z.number(),
393
434
  endAt: z.number(),
394
435
  translations: DocTranslations,
@@ -18,6 +18,8 @@ export interface SectionTargets {
18
18
  calendars: { slug: string; name: string }[];
19
19
  collections: { key: string; label: string }[];
20
20
  tags: string[];
21
+ /** tags on published events (the events source picks from these) */
22
+ eventTags: string[];
21
23
  resources: { slug: string; name: string }[];
22
24
  features: { events: boolean; bookings: boolean };
23
25
  /** AI help for text fields: provider configured + languages to translate into */
@@ -26,6 +28,13 @@ export interface SectionTargets {
26
28
  i18n: { defaultLocale: string; locales: { code: string; name: string }[]; drafts: boolean };
27
29
  }
28
30
 
31
+ // one entry per tag, ignoring case (the first spelling wins), A–Z
32
+ function uniqueTags(names: string[]) {
33
+ const seen = new Map<string, string>();
34
+ for (const n of names.map((x) => x.trim()).filter(Boolean)) if (!seen.has(n.toLowerCase())) seen.set(n.toLowerCase(), n);
35
+ return [...seen.values()].sort((a, b) => a.localeCompare(b));
36
+ }
37
+
29
38
  export async function sectionTargets(repo: ContentRepository, locals?: unknown): Promise<SectionTargets> {
30
39
  const bookings = bookingsEnabled();
31
40
  const settings = await repo.getSettings();
@@ -54,6 +63,7 @@ export async function sectionTargets(repo: ContentRepository, locals?: unknown):
54
63
  calendars: calendars.filter((c) => c.visibility !== 'private').map((c) => ({ slug: c.slug, name: c.name })),
55
64
  collections: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, label: c.plural })),
56
65
  tags: [...new Set(tags.map((t) => t.name))].sort((a, b) => a.localeCompare(b)),
66
+ eventTags: uniqueTags(events.flatMap((e) => e.tags)),
57
67
  resources: publicResources.map((r) => ({ slug: r.slug, name: r.name })),
58
68
  features: { events: CALENDAR.enabled, bookings },
59
69
  ai: { enabled: Boolean(settings.ai.enabled && settings.ai.model), languages: languageNames() },
@@ -1,7 +1,7 @@
1
1
  import sanitizeHtml from 'sanitize-html';
2
2
  import type { z } from 'zod';
3
3
  import type { ContentRepository } from './repo/types';
4
- import { authorSlug, type Settings, type User } from './schema';
4
+ import { authorSlug, tagFilter, type Settings, type User } from './schema';
5
5
  import { htmlToPlaintext } from './plaintext';
6
6
  import type { SectionInstance as SectionInstanceSchema, SectionDefinition } from '../theme-sdk/contract.mjs';
7
7
  import { normalizeData, safeUrl, SOURCE_LIMIT_MAX, isEmptyValue, safeFocus, focusKey } from '../theme-sdk/fields.mjs';
@@ -21,7 +21,8 @@ import { localizeDoc, isExtraLocale } from './translations';
21
21
  // • migrations/<type>.mjs upgrades older instances on the fly
22
22
  // • link fields resolve their ref (page/post/event/booking) to today's URL
23
23
  // • richtext is sanitized
24
- // • source fields ("next 3 Youth events") become fresh item arrays
24
+ // • source fields ("next 3 Youth events", "events not tagged Class") become
25
+ // fresh item arrays
25
26
 
26
27
  type Instance = z.infer<typeof SectionInstanceSchema>;
27
28
  type Definition = z.infer<typeof SectionDefinition>;
@@ -121,7 +122,7 @@ function resolveScalar(field: FieldDef, value: unknown, refs: RefMap, lp: Lp = s
121
122
  }
122
123
 
123
124
  async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value: unknown, ctx: ResolveContext): Promise<unknown[]> {
124
- const cfg = (value ?? {}) as { calendar?: string; collection?: string; tag?: string; resource?: string; limit?: number };
125
+ const cfg = (value ?? {}) as { calendar?: string; collection?: string; tag?: string; excludeTag?: string; resource?: string; limit?: number };
125
126
  const limit = Math.max(1, Math.min(SOURCE_LIMIT_MAX, Number(cfg.limit) || 3));
126
127
  const { repo, settings, now } = ctx;
127
128
  // another language (settings.locale is set to it by the renderer)
@@ -137,7 +138,7 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
137
138
  }
138
139
  const tz = settings.timezone || 'UTC';
139
140
  const locale = siteLocale(settings);
140
- return (await upcomingEvents(repo, settings, now, { calendarIds, limit })).map((o): EventItem => {
141
+ return (await upcomingEvents(repo, settings, now, { calendarIds, limit, tag: cfg.tag, excludeTag: cfg.excludeTag })).map((o): EventItem => {
141
142
  const e = lang ? localizeDoc(o.event, lang) : o.event;
142
143
  const f = formatOccurrence(o, tz, locale);
143
144
  const ctaHref = safeUrl(e.cta.href);
@@ -152,7 +153,8 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
152
153
  if (field.source === 'posts') {
153
154
  const collection = cfg.collection && CONTENT.collections[cfg.collection] ? cfg.collection : 'posts';
154
155
  let posts = await repo.listPublishedPosts(collection);
155
- if (cfg.tag) posts = posts.filter((p) => p.tags.some((t) => t.toLowerCase() === cfg.tag!.toLowerCase()));
156
+ const tagged = tagFilter(cfg.tag, cfg.excludeTag);
157
+ posts = posts.filter((p) => tagged(p.tags));
156
158
  const shown = posts.slice(0, limit);
157
159
  // public byline only: name, profile URL, avatar — never emails or roles
158
160
  const users = shown.some((p) => p.authorIds.length || p.authorId) ? new Map((await repo.listUsers()).map((u) => [u.id, u])) : new Map();
@@ -38,6 +38,9 @@ export const schemas = {
38
38
  startAt: true,
39
39
  endAt: true,
40
40
  sourceUid: true,
41
+ // people and their requests belong to one environment (user ids differ)
42
+ hostIds: true,
43
+ request: true,
41
44
  }).strict(),
42
45
  resource: Resource.omit({
43
46
  ...common,
package/src/middleware.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { defineMiddleware } from 'astro:middleware';
2
2
  import type { APIContext, MiddlewareNext } from 'astro';
3
3
  import { I18N } from './lib/i18n';
4
- import { bindAuthEnvironment, getRequestAuth, shouldResolveClerk, validateRequestSession, withClerkHeaders } from './lib/auth/request';
4
+ import { bindAuthEnvironment, getRequestAuth, requestPerson, shouldResolveClerk, validateRequestSession, withClerkHeaders } from './lib/auth/request';
5
+ import { roleHome } from './lib/roles';
5
6
  import { AUTH } from './lib/auth/config';
6
7
  import { getRepo } from './lib/repo';
7
8
  import { CONTENT } from './lib/content';
@@ -109,6 +110,12 @@ async function handle(context: APIContext, next: MiddlewareNext): Promise<Respon
109
110
  if (pathname.startsWith('/admin') && pathname !== '/admin/login' && pathname !== '/admin/logout') {
110
111
  if (auth?.error === 'configuration' || auth?.error === 'unavailable') return new Response('Sign-in is temporarily unavailable. Check the authentication configuration or try again later.', { status: 503, headers: { 'Cache-Control': 'no-store' } });
111
112
  if (!(await validateRequestSession(context.request, context.cookies, context.locals))) {
113
+ // signed in, but in a site role without admin access: their own home
114
+ // page, never the admin (the editor-app sign-in explains on the login page)
115
+ const person = await requestPerson(context.request, context.cookies, context.locals);
116
+ if (person && !(pathname === '/admin/app-auth' && context.request.method === 'GET')) {
117
+ return withClerkHeaders(context.redirect(roleHome(person.role)), auth, context.request);
118
+ }
112
119
  // editor-app sign-in comes back here after login (see login.astro's next)
113
120
  if (pathname === '/admin/app-auth' && context.request.method === 'GET') {
114
121
  return withClerkHeaders(context.redirect(`/admin/login?next=${encodeURIComponent(pathname + context.url.search)}`), auth, context.request);