@acms-subir/core 0.3.21 → 0.3.22

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 (52) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/cli/migrations.generated.mjs +6 -0
  3. package/docs/mobile.md +1 -1
  4. package/docs/server.md +203 -24
  5. package/integration.mjs +134 -1
  6. package/package.json +1 -1
  7. package/src/actions/index.ts +3 -1
  8. package/src/components/EmailSettings.tsx +1 -1
  9. package/src/components/RolesManager.tsx +15 -7
  10. package/src/components/calendar/CalendarBoard.tsx +5 -3
  11. package/src/layouts/Admin.astro +2 -2
  12. package/src/lib/auth/accounts.ts +2 -2
  13. package/src/lib/auth/request.ts +19 -7
  14. package/src/lib/auth/return.ts +32 -0
  15. package/src/lib/auth.ts +17 -4
  16. package/src/lib/calendar/board.ts +12 -1
  17. package/src/lib/calendar/member-form.ts +152 -0
  18. package/src/lib/calendar/requests.ts +284 -0
  19. package/src/lib/calendar/schedule.ts +1 -1
  20. package/src/lib/editor/auth.ts +5 -3
  21. package/src/lib/editor/schemas.ts +2 -1
  22. package/src/lib/editor/store.ts +5 -3
  23. package/src/lib/email/service.ts +16 -3
  24. package/src/lib/email/store.ts +3 -1
  25. package/src/lib/email/templates.ts +12 -0
  26. package/src/lib/permissions/catalog.ts +84 -36
  27. package/src/lib/permissions/service.ts +11 -8
  28. package/src/lib/repo/migrations.ts +6 -0
  29. package/src/lib/repo/sql.ts +23 -5
  30. package/src/lib/repo/types.ts +5 -1
  31. package/src/lib/roles.ts +46 -4
  32. package/src/lib/schema.ts +31 -2
  33. package/src/lib/sync/catalog.ts +3 -0
  34. package/src/middleware.ts +8 -1
  35. package/src/pages/admin/app-auth.astro +5 -3
  36. package/src/pages/admin/calendar/events/[id].astro +249 -2
  37. package/src/pages/admin/calendar/events/index.astro +165 -12
  38. package/src/pages/admin/index.astro +22 -0
  39. package/src/pages/admin/login.astro +37 -16
  40. package/src/pages/admin/roles.astro +3 -0
  41. package/src/pages/admin/users.astro +15 -14
  42. package/src/pages/api/search.ts +3 -1
  43. package/src/pages/author/[slug].astro +3 -1
  44. package/src/pages/members/_EventForm.astro +89 -0
  45. package/src/pages/members/_Layout.astro +76 -0
  46. package/src/pages/members/_http.ts +25 -0
  47. package/src/pages/members/events/[id].astro +86 -0
  48. package/src/pages/members/events/index.astro +218 -0
  49. package/src/pages/members/events/new.astro +48 -0
  50. package/src/pages/members/index.ts +6 -0
  51. package/src/server/index.ts +96 -18
  52. package/src/virtual.d.ts +4 -0
