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,353 @@
1
+ import { randomUUID } from "node:crypto";
2
+ export const PANEL_REGIONS = ["primary", "secondary", "sidebar", "drawer", "bottom", "overlay"];
3
+ export const LAYOUT_SCOPES = ["system", "brand", "role", "user", "event"];
4
+ export class LayoutError extends Error {
5
+ status;
6
+ constructor(message, status) {
7
+ super(message);
8
+ this.status = status;
9
+ }
10
+ }
11
+ export const PANEL_REGISTRY = [
12
+ { type: "event-header", title: "Event" },
13
+ { type: "player", title: "Listen" },
14
+ { type: "host", title: "Host" },
15
+ { type: "about", title: "About" },
16
+ { type: "join", title: "Join" },
17
+ { type: "stage", title: "Stage", permissions: ["room.speak"] },
18
+ { type: "chat", title: "Chat" },
19
+ { type: "participants", title: "People", permissions: ["room.listen"] },
20
+ { type: "speakers", title: "Speakers", permissions: ["room.speak"] },
21
+ { type: "hand-raises", title: "Hand Raises", permissions: ["event.moderate"] },
22
+ { type: "raise-hand", title: "Raise Hand", permissions: ["room.raise_hand"] },
23
+ { type: "questions", title: "Questions" },
24
+ { type: "invite", title: "Invite", permissions: ["event.invite"] },
25
+ { type: "share", title: "Share" },
26
+ { type: "schedule", title: "Schedule", permissions: ["event.update"] },
27
+ { type: "event-controls", title: "Event Controls", permissions: ["event.start"] },
28
+ { type: "recording", title: "Recording", permissions: ["recording.start"] },
29
+ { type: "resources", title: "Resources" },
30
+ { type: "replay", title: "Replay" },
31
+ { type: "moderation", title: "Moderation", permissions: ["event.moderate"] },
32
+ { type: "diagnostics", title: "Diagnostics", permissions: ["admin.diagnostics"] },
33
+ { type: "network-health", title: "Network Health", permissions: ["admin.network"] },
34
+ { type: "advanced-nixamp", title: "Advanced NixAmp", permissions: ["admin.network"] },
35
+ ];
36
+ const knownPanels = new Set(PANEL_REGISTRY.map((panel) => panel.type));
37
+ function presetPanel(id, type, region, order, options = {}) {
38
+ const now = new Date(0).toISOString();
39
+ const definition = PANEL_REGISTRY.find((panel) => panel.type === type);
40
+ return {
41
+ id,
42
+ type,
43
+ visible: options.visible ?? true,
44
+ enabled: options.enabled ?? true,
45
+ region,
46
+ order,
47
+ ...(options.collapsed !== undefined ? { collapsed: options.collapsed } : {}),
48
+ ...(options.permissions ?? definition?.permissions
49
+ ? { permissions: options.permissions ?? definition?.permissions }
50
+ : {}),
51
+ config: options.config ?? {},
52
+ version: 1,
53
+ createdAt: now,
54
+ updatedAt: now,
55
+ };
56
+ }
57
+ function preset(name, panels) {
58
+ const now = new Date(0).toISOString();
59
+ return {
60
+ id: name,
61
+ name,
62
+ scope: name === "nixamp-admin" ? "role" : "brand",
63
+ scopeId: name === "nixamp-admin" ? "admin" : "backtoschool",
64
+ panels,
65
+ version: 1,
66
+ createdAt: now,
67
+ updatedAt: now,
68
+ };
69
+ }
70
+ const viewerPanels = [
71
+ presetPanel("event-header", "event-header", "primary", 0),
72
+ presetPanel("player", "player", "primary", 1),
73
+ presetPanel("host", "host", "secondary", 2),
74
+ presetPanel("about", "about", "secondary", 3),
75
+ presetPanel("join", "join", "bottom", 4),
76
+ presetPanel("chat", "chat", "drawer", 5, { collapsed: true }),
77
+ presetPanel("questions", "questions", "drawer", 6, { collapsed: true }),
78
+ presetPanel("resources", "resources", "drawer", 7, { collapsed: true }),
79
+ ];
80
+ const memberPanels = [
81
+ presetPanel("event-header", "event-header", "primary", 0),
82
+ presetPanel("player", "player", "primary", 1),
83
+ presetPanel("host", "host", "secondary", 2),
84
+ presetPanel("chat", "chat", "secondary", 3),
85
+ presetPanel("questions", "questions", "secondary", 4),
86
+ presetPanel("participants", "participants", "sidebar", 5),
87
+ presetPanel("invite", "invite", "sidebar", 6),
88
+ presetPanel("raise-hand", "raise-hand", "bottom", 7),
89
+ presetPanel("resources", "resources", "bottom", 8),
90
+ ];
91
+ const hostPanels = [
92
+ presetPanel("event-header", "event-header", "primary", 0),
93
+ presetPanel("stage", "stage", "primary", 1),
94
+ presetPanel("speakers", "speakers", "secondary", 2),
95
+ presetPanel("participants", "participants", "secondary", 3),
96
+ presetPanel("invite", "invite", "sidebar", 4),
97
+ presetPanel("chat", "chat", "sidebar", 5),
98
+ presetPanel("hand-raises", "hand-raises", "sidebar", 6),
99
+ presetPanel("questions", "questions", "sidebar", 7),
100
+ presetPanel("event-controls", "event-controls", "bottom", 8),
101
+ presetPanel("schedule", "schedule", "bottom", 9),
102
+ presetPanel("recording", "recording", "bottom", 10),
103
+ presetPanel("share", "share", "bottom", 11),
104
+ ];
105
+ const adminPanels = [
106
+ ...hostPanels.map((panel) => ({ ...panel })),
107
+ presetPanel("moderation", "moderation", "sidebar", 20),
108
+ presetPanel("diagnostics", "diagnostics", "sidebar", 21),
109
+ presetPanel("network-health", "network-health", "sidebar", 22),
110
+ presetPanel("advanced-nixamp", "advanced-nixamp", "drawer", 23),
111
+ ];
112
+ export const LAYOUT_PRESETS = {
113
+ "backtoschool-viewer": preset("backtoschool-viewer", viewerPanels),
114
+ "backtoschool-member": preset("backtoschool-member", memberPanels),
115
+ "backtoschool-host": preset("backtoschool-host", hostPanels),
116
+ "nixamp-admin": preset("nixamp-admin", adminPanels),
117
+ };
118
+ const LAYOUT_SCHEMA = `
119
+ CREATE TABLE IF NOT EXISTS nixamp_layouts (
120
+ id TEXT PRIMARY KEY,
121
+ name TEXT NOT NULL UNIQUE,
122
+ scope TEXT NOT NULL,
123
+ scope_id TEXT,
124
+ owner_id TEXT,
125
+ panels JSONB NOT NULL DEFAULT '[]'::jsonb,
126
+ version INTEGER NOT NULL DEFAULT 1,
127
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
128
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
129
+ CHECK (scope IN ('system', 'brand', 'role', 'user', 'event'))
130
+ );
131
+ CREATE INDEX IF NOT EXISTS nixamp_layouts_resolution
132
+ ON nixamp_layouts (scope, scope_id, updated_at DESC);
133
+
134
+ CREATE TABLE IF NOT EXISTS nixamp_layout_history (
135
+ id BIGSERIAL PRIMARY KEY,
136
+ layout_id TEXT NOT NULL,
137
+ version INTEGER NOT NULL,
138
+ panels JSONB NOT NULL,
139
+ changed_by TEXT,
140
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
141
+ );
142
+ CREATE INDEX IF NOT EXISTS nixamp_layout_history_layout
143
+ ON nixamp_layout_history (layout_id, version DESC);
144
+ `;
145
+ function string(value, name, limit, required = false) {
146
+ if (value === undefined || value === null) {
147
+ if (required)
148
+ throw new LayoutError(`${name} is required`, 422);
149
+ return "";
150
+ }
151
+ if (typeof value !== "string")
152
+ throw new LayoutError(`${name} must be text`, 422);
153
+ const result = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim();
154
+ if (required && !result)
155
+ throw new LayoutError(`${name} is required`, 422);
156
+ if (result.length > limit)
157
+ throw new LayoutError(`${name} is too long`, 422);
158
+ return result;
159
+ }
160
+ function when(value) {
161
+ if (value instanceof Date)
162
+ return value.toISOString();
163
+ if (typeof value === "string")
164
+ return new Date(value).toISOString();
165
+ return new Date(0).toISOString();
166
+ }
167
+ function object(value, name) {
168
+ if (value === undefined)
169
+ return {};
170
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
171
+ throw new LayoutError(`${name} must be an object`, 422);
172
+ }
173
+ return value;
174
+ }
175
+ export function panelFrom(value, position = 0) {
176
+ const input = object(value, "panel");
177
+ const id = string(input["id"] ?? randomUUID(), "panel id", 100, true);
178
+ const type = string(input["type"], "panel type", 100, true);
179
+ const region = string(input["region"] ?? "primary", "panel region", 20, true);
180
+ if (!PANEL_REGIONS.includes(region))
181
+ throw new LayoutError("panel region is invalid", 422);
182
+ const order = input["order"] === undefined ? position : Number(input["order"]);
183
+ if (!Number.isInteger(order) || order < 0)
184
+ throw new LayoutError("panel order must be a non-negative integer", 422);
185
+ const permissions = input["permissions"];
186
+ if (permissions !== undefined && (!Array.isArray(permissions) || permissions.some((item) => typeof item !== "string"))) {
187
+ throw new LayoutError("panel permissions must be text", 422);
188
+ }
189
+ const version = input["version"] === undefined ? 1 : Number(input["version"]);
190
+ if (!Number.isInteger(version) || version < 1)
191
+ throw new LayoutError("panel version must be a positive integer", 422);
192
+ const createdAt = typeof input["createdAt"] === "string" ? input["createdAt"] : new Date().toISOString();
193
+ const updatedAt = typeof input["updatedAt"] === "string" ? input["updatedAt"] : createdAt;
194
+ return {
195
+ id,
196
+ type,
197
+ ...(input["title"] !== undefined ? { title: string(input["title"], "panel title", 100) } : {}),
198
+ visible: input["visible"] === undefined ? true : Boolean(input["visible"]),
199
+ enabled: input["enabled"] === undefined ? true : Boolean(input["enabled"]),
200
+ region: region,
201
+ order,
202
+ ...(input["size"] !== undefined ? { size: object(input["size"], "panel size") } : {}),
203
+ ...(input["collapsed"] !== undefined ? { collapsed: Boolean(input["collapsed"]) } : {}),
204
+ ...(permissions !== undefined ? { permissions: permissions } : {}),
205
+ config: object(input["config"], "panel config"),
206
+ version,
207
+ createdAt,
208
+ updatedAt,
209
+ };
210
+ }
211
+ function panelsFrom(value) {
212
+ const parsed = typeof value === "string" ? JSON.parse(value) : value;
213
+ if (!Array.isArray(parsed))
214
+ throw new LayoutError("panels must be a list", 422);
215
+ const ids = new Set();
216
+ return parsed.map((panel, index) => {
217
+ const result = panelFrom(panel, index);
218
+ if (ids.has(result.id))
219
+ throw new LayoutError(`panel id ${result.id} is duplicated`, 422);
220
+ ids.add(result.id);
221
+ return result;
222
+ }).sort((a, b) => a.order - b.order);
223
+ }
224
+ function layoutFrom(row) {
225
+ return {
226
+ id: String(row["id"] ?? ""),
227
+ name: String(row["name"] ?? ""),
228
+ scope: String(row["scope"] ?? "user"),
229
+ ...(row["scope_id"] ? { scopeId: String(row["scope_id"]) } : {}),
230
+ ...(row["owner_id"] ? { ownerId: String(row["owner_id"]) } : {}),
231
+ panels: panelsFrom(row["panels"] ?? []),
232
+ version: Number(row["version"] ?? 1),
233
+ createdAt: when(row["created_at"]),
234
+ updatedAt: when(row["updated_at"]),
235
+ };
236
+ }
237
+ export function presetLayout(name) {
238
+ const found = LAYOUT_PRESETS[name];
239
+ return found ? structuredClone(found) : null;
240
+ }
241
+ export function resolveLayout(layers, permissions) {
242
+ const resolved = new Map();
243
+ for (const layout of layers) {
244
+ for (const panel of layout.panels)
245
+ resolved.set(panel.id, structuredClone(panel));
246
+ }
247
+ return [...resolved.values()]
248
+ .filter((panel) => knownPanels.has(panel.type) && panel.enabled && panel.visible)
249
+ .filter((panel) => (panel.permissions ?? []).every((permission) => permissions.has(permission)))
250
+ .sort((a, b) => a.order - b.order);
251
+ }
252
+ export class Layouts {
253
+ db;
254
+ ready = null;
255
+ constructor(db) {
256
+ this.db = db;
257
+ }
258
+ async ensure() {
259
+ this.ready ??= this.db.query(LAYOUT_SCHEMA).then(() => undefined);
260
+ await this.ready;
261
+ }
262
+ async get(reference) {
263
+ const builtIn = presetLayout(reference);
264
+ if (builtIn)
265
+ return builtIn;
266
+ await this.ensure();
267
+ const { rows } = await this.db.query("SELECT * FROM nixamp_layouts WHERE id = $1 OR name = $1 LIMIT 1", [reference]);
268
+ return rows[0] ? layoutFrom(rows[0]) : null;
269
+ }
270
+ async scoped(scope, scopeId) {
271
+ await this.ensure();
272
+ const { rows } = await this.db.query("SELECT * FROM nixamp_layouts WHERE scope = $1 AND scope_id = $2 ORDER BY updated_at", [scope, scopeId]);
273
+ return rows.map(layoutFrom);
274
+ }
275
+ async create(input, ownerId) {
276
+ const name = string(input.name, "layout name", 100, true);
277
+ const scope = string(input.scope, "layout scope", 20, true);
278
+ if (!LAYOUT_SCOPES.includes(scope))
279
+ throw new LayoutError("layout scope is invalid", 422);
280
+ if ((scope === "user" || scope === "event") && !ownerId)
281
+ throw new LayoutError("sign in to create this layout", 401);
282
+ const panels = panelsFrom(input.panels ?? []);
283
+ await this.ensure();
284
+ const { rows } = await this.db.query(`INSERT INTO nixamp_layouts (id, name, scope, scope_id, owner_id, panels)
285
+ VALUES ($1, $2, $3, NULLIF($4, ''), NULLIF($5, ''), $6::jsonb)
286
+ RETURNING *`, [randomUUID(), name, scope, string(input.scopeId, "scopeId", 160), ownerId, JSON.stringify(panels)]);
287
+ const row = rows[0];
288
+ if (!row)
289
+ throw new LayoutError("could not create the layout", 500);
290
+ return layoutFrom(row);
291
+ }
292
+ async update(reference, panels, version, accountId) {
293
+ const current = await this.get(reference);
294
+ if (!current)
295
+ throw new LayoutError("layout not found", 404);
296
+ if (!current.ownerId || current.ownerId !== accountId) {
297
+ throw new LayoutError("only the layout owner can change it", 403);
298
+ }
299
+ if (!Number.isInteger(version) || Number(version) < 1)
300
+ throw new LayoutError("version is required", 428);
301
+ if (Number(version) !== current.version)
302
+ throw new LayoutError("the layout changed; reload it and try again", 409);
303
+ const nextPanels = panelsFrom(panels);
304
+ await this.ensure();
305
+ const { rows } = await this.db.query(`WITH previous AS (
306
+ SELECT id, version, panels FROM nixamp_layouts
307
+ WHERE (id = $1 OR name = $1) AND owner_id = $2 AND version = $3
308
+ ), remembered AS (
309
+ INSERT INTO nixamp_layout_history (layout_id, version, panels, changed_by)
310
+ SELECT id, version, panels, $2 FROM previous
311
+ )
312
+ UPDATE nixamp_layouts SET panels = $4::jsonb, version = version + 1, updated_at = now()
313
+ WHERE id IN (SELECT id FROM previous)
314
+ RETURNING *`, [reference, accountId, current.version, JSON.stringify(nextPanels)]);
315
+ const row = rows[0];
316
+ if (!row)
317
+ throw new LayoutError("the layout changed; reload it and try again", 409);
318
+ return layoutFrom(row);
319
+ }
320
+ async addPanel(reference, panel, version, accountId) {
321
+ const current = await this.get(reference);
322
+ if (!current)
323
+ throw new LayoutError("layout not found", 404);
324
+ return this.update(reference, [...current.panels, panelFrom(panel, current.panels.length)], version, accountId);
325
+ }
326
+ async updatePanel(reference, panelId, patch, version, accountId) {
327
+ const current = await this.get(reference);
328
+ if (!current)
329
+ throw new LayoutError("layout not found", 404);
330
+ const at = current.panels.findIndex((panel) => panel.id === panelId);
331
+ if (at < 0)
332
+ throw new LayoutError("panel not found", 404);
333
+ const changes = object(patch, "panel");
334
+ const previous = current.panels[at];
335
+ const updated = panelFrom({ ...previous, ...changes, id: previous.id, version: previous.version + 1 }, at);
336
+ const panels = current.panels.with(at, updated);
337
+ return this.update(reference, panels, version, accountId);
338
+ }
339
+ async removePanel(reference, panelId, version, accountId) {
340
+ const current = await this.get(reference);
341
+ if (!current)
342
+ throw new LayoutError("layout not found", 404);
343
+ if (!current.panels.some((panel) => panel.id === panelId))
344
+ throw new LayoutError("panel not found", 404);
345
+ return this.update(reference, current.panels.filter((panel) => panel.id !== panelId), version, accountId);
346
+ }
347
+ async reset(reference, presetName, version, accountId) {
348
+ const defaults = presetLayout(presetName);
349
+ if (!defaults)
350
+ throw new LayoutError("layout preset not found", 404);
351
+ return this.update(reference, defaults.panels, version, accountId);
352
+ }
353
+ }
@@ -0,0 +1,20 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { type Accounts } from "./accounts.ts";
3
+ import { type LiveEvents } from "./live-events.ts";
4
+ import { type Layouts } from "./layouts.ts";
5
+ import { type Rooms } from "./rooms.ts";
6
+ export interface LiveApiOptions {
7
+ events: LiveEvents;
8
+ accounts?: Accounts;
9
+ layouts?: Layouts;
10
+ rooms?: Rooms;
11
+ site?: string;
12
+ email?: (to: string, note: {
13
+ title: string;
14
+ body: string;
15
+ url: string;
16
+ }) => Promise<boolean>;
17
+ }
18
+ export declare function handleLiveApi(request: IncomingMessage, response: ServerResponse, url: URL, options: LiveApiOptions): Promise<boolean>;
19
+ export declare function liveApiPath(path: string): boolean;
20
+ export declare function invitationState(value: unknown): string | null;