@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.
- package/CHANGELOG.md +6 -0
- package/cli/migrations.generated.mjs +16 -1
- package/integration.mjs +2 -0
- package/package.json +1 -1
- package/src/actions/index.ts +76 -59
- package/src/components/RolesManager.tsx +276 -0
- package/src/layouts/Admin.astro +8 -3
- package/src/lib/auth/accounts.ts +2 -2
- package/src/lib/auth/store.ts +4 -4
- package/src/lib/content-service.ts +14 -0
- package/src/lib/design-service.ts +11 -3
- package/src/lib/editor/http.ts +7 -0
- package/src/lib/editor/me.ts +12 -8
- package/src/lib/editor/roles.ts +14 -6
- package/src/lib/editor/schedule.ts +12 -12
- package/src/lib/editor/sections.ts +11 -4
- package/src/lib/editor/site-data.ts +4 -2
- package/src/lib/editor/translations.ts +2 -1
- package/src/lib/email/accounts.ts +2 -2
- package/src/lib/email/store.ts +3 -3
- package/src/lib/email/types.ts +1 -1
- package/src/lib/permissions/access.ts +73 -0
- package/src/lib/permissions/catalog.ts +147 -0
- package/src/lib/permissions/migration.ts +17 -0
- package/src/lib/permissions/service.ts +59 -0
- package/src/lib/permissions/store.ts +54 -0
- package/src/lib/repo/migrations.ts +2 -0
- package/src/lib/repo/sql.ts +3 -0
- package/src/lib/repo/types.ts +2 -0
- package/src/lib/roles.ts +9 -0
- package/src/pages/admin/c/[collection]/index.astro +6 -0
- package/src/pages/admin/calendar/bookings.astro +2 -0
- package/src/pages/admin/calendar/events/index.astro +11 -0
- package/src/pages/admin/calendar/resources/[id].astro +2 -0
- package/src/pages/admin/calendar/resources/index.astro +2 -0
- package/src/pages/admin/calendar/resources/link.astro +2 -0
- package/src/pages/admin/calendar/settings.astro +2 -0
- package/src/pages/admin/email.astro +2 -2
- package/src/pages/admin/forms/[id]/share.astro +2 -0
- package/src/pages/admin/forms/[id]/submissions.astro +2 -0
- package/src/pages/admin/forms/index.astro +2 -0
- package/src/pages/admin/import.astro +3 -6
- package/src/pages/admin/integrations.astro +10 -12
- package/src/pages/admin/live.astro +3 -3
- package/src/pages/admin/mobile.astro +3 -3
- package/src/pages/admin/pages/index.astro +8 -1
- package/src/pages/admin/roles.astro +80 -0
- package/src/pages/admin/settings.astro +31 -28
- package/src/pages/admin/tags.astro +2 -0
- package/src/pages/admin/users.astro +87 -41
- package/src/pages/api/ai/clean.ts +3 -2
- package/src/pages/api/ai/draft.ts +3 -2
- package/src/pages/api/ai/settings.ts +4 -3
- package/src/pages/api/ai/test.ts +3 -2
- package/src/pages/api/design/export.ts +2 -1
- package/src/pages/api/design/import.ts +2 -1
- package/src/pages/api/design/keys.ts +3 -2
- package/src/pages/api/email/settings.ts +4 -3
- package/src/pages/api/export.ts +3 -2
- package/src/pages/api/import/asset.ts +3 -2
- package/src/pages/api/import/index.ts +4 -3
- package/src/pages/api/import/media.ts +3 -2
- package/src/pages/api/live/oauth/youtube/callback.ts +4 -4
- package/src/pages/api/live/settings.ts +4 -3
- package/src/pages/api/media/upload.ts +4 -0
- package/src/pages/api/mobile/access.ts +4 -3
- package/src/pages/api/monitoring.ts +4 -3
- package/src/pages/api/push/admin.ts +4 -3
- package/src/pages/api/roles.ts +36 -0
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +2 -1
- package/src/pages/api/v1/admin/bookings/index.ts +3 -2
- package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +3 -2
- package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +3 -2
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +3 -2
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +3 -2
- package/src/pages/api/v1/admin/documents/index.ts +2 -1
- package/src/pages/api/v1/admin/links.ts +2 -1
- package/src/pages/api/v1/admin/media/index.ts +2 -1
|
@@ -210,24 +210,24 @@ const linkView = (l: BookingLink) => ({ ...l, createdAt: iso(l.createdAt), updat
|
|
|
210
210
|
export async function listResourcesFor(c: EditorContext) {
|
|
211
211
|
requireBookings();
|
|
212
212
|
const [resources, links, settings] = await Promise.all([c.repo.listResources(), c.repo.listBookingLinks(), c.repo.getSettings()]);
|
|
213
|
-
const canEdit = canManageSchedule(c.who, settings);
|
|
213
|
+
const canEdit = await canManageSchedule(c.repo, c.who, settings);
|
|
214
214
|
return { canEdit, resources: resources.map((r) => resourceView(r, links, canEdit)) };
|
|
215
215
|
}
|
|
216
216
|
export async function getResourceFor(c: EditorContext, id: string) {
|
|
217
217
|
requireBookings();
|
|
218
218
|
const r = await c.repo.getResource(id);
|
|
219
219
|
if (!r) throw new ApiError(404, 'Room or service not found.');
|
|
220
|
-
return resourceView(r, await c.repo.listBookingLinks(r.id), canManageSchedule(c.who, await c.repo.getSettings()));
|
|
220
|
+
return resourceView(r, await c.repo.listBookingLinks(r.id), await canManageSchedule(c.repo, c.who, await c.repo.getSettings()));
|
|
221
221
|
}
|
|
222
222
|
export async function createResourceFor(c: EditorContext, body: z.infer<typeof ResourceInput>) {
|
|
223
223
|
requireBookings();
|
|
224
|
-
requireScheduleRole(c.who, await c.repo.getSettings());
|
|
224
|
+
await requireScheduleRole(c.repo, c.who, await c.repo.getSettings());
|
|
225
225
|
const r = await c.repo.createResource({ ...(await resourceFields(c.repo, body)), name: body.name, slug: await uniqueResourceSlug(c.repo, body.slug || body.name) });
|
|
226
226
|
return resourceView(r, [], true);
|
|
227
227
|
}
|
|
228
228
|
export async function updateResourceFor(c: EditorContext, id: string, body: z.infer<typeof ResourcePatch>) {
|
|
229
229
|
requireBookings();
|
|
230
|
-
requireScheduleRole(c.who, await c.repo.getSettings());
|
|
230
|
+
await requireScheduleRole(c.repo, c.who, await c.repo.getSettings());
|
|
231
231
|
const cur = await c.repo.getResource(id);
|
|
232
232
|
if (!cur) throw new ApiError(404, 'Room or service not found.');
|
|
233
233
|
await c.repo.updateResource(id, { ...(await resourceFields(c.repo, body)), ...(body.slug !== undefined ? { slug: await uniqueResourceSlug(c.repo, body.slug || cur.name, id) } : {}) });
|
|
@@ -235,7 +235,7 @@ export async function updateResourceFor(c: EditorContext, id: string, body: z.in
|
|
|
235
235
|
}
|
|
236
236
|
export async function deleteResourceFor(c: EditorContext, id: string) {
|
|
237
237
|
requireBookings();
|
|
238
|
-
requireScheduleRole(c.who, await c.repo.getSettings());
|
|
238
|
+
await requireScheduleRole(c.repo, c.who, await c.repo.getSettings());
|
|
239
239
|
if (!await c.repo.getResource(id)) throw new ApiError(404, 'Room or service not found.');
|
|
240
240
|
await c.repo.deleteResource(id);
|
|
241
241
|
return { id, deleted: true as const };
|
|
@@ -276,7 +276,7 @@ async function linkOf(repo: ContentRepository, resourceId: string, linkId: strin
|
|
|
276
276
|
}
|
|
277
277
|
export async function createLinkFor(c: EditorContext, resourceId: string, body: z.infer<typeof LinkInput>) {
|
|
278
278
|
requireBookings();
|
|
279
|
-
requireScheduleRole(c.who, await c.repo.getSettings());
|
|
279
|
+
await requireScheduleRole(c.repo, c.who, await c.repo.getSettings());
|
|
280
280
|
if (!await c.repo.getResource(resourceId)) throw new ApiError(404, 'Room or service not found.');
|
|
281
281
|
const { slug, ...rest } = body;
|
|
282
282
|
const created = await c.repo.createBookingLink({ resourceId, name: body.name, slug: await uniqueLinkSlug(c.repo, resourceId, slug || body.name) });
|
|
@@ -285,7 +285,7 @@ export async function createLinkFor(c: EditorContext, resourceId: string, body:
|
|
|
285
285
|
}
|
|
286
286
|
export async function updateLinkFor(c: EditorContext, resourceId: string, linkId: string, body: z.infer<typeof LinkPatch>) {
|
|
287
287
|
requireBookings();
|
|
288
|
-
requireScheduleRole(c.who, await c.repo.getSettings());
|
|
288
|
+
await requireScheduleRole(c.repo, c.who, await c.repo.getSettings());
|
|
289
289
|
const cur = await linkOf(c.repo, resourceId, linkId);
|
|
290
290
|
const { slug, ...rest } = body;
|
|
291
291
|
await c.repo.updateBookingLink(cur.id, { ...rest, ...(slug !== undefined ? { slug: await uniqueLinkSlug(c.repo, resourceId, slug || cur.name, cur.id) } : {}) });
|
|
@@ -293,7 +293,7 @@ export async function updateLinkFor(c: EditorContext, resourceId: string, linkId
|
|
|
293
293
|
}
|
|
294
294
|
export async function deleteLinkFor(c: EditorContext, resourceId: string, linkId: string) {
|
|
295
295
|
requireBookings();
|
|
296
|
-
requireScheduleRole(c.who, await c.repo.getSettings());
|
|
296
|
+
await requireScheduleRole(c.repo, c.who, await c.repo.getSettings());
|
|
297
297
|
const cur = await linkOf(c.repo, resourceId, linkId);
|
|
298
298
|
await c.repo.deleteBookingLink(cur.id);
|
|
299
299
|
return { id: cur.id, deleted: true as const };
|
|
@@ -315,24 +315,24 @@ const calendarView = (cal: Awaited<ReturnType<ContentRepository['listCalendars']
|
|
|
315
315
|
export async function listCalendarsFor(c: EditorContext) {
|
|
316
316
|
requireCalendarModule();
|
|
317
317
|
const [list, settings] = await Promise.all([c.repo.listCalendars(), c.repo.getSettings()]);
|
|
318
|
-
const canEdit = canManageSchedule(c.who, settings);
|
|
318
|
+
const canEdit = await canManageSchedule(c.repo, c.who, settings);
|
|
319
319
|
return { canEdit, calendars: list.map((cal) => calendarView(cal, canEdit)) };
|
|
320
320
|
}
|
|
321
321
|
export async function createCalendarFor(c: EditorContext, body: z.infer<typeof CalendarBody>) {
|
|
322
322
|
requireCalendarModule();
|
|
323
|
-
requireScheduleRole(c.who, await c.repo.getSettings());
|
|
323
|
+
await requireScheduleRole(c.repo, c.who, await c.repo.getSettings());
|
|
324
324
|
return calendarView(await saveCalendar(c.repo, undefined, body));
|
|
325
325
|
}
|
|
326
326
|
export async function updateCalendarFor(c: EditorContext, id: string, body: z.infer<typeof CalendarPatch>) {
|
|
327
327
|
requireCalendarModule();
|
|
328
|
-
requireScheduleRole(c.who, await c.repo.getSettings());
|
|
328
|
+
await requireScheduleRole(c.repo, c.who, await c.repo.getSettings());
|
|
329
329
|
const cur = await c.repo.getCalendar(id);
|
|
330
330
|
if (!cur) throw new ApiError(404, 'Calendar not found.');
|
|
331
331
|
return calendarView(await saveCalendar(c.repo, id, { ...body, name: body.name ?? cur.name, slug: body.slug ?? cur.slug }));
|
|
332
332
|
}
|
|
333
333
|
export async function deleteCalendarFor(c: EditorContext, id: string) {
|
|
334
334
|
requireCalendarModule();
|
|
335
|
-
requireScheduleRole(c.who, await c.repo.getSettings());
|
|
335
|
+
await requireScheduleRole(c.repo, c.who, await c.repo.getSettings());
|
|
336
336
|
if (!await c.repo.getCalendar(id)) throw new ApiError(404, 'Calendar not found.');
|
|
337
337
|
await c.repo.deleteCalendar(id);
|
|
338
338
|
return { id, deleted: true as const };
|
|
@@ -4,6 +4,7 @@ import type { Settings } from '../schema';
|
|
|
4
4
|
import { ApiError } from '../api';
|
|
5
5
|
import { DesignRequest, runDesignOperation } from '../design-service';
|
|
6
6
|
import type { EditorPrincipal } from './auth';
|
|
7
|
+
import { principalCan, requirePrincipal } from '../permissions/access';
|
|
7
8
|
|
|
8
9
|
// Section layouts for editor apps. Every write is a design operation run by
|
|
9
10
|
// the same code as the Design screen (lib/design-service runDesignOperation)
|
|
@@ -27,7 +28,12 @@ export const stagedPages = (s: Settings) => (s.draftDesign?.sectionPages ?? s.se
|
|
|
27
28
|
// lazy: the theme registry pulls in Astro templates (keeps the OpenAPI/schema imports light)
|
|
28
29
|
const getTheme = async (slug: string) => (await import('../theme')).getTheme(slug);
|
|
29
30
|
export const stagedTheme = (s: Settings) => getTheme(String(s.draftDesign?.activeTheme ?? s.activeTheme));
|
|
30
|
-
|
|
31
|
+
/** the design rules for an app user: Arrange and lock sections acts like an owner; otherwise Edit page sections */
|
|
32
|
+
export async function designIdentityFor(repo: ContentRepository, who: EditorPrincipal) {
|
|
33
|
+
if (await principalCan(repo, who, 'site.layout')) return { actor: who.actor, role: 'owner' as const };
|
|
34
|
+
await requirePrincipal(repo, who, 'site.sections');
|
|
35
|
+
return { actor: who.actor, role: 'editor' as const };
|
|
36
|
+
}
|
|
31
37
|
|
|
32
38
|
export async function listSections(repo: ContentRepository, query: z.infer<typeof SectionsQuery>, who: EditorPrincipal) {
|
|
33
39
|
const [settings, pages] = await Promise.all([repo.getSettings(), repo.listPages()]);
|
|
@@ -35,6 +41,7 @@ export async function listSections(repo: ContentRepository, query: z.infer<typeo
|
|
|
35
41
|
const layouts = draft ? stagedPages(settings) : settings.sectionPages;
|
|
36
42
|
const theme = draft ? await stagedTheme(settings) : await getTheme(settings.activeTheme);
|
|
37
43
|
const byId = new Map(pages.map((p) => [p.id, p]));
|
|
44
|
+
const arranges = await principalCan(repo, who, 'site.layout');
|
|
38
45
|
const out = Object.entries(layouts)
|
|
39
46
|
.filter(([id, layout]) => layout && byId.has(id) && (!query.pageId || query.pageId === id))
|
|
40
47
|
.map(([id, layout]) => {
|
|
@@ -49,7 +56,7 @@ export async function listSections(repo: ContentRepository, query: z.infer<typeo
|
|
|
49
56
|
/** new on the page (not live yet): only a full design publish adds it */
|
|
50
57
|
isNew: !liveLayout?.sections.some((x) => x.id === s.id),
|
|
51
58
|
changed: draft && JSON.stringify(liveLayout?.sections.find((x) => x.id === s.id) ?? null) !== JSON.stringify(s),
|
|
52
|
-
editable:
|
|
59
|
+
editable: arranges || !s.locked,
|
|
53
60
|
})),
|
|
54
61
|
};
|
|
55
62
|
});
|
|
@@ -80,7 +87,7 @@ export async function stageSection(repo: ContentRepository, who: EditorPrincipal
|
|
|
80
87
|
if (!layout || index < 0) throw new ApiError(404, 'Section not found.');
|
|
81
88
|
layout.sections[index] = change(layout.sections[index]);
|
|
82
89
|
const input = DesignRequest.parse({ operation: 'stage', revision, patch: { sectionPages: pages } });
|
|
83
|
-
const result = await runDesignOperation(repo, designIdentityFor(who), input, origin);
|
|
90
|
+
const result = await runDesignOperation(repo, await designIdentityFor(repo, who), input, origin);
|
|
84
91
|
return { revision: result.revision, section: layout.sections[index] };
|
|
85
92
|
}
|
|
86
93
|
|
|
@@ -91,6 +98,6 @@ export async function patchSection(repo: ContentRepository, who: EditorPrincipal
|
|
|
91
98
|
|
|
92
99
|
export async function publishSection(repo: ContentRepository, who: EditorPrincipal, pageId: string, sectionId: string, revision: number, origin: string) {
|
|
93
100
|
const input = DesignRequest.parse({ operation: 'publishSection', revision, pageId, sectionId });
|
|
94
|
-
const result = await runDesignOperation(repo, designIdentityFor(who), input, origin);
|
|
101
|
+
const result = await runDesignOperation(repo, await designIdentityFor(repo, who), input, origin);
|
|
95
102
|
return { revision: result.revision };
|
|
96
103
|
}
|
|
@@ -5,6 +5,7 @@ import { ApiError } from '../api';
|
|
|
5
5
|
import { displayAnswers } from '../forms/service';
|
|
6
6
|
import type { EditorContext } from './http';
|
|
7
7
|
import { isAdmin, requireAdmin } from './roles';
|
|
8
|
+
import { principalCan, requirePrincipal } from '../permissions/access';
|
|
8
9
|
|
|
9
10
|
// Forms and the links page from editor apps. Any staff account sees which
|
|
10
11
|
// forms exist and how many responses they have; reading responses (personal
|
|
@@ -17,7 +18,7 @@ const iso = (n: number | null) => (n === null ? null : new Date(n).toISOString()
|
|
|
17
18
|
export async function listFormsFor(c: EditorContext) {
|
|
18
19
|
const [forms, stats] = await Promise.all([c.repo.forms.list(), c.repo.forms.stats()]);
|
|
19
20
|
return {
|
|
20
|
-
canReadResponses: isAdmin(c.who),
|
|
21
|
+
canReadResponses: isAdmin(c.who) && await principalCan(c.repo, c.who, 'forms.responses'),
|
|
21
22
|
forms: forms.filter((f) => f.kind === 'form').map((f) => ({
|
|
22
23
|
id: f.id, name: f.name, title: f.definition.title || f.name, purpose: f.purpose, status: f.status, hasDraft: Boolean(f.draft),
|
|
23
24
|
responses: stats.get(f.id)?.count ?? 0, lastResponseAt: iso(stats.get(f.id)?.last ?? null), updatedAt: iso(f.updatedAt),
|
|
@@ -31,6 +32,7 @@ export const ResponsesQuery = z.object({
|
|
|
31
32
|
}).strict();
|
|
32
33
|
export async function formResponsesFor(c: EditorContext, id: string, q: z.infer<typeof ResponsesQuery>) {
|
|
33
34
|
requireAdmin(c.who, 'read form responses');
|
|
35
|
+
await requirePrincipal(c.repo, c.who, 'forms.responses');
|
|
34
36
|
const form = await c.repo.forms.get(id);
|
|
35
37
|
if (!form || form.kind !== 'form') throw new ApiError(404, 'Form not found.');
|
|
36
38
|
const rows = await c.repo.forms.searchSubmissions(id, { q: q.q, since: q.since ? Date.parse(q.since) : undefined, limit: q.limit + 1, offset: (q.page - 1) * q.limit });
|
|
@@ -54,7 +56,7 @@ export async function getLinksFor(c: EditorContext) {
|
|
|
54
56
|
// next save gets a 409), never newer than the blocks it is sent with
|
|
55
57
|
const revision = await c.repo.linksRevision();
|
|
56
58
|
const [settings, links] = await Promise.all([c.repo.getSettings(), c.repo.listLinks()]);
|
|
57
|
-
return { revision, name: settings.linksName, bio: settings.linksBio, avatar: settings.linksAvatar, canEditHtml: isAdmin(c.who), items: links.map(linkView) };
|
|
59
|
+
return { revision, name: settings.linksName, bio: settings.linksBio, avatar: settings.linksAvatar, canEditHtml: isAdmin(c.who) && await principalCan(c.repo, c.who, 'site.links'), items: links.map(linkView) };
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
const Item = z.object({
|
|
@@ -10,6 +10,7 @@ import type { SaveHooks } from '../content-service';
|
|
|
10
10
|
import type { EditorPrincipal } from './auth';
|
|
11
11
|
import { checkRevision, loadDoc, mergedDoc, revisionOf, saveRaw, withDocLock, type DocKind } from './documents';
|
|
12
12
|
import { findSection, stagedPages, stagedTheme, stageSection } from './sections';
|
|
13
|
+
import { principalCan } from '../permissions/access';
|
|
13
14
|
|
|
14
15
|
// Translations for editor apps, same rules as the web editors: a person
|
|
15
16
|
// setting a text marks it reviewed; AI drafts are stored as "AI draft" for
|
|
@@ -147,7 +148,7 @@ export async function changeSectionTranslation(repo: ContentRepository, who: Edi
|
|
|
147
148
|
const section = findSection(stagedPages(settings), pageId, sectionId);
|
|
148
149
|
const def = (await stagedTheme(settings)).sections[section.type];
|
|
149
150
|
if (!def) throw new ApiError(409, `The “${section.type}” section isn’t available in this theme.`);
|
|
150
|
-
if (section.locked && who
|
|
151
|
+
if (section.locked && !await principalCan(repo, who, 'site.layout')) throw new ApiError(403, `Only someone who can arrange and lock sections can edit the locked “${def.name}” section.`);
|
|
151
152
|
const units: Unit[] = translationUnits(def, section.data).map((u) => ({ path: u.path, label: u.label, kind: u.kind as Unit['kind'], source: u.source, hash: u.hash }));
|
|
152
153
|
const cur = section.translations?.[locale] ?? { data: {}, meta: {} };
|
|
153
154
|
const next = { data: { ...cur.data } as Record<string, unknown>, meta: { ...cur.meta } as Meta };
|
|
@@ -30,7 +30,7 @@ export async function reauthenticate(repo:ContentRepository, userId:string, pass
|
|
|
30
30
|
const user=await repo.getUser(userId); if(!user)return false;
|
|
31
31
|
const auth=await repo.getUserAuth(user.email);return !!auth && verifyPassword(password,auth.passHash);
|
|
32
32
|
}
|
|
33
|
-
export async function issueAccountToken(repo:ContentRepository, data:{purpose:'invite'|'reset';email:string;name?:string;role?:Role;expiresDays?:number}, locals?:unknown) {
|
|
33
|
+
export async function issueAccountToken(repo:ContentRepository, data:{purpose:'invite'|'reset';email:string;name?:string;role?:Role;expiresDays?:number;invitedBy?:string}, locals?:unknown) {
|
|
34
34
|
if (AUTH.provider !== 'builtin') throw new Error('Account access is managed by Clerk.');
|
|
35
35
|
const email=data.email.trim().toLowerCase();
|
|
36
36
|
if(!validAddress(email))throw new Error('Enter a valid email address.');
|
|
@@ -43,7 +43,7 @@ export async function issueAccountToken(repo:ContentRepository, data:{purpose:'i
|
|
|
43
43
|
canonicalOrigin((await repo.getSettings()).siteUrl);
|
|
44
44
|
const id=crypto.randomUUID(),raw=randomBytes(32).toString('base64url');
|
|
45
45
|
const expiresAt=Date.now()+(data.purpose==='reset'?30*60000:(data.expiresDays||7)*86400000);
|
|
46
|
-
const token={id,hash:sha256(raw),encrypted:encrypt(raw,`token:${id}`,locals),purpose:data.purpose,email,name:data.name?.trim().slice(0,120)||email.split('@')[0],role:data.role||'editor',userId:auth?.user.id||'',expiresAt};
|
|
46
|
+
const token={id,hash:sha256(raw),encrypted:encrypt(raw,`token:${id}`,locals),purpose:data.purpose,email,name:data.name?.trim().slice(0,120)||email.split('@')[0],role:data.role||'editor',userId:auth?.user.id||'',expiresAt,invitedBy:data.invitedBy||''};
|
|
47
47
|
const job:NewEmailJob={dedupe:`${data.purpose}:${id}`,kind:'auth',recipient:email,payload:JSON.stringify({tokenId:id,purpose:data.purpose}),configVersion:c.version,expiresAt};
|
|
48
48
|
await repo.email.saveToken(token,job);
|
|
49
49
|
}
|
package/src/lib/email/store.ts
CHANGED
|
@@ -143,16 +143,16 @@ export class EmailStore {
|
|
|
143
143
|
}
|
|
144
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; }
|
|
145
145
|
async invitations(): Promise<Invitation[]> {
|
|
146
|
-
return (await (await this.getDriver()).all("SELECT id,email,name,role,expires_at,created_at,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) }));
|
|
146
|
+
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
147
|
}
|
|
148
148
|
async token(id: string) { return (await (await this.getDriver()).all('SELECT * FROM email_tokens WHERE id=?', [id]))[0] || null; }
|
|
149
149
|
async tokenByHash(hash: string) { return (await (await this.getDriver()).all('SELECT * FROM email_tokens WHERE token_hash=? AND revoked=0 AND used_by=\'\' AND expires_at > ?', [hash,Date.now()]))[0] || null; }
|
|
150
|
-
async saveToken(t: { id:string; hash:string; encrypted:string; purpose:'invite'|'reset'; email:string; name:string; role:string; userId:string; expiresAt:number }, j: NewEmailJob) {
|
|
150
|
+
async saveToken(t: { id:string; hash:string; encrypted:string; purpose:'invite'|'reset'; email:string; name:string; role:string; userId:string; expiresAt:number; invitedBy?:string }, j: NewEmailJob) {
|
|
151
151
|
const d=await this.getDriver();
|
|
152
152
|
await d.batch([
|
|
153
153
|
{sql:"UPDATE email_tokens SET revoked=1,encrypted='' WHERE email=? AND purpose=? AND used_by=''",args:[t.email,t.purpose]},
|
|
154
154
|
{sql:"UPDATE email_jobs SET state='cancelled',payload='',rendered='' WHERE kind='auth' AND recipient=? AND json_extract(NULLIF(payload,''),'$.purpose')=? AND state IN ('queued','retrying','failed','unknown')",args:[t.email,t.purpose]},
|
|
155
|
-
{sql:'INSERT INTO email_tokens (id,token_hash,encrypted,purpose,email,name,role,user_id,expires_at,created_at) VALUES (
|
|
155
|
+
{sql:'INSERT INTO email_tokens (id,token_hash,encrypted,purpose,email,name,role,user_id,expires_at,created_at,invited_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)',args:[t.id,t.hash,t.encrypted,t.purpose,t.email,t.name,t.role,t.userId,t.expiresAt,Date.now(),t.invitedBy??'']},
|
|
156
156
|
jobStatement(j),
|
|
157
157
|
]);
|
|
158
158
|
}
|
package/src/lib/email/types.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface EmailJob {
|
|
|
21
21
|
}
|
|
22
22
|
export interface NewEmailJob { id?: string; dedupe: string; kind: string; recipient: string; payload: string; configVersion: number; nextAt?: number; expiresAt?: number }
|
|
23
23
|
export type SendResult = { state: 'accepted'; providerId: string } | { state: 'retrying' | 'failed' | 'unknown'; error: string; retryAfter?: number };
|
|
24
|
-
export interface Invitation { id: string; email: string; name: string; role: Role; state: string; expiresAt: number; createdAt: number }
|
|
24
|
+
export interface Invitation { id: string; email: string; name: string; role: Role; state: string; expiresAt: number; createdAt: number; invitedBy: string }
|
|
25
25
|
export interface PublicEmailSettings {
|
|
26
26
|
authProvider?: 'builtin' | 'clerk';
|
|
27
27
|
version: number; config: EmailConfig; provider: Provider | 'none'; configured: boolean; error: string;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Role } from '../schema';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import { OWNER_ENV } from '../auth';
|
|
4
|
+
import { validateRequestSession } from '../auth/request';
|
|
5
|
+
import { ApiError, requireSession } from '../api';
|
|
6
|
+
import { allows, deniedMessage, resolveGrants, type Grants, type Permission } from './catalog';
|
|
7
|
+
|
|
8
|
+
// Every permission check reads the saved policy fresh (memoised per request
|
|
9
|
+
// only), so a save on Team → Roles applies to everyone's next request,
|
|
10
|
+
// including sessions and editor apps that are already signed in.
|
|
11
|
+
|
|
12
|
+
export interface Access { userId: string; role: Role; name: string; can(permission: Permission): boolean }
|
|
13
|
+
|
|
14
|
+
const requestGrants = new WeakMap<object, Promise<Grants>>();
|
|
15
|
+
/** the effective grants, read once per request (or other key object) */
|
|
16
|
+
export function grantsFor(repo: ContentRepository, key?: object): Promise<Grants> {
|
|
17
|
+
const load = () => repo.permissions.policy().then((p) => resolveGrants(p.overrides));
|
|
18
|
+
if (!key) return load();
|
|
19
|
+
let grants = requestGrants.get(key);
|
|
20
|
+
if (!grants) {
|
|
21
|
+
grants = load();
|
|
22
|
+
requestGrants.set(key, grants);
|
|
23
|
+
grants.catch(() => requestGrants.delete(key));
|
|
24
|
+
}
|
|
25
|
+
return grants;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const requestAccess = new WeakMap<Request, Promise<Access | null>>();
|
|
29
|
+
type Cookies = { get(name: string): { value: string } | undefined };
|
|
30
|
+
|
|
31
|
+
/** who is signed in on this request and what their role allows (null: signed out or removed) */
|
|
32
|
+
export function accessFor(request: Request, cookies: Cookies, repo: ContentRepository): Promise<Access | null> {
|
|
33
|
+
let access = requestAccess.get(request);
|
|
34
|
+
if (!access) {
|
|
35
|
+
access = (async () => {
|
|
36
|
+
const userId = await validateRequestSession(request, cookies, repo);
|
|
37
|
+
if (!userId) return null;
|
|
38
|
+
const user = userId === OWNER_ENV ? null : await repo.getUser(userId);
|
|
39
|
+
if (userId !== OWNER_ENV && !user) return null;
|
|
40
|
+
const role: Role = user?.role ?? 'owner';
|
|
41
|
+
const grants = await grantsFor(repo, request);
|
|
42
|
+
return { userId, role, name: user?.name ?? 'Owner', can: (p: Permission) => allows(grants, role, p) };
|
|
43
|
+
})();
|
|
44
|
+
requestAccess.set(request, access);
|
|
45
|
+
access.catch(() => requestAccess.delete(request));
|
|
46
|
+
}
|
|
47
|
+
return access;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** an API request from a signed-in person whose role grants this permission */
|
|
51
|
+
export async function requirePermission(request: Request, cookies: Cookies, repo: ContentRepository, permission: Permission): Promise<Access> {
|
|
52
|
+
await requireSession(request, cookies, repo);
|
|
53
|
+
const access = await accessFor(request, cookies, repo);
|
|
54
|
+
if (!access) throw new ApiError(401, 'Sign in to continue.');
|
|
55
|
+
if (!access.can(permission)) throw new ApiError(403, deniedMessage(permission));
|
|
56
|
+
return access;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** the same check for an editor-app principal (role read live per request) */
|
|
60
|
+
export async function principalCan(repo: ContentRepository, who: { user: { role: Role } }, permission: Permission): Promise<boolean> {
|
|
61
|
+
return allows(await grantsFor(repo, who), who.user.role, permission);
|
|
62
|
+
}
|
|
63
|
+
export async function requirePrincipal(repo: ContentRepository, who: { user: { role: Role } }, permission: Permission) {
|
|
64
|
+
if (!await principalCan(repo, who, permission)) throw new ApiError(403, deniedMessage(permission));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** admin pages: a plain 403 when this person's role lacks the permission (null: allowed) */
|
|
68
|
+
export async function forbiddenUnless(request: Request, cookies: Cookies, repo: ContentRepository, ...permissions: Permission[]): Promise<Response | null> {
|
|
69
|
+
const access = await accessFor(request, cookies, repo);
|
|
70
|
+
const missing = permissions.find((p) => !access?.can(p));
|
|
71
|
+
if (!missing) return null;
|
|
72
|
+
return new Response(deniedMessage(missing), { status: 403, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } });
|
|
73
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Role } from '../schema';
|
|
2
|
+
|
|
3
|
+
// What each role may do. The catalog and its defaults live in code; the
|
|
4
|
+
// owner's choices on Team → Roles are stored as differences from these
|
|
5
|
+
// defaults (lib/permissions/store.ts), so a framework update that changes a
|
|
6
|
+
// default never overwrites a deliberate choice, and "Restore defaults" just
|
|
7
|
+
// drops the differences.
|
|
8
|
+
//
|
|
9
|
+
// Defaults reproduce the access ACMS had before roles became editable: every
|
|
10
|
+
// signed-in staff member could do everyday work, owners did the rest. The one
|
|
11
|
+
// deliberate change is that administrators can invite people.
|
|
12
|
+
//
|
|
13
|
+
// Owners always have every permission. Locked permissions stay owner-only and
|
|
14
|
+
// can't be granted, so an owner can never lock themselves out of recovery.
|
|
15
|
+
|
|
16
|
+
/** roles whose permissions an owner can change */
|
|
17
|
+
export const EDITABLE_ROLES = ['administrator', 'editor', 'author', 'contributor'] as const;
|
|
18
|
+
export type EditableRole = typeof EDITABLE_ROLES[number];
|
|
19
|
+
|
|
20
|
+
const STAFF: readonly EditableRole[] = EDITABLE_ROLES;
|
|
21
|
+
const NONE: readonly EditableRole[] = [];
|
|
22
|
+
|
|
23
|
+
export interface PermissionDef {
|
|
24
|
+
key: string;
|
|
25
|
+
label: string;
|
|
26
|
+
description: string;
|
|
27
|
+
/** default holders (owners hold everything) */
|
|
28
|
+
defaults: readonly EditableRole[];
|
|
29
|
+
/** owner-only, never grantable */
|
|
30
|
+
locked?: boolean;
|
|
31
|
+
/** grants code, credentials or people: the page asks owners to grant with care */
|
|
32
|
+
sensitive?: boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface PermissionArea { id: string; label: string; permissions: readonly PermissionDef[] }
|
|
35
|
+
|
|
36
|
+
export const PERMISSION_AREAS = [
|
|
37
|
+
{ id: 'content', label: 'Content', permissions: [
|
|
38
|
+
{ key: 'content.edit', label: 'Create and edit content', description: 'Write posts, pages and events, stage edits to published ones, and manage tags and snippets.', defaults: STAFF },
|
|
39
|
+
{ key: 'content.publish', label: 'Publish content', description: 'Publish, schedule and unpublish, and put staged edits live.', defaults: STAFF },
|
|
40
|
+
{ key: 'content.delete', label: 'Delete content', description: 'Delete posts, pages and events.', defaults: STAFF },
|
|
41
|
+
] },
|
|
42
|
+
{ id: 'media', label: 'Media', permissions: [
|
|
43
|
+
{ key: 'media.upload', label: 'Upload media', description: 'Add images and files, and edit their descriptions.', defaults: STAFF },
|
|
44
|
+
{ key: 'media.delete', label: 'Delete media', description: 'Remove files from the media library.', defaults: STAFF },
|
|
45
|
+
] },
|
|
46
|
+
{ id: 'forms', label: 'Forms', permissions: [
|
|
47
|
+
{ key: 'forms.manage', label: 'Manage forms', description: 'Create, edit, publish and archive forms.', defaults: STAFF },
|
|
48
|
+
{ key: 'forms.responses', label: 'View form responses', description: 'Read, export and delete what people submitted. In the editor app, responses also stay limited to administrators.', defaults: STAFF },
|
|
49
|
+
] },
|
|
50
|
+
{ id: 'site', label: 'Site', permissions: [
|
|
51
|
+
{ key: 'site.links', label: 'Edit the links page', description: 'Add, change and reorder links-page blocks. In the editor app, HTML blocks stay limited to administrators.', defaults: STAFF },
|
|
52
|
+
{ key: 'site.navigation', label: 'Edit navigation', description: 'Change header, footer and social menus.', defaults: STAFF },
|
|
53
|
+
{ key: 'site.sections', label: 'Edit page sections', description: 'Change section text and pictures and publish single sections. Never layout or locked sections.', defaults: STAFF },
|
|
54
|
+
{ key: 'site.design', label: 'Theme, colours and logo', description: 'Pick the theme, colours, fonts, logo and favicon, share design previews and publish design changes.', defaults: STAFF },
|
|
55
|
+
{ key: 'site.layout', label: 'Arrange and lock sections', description: 'Add, move, restyle and lock page sections, edit locked ones, and import or export themes.', defaults: NONE },
|
|
56
|
+
{ key: 'site.settings', label: 'General settings', description: 'Site title, timezone and language, the announcement bar and redirects.', defaults: STAFF },
|
|
57
|
+
{ key: 'site.code', label: 'Code injection and analytics', description: 'Add scripts to every page and configure analytics and tracking pixels.', defaults: NONE, sensitive: true },
|
|
58
|
+
] },
|
|
59
|
+
{ id: 'scheduling', label: 'Scheduling', permissions: [
|
|
60
|
+
{ key: 'schedule.bookings', label: 'Manage bookings', description: 'See bookings, approve, reschedule, cancel and block time.', defaults: STAFF },
|
|
61
|
+
{ key: 'schedule.manage', label: 'Manage rooms and calendars', description: 'Rooms, booking types, opening hours, places and calendars. The editor app also follows Mobile app → schedule access.', defaults: STAFF },
|
|
62
|
+
] },
|
|
63
|
+
{ id: 'team', label: 'Team', permissions: [
|
|
64
|
+
{ key: 'team.invite', label: 'Invite people', description: 'Send, resend and revoke invitations for roles below their own.', defaults: ['administrator'], sensitive: true },
|
|
65
|
+
{ key: 'team.manage', label: 'Manage members', description: 'Edit, re-assign and remove people in roles below their own.', defaults: NONE, sensitive: true },
|
|
66
|
+
] },
|
|
67
|
+
{ id: 'advanced', label: 'Integrations and services', permissions: [
|
|
68
|
+
{ key: 'integrations.manage', label: 'API keys and webhooks', description: 'Create and revoke API keys, webhooks, the drafts API and design keys.', defaults: NONE, sensitive: true },
|
|
69
|
+
{ key: 'services.manage', label: 'Connected services', description: 'Email delivery, AI, live streaming, monitoring, push notifications and the mobile app.', defaults: NONE, sensitive: true },
|
|
70
|
+
{ key: 'data.import', label: 'Import and export content', description: 'Bring in content from other platforms and download exports.', defaults: NONE, sensitive: true },
|
|
71
|
+
] },
|
|
72
|
+
{ id: 'owner', label: 'Owner only', permissions: [
|
|
73
|
+
{ key: 'roles.manage', label: 'Change role permissions', description: 'Edit this page. Always owner-only.', defaults: NONE, locked: true },
|
|
74
|
+
{ key: 'data.backup', label: 'Backup and restore', description: 'Download full backups and restore the site from one. Always owner-only.', defaults: NONE, locked: true },
|
|
75
|
+
] },
|
|
76
|
+
] as const satisfies readonly PermissionArea[];
|
|
77
|
+
|
|
78
|
+
export type Permission = typeof PERMISSION_AREAS[number]['permissions'][number]['key'];
|
|
79
|
+
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);
|
|
83
|
+
|
|
84
|
+
/** grants that differ from the defaults: { editor: { 'site.design': true } } */
|
|
85
|
+
export type PolicyOverrides = Partial<Record<EditableRole, Partial<Record<Permission, boolean>>>>;
|
|
86
|
+
/** every editable role's full grant set */
|
|
87
|
+
export type Grants = Record<EditableRole, Record<Permission, boolean>>;
|
|
88
|
+
|
|
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);
|
|
91
|
+
|
|
92
|
+
/** the effective grants: defaults, then overrides (unknown keys and locked ones ignored) */
|
|
93
|
+
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);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** a full grant set back to the minimal difference from defaults */
|
|
107
|
+
export function diffFromDefaults(grants: Partial<Record<string, Partial<Record<string, unknown>>>>): PolicyOverrides {
|
|
108
|
+
const out: PolicyOverrides = {};
|
|
109
|
+
for (const role of EDITABLE_ROLES) {
|
|
110
|
+
for (const p of PERMISSIONS) {
|
|
111
|
+
if (p.locked) continue;
|
|
112
|
+
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;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
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.`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function allows(grants: Grants, role: Role, key: Permission): boolean {
|
|
125
|
+
if (role === 'owner') return true;
|
|
126
|
+
return isEditableRole(role) ? grants[role][key] === true : false;
|
|
127
|
+
}
|
|
128
|
+
|
|
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'];
|
|
132
|
+
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]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 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] });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Team → Roles: the owner's permission choices (stored as differences from
|
|
2
|
+
// the code defaults) with a revision for conflict-safe saves, an audit trail,
|
|
3
|
+
// and who sent each invitation (admins can invite now, so it matters).
|
|
4
|
+
// Backups don't carry this table: restoring old content never changes who
|
|
5
|
+
// can do what.
|
|
6
|
+
export const ROLES_MIGRATION = {
|
|
7
|
+
version: 51,
|
|
8
|
+
statements: [
|
|
9
|
+
`CREATE TABLE role_policy (id INTEGER PRIMARY KEY CHECK (id = 1), overrides TEXT NOT NULL DEFAULT '{}', revision INTEGER NOT NULL DEFAULT 0,
|
|
10
|
+
updated_by TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL DEFAULT 0)`,
|
|
11
|
+
`INSERT INTO role_policy (id) VALUES (1)`,
|
|
12
|
+
`CREATE TABLE role_policy_log (id TEXT PRIMARY KEY, revision INTEGER NOT NULL, actor TEXT NOT NULL, changes TEXT NOT NULL, created_at INTEGER NOT NULL)`,
|
|
13
|
+
`CREATE INDEX role_policy_log_created ON role_policy_log(created_at)`,
|
|
14
|
+
`ALTER TABLE auth_invitations ADD COLUMN invited_by TEXT NOT NULL DEFAULT ''`,
|
|
15
|
+
`ALTER TABLE email_tokens ADD COLUMN invited_by TEXT NOT NULL DEFAULT ''`,
|
|
16
|
+
],
|
|
17
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import { OWNER_ENV } from '../auth';
|
|
4
|
+
import { ApiError } from '../api';
|
|
5
|
+
import { roleLabels, roleSummaries } from '../roles';
|
|
6
|
+
import type { Role } from '../schema';
|
|
7
|
+
import {
|
|
8
|
+
PERMISSION_AREAS, ROLES_BY_RANK, describeChanges, diffFromDefaults, isEditableRole, isPermission, resolveGrants,
|
|
9
|
+
type Grants,
|
|
10
|
+
} from './catalog';
|
|
11
|
+
import type { Access } from './access';
|
|
12
|
+
|
|
13
|
+
// Team → Roles: the page's state and its one write. The page edits a local
|
|
14
|
+
// draft; nothing changes for anyone until Save sends the whole grant set,
|
|
15
|
+
// which replaces the policy atomically (or fails on a newer revision).
|
|
16
|
+
|
|
17
|
+
export const RolesSave = z.object({
|
|
18
|
+
revision: z.number().int().nonnegative(),
|
|
19
|
+
grants: z.record(z.record(z.boolean())),
|
|
20
|
+
}).strict();
|
|
21
|
+
|
|
22
|
+
const actorName = (id: string, names: Map<string, string>) => (id === OWNER_ENV ? 'Owner' : names.get(id) ?? 'Removed user');
|
|
23
|
+
|
|
24
|
+
export async function rolesState(repo: ContentRepository, access: Access) {
|
|
25
|
+
const [policy, users, history] = await Promise.all([repo.permissions.policy(), repo.listUsers(), repo.permissions.history(20)]);
|
|
26
|
+
const names = new Map(users.map((u) => [u.id, u.name]));
|
|
27
|
+
const members = (role: Role) => users.filter((u) => u.role === role).length;
|
|
28
|
+
return {
|
|
29
|
+
revision: policy.revision,
|
|
30
|
+
canEdit: access.role === 'owner',
|
|
31
|
+
updatedAt: policy.updatedAt || null,
|
|
32
|
+
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) => ({
|
|
35
|
+
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 })),
|
|
37
|
+
})),
|
|
38
|
+
grants: resolveGrants(policy.overrides),
|
|
39
|
+
defaults: resolveGrants({}),
|
|
40
|
+
history: history.map((h) => ({ id: h.id, at: h.createdAt, by: actorName(h.actor, names), changes: h.changes })),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export type RolesState = Awaited<ReturnType<typeof rolesState>>;
|
|
44
|
+
|
|
45
|
+
/** replace the whole policy; unknown roles or permissions are a client bug, not silently dropped */
|
|
46
|
+
export async function saveRoles(repo: ContentRepository, access: Access, input: z.infer<typeof RolesSave>) {
|
|
47
|
+
if (access.role !== 'owner') throw new ApiError(403, 'Only owners can change role permissions.');
|
|
48
|
+
for (const [role, grants] of Object.entries(input.grants)) {
|
|
49
|
+
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}”.`);
|
|
51
|
+
}
|
|
52
|
+
const current = await repo.permissions.policy();
|
|
53
|
+
if (current.revision !== input.revision) throw new ApiError(409, 'Permissions were changed by someone else since you opened this page.');
|
|
54
|
+
const overrides = diffFromDefaults(input.grants);
|
|
55
|
+
const changes = describeChanges(resolveGrants(current.overrides), resolveGrants(overrides) as Grants);
|
|
56
|
+
if (!changes.length) return current.revision;
|
|
57
|
+
return repo.permissions.save(overrides, input.revision, access.userId, changes);
|
|
58
|
+
}
|
|
59
|
+
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { SqlDriver } from '../repo/sql';
|
|
2
|
+
import { ApiError } from '../api';
|
|
3
|
+
import { diffFromDefaults, type PolicyOverrides } from './catalog';
|
|
4
|
+
|
|
5
|
+
export interface RolePolicy { overrides: PolicyOverrides; revision: number; updatedBy: string; updatedAt: number }
|
|
6
|
+
export interface RolePolicyChange { id: string; revision: number; actor: string; changes: Array<{ role: string; permission: string; granted: boolean }>; createdAt: number }
|
|
7
|
+
|
|
8
|
+
function parseOverrides(raw: unknown): PolicyOverrides {
|
|
9
|
+
// a corrupt row falls back to the defaults rather than locking everyone out
|
|
10
|
+
try {
|
|
11
|
+
const value = JSON.parse(String(raw ?? '{}'));
|
|
12
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? diffFromDefaults(value) : {};
|
|
13
|
+
} catch { return {}; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class PermissionStore {
|
|
17
|
+
constructor(private driver: () => Promise<SqlDriver>) {}
|
|
18
|
+
|
|
19
|
+
async policy(): Promise<RolePolicy> {
|
|
20
|
+
const r = (await (await this.driver()).all('SELECT overrides, revision, updated_by, updated_at FROM role_policy WHERE id = 1'))[0];
|
|
21
|
+
if (!r) return { overrides: {}, revision: 0, updatedBy: '', updatedAt: 0 };
|
|
22
|
+
return { overrides: parseOverrides(r.overrides), revision: Number(r.revision), updatedBy: String(r.updated_by), updatedAt: Number(r.updated_at) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Replace the whole policy in one batch. A save made against an older
|
|
27
|
+
* revision fails the revision_checks CHECK, so the batch (policy + audit
|
|
28
|
+
* row) rolls back and nothing changes.
|
|
29
|
+
*/
|
|
30
|
+
async save(overrides: PolicyOverrides, expectedRevision: number, actor: string, changes: RolePolicyChange['changes']): Promise<number> {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
try {
|
|
33
|
+
await (await this.driver()).batch([
|
|
34
|
+
{ sql: 'INSERT INTO revision_checks (expected, actual) SELECT ?, revision FROM role_policy WHERE id = 1', args: [expectedRevision] },
|
|
35
|
+
{ sql: 'DELETE FROM revision_checks' },
|
|
36
|
+
{ sql: 'UPDATE role_policy SET overrides = ?, revision = revision + 1, updated_by = ?, updated_at = ? WHERE id = 1', args: [JSON.stringify(diffFromDefaults(overrides)), actor, now] },
|
|
37
|
+
{ sql: 'INSERT INTO role_policy_log (id, revision, actor, changes, created_at) VALUES (?, ?, ?, ?, ?)', args: [crypto.randomUUID(), expectedRevision + 1, actor, JSON.stringify(changes), now] },
|
|
38
|
+
]);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
if (/CHECK constraint/i.test(String(e))) throw new ApiError(409, 'Permissions were changed by someone else since you opened this page.');
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
43
|
+
return expectedRevision + 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async history(limit = 20): Promise<RolePolicyChange[]> {
|
|
47
|
+
const rows = await (await this.driver()).all('SELECT * FROM role_policy_log ORDER BY created_at DESC LIMIT ?', [limit]);
|
|
48
|
+
return rows.map((r) => {
|
|
49
|
+
let changes: RolePolicyChange['changes'] = [];
|
|
50
|
+
try { const parsed = JSON.parse(String(r.changes)); if (Array.isArray(parsed)) changes = parsed; } catch { /* keep the entry, drop its details */ }
|
|
51
|
+
return { id: String(r.id), revision: Number(r.revision), actor: String(r.actor), changes, createdAt: Number(r.created_at) };
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -6,6 +6,7 @@ import { PUSH_MIGRATION } from '../push/migration';
|
|
|
6
6
|
import { EDITOR_MIGRATION, EDITOR_GRACE_MIGRATION } from '../editor/migration';
|
|
7
7
|
import { CALENDAR_SETTINGS_MIGRATION, ROOM_HOLD_MIGRATION } from '../calendar/migration';
|
|
8
8
|
import { PLACES_MIGRATION } from '../maps/migration';
|
|
9
|
+
import { ROLES_MIGRATION } from '../permissions/migration';
|
|
9
10
|
// Versioned schema migrations shared by the two SQLite-dialect adapters
|
|
10
11
|
// (D1 and libSQL). Applied versions are recorded in the `migrations` table;
|
|
11
12
|
// each adapter's runner applies whatever is newer, in order, at startup.
|
|
@@ -630,6 +631,7 @@ export const MIGRATIONS: Migration[] = [
|
|
|
630
631
|
last_key TEXT NOT NULL DEFAULT '', last_error TEXT NOT NULL DEFAULT '', failed_at INTEGER NOT NULL DEFAULT 0)`,
|
|
631
632
|
`INSERT INTO backup_state (id) VALUES (1)`,
|
|
632
633
|
] },
|
|
634
|
+
ROLES_MIGRATION,
|
|
633
635
|
];
|
|
634
636
|
|
|
635
637
|
export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|