@@ -0,0 +1,284 @@
1
+ import { userConfig } from 'virtual:acms-config';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import type { CalEvent, EventRequest } from '../schema';
4
+ import { EventLocation } from '../schema';
5
+ import { ApiError } from '../api';
6
+ import { applyEventUpdate, saveEvent, type SaveHooks } from '../content-service';
7
+ import { CALENDAR } from './config';
8
+ import { cancelOccurrence, restoreOccurrence, uniqueSlug } from './schedule';
9
+ import { eventDeleted } from './hooks';
10
+ import { waitUntilFrom } from '../webhooks';
11
+ import { kickEmail } from '../email/service';
12
+
13
+ // Members' event requests: the rules behind "My events". A member whose role
14
+ // holds "Request events" (events.request) asks for a new event (a draft that
15
+ // waits for approval) or suggests changes to an event they host (staged on
16
+ // the live event, which stays as it is). Staff who can "Publish content"
17
+ // approve or decline. Hosts may always cancel or restore single dates of
18
+ // their own events, which applies at once like it does for staff.
19
+ //
20
+ // Everything here checks who is acting, so the built-in member pages and a
21
+ // site's own pages (through @acms-subir/core/server) share the same rules.
22
+
23
+ /** where the built-in member pages live (acms({ members })); `notify` hears about new requests */
24
+ export const MEMBERS: { enabled: boolean; base: string; notify: string[] } = { enabled: false, base: '/members', notify: [], ...(userConfig.members ?? {}) };
25
+ export const memberEventsPath = () => `${MEMBERS.base}/events`;
26
+
27
+ /** who is acting: currentUser() from @acms-subir/core/server fits */
28
+ export interface Actor { id: string; can(permission: string): boolean }
29
+
30
+ /** what a member may fill in; staff can change anything else in the admin */
31
+ export interface EventRequestInput {
32
+ title: string;
33
+ /** plain text; blank lines start new paragraphs */
34
+ description?: string;
35
+ /** YYYY-MM-DDTHH:mm (or YYYY-MM-DD when allDay), in the site's time zone */
36
+ start: string;
37
+ end?: string;
38
+ allDay?: boolean;
39
+ /** '' (once), or FREQ=DAILY|WEEKLY|MONTHLY with an optional INTERVAL and COUNT */
40
+ repeat?: string;
41
+ /** where it happens, as text */
42
+ where?: string;
43
+ /** one of the site's own calendars */
44
+ calendarId?: string;
45
+ /** the site's custom event fields (private ones are staff-only and ignored) */
46
+ fields?: Record<string, string>;
47
+ /** a message for staff; never public */
48
+ note?: string;
49
+ }
50
+
51
+ const LIMITS = { title: 200, description: 5000, where: 300, note: 1000, field: 500, pending: 20 };
52
+ const REPEAT = /^FREQ=(DAILY|WEEKLY|MONTHLY)(;INTERVAL=[1-9]\d?)?(;COUNT=[1-9]\d{0,2})?$/;
53
+ const live = (e: CalEvent) => e.status === 'published' || e.status === 'scheduled';
54
+ const text = (v: unknown, max: number) => String(v ?? '').replace(/\r\n?/g, '\n').replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, '').trim().slice(0, max);
55
+ /** one line: titles and places end up in email subjects and headings */
56
+ const line = (v: unknown, max: number) => text(v, max).replace(/\s+/g, ' ');
57
+ const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!));
58
+
59
+ /** plain text → the editor's document and its HTML (escaped: members are the public) */
60
+ export function textToBody(value: string): { bodyJson: string; bodyHtml: string } {
61
+ const paras = value.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
62
+ if (!paras.length) return { bodyJson: '', bodyHtml: '' };
63
+ const content = paras.map((p) => {
64
+ const lines = p.split('\n');
65
+ return { type: 'paragraph', content: lines.flatMap((l, i) => [...(i ? [{ type: 'hardBreak' }] : []), ...(l ? [{ type: 'text', text: l }] : [])]) };
66
+ });
67
+ return { bodyJson: JSON.stringify({ type: 'doc', content }), bodyHtml: paras.map((p) => `<p>${p.split('\n').map(esc).join('<br>')}</p>`).join('') };
68
+ }
69
+
70
+ /** the description a member typed back from an event (for editing a request) */
71
+ export function bodyToText(e: Pick<CalEvent, 'bodyHtml'>): string {
72
+ return e.bodyHtml
73
+ .replace(/<br\s*\/?>/gi, '\n').replace(/<\/p>\s*<p[^>]*>/gi, '\n\n').replace(/<[^>]+>/g, '')
74
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&').trim();
75
+ }
76
+
77
+ function requireMember(who: Actor | null): asserts who is Actor {
78
+ if (!who) throw new ApiError(401, 'Sign in to continue.');
79
+ if (!who.can('events.request')) throw new ApiError(403, 'Your role doesn’t include “Request events”. Ask the site’s team if you need it.');
80
+ }
81
+ function requireApprover(who: Actor | null): asserts who is Actor {
82
+ if (!who) throw new ApiError(401, 'Sign in to continue.');
83
+ if (!who.can('content.publish')) throw new ApiError(403, 'Your role doesn’t include “Publish content”, which approving requests needs.');
84
+ }
85
+ /** a host of this event, or the member who asked for it */
86
+ export const isHost = (e: CalEvent, userId: string) => e.hostIds.includes(userId) || (e.request?.by === userId && e.request.kind === 'new');
87
+ async function hostedEvent(repo: ContentRepository, who: Actor, id: string) {
88
+ const e = await repo.getEvent(id);
89
+ if (!e || e.status === 'archived' || !isHost(e, who.id)) throw new ApiError(404, 'Event not found.');
90
+ return e;
91
+ }
92
+
93
+ /** the request form's input → event fields, validated (`current`: the event being edited, whose own values may be kept) */
94
+ async function clean(repo: ContentRepository, input: EventRequestInput, current?: CalEvent) {
95
+ const title = line(input.title, LIMITS.title);
96
+ if (!title) throw new ApiError(400, 'Give the event a title.');
97
+ const start = text(input.start, 16), allDay = input.allDay === true;
98
+ if (!(allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/).test(start)) throw new ApiError(400, 'Pick a date and start time.');
99
+ const end = text(input.end, 16);
100
+ if (end && !(allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/).test(end)) throw new ApiError(400, 'Pick a valid end time.');
101
+ if (end && end < start) throw new ApiError(400, 'The end has to come after the start.');
102
+ const rrule = text(input.repeat, 80).toUpperCase();
103
+ // a pattern staff set (weekly on two days, yearly…) may be kept as it is
104
+ if (rrule && !REPEAT.test(rrule) && rrule !== current?.rrule.toUpperCase()) throw new ApiError(400, 'Choose how often it repeats from the list.');
105
+ // members pick among the public calendars; any other calendars staff put it in stay
106
+ const calendars = await repo.listCalendars();
107
+ const open = new Set(calendars.filter((c) => c.kind === 'native' && c.visibility === 'public').map((c) => c.id));
108
+ const chosen = input.calendarId ? String(input.calendarId) : '';
109
+ if (chosen && !open.has(chosen) && !current?.calendarIds.includes(chosen)) throw new ApiError(400, 'Choose a calendar from the list.');
110
+ const calendarIds = current && (!chosen || current.calendarIds.includes(chosen))
111
+ ? current.calendarIds
112
+ : [...(current?.calendarIds ?? []).filter((id) => !open.has(id)), ...(chosen ? [chosen] : [])];
113
+ const fields: Record<string, string> = {};
114
+ for (const f of CALENDAR.eventFields) {
115
+ if (f.private || input.fields?.[f.key] === undefined) continue;
116
+ const value = text(input.fields[f.key], LIMITS.field);
117
+ if (f.type === 'select' && value && !f.options.includes(value)) throw new ApiError(400, `Choose ${f.label} from the list.`);
118
+ fields[f.key] = value; // '' clears it
119
+ }
120
+ return { title, start, end: end || undefined, allDay, rrule, calendarIds, fields, description: text(input.description, LIMITS.description), where: line(input.where, LIMITS.where) };
121
+ }
122
+ const filled = (fields: Record<string, string>) => Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== ''));
123
+
124
+ const hooksFor = (h: SaveHooks): SaveHooks => ({ url: h.url, locals: h.locals });
125
+ const pending = (by: string, kind: EventRequest['kind'], note: string, created: boolean): EventRequest =>
126
+ ({ kind, state: 'pending', by, at: Date.now(), note, reply: '', reviewedBy: '', reviewedAt: 0, created });
127
+ /** a host's own suggestion waiting on this live event (its staged draft is theirs) */
128
+ const hostChanges = (e: CalEvent) => e.request?.kind === 'changes' && e.request.state === 'pending';
129
+
130
+ /** events this person hosts, plus their own requests, soonest first (archived ones left out) */
131
+ export async function hostedEvents(repo: ContentRepository, userId: string): Promise<CalEvent[]> {
132
+ return (await repo.listEvents()).filter((e) => e.status !== 'archived' && isHost(e, userId)).sort((a, b) => a.startAt - b.startAt);
133
+ }
134
+
135
+ /** requests waiting for staff, oldest first */
136
+ export async function pendingRequests(repo: ContentRepository): Promise<CalEvent[]> {
137
+ return (await repo.listEvents()).filter((e) => e.request?.state === 'pending').sort((a, b) => a.request!.at - b.request!.at);
138
+ }
139
+
140
+ /** a member asks for a new event: a draft they host, waiting for approval */
141
+ export async function submitEventRequest(repo: ContentRepository, who: Actor | null, input: EventRequestInput, hooks: SaveHooks): Promise<CalEvent> {
142
+ requireMember(who);
143
+ if (!await repo.email.rateLimit(`event-request:${who.id}`, 20, 3_600_000)) throw new ApiError(429, 'That’s a lot of requests in an hour. Try again later.', 3600);
144
+ const open = (await hostedEvents(repo, who.id)).filter((e) => e.request?.state === 'pending').length;
145
+ if (open >= LIMITS.pending) throw new ApiError(429, 'You have many requests waiting already. Wait for the team to answer them first.');
146
+ const c = await clean(repo, input);
147
+ const created = await repo.createEvent({
148
+ title: c.title, slug: await uniqueSlug(repo, c.title), start: c.start, allDay: c.allDay, status: 'draft',
149
+ calendarIds: c.calendarIds, fields: filled(c.fields), ...textToBody(c.description),
150
+ location: EventLocation.parse({ name: c.where }),
151
+ hostIds: [who.id], request: pending(who.id, 'new', text(input.note, LIMITS.note), true),
152
+ });
153
+ // times and repeat go through the editor's own validation
154
+ await saveEvent(repo, created.id, { start: c.start, end: c.end, allDay: c.allDay, rrule: c.rrule }, hooksFor(hooks));
155
+ await notifyStaff(repo, created.id, hooks);
156
+ return (await repo.getEvent(created.id))!;
157
+ }
158
+
159
+ /**
160
+ * A host edits their request, or suggests changes to their live event. A
161
+ * request that hasn't gone live is updated and (re)sent for approval; a live
162
+ * event keeps showing what it shows until staff approve the changes.
163
+ */
164
+ export async function updateEventRequest(repo: ContentRepository, who: Actor | null, id: string, input: EventRequestInput, hooks: SaveHooks): Promise<CalEvent> {
165
+ requireMember(who);
166
+ const e = await hostedEvent(repo, who, id);
167
+ // staff have unpublished edits of their own on this live event: don't mix a
168
+ // host's suggestion into them (declining it would throw both away)
169
+ if (live(e) && e.draft && !hostChanges(e)) throw new ApiError(409, 'The team is editing this event right now. Try again later.');
170
+ if (!await repo.email.rateLimit(`event-request:${who.id}`, 20, 3_600_000)) throw new ApiError(429, 'That’s a lot of changes in an hour. Try again later.', 3600);
171
+ // what the host sees is the event with their own waiting changes on top
172
+ const current = (hostChanges(e) ? { ...e, ...(e.draft ?? {}) } : e) as CalEvent;
173
+ const c = await clean(repo, input, current);
174
+ const data: Parameters<typeof saveEvent>[2] = { title: c.title, start: c.start, end: c.end, allDay: c.allDay, rrule: c.rrule, calendarIds: c.calendarIds,
175
+ fields: filled({ ...current.fields, ...c.fields }) };
176
+ // untouched description or place: keep what staff may have added (formatting, images, address, map pin)
177
+ if (c.description !== bodyToText(current)) Object.assign(data, textToBody(c.description));
178
+ if (c.where !== current.location.name) data.location = EventLocation.parse({ name: c.where });
179
+ // live: saveEvent stages the edit (the event's own draft); not live: it writes it
180
+ await saveEvent(repo, id, data, hooksFor(hooks));
181
+ await repo.setEventRequest(id, pending(who.id, live(e) ? 'changes' : 'new', text(input.note, LIMITS.note), !live(e) && e.request?.created === true));
182
+ await notifyStaff(repo, id, hooks);
183
+ return (await repo.getEvent(id))!;
184
+ }
185
+
186
+ /** a host takes back a request: a new event that never went live is deleted; suggested changes are dropped */
187
+ export async function withdrawEventRequest(repo: ContentRepository, who: Actor | null, id: string, hooks: SaveHooks): Promise<{ deleted: boolean }> {
188
+ requireMember(who);
189
+ const e = await hostedEvent(repo, who, id);
190
+ if (!e.request) throw new ApiError(400, 'There’s no request on this event to withdraw.');
191
+ // only an event the member's own request made is deleted; a draft staff wrote stays
192
+ if (e.request.kind === 'new' && e.request.created && !live(e) && !e.publishedAt) {
193
+ await repo.deleteEvent(id);
194
+ await eventDeleted(repo, e, { waitUntil: waitUntilFrom(hooks.locals) });
195
+ return { deleted: true };
196
+ }
197
+ if (e.request.kind === 'changes') await repo.discardEventDraft(id);
198
+ await repo.setEventRequest(id, null);
199
+ return { deleted: false };
200
+ }
201
+
202
+ /** the request staff are answering, unchanged since they opened it (`seen`: its `at`) */
203
+ async function answerable(repo: ContentRepository, id: string, seen?: number) {
204
+ const e = await repo.getEvent(id);
205
+ if (!e?.request || e.request.state !== 'pending') throw new ApiError(409, 'This request was already answered or withdrawn.');
206
+ if (seen !== undefined && e.request.at !== seen) throw new ApiError(409, 'This request changed after you opened it. Look at it again, then answer.');
207
+ return e as CalEvent & { request: EventRequest };
208
+ }
209
+
210
+ /** staff approve: a new event is published; suggested changes go live. Pass `seen` (the request's `at` they reviewed) so later edits aren't approved unseen. */
211
+ export async function approveEventRequest(repo: ContentRepository, who: Actor | null, id: string, hooks: SaveHooks, seen?: number): Promise<CalEvent> {
212
+ requireApprover(who);
213
+ const e = await answerable(repo, id, seen);
214
+ const h = { ...hooksFor(hooks), canPublish: true };
215
+ if (e.request.kind === 'changes') { if (e.draft) await applyEventUpdate(repo, id, h); }
216
+ else if (!live(e)) await saveEvent(repo, id, { status: 'published' }, h);
217
+ await repo.setEventRequest(id, null);
218
+ await notifyHost(repo, e, 'approved', '', hooks);
219
+ return (await repo.getEvent(id))!;
220
+ }
221
+
222
+ /** staff decline, with a note for the host: a new request stays a draft they can edit and resend; suggested changes are dropped */
223
+ export async function declineEventRequest(repo: ContentRepository, who: Actor | null, id: string, reply: string, hooks: SaveHooks, seen?: number): Promise<CalEvent> {
224
+ requireApprover(who);
225
+ const e = await answerable(repo, id, seen);
226
+ if (e.request.kind === 'changes') await repo.discardEventDraft(id);
227
+ const answer = text(reply, LIMITS.note);
228
+ await repo.setEventRequest(id, { ...e.request, state: 'declined', reply: answer, reviewedBy: who.id, reviewedAt: Date.now() });
229
+ await notifyHost(repo, e, 'declined', answer, hooks);
230
+ return (await repo.getEvent(id))!;
231
+ }
232
+
233
+ /** a host clears a declined answer they've read (a declined new request stays as a draft) */
234
+ export async function dismissEventRequest(repo: ContentRepository, who: Actor | null, id: string): Promise<void> {
235
+ if (!who) throw new ApiError(401, 'Sign in to continue.');
236
+ const e = await hostedEvent(repo, who, id);
237
+ if (e.request?.state === 'declined' && e.request.kind === 'changes') await repo.setEventRequest(id, null);
238
+ }
239
+
240
+ /** hosts cancel or restore one date of their own event at once (no permission needed beyond hosting it) */
241
+ export async function setHostedOccurrence(repo: ContentRepository, who: Actor | null, id: string, key: string, cancelled: boolean, hooks: SaveHooks): Promise<CalEvent> {
242
+ if (!who) throw new ApiError(401, 'Sign in to continue.');
243
+ const e = await hostedEvent(repo, who, id);
244
+ if (!e.hostIds.includes(who.id)) throw new ApiError(404, 'Event not found.');
245
+ return (cancelled ? cancelOccurrence : restoreOccurrence)(repo, id, key, hooksFor(hooks));
246
+ }
247
+
248
+ /** staff set who hosts an event (Team members only) */
249
+ export async function setEventHosts(repo: ContentRepository, who: Actor | null, id: string, hostIds: string[]): Promise<CalEvent> {
250
+ if (!who) throw new ApiError(401, 'Sign in to continue.');
251
+ if (!who.can('content.edit')) throw new ApiError(403, 'Your role doesn’t include “Create and edit content”.');
252
+ const e = await repo.getEvent(id);
253
+ if (!e) throw new ApiError(404, 'Event not found.');
254
+ const known = new Set((await repo.listUsers()).map((u) => u.id));
255
+ const ids = [...new Set(hostIds)].filter((h) => known.has(h)).slice(0, 10);
256
+ await repo.setEventHosts(id, ids);
257
+ return (await repo.getEvent(id))!;
258
+ }
259
+
260
+ // ---- email ------------------------------------------------------------
261
+
262
+ /** staff: "New event request" / "Changes to review" (to members.notify, else the owners) */
263
+ async function notifyStaff(repo: ContentRepository, id: string, hooks: SaveHooks) {
264
+ const e = await repo.getEvent(id);
265
+ if (!e?.request) return;
266
+ const to = MEMBERS.notify.length ? MEMBERS.notify : (await repo.listUsers()).filter((u) => u.role === 'owner' && u.email).map((u) => u.email);
267
+ await queue(repo, e, to, e.request.kind === 'changes' ? 'staff-changes' : 'staff-new', hooks);
268
+ }
269
+ /** the host who asked: "approved" / "declined" (with staff's reply) */
270
+ async function notifyHost(repo: ContentRepository, e: CalEvent, kind: 'approved' | 'declined', reply: string, hooks: SaveHooks) {
271
+ const by = e.request?.by ? await repo.getUser(e.request.by) : null;
272
+ if (by?.email) await queue(repo, e, [by.email], kind, hooks, reply);
273
+ }
274
+ async function queue(repo: ContentRepository, e: CalEvent, to: string[], kind: string, hooks: SaveHooks, reply = '') {
275
+ if (!to.length) return;
276
+ // like booking emails: the latest saved email settings, or 0 for environment-only setups
277
+ const version = (await repo.email.config().catch(() => null))?.version ?? 0;
278
+ const at = e.request?.at ?? Date.now();
279
+ for (const recipient of to) {
280
+ await repo.email.enqueue({ dedupe: `event-request:${e.id}:${at}:${kind}:${recipient}`, kind: 'event-request', recipient,
281
+ payload: JSON.stringify({ eventId: e.id, kind, at, reply }), configVersion: version, expiresAt: Date.now() + 7 * 86_400_000 });
282
+ }
283
+ await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals)).catch(() => {});
284
+ }
@@ -16,7 +16,7 @@ import { rruleMap, WEEKDAY_CODES, weekdayOf } from './rrule';
16
16
  import { eventDeleted } from './hooks';
