nixamp 0.15.0 → 0.16.0

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.
@@ -0,0 +1,447 @@
1
+ import { tokenFrom } from "./accounts.js";
2
+ import { INVITATION_STATES, LIVE_EVENT_STATUSES, LiveEventError, } from "./live-events.js";
3
+ import { LAYOUT_SCOPES, LayoutError, PANEL_REGISTRY, presetLayout, } from "./layouts.js";
4
+ import { HAND_RAISE_STATES, RoomError } from "./rooms.js";
5
+ const CORS = {
6
+ "access-control-allow-origin": "*",
7
+ "access-control-allow-methods": "GET, POST, PATCH, DELETE, OPTIONS",
8
+ "access-control-allow-headers": "content-type, authorization, if-match",
9
+ };
10
+ function json(response, code, body) {
11
+ const value = JSON.stringify(body);
12
+ response.writeHead(code, {
13
+ ...CORS,
14
+ "content-type": "application/json; charset=utf-8",
15
+ "content-length": Buffer.byteLength(value),
16
+ "cache-control": "no-store",
17
+ });
18
+ response.end(value);
19
+ }
20
+ async function body(request) {
21
+ const chunks = [];
22
+ let size = 0;
23
+ for await (const chunk of request) {
24
+ const buffer = chunk;
25
+ size += buffer.length;
26
+ if (size > 64 * 1024)
27
+ throw new LiveEventError("body too large", 413);
28
+ chunks.push(buffer);
29
+ }
30
+ try {
31
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
32
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
33
+ throw new Error("object required");
34
+ return parsed;
35
+ }
36
+ catch (error) {
37
+ if (error instanceof LiveEventError)
38
+ throw error;
39
+ throw new LiveEventError("bad JSON", 400);
40
+ }
41
+ }
42
+ function reference(value) {
43
+ try {
44
+ return decodeURIComponent(value);
45
+ }
46
+ catch {
47
+ throw new LiveEventError("bad event reference", 400);
48
+ }
49
+ }
50
+ function requestedVersion(request, input) {
51
+ const header = request.headers["if-match"];
52
+ const raw = Array.isArray(header) ? header[0] : header;
53
+ const fromHeader = raw ? Number(raw.replace(/^W\//, "").replace(/^"|"$/g, "")) : NaN;
54
+ return Number.isInteger(fromHeader) && fromHeader > 0 ? fromHeader : Number(input["version"]);
55
+ }
56
+ function accountName(account) {
57
+ return account.email.split("@")[0]?.replace(/[._-]+/g, " ").trim() || "Someone";
58
+ }
59
+ function permissionsFor(event, account) {
60
+ if (account?.id === event.ownerId || event.moderatorIds.includes(account?.id ?? "")) {
61
+ return [
62
+ "event.update",
63
+ "event.start",
64
+ "event.end",
65
+ "event.invite",
66
+ "event.moderate",
67
+ "room.listen",
68
+ "room.chat",
69
+ "room.raise_hand",
70
+ "room.speak",
71
+ "recording.start",
72
+ "recording.stop",
73
+ "recording.publish",
74
+ "layout.read",
75
+ "layout.update",
76
+ "panel.add",
77
+ "panel.update",
78
+ "panel.remove",
79
+ ];
80
+ }
81
+ return account
82
+ ? ["room.listen", "room.chat", "room.raise_hand", "layout.read"]
83
+ : ["room.listen", "layout.read"];
84
+ }
85
+ function layoutFor(event, account) {
86
+ const name = event.ownerId === account?.id || event.moderatorIds.includes(account?.id ?? "")
87
+ ? "backtoschool-host"
88
+ : account
89
+ ? "backtoschool-member"
90
+ : "backtoschool-viewer";
91
+ return presetLayout(event.layoutId ?? name) ?? presetLayout(name);
92
+ }
93
+ function view(event, account) {
94
+ return { event, permissions: permissionsFor(event, account), layout: layoutFor(event, account) };
95
+ }
96
+ async function signedIn(request, accounts) {
97
+ return accounts?.whoIs(tokenFrom(request.headers)) ?? null;
98
+ }
99
+ async function requiredAccount(request, response, options) {
100
+ const account = await signedIn(request, options.accounts);
101
+ if (!account)
102
+ json(response, 401, { error: "sign in first" });
103
+ return account;
104
+ }
105
+ async function eventAccess(request, response, url, options, event) {
106
+ if (!event) {
107
+ json(response, 404, { error: "event not found" });
108
+ return false;
109
+ }
110
+ const account = await signedIn(request, options.accounts);
111
+ const invited = await options.events.invitationAllows(event.id, url.searchParams.get("invite") ?? "");
112
+ if (!(await options.events.canAccess(event, account?.id)) && !invited) {
113
+ json(response, 404, { error: "event not found" });
114
+ return false;
115
+ }
116
+ return account;
117
+ }
118
+ async function manage(request, response, options, event) {
119
+ const account = await requiredAccount(request, response, options);
120
+ if (!account)
121
+ return null;
122
+ if (!options.events.canManage(event, account.id)) {
123
+ json(response, 403, { error: "only hosts and moderators can do that" });
124
+ return null;
125
+ }
126
+ return account;
127
+ }
128
+ export async function handleLiveApi(request, response, url, options) {
129
+ const path = url.pathname;
130
+ if (!path.startsWith("/api/v1/events") &&
131
+ !path.startsWith("/api/v1/invitations/") &&
132
+ !path.startsWith("/api/v1/layouts") &&
133
+ !path.startsWith("/api/v1/layout-presets"))
134
+ return false;
135
+ try {
136
+ if (path === "/api/v1/events") {
137
+ if (request.method === "GET") {
138
+ const account = await signedIn(request, options.accounts);
139
+ const statusValue = url.searchParams.get("status") ?? undefined;
140
+ if (statusValue && !LIVE_EVENT_STATUSES.includes(statusValue)) {
141
+ throw new LiveEventError(`status must be one of ${LIVE_EVENT_STATUSES.join(", ")}`, 422);
142
+ }
143
+ const mine = url.searchParams.get("mine") === "true";
144
+ if (mine && !account) {
145
+ json(response, 401, { error: "sign in to see your events" });
146
+ return true;
147
+ }
148
+ const requestedLimit = Number(url.searchParams.get("limit") ?? 30);
149
+ if (!Number.isInteger(requestedLimit) || requestedLimit < 1) {
150
+ throw new LiveEventError("limit must be a positive integer", 422);
151
+ }
152
+ const events = await options.events.list({
153
+ ...(mine && account ? { ownerId: account.id } : {}),
154
+ ...(statusValue ? { status: statusValue } : {}),
155
+ ...(url.searchParams.get("topic") ? { topic: url.searchParams.get("topic") } : {}),
156
+ ...(url.searchParams.get("from") ? { from: url.searchParams.get("from") } : {}),
157
+ limit: requestedLimit,
158
+ });
159
+ json(response, 200, { events });
160
+ return true;
161
+ }
162
+ if (request.method === "POST") {
163
+ const account = await requiredAccount(request, response, options);
164
+ if (!account)
165
+ return true;
166
+ const input = await body(request);
167
+ const event = await options.events.create(account.id, { ...input, title: input["title"] });
168
+ json(response, 201, view(event, account));
169
+ return true;
170
+ }
171
+ json(response, 405, { error: "GET or POST" });
172
+ return true;
173
+ }
174
+ const invitationMatch = /^\/api\/v1\/invitations\/([^/]+)\/(accept|decline)$/.exec(path);
175
+ if (invitationMatch) {
176
+ if (request.method !== "POST") {
177
+ json(response, 405, { error: "POST only" });
178
+ return true;
179
+ }
180
+ const account = await requiredAccount(request, response, options);
181
+ if (!account)
182
+ return true;
183
+ const invitation = await options.events.respond(reference(invitationMatch[1]), account.id, invitationMatch[2] === "accept" ? "accepted" : "declined");
184
+ json(response, 200, { invitation });
185
+ return true;
186
+ }
187
+ const presetMatch = /^\/api\/v1\/layout-presets\/([^/]+)$/.exec(path);
188
+ if (presetMatch) {
189
+ if (request.method !== "GET") {
190
+ json(response, 405, { error: "GET only" });
191
+ return true;
192
+ }
193
+ const layout = presetLayout(reference(presetMatch[1]));
194
+ json(response, layout ? 200 : 404, layout ? { layout, registry: PANEL_REGISTRY } : { error: "layout preset not found" });
195
+ return true;
196
+ }
197
+ if (path === "/api/v1/layouts" && options.layouts) {
198
+ if (request.method !== "POST") {
199
+ json(response, 405, { error: "POST only" });
200
+ return true;
201
+ }
202
+ const account = await requiredAccount(request, response, options);
203
+ if (!account)
204
+ return true;
205
+ const input = await body(request);
206
+ const scope = String(input["scope"] ?? "");
207
+ if (!LAYOUT_SCOPES.includes(scope) || !["user", "event"].includes(scope)) {
208
+ throw new LayoutError("regular users may create user or event layouts", 403);
209
+ }
210
+ if (scope === "event") {
211
+ const event = await options.events.get(String(input["scopeId"] ?? ""));
212
+ if (!event || !options.events.canManage(event, account.id)) {
213
+ throw new LayoutError("only an event host can create its layout", 403);
214
+ }
215
+ }
216
+ json(response, 201, {
217
+ layout: await options.layouts.create({
218
+ ...input,
219
+ name: input["name"],
220
+ scope: input["scope"],
221
+ }, account.id),
222
+ });
223
+ return true;
224
+ }
225
+ const layoutMatch = /^\/api\/v1\/layouts\/([^/]+)(?:\/(panels)(?:\/([^/]+))?|\/(reset))?$/.exec(path);
226
+ if (layoutMatch && options.layouts) {
227
+ const layoutId = reference(layoutMatch[1]);
228
+ if (request.method === "GET" && !layoutMatch[2] && !layoutMatch[4]) {
229
+ const layout = await options.layouts.get(layoutId);
230
+ if (!layout) {
231
+ json(response, 404, { error: "layout not found" });
232
+ return true;
233
+ }
234
+ const account = await signedIn(request, options.accounts);
235
+ if (layout.ownerId && layout.ownerId !== account?.id) {
236
+ json(response, 404, { error: "layout not found" });
237
+ return true;
238
+ }
239
+ json(response, 200, { layout, registry: PANEL_REGISTRY });
240
+ return true;
241
+ }
242
+ const account = await requiredAccount(request, response, options);
243
+ if (!account)
244
+ return true;
245
+ const input = request.method === "DELETE" ? {} : await body(request);
246
+ const version = requestedVersion(request, input);
247
+ let layout;
248
+ if (layoutMatch[4] && request.method === "POST") {
249
+ layout = await options.layouts.reset(layoutId, String(input["preset"] ?? ""), version, account.id);
250
+ }
251
+ else if (layoutMatch[2] && !layoutMatch[3] && request.method === "POST") {
252
+ layout = await options.layouts.addPanel(layoutId, input["panel"] ?? input, version, account.id);
253
+ }
254
+ else if (layoutMatch[2] && layoutMatch[3] && request.method === "PATCH") {
255
+ layout = await options.layouts.updatePanel(layoutId, reference(layoutMatch[3]), input["panel"] ?? input, version, account.id);
256
+ }
257
+ else if (layoutMatch[2] && layoutMatch[3] && request.method === "DELETE") {
258
+ layout = await options.layouts.removePanel(layoutId, reference(layoutMatch[3]), version, account.id);
259
+ }
260
+ else if (!layoutMatch[2] && !layoutMatch[4] && request.method === "PATCH") {
261
+ layout = await options.layouts.update(layoutId, input["panels"], version, account.id);
262
+ }
263
+ else {
264
+ json(response, 405, { error: "method not allowed" });
265
+ return true;
266
+ }
267
+ json(response, 200, { layout });
268
+ return true;
269
+ }
270
+ const eventMatch = /^\/api\/v1\/events\/([^/]+)(?:\/(invitations|chat|hand-raises)(?:\/([^/]+))?|\/(start|end|cancel|archive))?$/.exec(path);
271
+ if (!eventMatch) {
272
+ json(response, 404, { error: "no such endpoint" });
273
+ return true;
274
+ }
275
+ const eventRef = reference(eventMatch[1]);
276
+ const event = await options.events.get(eventRef);
277
+ const collection = eventMatch[2];
278
+ const child = eventMatch[3] ? reference(eventMatch[3]) : undefined;
279
+ const action = eventMatch[4];
280
+ if (!collection && !action) {
281
+ if (request.method === "GET") {
282
+ const account = await eventAccess(request, response, url, options, event);
283
+ if (account === false || !event)
284
+ return true;
285
+ json(response, 200, view(event, account));
286
+ return true;
287
+ }
288
+ const account = await requiredAccount(request, response, options);
289
+ if (!account)
290
+ return true;
291
+ if (request.method === "PATCH") {
292
+ const input = await body(request);
293
+ const updated = await options.events.update(eventRef, account.id, {
294
+ ...input,
295
+ version: requestedVersion(request, input),
296
+ });
297
+ json(response, 200, view(updated, account));
298
+ return true;
299
+ }
300
+ if (request.method === "DELETE") {
301
+ const removed = await options.events.remove(eventRef, account.id);
302
+ json(response, removed ? 200 : 404, removed ? { ok: true } : { error: "event not found" });
303
+ return true;
304
+ }
305
+ json(response, 405, { error: "GET, PATCH or DELETE" });
306
+ return true;
307
+ }
308
+ if (!event) {
309
+ json(response, 404, { error: "event not found" });
310
+ return true;
311
+ }
312
+ if (action) {
313
+ if (request.method !== "POST") {
314
+ json(response, 405, { error: "POST only" });
315
+ return true;
316
+ }
317
+ const account = await manage(request, response, options, event);
318
+ if (!account)
319
+ return true;
320
+ const input = await body(request);
321
+ const target = {
322
+ start: "live",
323
+ end: "ended",
324
+ cancel: "cancelled",
325
+ archive: "archived",
326
+ };
327
+ const updated = await options.events.transition(event.id, event.ownerId, target[action], requestedVersion(request, input));
328
+ json(response, 200, view(updated, account));
329
+ return true;
330
+ }
331
+ if (collection === "invitations") {
332
+ const account = await manage(request, response, options, event);
333
+ if (!account)
334
+ return true;
335
+ if (request.method === "GET" && !child) {
336
+ json(response, 200, { invitations: await options.events.invitations(event.id, account.id) });
337
+ return true;
338
+ }
339
+ if (request.method === "POST" && !child) {
340
+ const input = await body(request);
341
+ const invitation = await options.events.invite(event.id, account.id, input);
342
+ const inviteUrl = `${(options.site ?? "").replace(/\/$/, "")}/live/${event.slug}?invite=${encodeURIComponent(invitation.token)}`;
343
+ let sent = false;
344
+ if (invitation.email && options.email) {
345
+ sent = await options.email(invitation.email, {
346
+ title: `You are invited to ${event.title}`,
347
+ body: `You are invited as a ${invitation.role} to ${event.title}.`,
348
+ url: inviteUrl,
349
+ });
350
+ }
351
+ json(response, 201, { invitation, inviteUrl, sent });
352
+ return true;
353
+ }
354
+ if (request.method === "DELETE" && child) {
355
+ const revoked = await options.events.revoke(event.id, child, account.id);
356
+ json(response, revoked ? 200 : 404, revoked ? { ok: true } : { error: "invitation not found" });
357
+ return true;
358
+ }
359
+ json(response, 405, { error: "GET, POST or DELETE" });
360
+ return true;
361
+ }
362
+ if (collection === "chat" && options.rooms) {
363
+ const account = await eventAccess(request, response, url, options, event);
364
+ if (account === false)
365
+ return true;
366
+ if (request.method === "GET" && !child) {
367
+ json(response, 200, { messages: await options.rooms.chat(event.id, url.searchParams.get("after") ?? undefined) });
368
+ return true;
369
+ }
370
+ if (request.method === "POST" && !child) {
371
+ if (!event.chatEnabled)
372
+ throw new RoomError("chat is off for this event", 409);
373
+ if (!account) {
374
+ json(response, 401, { error: "sign in to chat" });
375
+ return true;
376
+ }
377
+ const input = await body(request);
378
+ json(response, 201, { message: await options.rooms.post(event.id, account.id, accountName(account), input["body"]) });
379
+ return true;
380
+ }
381
+ if (request.method === "DELETE" && child) {
382
+ const moderator = await manage(request, response, options, event);
383
+ if (!moderator)
384
+ return true;
385
+ const removed = await options.rooms.removeMessage(event.id, child);
386
+ json(response, removed ? 200 : 404, removed ? { ok: true } : { error: "message not found" });
387
+ return true;
388
+ }
389
+ json(response, 405, { error: "GET, POST or DELETE" });
390
+ return true;
391
+ }
392
+ if (collection === "hand-raises" && options.rooms) {
393
+ if (request.method === "POST" && !child) {
394
+ if (!event.handRaiseEnabled)
395
+ throw new RoomError("hand raising is off for this event", 409);
396
+ const account = await eventAccess(request, response, url, options, event);
397
+ if (account === false)
398
+ return true;
399
+ if (!account) {
400
+ json(response, 401, { error: "sign in to raise your hand" });
401
+ return true;
402
+ }
403
+ json(response, 200, { handRaise: await options.rooms.raiseHand(event.id, account.id, accountName(account)) });
404
+ return true;
405
+ }
406
+ const account = await manage(request, response, options, event);
407
+ if (!account)
408
+ return true;
409
+ if (request.method === "GET" && !child) {
410
+ json(response, 200, { handRaises: await options.rooms.handRaises(event.id) });
411
+ return true;
412
+ }
413
+ if (request.method === "PATCH" && child) {
414
+ const input = await body(request);
415
+ const state = input["state"];
416
+ if (typeof state !== "string" || !HAND_RAISE_STATES.includes(state)) {
417
+ throw new RoomError(`state must be one of ${HAND_RAISE_STATES.join(", ")}`, 422);
418
+ }
419
+ json(response, 200, { handRaise: await options.rooms.setHandRaise(event.id, child, state) });
420
+ return true;
421
+ }
422
+ json(response, 405, { error: "GET, POST or PATCH" });
423
+ return true;
424
+ }
425
+ json(response, 404, { error: "no such endpoint" });
426
+ return true;
427
+ }
428
+ catch (error) {
429
+ if (error instanceof LiveEventError || error instanceof LayoutError || error instanceof RoomError) {
430
+ json(response, error.status, { error: error.message });
431
+ return true;
432
+ }
433
+ json(response, 500, { error: "the live event service failed" });
434
+ return true;
435
+ }
436
+ }
437
+ export function liveApiPath(path) {
438
+ return path.startsWith("/api/v1/events") ||
439
+ path.startsWith("/api/v1/invitations/") ||
440
+ path.startsWith("/api/v1/layouts") ||
441
+ path.startsWith("/api/v1/layout-presets");
442
+ }
443
+ export function invitationState(value) {
444
+ return typeof value === "string" && INVITATION_STATES.includes(value)
445
+ ? value
446
+ : null;
447
+ }
@@ -0,0 +1,107 @@
1
+ import type { Queryable } from "./follows.ts";
2
+ export declare const LIVE_EVENT_STATUSES: readonly ["draft", "scheduled", "starting", "live", "ended", "cancelled", "archived"];
3
+ export declare const LIVE_EVENT_VISIBILITIES: readonly ["public", "unlisted", "private"];
4
+ export declare const INVITATION_ROLES: readonly ["listener", "speaker", "moderator"];
5
+ export declare const INVITATION_STATES: readonly ["pending", "accepted", "declined", "revoked", "expired"];
6
+ export type LiveEventStatus = (typeof LIVE_EVENT_STATUSES)[number];
7
+ export type LiveEventVisibility = (typeof LIVE_EVENT_VISIBILITIES)[number];
8
+ export type InvitationRole = (typeof INVITATION_ROLES)[number];
9
+ export type InvitationState = (typeof INVITATION_STATES)[number];
10
+ export interface LiveEvent {
11
+ id: string;
12
+ slug: string;
13
+ ownerId: string;
14
+ title: string;
15
+ description?: string;
16
+ topic?: string;
17
+ startsAt?: string;
18
+ endsAt?: string;
19
+ timezone: string;
20
+ expectedDurationMinutes?: number;
21
+ status: LiveEventStatus;
22
+ visibility: LiveEventVisibility;
23
+ roomId: string;
24
+ inviteeIds: string[];
25
+ speakerIds: string[];
26
+ moderatorIds: string[];
27
+ chatEnabled: boolean;
28
+ handRaiseEnabled: boolean;
29
+ recordingEnabled: boolean;
30
+ recordingId?: string;
31
+ layoutId?: string;
32
+ version: number;
33
+ createdAt: string;
34
+ updatedAt: string;
35
+ }
36
+ export interface EventInvitation {
37
+ id: string;
38
+ eventId: string;
39
+ inviterId: string;
40
+ inviteeId?: string;
41
+ email?: string;
42
+ role: InvitationRole;
43
+ state: InvitationState;
44
+ expiresAt?: string;
45
+ createdAt: string;
46
+ updatedAt: string;
47
+ }
48
+ export interface CreatedInvitation extends EventInvitation {
49
+ token: string;
50
+ }
51
+ export interface CreateLiveEventInput {
52
+ title: unknown;
53
+ description?: unknown;
54
+ topic?: unknown;
55
+ startsAt?: unknown;
56
+ endsAt?: unknown;
57
+ timezone?: unknown;
58
+ expectedDurationMinutes?: unknown;
59
+ visibility?: unknown;
60
+ chatEnabled?: unknown;
61
+ handRaiseEnabled?: unknown;
62
+ recordingEnabled?: unknown;
63
+ layoutId?: unknown;
64
+ }
65
+ export interface UpdateLiveEventInput extends Partial<CreateLiveEventInput> {
66
+ version: unknown;
67
+ status?: unknown;
68
+ recordingId?: unknown;
69
+ }
70
+ export interface EventListQuery {
71
+ ownerId?: string;
72
+ status?: LiveEventStatus;
73
+ topic?: string;
74
+ from?: string;
75
+ limit?: number;
76
+ }
77
+ export declare class LiveEventError extends Error {
78
+ readonly status: number;
79
+ constructor(message: string, status: number);
80
+ }
81
+ export declare function eventSlug(value: unknown): string;
82
+ export declare function canTransition(from: LiveEventStatus, to: LiveEventStatus): boolean;
83
+ export declare class LiveEvents {
84
+ private readonly db;
85
+ private ready;
86
+ constructor(db: Queryable);
87
+ private ensure;
88
+ create(ownerId: string, input: CreateLiveEventInput): Promise<LiveEvent>;
89
+ list(query?: EventListQuery): Promise<LiveEvent[]>;
90
+ get(reference: string): Promise<LiveEvent | null>;
91
+ byRoom(roomId: string): Promise<LiveEvent | null>;
92
+ canAccess(event: LiveEvent, accountId: string | undefined): Promise<boolean>;
93
+ invitationAllows(eventId: string, token: string): Promise<boolean>;
94
+ canManage(event: LiveEvent, accountId: string | undefined): boolean;
95
+ update(reference: string, ownerId: string, input: UpdateLiveEventInput): Promise<LiveEvent>;
96
+ transition(reference: string, ownerId: string, status: LiveEventStatus, version: number): Promise<LiveEvent>;
97
+ remove(reference: string, ownerId: string): Promise<boolean>;
98
+ invite(eventId: string, inviterId: string, input: {
99
+ inviteeId?: unknown;
100
+ email?: unknown;
101
+ role?: unknown;
102
+ expiresAt?: unknown;
103
+ }): Promise<CreatedInvitation>;
104
+ invitations(eventId: string, accountId: string): Promise<EventInvitation[]>;
105
+ respond(token: string, accountId: string, state: "accepted" | "declined"): Promise<EventInvitation>;
106
+ revoke(eventId: string, invitationId: string, accountId: string): Promise<boolean>;
107
+ }