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,520 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ export const LIVE_EVENT_STATUSES = [
3
+ "draft",
4
+ "scheduled",
5
+ "starting",
6
+ "live",
7
+ "ended",
8
+ "cancelled",
9
+ "archived",
10
+ ];
11
+ export const LIVE_EVENT_VISIBILITIES = ["public", "unlisted", "private"];
12
+ export const INVITATION_ROLES = ["listener", "speaker", "moderator"];
13
+ export const INVITATION_STATES = ["pending", "accepted", "declined", "revoked", "expired"];
14
+ export class LiveEventError extends Error {
15
+ status;
16
+ constructor(message, status) {
17
+ super(message);
18
+ this.status = status;
19
+ }
20
+ }
21
+ const EVENT_SCHEMA = `
22
+ CREATE TABLE IF NOT EXISTS live_events (
23
+ id TEXT PRIMARY KEY,
24
+ slug TEXT NOT NULL UNIQUE,
25
+ owner_id TEXT NOT NULL,
26
+ title TEXT NOT NULL,
27
+ description TEXT NOT NULL DEFAULT '',
28
+ topic TEXT NOT NULL DEFAULT '',
29
+ starts_at TIMESTAMPTZ,
30
+ ends_at TIMESTAMPTZ,
31
+ timezone TEXT NOT NULL DEFAULT 'UTC',
32
+ expected_duration_minutes INTEGER,
33
+ status TEXT NOT NULL,
34
+ visibility TEXT NOT NULL,
35
+ room_id TEXT NOT NULL UNIQUE,
36
+ chat_enabled BOOLEAN NOT NULL DEFAULT TRUE,
37
+ hand_raise_enabled BOOLEAN NOT NULL DEFAULT TRUE,
38
+ recording_enabled BOOLEAN NOT NULL DEFAULT FALSE,
39
+ recording_id TEXT,
40
+ layout_id TEXT,
41
+ version INTEGER NOT NULL DEFAULT 1,
42
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
43
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
44
+ CHECK (status IN ('draft', 'scheduled', 'starting', 'live', 'ended', 'cancelled', 'archived')),
45
+ CHECK (visibility IN ('public', 'unlisted', 'private')),
46
+ CHECK (expected_duration_minutes IS NULL OR expected_duration_minutes BETWEEN 1 AND 1440),
47
+ CHECK (ends_at IS NULL OR starts_at IS NULL OR ends_at > starts_at)
48
+ );
49
+ CREATE INDEX IF NOT EXISTS live_events_discovery
50
+ ON live_events (visibility, status, starts_at, updated_at DESC);
51
+ CREATE INDEX IF NOT EXISTS live_events_owner
52
+ ON live_events (owner_id, updated_at DESC);
53
+
54
+ CREATE TABLE IF NOT EXISTS live_event_invitations (
55
+ id TEXT PRIMARY KEY,
56
+ event_id TEXT NOT NULL REFERENCES live_events(id) ON DELETE CASCADE,
57
+ inviter_id TEXT NOT NULL,
58
+ invitee_id TEXT,
59
+ email TEXT,
60
+ role TEXT NOT NULL,
61
+ state TEXT NOT NULL DEFAULT 'pending',
62
+ token_hash TEXT NOT NULL UNIQUE,
63
+ expires_at TIMESTAMPTZ,
64
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
65
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
66
+ CHECK (role IN ('listener', 'speaker', 'moderator')),
67
+ CHECK (state IN ('pending', 'accepted', 'declined', 'revoked', 'expired')),
68
+ CHECK (invitee_id IS NOT NULL OR email IS NOT NULL)
69
+ );
70
+ CREATE INDEX IF NOT EXISTS live_event_invitations_event
71
+ ON live_event_invitations (event_id, state, created_at);
72
+ CREATE INDEX IF NOT EXISTS live_event_invitations_invitee
73
+ ON live_event_invitations (invitee_id, state);
74
+ `;
75
+ const SELECT_EVENT = `
76
+ SELECT e.*,
77
+ COALESCE((
78
+ SELECT array_agg(DISTINCT i.invitee_id) FILTER (WHERE i.invitee_id IS NOT NULL)
79
+ FROM live_event_invitations i
80
+ WHERE i.event_id = e.id AND i.state IN ('pending', 'accepted')
81
+ ), ARRAY[]::text[]) AS invitee_ids,
82
+ COALESCE((
83
+ SELECT array_agg(DISTINCT i.invitee_id) FILTER (WHERE i.invitee_id IS NOT NULL)
84
+ FROM live_event_invitations i
85
+ WHERE i.event_id = e.id AND i.state = 'accepted' AND i.role = 'speaker'
86
+ ), ARRAY[]::text[]) AS speaker_ids,
87
+ COALESCE((
88
+ SELECT array_agg(DISTINCT i.invitee_id) FILTER (WHERE i.invitee_id IS NOT NULL)
89
+ FROM live_event_invitations i
90
+ WHERE i.event_id = e.id AND i.state = 'accepted' AND i.role = 'moderator'
91
+ ), ARRAY[]::text[]) AS moderator_ids
92
+ FROM live_events e
93
+ `;
94
+ const TRANSITIONS = {
95
+ draft: ["scheduled", "starting", "live", "cancelled"],
96
+ scheduled: ["draft", "starting", "live", "cancelled"],
97
+ starting: ["live", "cancelled"],
98
+ live: ["ended", "cancelled"],
99
+ ended: ["archived"],
100
+ cancelled: ["archived"],
101
+ archived: [],
102
+ };
103
+ function text(value, name, limit, required = false) {
104
+ if (value === undefined || value === null) {
105
+ if (required)
106
+ throw new LiveEventError(`${name} is required`, 422);
107
+ return "";
108
+ }
109
+ if (typeof value !== "string")
110
+ throw new LiveEventError(`${name} must be text`, 422);
111
+ const cleaned = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim();
112
+ if (required && cleaned === "")
113
+ throw new LiveEventError(`${name} is required`, 422);
114
+ if (cleaned.length > limit)
115
+ throw new LiveEventError(`${name} is too long`, 422);
116
+ return cleaned;
117
+ }
118
+ function timestamp(value, name) {
119
+ if (value === undefined || value === null || value === "")
120
+ return null;
121
+ if (typeof value !== "string")
122
+ throw new LiveEventError(`${name} must be an ISO timestamp`, 422);
123
+ const date = new Date(value);
124
+ if (!Number.isFinite(date.getTime()))
125
+ throw new LiveEventError(`${name} must be an ISO timestamp`, 422);
126
+ return date.toISOString();
127
+ }
128
+ function boolean(value, fallback, name) {
129
+ if (value === undefined)
130
+ return fallback;
131
+ if (typeof value !== "boolean")
132
+ throw new LiveEventError(`${name} must be true or false`, 422);
133
+ return value;
134
+ }
135
+ function optionalInteger(value, name) {
136
+ if (value === undefined || value === null || value === "")
137
+ return null;
138
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 1440) {
139
+ throw new LiveEventError(`${name} must be between 1 and 1440`, 422);
140
+ }
141
+ return value;
142
+ }
143
+ function enumValue(value, allowed, fallback, name) {
144
+ if (value === undefined)
145
+ return fallback;
146
+ if (typeof value !== "string" || !allowed.includes(value)) {
147
+ throw new LiveEventError(`${name} must be one of ${allowed.join(", ")}`, 422);
148
+ }
149
+ return value;
150
+ }
151
+ function iso(value) {
152
+ if (value instanceof Date)
153
+ return value.toISOString();
154
+ if (typeof value !== "string" || value === "")
155
+ return undefined;
156
+ const parsed = new Date(value);
157
+ return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : value;
158
+ }
159
+ function strings(value) {
160
+ if (Array.isArray(value))
161
+ return value.filter((item) => typeof item === "string");
162
+ if (typeof value === "string" && value.startsWith("{")) {
163
+ return value.slice(1, -1).split(",").filter(Boolean);
164
+ }
165
+ return [];
166
+ }
167
+ function eventFrom(row) {
168
+ return {
169
+ id: String(row["id"] ?? ""),
170
+ slug: String(row["slug"] ?? ""),
171
+ ownerId: String(row["owner_id"] ?? ""),
172
+ title: String(row["title"] ?? ""),
173
+ ...(row["description"] ? { description: String(row["description"]) } : {}),
174
+ ...(row["topic"] ? { topic: String(row["topic"]) } : {}),
175
+ ...(iso(row["starts_at"]) ? { startsAt: iso(row["starts_at"]) } : {}),
176
+ ...(iso(row["ends_at"]) ? { endsAt: iso(row["ends_at"]) } : {}),
177
+ timezone: String(row["timezone"] ?? "UTC"),
178
+ ...(row["expected_duration_minutes"] !== null && row["expected_duration_minutes"] !== undefined
179
+ ? { expectedDurationMinutes: Number(row["expected_duration_minutes"]) }
180
+ : {}),
181
+ status: String(row["status"] ?? "draft"),
182
+ visibility: String(row["visibility"] ?? "private"),
183
+ roomId: String(row["room_id"] ?? ""),
184
+ inviteeIds: strings(row["invitee_ids"]),
185
+ speakerIds: strings(row["speaker_ids"]),
186
+ moderatorIds: strings(row["moderator_ids"]),
187
+ chatEnabled: Boolean(row["chat_enabled"]),
188
+ handRaiseEnabled: Boolean(row["hand_raise_enabled"]),
189
+ recordingEnabled: Boolean(row["recording_enabled"]),
190
+ ...(row["recording_id"] ? { recordingId: String(row["recording_id"]) } : {}),
191
+ ...(row["layout_id"] ? { layoutId: String(row["layout_id"]) } : {}),
192
+ version: Number(row["version"] ?? 1),
193
+ createdAt: iso(row["created_at"]) ?? new Date(0).toISOString(),
194
+ updatedAt: iso(row["updated_at"]) ?? new Date(0).toISOString(),
195
+ };
196
+ }
197
+ function invitationFrom(row) {
198
+ return {
199
+ id: String(row["id"] ?? ""),
200
+ eventId: String(row["event_id"] ?? ""),
201
+ inviterId: String(row["inviter_id"] ?? ""),
202
+ ...(row["invitee_id"] ? { inviteeId: String(row["invitee_id"]) } : {}),
203
+ ...(row["email"] ? { email: String(row["email"]) } : {}),
204
+ role: String(row["role"] ?? "listener"),
205
+ state: String(row["state"] ?? "pending"),
206
+ ...(iso(row["expires_at"]) ? { expiresAt: iso(row["expires_at"]) } : {}),
207
+ createdAt: iso(row["created_at"]) ?? new Date(0).toISOString(),
208
+ updatedAt: iso(row["updated_at"]) ?? new Date(0).toISOString(),
209
+ };
210
+ }
211
+ export function eventSlug(value) {
212
+ if (typeof value !== "string")
213
+ throw new LiveEventError("title must be text", 422);
214
+ return value
215
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
216
+ .trim()
217
+ .normalize("NFKD")
218
+ .replace(/[\u0300-\u036f]/g, "")
219
+ .toLowerCase()
220
+ .replace(/[^a-z0-9]+/g, "-")
221
+ .replace(/^-+|-+$/g, "")
222
+ .slice(0, 72) || "live";
223
+ }
224
+ export function canTransition(from, to) {
225
+ return from === to || TRANSITIONS[from].includes(to);
226
+ }
227
+ export class LiveEvents {
228
+ db;
229
+ ready = null;
230
+ constructor(db) {
231
+ this.db = db;
232
+ }
233
+ async ensure() {
234
+ this.ready ??= this.db.query(EVENT_SCHEMA).then(() => undefined);
235
+ await this.ready;
236
+ }
237
+ async create(ownerId, input) {
238
+ if (!ownerId)
239
+ throw new LiveEventError("sign in to create an event", 401);
240
+ const id = randomUUID();
241
+ const title = text(input.title, "title", 160, true);
242
+ const startsAt = timestamp(input.startsAt, "startsAt");
243
+ const endsAt = timestamp(input.endsAt, "endsAt");
244
+ if (startsAt && endsAt && endsAt <= startsAt) {
245
+ throw new LiveEventError("endsAt must be after startsAt", 422);
246
+ }
247
+ const baseSlug = eventSlug(title);
248
+ const values = [
249
+ id,
250
+ baseSlug,
251
+ ownerId,
252
+ title,
253
+ text(input.description, "description", 5000),
254
+ text(input.topic, "topic", 100),
255
+ startsAt,
256
+ endsAt,
257
+ text(input.timezone ?? "UTC", "timezone", 100, true),
258
+ optionalInteger(input.expectedDurationMinutes, "expectedDurationMinutes"),
259
+ startsAt ? "scheduled" : "draft",
260
+ enumValue(input.visibility, LIVE_EVENT_VISIBILITIES, "public", "visibility"),
261
+ `event-${id.slice(0, 12)}`,
262
+ boolean(input.chatEnabled, true, "chatEnabled"),
263
+ boolean(input.handRaiseEnabled, true, "handRaiseEnabled"),
264
+ boolean(input.recordingEnabled, false, "recordingEnabled"),
265
+ text(input.layoutId, "layoutId", 100),
266
+ ];
267
+ await this.ensure();
268
+ let rows;
269
+ try {
270
+ ({ rows } = await this.db.query(`INSERT INTO live_events (
271
+ id, slug, owner_id, title, description, topic, starts_at, ends_at, timezone,
272
+ expected_duration_minutes, status, visibility, room_id, chat_enabled,
273
+ hand_raise_enabled, recording_enabled, layout_id
274
+ ) VALUES (
275
+ $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NULLIF($17, '')
276
+ ) RETURNING *`, values));
277
+ }
278
+ catch (error) {
279
+ if (error.code !== "23505")
280
+ throw error;
281
+ values[1] = `${baseSlug}-${id.slice(0, 6)}`;
282
+ ({ rows } = await this.db.query(`INSERT INTO live_events (
283
+ id, slug, owner_id, title, description, topic, starts_at, ends_at, timezone,
284
+ expected_duration_minutes, status, visibility, room_id, chat_enabled,
285
+ hand_raise_enabled, recording_enabled, layout_id
286
+ ) VALUES (
287
+ $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NULLIF($17, '')
288
+ ) RETURNING *`, values));
289
+ }
290
+ const row = rows[0];
291
+ if (!row)
292
+ throw new LiveEventError("could not create the event", 500);
293
+ return eventFrom({ ...row, invitee_ids: [], speaker_ids: [], moderator_ids: [] });
294
+ }
295
+ async list(query = {}) {
296
+ await this.ensure();
297
+ const values = [];
298
+ const where = [];
299
+ if (query.ownerId) {
300
+ values.push(query.ownerId);
301
+ where.push(`e.owner_id = $${values.length}`);
302
+ }
303
+ else {
304
+ where.push("e.visibility = 'public'");
305
+ where.push("e.status <> 'archived'");
306
+ }
307
+ if (query.status) {
308
+ values.push(query.status);
309
+ where.push(`e.status = $${values.length}`);
310
+ }
311
+ if (query.topic) {
312
+ values.push(query.topic);
313
+ where.push(`lower(e.topic) = lower($${values.length})`);
314
+ }
315
+ if (query.from) {
316
+ const from = timestamp(query.from, "from");
317
+ values.push(from);
318
+ where.push(`COALESCE(e.starts_at, e.updated_at) >= $${values.length}`);
319
+ }
320
+ const requestedLimit = Number.isFinite(query.limit) ? Math.floor(query.limit) : 30;
321
+ const limit = Math.min(100, Math.max(1, requestedLimit));
322
+ values.push(limit);
323
+ const { rows } = await this.db.query(`${SELECT_EVENT}
324
+ WHERE ${where.join(" AND ")}
325
+ ORDER BY
326
+ CASE e.status WHEN 'live' THEN 0 WHEN 'starting' THEN 1 WHEN 'scheduled' THEN 2 ELSE 3 END,
327
+ e.starts_at NULLS LAST, e.updated_at DESC
328
+ LIMIT $${values.length}`, values);
329
+ return rows.map(eventFrom);
330
+ }
331
+ async get(reference) {
332
+ if (!reference)
333
+ return null;
334
+ await this.ensure();
335
+ const { rows } = await this.db.query(`${SELECT_EVENT} WHERE e.id = $1 OR e.slug = $1 LIMIT 1`, [reference]);
336
+ return rows[0] ? eventFrom(rows[0]) : null;
337
+ }
338
+ async byRoom(roomId) {
339
+ if (!roomId)
340
+ return null;
341
+ await this.ensure();
342
+ const { rows } = await this.db.query(`${SELECT_EVENT} WHERE e.room_id = $1 LIMIT 1`, [roomId]);
343
+ return rows[0] ? eventFrom(rows[0]) : null;
344
+ }
345
+ async canAccess(event, accountId) {
346
+ if (event.visibility !== "private")
347
+ return true;
348
+ if (!accountId)
349
+ return false;
350
+ if (event.ownerId === accountId || event.moderatorIds.includes(accountId))
351
+ return true;
352
+ await this.ensure();
353
+ const { rows } = await this.db.query(`SELECT 1 FROM live_event_invitations
354
+ WHERE event_id = $1 AND invitee_id = $2 AND state = 'accepted' LIMIT 1`, [event.id, accountId]);
355
+ return rows.length > 0;
356
+ }
357
+ async invitationAllows(eventId, token) {
358
+ if (!eventId || !token)
359
+ return false;
360
+ await this.ensure();
361
+ const { rows } = await this.db.query(`SELECT 1 FROM live_event_invitations
362
+ WHERE event_id = $1 AND token_hash = $2
363
+ AND state IN ('pending', 'accepted')
364
+ AND (expires_at IS NULL OR expires_at > now())
365
+ LIMIT 1`, [eventId, hashToken(token)]);
366
+ return rows.length > 0;
367
+ }
368
+ canManage(event, accountId) {
369
+ return Boolean(accountId && (event.ownerId === accountId || event.moderatorIds.includes(accountId)));
370
+ }
371
+ async update(reference, ownerId, input) {
372
+ const current = await this.get(reference);
373
+ if (!current)
374
+ throw new LiveEventError("event not found", 404);
375
+ if (current.ownerId !== ownerId)
376
+ throw new LiveEventError("only the event owner can change it", 403);
377
+ if (!Number.isInteger(input.version) || Number(input.version) < 1) {
378
+ throw new LiveEventError("version is required", 428);
379
+ }
380
+ if (Number(input.version) !== current.version) {
381
+ throw new LiveEventError("the event changed; reload it and try again", 409);
382
+ }
383
+ const title = input.title === undefined ? current.title : text(input.title, "title", 160, true);
384
+ const startsAt = input.startsAt === undefined ? current.startsAt ?? null : timestamp(input.startsAt, "startsAt");
385
+ const endsAt = input.endsAt === undefined ? current.endsAt ?? null : timestamp(input.endsAt, "endsAt");
386
+ if (startsAt && endsAt && endsAt <= startsAt) {
387
+ throw new LiveEventError("endsAt must be after startsAt", 422);
388
+ }
389
+ const nextStatus = enumValue(input.status, LIVE_EVENT_STATUSES, current.status, "status");
390
+ if (!canTransition(current.status, nextStatus)) {
391
+ throw new LiveEventError(`an event cannot move from ${current.status} to ${nextStatus}`, 409);
392
+ }
393
+ if (nextStatus === "scheduled" && !startsAt) {
394
+ throw new LiveEventError("a scheduled event needs startsAt", 422);
395
+ }
396
+ const { rows } = await this.db.query(`UPDATE live_events SET
397
+ title = $4,
398
+ description = $5,
399
+ topic = $6,
400
+ starts_at = $7,
401
+ ends_at = $8,
402
+ timezone = $9,
403
+ expected_duration_minutes = $10,
404
+ status = $11,
405
+ visibility = $12,
406
+ chat_enabled = $13,
407
+ hand_raise_enabled = $14,
408
+ recording_enabled = $15,
409
+ recording_id = $16,
410
+ layout_id = $17,
411
+ version = version + 1,
412
+ updated_at = now()
413
+ WHERE (id = $1 OR slug = $1) AND owner_id = $2 AND version = $3
414
+ RETURNING *`, [
415
+ reference,
416
+ ownerId,
417
+ current.version,
418
+ title,
419
+ input.description === undefined ? current.description ?? "" : text(input.description, "description", 5000),
420
+ input.topic === undefined ? current.topic ?? "" : text(input.topic, "topic", 100),
421
+ startsAt,
422
+ endsAt,
423
+ input.timezone === undefined ? current.timezone : text(input.timezone, "timezone", 100, true),
424
+ input.expectedDurationMinutes === undefined
425
+ ? current.expectedDurationMinutes ?? null
426
+ : optionalInteger(input.expectedDurationMinutes, "expectedDurationMinutes"),
427
+ nextStatus,
428
+ enumValue(input.visibility, LIVE_EVENT_VISIBILITIES, current.visibility, "visibility"),
429
+ boolean(input.chatEnabled, current.chatEnabled, "chatEnabled"),
430
+ boolean(input.handRaiseEnabled, current.handRaiseEnabled, "handRaiseEnabled"),
431
+ boolean(input.recordingEnabled, current.recordingEnabled, "recordingEnabled"),
432
+ input.recordingId === undefined ? current.recordingId ?? null : text(input.recordingId, "recordingId", 160) || null,
433
+ input.layoutId === undefined ? current.layoutId ?? null : text(input.layoutId, "layoutId", 100) || null,
434
+ ]);
435
+ const row = rows[0];
436
+ if (!row)
437
+ throw new LiveEventError("the event changed; reload it and try again", 409);
438
+ return eventFrom({
439
+ ...row,
440
+ invitee_ids: current.inviteeIds,
441
+ speaker_ids: current.speakerIds,
442
+ moderator_ids: current.moderatorIds,
443
+ });
444
+ }
445
+ async transition(reference, ownerId, status, version) {
446
+ return this.update(reference, ownerId, { version, status });
447
+ }
448
+ async remove(reference, ownerId) {
449
+ await this.ensure();
450
+ const { rows } = await this.db.query(`DELETE FROM live_events WHERE (id = $1 OR slug = $1) AND owner_id = $2 RETURNING id`, [reference, ownerId]);
451
+ return rows.length > 0;
452
+ }
453
+ async invite(eventId, inviterId, input) {
454
+ const event = await this.get(eventId);
455
+ if (!event)
456
+ throw new LiveEventError("event not found", 404);
457
+ if (!this.canManage(event, inviterId))
458
+ throw new LiveEventError("only hosts and moderators can invite", 403);
459
+ const inviteeId = text(input.inviteeId, "inviteeId", 160);
460
+ const email = text(input.email, "email", 320).toLowerCase();
461
+ if (!inviteeId && !email)
462
+ throw new LiveEventError("give an inviteeId or email", 422);
463
+ if (email && !/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) {
464
+ throw new LiveEventError("that does not look like an email address", 422);
465
+ }
466
+ const role = enumValue(input.role, INVITATION_ROLES, "listener", "role");
467
+ const expiresAt = timestamp(input.expiresAt, "expiresAt");
468
+ const token = randomBytes(24).toString("base64url");
469
+ const id = randomUUID();
470
+ await this.ensure();
471
+ const { rows } = await this.db.query(`INSERT INTO live_event_invitations (
472
+ id, event_id, inviter_id, invitee_id, email, role, token_hash, expires_at
473
+ ) VALUES ($1, $2, $3, NULLIF($4, ''), NULLIF($5, ''), $6, $7, $8)
474
+ RETURNING *`, [id, event.id, inviterId, inviteeId, email, role, hashToken(token), expiresAt]);
475
+ const row = rows[0];
476
+ if (!row)
477
+ throw new LiveEventError("could not create the invitation", 500);
478
+ return { ...invitationFrom(row), token };
479
+ }
480
+ async invitations(eventId, accountId) {
481
+ const event = await this.get(eventId);
482
+ if (!event)
483
+ throw new LiveEventError("event not found", 404);
484
+ if (!this.canManage(event, accountId))
485
+ throw new LiveEventError("only hosts and moderators can see invitations", 403);
486
+ await this.ensure();
487
+ const { rows } = await this.db.query(`SELECT * FROM live_event_invitations WHERE event_id = $1 ORDER BY created_at DESC`, [event.id]);
488
+ return rows.map(invitationFrom);
489
+ }
490
+ async respond(token, accountId, state) {
491
+ if (!token)
492
+ throw new LiveEventError("invitation not found", 404);
493
+ await this.ensure();
494
+ const { rows } = await this.db.query(`UPDATE live_event_invitations SET
495
+ state = $3,
496
+ invitee_id = COALESCE(invitee_id, NULLIF($2, '')),
497
+ updated_at = now()
498
+ WHERE token_hash = $1 AND state = 'pending'
499
+ AND (expires_at IS NULL OR expires_at > now())
500
+ RETURNING *`, [hashToken(token), accountId, state]);
501
+ const row = rows[0];
502
+ if (!row)
503
+ throw new LiveEventError("that invitation is invalid or expired", 410);
504
+ return invitationFrom(row);
505
+ }
506
+ async revoke(eventId, invitationId, accountId) {
507
+ const event = await this.get(eventId);
508
+ if (!event)
509
+ throw new LiveEventError("event not found", 404);
510
+ if (!this.canManage(event, accountId))
511
+ throw new LiveEventError("only hosts and moderators can revoke invitations", 403);
512
+ await this.ensure();
513
+ const { rows } = await this.db.query(`UPDATE live_event_invitations SET state = 'revoked', updated_at = now()
514
+ WHERE id = $1 AND event_id = $2 AND state = 'pending' RETURNING id`, [invitationId, event.id]);
515
+ return rows.length > 0;
516
+ }
517
+ }
518
+ function hashToken(token) {
519
+ return createHash("sha256").update(token).digest("hex");
520
+ }
package/dist/owner.d.ts CHANGED
@@ -51,3 +51,15 @@ export declare class Owner {
51
51
  /** Paths only an administrator may reach. */
52
52
  export declare const ADMIN_PATHS: string[];
53
53
  export declare function needsAdmin(path: string, method?: string): boolean;
54
+ /**
55
+ * The administering that a member may do too.
56
+ *
57
+ * A member is anybody signed in to nixamp.com: not the owner, not holding
58
+ * the control link, but a known account. Going live is theirs -- a file on
59
+ * this server, a catalog entry, keeping something that was started on
60
+ * demand, and taking off what they put on -- because a directory of servers
61
+ * nobody but their owners can go live on is a directory of empty rooms.
62
+ * Everything else that administers stays the owner's: the source, the
63
+ * connections, the catalogs, the links the server fetches.
64
+ */
65
+ export declare function needsMember(path: string, method?: string): boolean;
package/dist/owner.js CHANGED
@@ -112,5 +112,32 @@ export function needsAdmin(path, method = "GET") {
112
112
  // it liked. A link a viewer wants to watch plays in their own browser.
113
113
  if (path.startsWith("/api/links"))
114
114
  return true;
115
+ // Going live with a file on this server is the same act as going live
116
+ // with a catalog entry, and the same question.
117
+ if (/^\/api\/tracks\/\d+\/live$/.test(path))
118
+ return true;
119
+ return false;
120
+ }
121
+ /**
122
+ * The administering that a member may do too.
123
+ *
124
+ * A member is anybody signed in to nixamp.com: not the owner, not holding
125
+ * the control link, but a known account. Going live is theirs -- a file on
126
+ * this server, a catalog entry, keeping something that was started on
127
+ * demand, and taking off what they put on -- because a directory of servers
128
+ * nobody but their owners can go live on is a directory of empty rooms.
129
+ * Everything else that administers stays the owner's: the source, the
130
+ * connections, the catalogs, the links the server fetches.
131
+ */
132
+ export function needsMember(path, method = "GET") {
133
+ if (method === "POST" && /^\/api\/tracks\/\d+\/live$/.test(path))
134
+ return true;
135
+ if (method === "POST" && /^\/api\/catalogs\/[^/]+\/entries\/[^/]+\/live$/.test(path))
136
+ return true;
137
+ if (method === "POST" && /^\/api\/channels\/[^/]+\/keep$/.test(path))
138
+ return true;
139
+ // Only their own; the handler checks whose it is.
140
+ if (method === "DELETE" && /^\/api\/channels\/[^/]+$/.test(path))
141
+ return true;
115
142
  return false;
116
143
  }
@@ -0,0 +1,38 @@
1
+ import type { Queryable } from "./follows.ts";
2
+ export declare const ROOM_ROLES: readonly ["listener", "speaker", "moderator", "host"];
3
+ export declare const HAND_RAISE_STATES: readonly ["raised", "invited", "dismissed", "returned"];
4
+ export type RoomRole = (typeof ROOM_ROLES)[number];
5
+ export type HandRaiseState = (typeof HAND_RAISE_STATES)[number];
6
+ export interface ChatMessage {
7
+ id: string;
8
+ eventId: string;
9
+ authorId: string;
10
+ authorName: string;
11
+ body: string;
12
+ createdAt: string;
13
+ deletedAt?: string;
14
+ }
15
+ export interface HandRaise {
16
+ eventId: string;
17
+ accountId: string;
18
+ displayName: string;
19
+ state: HandRaiseState;
20
+ raisedAt: string;
21
+ updatedAt: string;
22
+ }
23
+ export declare class RoomError extends Error {
24
+ readonly status: number;
25
+ constructor(message: string, status: number);
26
+ }
27
+ export declare class Rooms {
28
+ private readonly db;
29
+ private ready;
30
+ constructor(db: Queryable);
31
+ private ensure;
32
+ chat(eventId: string, after?: string, limit?: number): Promise<ChatMessage[]>;
33
+ post(eventId: string, authorId: string, authorName: string, body: unknown): Promise<ChatMessage>;
34
+ removeMessage(eventId: string, messageId: string): Promise<boolean>;
35
+ raiseHand(eventId: string, accountId: string, displayName: string): Promise<HandRaise>;
36
+ handRaises(eventId: string): Promise<HandRaise[]>;
37
+ setHandRaise(eventId: string, accountId: string, state: unknown): Promise<HandRaise>;
38
+ }