17
17
  import { waitUntilFrom } from '../webhooks';
18
18
 
19
- async function uniqueSlug(repo: ContentRepository, title: string) {
19
+ export async function uniqueSlug(repo: ContentRepository, title: string) {
20
20
  const base = slugify(title) || 'event';
21
21
  let slug = base;
22
22
  for (let n = 2; await repo.getEventBySlug(slug); n++) slug = `${base}-${n}`;
@@ -1,7 +1,8 @@
1
1
  import { AUTH } from '../auth/config';
2
2
  import { validateMobileBinding, authEnvironment, ClerkUnavailableError } from '../auth/request';
3
3
  import type { ClerkBinding } from '../auth/store';
4
- import { Role } from '../schema';
4
+ import { isStaffRole } from '../roles';
5
+ import type { Role } from '../schema';
5
6
  import { createHmac } from 'node:crypto';
6
7
  import { env } from '../env';
7
8
  import { sha256Hex } from '../api-keys';
@@ -161,12 +162,13 @@ async function pair(now: number) {
161
162
  }
162
163
  async function liveUser(repo: ContentRepository, id: string, sessionVersion: number) {
163
164
  const [user, version] = await Promise.all([repo.getUser(id), repo.email.sessionVersion(id)]);
164
- return user && version === sessionVersion ? user : null;
165
+ // only staff hold app tokens: someone moved to a site role without admin access loses theirs
166
+ return user && version === sessionVersion && isStaffRole(user.role) ? user : null;
165
167
  }
166
168
  function tokenResponse(t: { access: string; refresh: string }, user: { id: string; name: string; email: string; role: string }, device: { id: string; name: string }): TokenResponse {
167
169
  return { access_token: t.access, token_type: 'Bearer', expires_in: Math.floor(EDITOR.accessTokenMs / 1000), refresh_token: t.refresh,
168
170
  refresh_token_expires_in: Math.floor(EDITOR.refreshTokenMs / 1000),
169
- user: { id: user.id, name: user.name, email: user.email, role: Role.parse(user.role) }, device };
171
+ user: { id: user.id, name: user.name, email: user.email, role: user.role }, device };
170
172
  }
171
173
 
172
174
  async function mobileBindingActive(...args: Parameters<typeof validateMobileBinding>) {
@@ -4,7 +4,8 @@ import { Booking, BookingLink, Calendar, LinksAvatarShape, LinksAvatarFit, Links
4
4
 
5
5
  // Response shapes of the editor-app API (OpenAPI components, client types).
6
6
  const str = z.string(), bool = z.boolean(), num = z.number(), date = str.datetime();
7
- const role = z.enum(['contributor', 'author', 'editor', 'administrator', 'owner']);
7
+ // a core role (owner, administrator, editor, author, contributor) or a staff role the site defines
8
+ const role = str.min(1);
8
9
  const Pagination = z.object({ page: num.int(), limit: num.int(), nextPage: num.int().nullable() });
9
10
  const User = z.object({ id: str, name: str, email: str, role });
10
11
 
@@ -1,4 +1,5 @@
1
- import { Role } from '../schema';
1
+ import { isStaffRole } from '../roles';
2
+ import type { Role } from '../schema';
2
3
  import type { SqlDriver } from '../repo/sql';
3
4
 
4
5
  type Row = Record<string, unknown>;
@@ -115,10 +116,11 @@ export class EditorStore {
115
116
  FROM editor_tokens t JOIN editor_devices d ON d.id=t.device_id JOIN users u ON u.id=d.user_id
116
117
  WHERE t.hash=? AND t.kind='access' AND t.expires_at>? AND d.revoked_at IS NULL`, [hash, now]);
117
118
  const r = rows[0];
118
- if (!r) return null;
119
+ // only staff use the app: a role without admin access (or one the site removed) gets nothing
120
+ if (!r || !isStaffRole(String(r.u_role))) return null;
119
121
  // first use proves the app received this pair (ends the refresh grace)
120
122
  if (r.t_used == null) await this.run('UPDATE editor_tokens SET used_at=? WHERE hash=? AND used_at IS NULL', [now, hash]);
121
- return { device: device(r), user: { id: String(r.user_id), name: String(r.u_name), email: String(r.u_email), role: Role.parse(r.u_role), sessionVersion: Number(r.u_sv) } };
123
+ return { device: device(r), user: { id: String(r.user_id), name: String(r.u_name), email: String(r.u_email), role: String(r.u_role), sessionVersion: Number(r.u_sv) } };
122
124
  }
123
125
  /** the device a token (access or refresh) belongs to, for revocation */
124
126
  async deviceForToken(hash: string): Promise<string | null> {
@@ -1,7 +1,8 @@
1
1
  import type { ContentRepository } from '../repo/types';
2
2
  import { resolveConfig, decrypt, encrypt, runtimeValue } from './config';
3
3
  import { sendWithProvider } from './providers';
4
- import { accountMail, canonicalOrigin, guestCodeMail, EmailLinkError } from './templates';
4
+ import { accountMail, canonicalOrigin, eventRequestMail, guestCodeMail, EmailLinkError } from './templates';
5
+ import { formatDate } from '../dates';
5
6
  import { bookingMail } from '../booking/service';
6
7
  import type { EmailJob, Mail } from './types';
7
8
 
@@ -31,6 +32,18 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
31
32
  const raw = decrypt<string>(String(token.encrypted), `token:${token.id}`, locals);
32
33
  return accountMail(payload.purpose, job.recipient, settings.siteTitle, `${origin}/account/${payload.purpose}?token=${encodeURIComponent(raw)}`);
33
34
  }
35
+ if (job.kind === 'event-request') {
36
+ const p = JSON.parse(job.payload) as { eventId: string; kind: 'staff-new' | 'staff-changes' | 'approved' | 'declined'; at: number; reply: string };
37
+ const e = await repo.getEvent(p.eventId);
38
+ if (!e) return null;
39
+ const staff = p.kind.startsWith('staff');
40
+ // staff hear only about requests still waiting (not withdrawn or already answered)
41
+ if (staff && (e.request?.state !== 'pending' || e.request.at !== p.at)) return null;
42
+ const by = e.request?.by ? await repo.getUser(e.request.by) : null;
43
+ const { memberEventsPath, MEMBERS } = await import('../calendar/requests');
44
+ const url = staff ? `${origin}/admin/calendar/events/${e.id}` : `${origin}${MEMBERS.enabled ? memberEventsPath() : '/'}`;
45
+ return eventRequestMail(p.kind, job.recipient, settings.siteTitle, { title: e.title, when: formatDate(e.startAt, settings), by: by?.name ?? '', note: e.request?.note ?? '' }, p.reply, url);
46
+ }
34
47
  if (job.kind === 'welcome' || job.kind === 'password-changed') return accountMail(job.kind, job.recipient, settings.siteTitle, `${origin}/admin/login`);
35
48
  if (job.kind === 'email-changed') return accountMail(job.kind, job.recipient, settings.siteTitle);
36
49
  return null;
@@ -57,9 +70,9 @@ export async function dispatchEmail(repo: ContentRepository, locals?: unknown, l
57
70
  }
58
71
  await repo.email.pin(j,resolved.provider,resolved.fingerprint,config?.version || 0);
59
72
  if (await repo.email.suppressed(j.recipient)) { await repo.email.finish(j,{state:'suppressed',error:'Recipient is suppressed after a bounce or complaint.'}); continue; }
60
- if (j.kind === 'booking') {
73
+ if (j.kind === 'booking' || j.kind === 'event-request') {
61
74
  const payload = JSON.parse(j.payload);
62
- if ((payload.kind === 'reminder' && !current.config.reminders) || (payload.kind === 'staff-new' && !current.config.staffNotifications)) {
75
+ if ((payload.kind === 'reminder' && !current.config.reminders) || (String(payload.kind).startsWith('staff-') && !current.config.staffNotifications)) {
63
76
  await repo.email.finish(j,{state:'cancelled',error:'Optional notification disabled.'}); continue;
64
77
  }
65
78
  }
@@ -141,7 +141,9 @@ export class EmailStore {
141
141
  { sql: "UPDATE email_tokens SET encrypted='' WHERE expires_at <= ? OR used_by != '' OR revoked=1", args:[now] },
142
142
  ]);
143
143
  }
144
- async sessionVersion(id: string): Promise<number | null> { const r = await (await this.getDriver()).all('SELECT session_version FROM users WHERE id=?', [id]); return r[0] ? Number(r[0].session_version) : null; }
144
+ async sessionVersion(id: string): Promise<number | null> { return (await this.sessionState(id))?.version ?? null; }
145
+ /** the session version and role in one read (every signed-in request asks for both) */
146
+ async sessionState(id: string): Promise<{ version: number; role: string } | null> { const r = await (await this.getDriver()).all('SELECT session_version, role FROM users WHERE id=?', [id]); return r[0] ? { version: Number(r[0].session_version), role: String(r[0].role) } : null; }
145
147
  async invitations(): Promise<Invitation[]> {
146
148
  return (await (await this.getDriver()).all("SELECT id,email,name,role,expires_at,created_at,invited_by,CASE WHEN revoked=1 THEN 'revoked' WHEN used_by!='' THEN 'accepted' WHEN expires_at < ? THEN 'expired' ELSE 'pending' END state FROM email_tokens WHERE purpose='invite' ORDER BY created_at DESC LIMIT 100", [Date.now()])).map((r) => ({ id: String(r.id), email:String(r.email),name:String(r.name),role:r.role as Invitation['role'],expiresAt:Number(r.expires_at),createdAt:Number(r.created_at),state:String(r.state),invitedBy:String(r.invited_by ?? '') }));
147
149
  }
@@ -12,6 +12,18 @@ export function accountMail(kind: 'invite'|'reset'|'welcome'|'password-changed'|
12
12
  return { to, subject: `${copy[0]} · ${site}`, text: [copy[0], '', copy[1], ...(url ? ['',`${copy[2]}: ${url}`] : [])].join('\n'),
13
13
  html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:24px">${escape(copy[0])}</h1><p>${escape(copy[1])}</p>${url ? `<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">${escape(copy[2])}</a></p>` : ''}</div></body></html>` };
14
14
  }
15
+ /** members' event requests (lib/calendar/requests.ts): to staff when one arrives, to the member when it's answered */
16
+ export function eventRequestMail(kind: 'staff-new' | 'staff-changes' | 'approved' | 'declined', to: string, site: string, event: { title: string; when: string; by: string; note: string }, reply: string, url: string): Mail {
17
+ const copy = {
18
+ 'staff-new': [`New event request: ${event.title}`, `${event.by || 'A member'} asked for “${event.title}” (${event.when}). It stays a draft until someone approves it.`, 'Review it'],
19
+ 'staff-changes': [`Changes to review: ${event.title}`, `${event.by || 'A host'} suggested changes to “${event.title}” (${event.when}). The live event stays as it is until someone approves them.`, 'Review the changes'],
20
+ approved: [`Approved: ${event.title}`, `Your event “${event.title}” (${event.when}) was approved and is now on the calendar.`, 'See your events'],
21
+ declined: [`Not approved: ${event.title}`, `Your request for “${event.title}” (${event.when}) wasn’t approved.`, 'See your events'],
22
+ }[kind];
23
+ const extra = kind.startsWith('staff') ? (event.note ? `Their note: “${event.note}”` : '') : reply ? `From the team: “${reply}”` : '';
24
+ return { to, subject: `${copy[0]} · ${site}`, text: [copy[0], '', copy[1], ...(extra ? ['', extra] : []), '', `${copy[2]}: ${url}`].join('\n'),
25
+ html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:22px">${escape(copy[0])}</h1><p>${escape(copy[1])}</p>${extra ? `<p style="background:#efebe4;border-radius:8px;padding:10px 12px">${escape(extra)}</p>` : ''}<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">${escape(copy[2])}</a></p></div></body></html>` };
26
+ }
15
27
  /** a guest's app sign-in code; no link, so it works on any device */
16
28
  export function guestCodeMail(to: string, site: string, code: string): Mail {
17
29
  const intro = `Enter this code in the ${site} app to see your bookings. It expires in 10 minutes. If you did not ask for it, ignore this message.`;
@@ -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;