@dotwire/host 0.0.0-stage → 0.2.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.
package/dist/index.cjs ADDED
@@ -0,0 +1,1000 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ BatchModerationWorker: () => BatchModerationWorker,
24
+ DotwireApiError: () => DotwireApiError,
25
+ DotwireHostClient: () => DotwireHostClient,
26
+ DotwireHostRoom: () => DotwireHostRoom,
27
+ DotwireTokenSigner: () => DotwireTokenSigner,
28
+ ModerationQueue: () => ModerationQueue,
29
+ PostSendError: () => PostSendError,
30
+ PresendRejectedError: () => PresendRejectedError,
31
+ PresendResult: () => PresendResult,
32
+ mapErrorKind: () => mapErrorKind
33
+ });
34
+ module.exports = __toCommonJS(index_exports);
35
+
36
+ // src/types.ts
37
+ var PresendResult = {
38
+ accept(content) {
39
+ return { allow: true, content };
40
+ },
41
+ reject(reason) {
42
+ return { allow: false, rejectionReason: reason };
43
+ }
44
+ };
45
+
46
+ // src/errors.ts
47
+ var DotwireApiError = class extends Error {
48
+ status;
49
+ kind;
50
+ code;
51
+ reason;
52
+ retryAfterMs;
53
+ method;
54
+ path;
55
+ constructor(init) {
56
+ super(`dotwire ${init.method} ${init.path} failed: ${init.status}${init.code ? ` ${init.code}` : ""}`);
57
+ this.name = "DotwireApiError";
58
+ this.status = init.status;
59
+ this.kind = init.kind;
60
+ this.code = init.code;
61
+ this.reason = init.reason;
62
+ this.retryAfterMs = init.retryAfterMs;
63
+ this.method = init.method;
64
+ this.path = init.path;
65
+ }
66
+ };
67
+ function mapErrorKind(status, code) {
68
+ switch (status) {
69
+ case 400:
70
+ return "BadRequest";
71
+ case 401:
72
+ return "Unauthorized";
73
+ case 403:
74
+ return "Forbidden";
75
+ case 404:
76
+ return "NotFound";
77
+ case 422:
78
+ return code === "presend_rejected" ? "PresendRejected" : "BadRequest";
79
+ case 429:
80
+ return "RateLimited";
81
+ case 503:
82
+ return "Unavailable";
83
+ default:
84
+ return "Unknown";
85
+ }
86
+ }
87
+ var PresendRejectedError = class extends Error {
88
+ reason;
89
+ constructor(reason) {
90
+ super(reason);
91
+ this.name = "PresendRejectedError";
92
+ this.reason = reason;
93
+ }
94
+ };
95
+ var PostSendError = class extends Error {
96
+ sent;
97
+ constructor(sent, cause) {
98
+ super("The message was sent, but the postSend hook failed.", { cause });
99
+ this.name = "PostSendError";
100
+ this.sent = sent;
101
+ }
102
+ };
103
+
104
+ // src/signer.ts
105
+ var import_jose = require("jose");
106
+ var DotwireTokenSigner = class {
107
+ config;
108
+ keyPromise;
109
+ defaultTtl;
110
+ constructor(config) {
111
+ this.config = config;
112
+ this.keyPromise = (0, import_jose.importPKCS8)(config.privateKeyPem, "RS256");
113
+ this.defaultTtl = config.defaultTokenTtl ?? "15m";
114
+ }
115
+ async mintToken(userId, options = {}) {
116
+ const key = await this.keyPromise;
117
+ const jwt = new import_jose.SignJWT({
118
+ "dw:role": options.role ?? "member",
119
+ ...options.displayName ? { "dw:name": options.displayName } : {}
120
+ }).setProtectedHeader({ alg: "RS256", kid: this.config.keyId }).setIssuer(this.config.issuer).setSubject(userId).setAudience(this.config.audience ?? "dotwire").setIssuedAt().setExpirationTime(options.ttl ?? this.defaultTtl);
121
+ return jwt.sign(key);
122
+ }
123
+ };
124
+
125
+ // src/room.ts
126
+ var DotwireHostRoom = class {
127
+ client;
128
+ roomId;
129
+ constructor(client, roomId) {
130
+ this.client = client;
131
+ this.roomId = roomId;
132
+ }
133
+ addMembers(userIds, options) {
134
+ return this.client.addRoomMembers(this.roomId, userIds, options);
135
+ }
136
+ removeMember(userId) {
137
+ return this.client.removeRoomMember(this.roomId, userId);
138
+ }
139
+ getMembers() {
140
+ return this.client.getRoomMembers(this.roomId);
141
+ }
142
+ getParticipants(asUserId) {
143
+ return this.client.getRoomParticipants(this.roomId, asUserId);
144
+ }
145
+ sendAsUser(userId, content) {
146
+ return this.client.sendAsUser(this.roomId, userId, content);
147
+ }
148
+ sendSystemMessage(content, options) {
149
+ return this.client.sendSystemMessage(this.roomId, content, options);
150
+ }
151
+ getMessages(options) {
152
+ return this.client.getMessages(this.roomId, options);
153
+ }
154
+ events(options) {
155
+ return this.client.streamRoomEvents(this.roomId, options);
156
+ }
157
+ redact(seq) {
158
+ return this.client.redactMessage(this.roomId, seq);
159
+ }
160
+ };
161
+
162
+ // src/client.ts
163
+ var import_node_crypto = require("crypto");
164
+
165
+ // src/sse.ts
166
+ async function* parseSseStream(body, signal) {
167
+ const reader = body.getReader();
168
+ const decoder = new TextDecoder();
169
+ let buffer = "";
170
+ try {
171
+ while (true) {
172
+ if (signal?.aborted) return;
173
+ const { done, value } = await reader.read();
174
+ if (done) break;
175
+ buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
176
+ let idx;
177
+ while ((idx = buffer.indexOf("\n\n")) !== -1) {
178
+ const block = buffer.slice(0, idx);
179
+ buffer = buffer.slice(idx + 2);
180
+ const parsed = parseEventBlock(block);
181
+ if (parsed) yield parsed;
182
+ }
183
+ }
184
+ } finally {
185
+ try {
186
+ reader.releaseLock();
187
+ } catch {
188
+ }
189
+ }
190
+ }
191
+ function parseEventBlock(block) {
192
+ let eventName = "message";
193
+ let id;
194
+ const dataLines = [];
195
+ let sawData = false;
196
+ for (const line of block.split("\n")) {
197
+ if (line.length === 0) continue;
198
+ if (line.startsWith(":")) continue;
199
+ if (line.startsWith("event:")) {
200
+ eventName = line.slice("event:".length).trim();
201
+ } else if (line.startsWith("id:")) {
202
+ id = line.slice("id:".length).trim();
203
+ } else if (line.startsWith("data:")) {
204
+ dataLines.push(line.slice("data:".length).trim());
205
+ sawData = true;
206
+ }
207
+ }
208
+ if (!sawData) return null;
209
+ return { event: eventName, data: dataLines.join("\n"), id };
210
+ }
211
+ function toRoomEvent(raw) {
212
+ let data;
213
+ try {
214
+ data = JSON.parse(raw.data);
215
+ } catch {
216
+ return null;
217
+ }
218
+ if (typeof data !== "object" || data === null) return null;
219
+ const d = data;
220
+ switch (raw.event) {
221
+ case "message":
222
+ return {
223
+ type: "message",
224
+ roomId: String(d.roomId),
225
+ seq: Number(d.seq),
226
+ senderId: String(d.senderId),
227
+ time: String(d.time),
228
+ content: String(d.content),
229
+ replayed: d.replayed === true
230
+ };
231
+ case "retracted":
232
+ return { type: "retracted", roomId: String(d.roomId), seq: Number(d.seq) };
233
+ case "typing":
234
+ return { type: "typing", roomId: String(d.roomId), userId: String(d.userId), isTyping: Boolean(d.isTyping) };
235
+ case "presence":
236
+ return {
237
+ type: "presence",
238
+ roomId: String(d.roomId),
239
+ joined: Array.isArray(d.joined) ? d.joined.map(String) : [],
240
+ left: Array.isArray(d.left) ? d.left.map(String) : []
241
+ };
242
+ case "revoked":
243
+ return { type: "revoked", roomId: String(d.roomId) };
244
+ default:
245
+ return null;
246
+ }
247
+ }
248
+
249
+ // src/util.ts
250
+ function delay(ms, signal) {
251
+ return new Promise((resolve, reject) => {
252
+ if (signal?.aborted) {
253
+ reject(new DOMException("Aborted", "AbortError"));
254
+ return;
255
+ }
256
+ const timer = setTimeout(() => {
257
+ cleanup();
258
+ resolve();
259
+ }, ms);
260
+ const onAbort = () => {
261
+ cleanup();
262
+ reject(new DOMException("Aborted", "AbortError"));
263
+ };
264
+ const cleanup = () => {
265
+ clearTimeout(timer);
266
+ signal?.removeEventListener("abort", onAbort);
267
+ };
268
+ signal?.addEventListener("abort", onAbort, { once: true });
269
+ });
270
+ }
271
+ function isAbortError(err) {
272
+ return err instanceof Error && err.name === "AbortError";
273
+ }
274
+
275
+ // src/client.ts
276
+ var DEFAULT_ADMIN_USER_ID = "host-admin";
277
+ var DEFAULT_AUDITOR_USER_ID = "host-auditor";
278
+ var DEFAULT_AUDIENCE = "dotwire";
279
+ var DEFAULT_TOKEN_TTL = "15m";
280
+ var DEFAULT_REQUEST_TIMEOUT_MS = 15e3;
281
+ var DEFAULT_ROLE_CACHE_TTL_MS = 3e5;
282
+ var TOKEN_REFRESH_MARGIN_SECONDS = 60;
283
+ var CachedToken = class {
284
+ mint;
285
+ token;
286
+ expiresAtSeconds = 0;
287
+ constructor(mint) {
288
+ this.mint = mint;
289
+ }
290
+ async get() {
291
+ const now = Date.now() / 1e3;
292
+ if (this.token && this.expiresAtSeconds - now > TOKEN_REFRESH_MARGIN_SECONDS) {
293
+ return this.token;
294
+ }
295
+ this.token = await this.mint();
296
+ this.expiresAtSeconds = decodeExpiry(this.token) ?? now + 900;
297
+ return this.token;
298
+ }
299
+ };
300
+ function decodeExpiry(jwt) {
301
+ try {
302
+ const payloadSegment = jwt.split(".")[1];
303
+ const json = Buffer.from(payloadSegment, "base64url").toString("utf8");
304
+ const payload = JSON.parse(json);
305
+ return typeof payload.exp === "number" ? payload.exp : void 0;
306
+ } catch {
307
+ return void 0;
308
+ }
309
+ }
310
+ var RoleCache = class {
311
+ entries = /* @__PURE__ */ new Map();
312
+ ttlMs;
313
+ constructor(ttlMs) {
314
+ this.ttlMs = ttlMs;
315
+ }
316
+ get(userId) {
317
+ const entry = this.entries.get(userId);
318
+ if (!entry) return void 0;
319
+ if (Date.now() > entry.expiresAt) {
320
+ this.entries.delete(userId);
321
+ return void 0;
322
+ }
323
+ return entry.role;
324
+ }
325
+ set(userId, role) {
326
+ this.entries.set(userId, { role, expiresAt: Date.now() + this.ttlMs });
327
+ }
328
+ };
329
+ var DotwireHostClient = class {
330
+ config;
331
+ signer;
332
+ baseUrl;
333
+ fetchImpl;
334
+ requestTimeoutMs;
335
+ adminUserId;
336
+ auditorUserId;
337
+ defaultTtl;
338
+ roleCache;
339
+ adminToken;
340
+ auditorToken;
341
+ presend;
342
+ postSend;
343
+ constructor(config) {
344
+ this.config = config;
345
+ this.signer = new DotwireTokenSigner({
346
+ ...config,
347
+ audience: config.audience ?? DEFAULT_AUDIENCE,
348
+ defaultTokenTtl: config.defaultTokenTtl ?? DEFAULT_TOKEN_TTL
349
+ });
350
+ this.baseUrl = config.baseUrl.replace(/\/$/, "");
351
+ this.fetchImpl = config.fetch ?? fetch;
352
+ this.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
353
+ this.adminUserId = config.adminUserId ?? DEFAULT_ADMIN_USER_ID;
354
+ this.auditorUserId = config.auditorUserId ?? DEFAULT_AUDITOR_USER_ID;
355
+ this.defaultTtl = config.defaultTokenTtl ?? DEFAULT_TOKEN_TTL;
356
+ this.roleCache = new RoleCache(config.roleCacheTtlMs ?? DEFAULT_ROLE_CACHE_TTL_MS);
357
+ this.adminToken = new CachedToken(() => this.signer.mintToken(this.adminUserId, { role: "admin", ttl: this.defaultTtl }));
358
+ this.auditorToken = new CachedToken(() => this.signer.mintToken(this.auditorUserId, { role: "auditor", ttl: this.defaultTtl }));
359
+ this.presend = config.presend;
360
+ this.postSend = config.postSend;
361
+ }
362
+ // ---------------------------------------------------------------------
363
+ // Tokens
364
+ // ---------------------------------------------------------------------
365
+ /** Mints a token directly. An explicit `role` bypasses all role resolution. */
366
+ async mintToken(userId, options = {}) {
367
+ return this.signer.mintToken(userId, { ttl: this.defaultTtl, ...options });
368
+ }
369
+ /**
370
+ * Returns a `Response` with body `{ token }`. When `role` is omitted, resolves it through the
371
+ * cached admin lookup and falls back to `member` for an unknown user (404 on the lookup) since
372
+ * there is no request of its own to fail here.
373
+ */
374
+ async tokenResponse(options) {
375
+ const role = options.role ?? await this.resolveRoleForToken(options.userId);
376
+ const token = await this.mintToken(options.userId, { role, displayName: options.displayName, ttl: options.ttl });
377
+ return jsonResponse(200, { token });
378
+ }
379
+ async resolveRoleForToken(userId) {
380
+ const cached = this.roleCache.get(userId);
381
+ if (cached !== void 0) return cached;
382
+ const looked = await this.lookupRole(userId);
383
+ const role = looked ?? "member";
384
+ this.roleCache.set(userId, role);
385
+ return role;
386
+ }
387
+ // ---------------------------------------------------------------------
388
+ // Roles
389
+ // ---------------------------------------------------------------------
390
+ async setUserRole(userId, role) {
391
+ const res = await this.adminRequest("PUT", `/admin/users/${encodeURIComponent(userId)}/role`, { body: { role } });
392
+ await this.throwIfError(res, "PUT", `/admin/users/${userId}/role`);
393
+ this.roleCache.set(userId, role);
394
+ return await res.json();
395
+ }
396
+ async getUserRole(userId) {
397
+ const path = `/admin/users/${userId}/role`;
398
+ const res = await this.adminRequest("GET", `/admin/users/${encodeURIComponent(userId)}/role`);
399
+ if (res.status === 404) return null;
400
+ await this.throwIfError(res, "GET", path);
401
+ return await res.json();
402
+ }
403
+ async deleteUserRole(userId) {
404
+ const path = `/admin/users/${userId}/role`;
405
+ const res = await this.adminRequest("DELETE", `/admin/users/${encodeURIComponent(userId)}/role`);
406
+ if (res.status === 404) return;
407
+ await this.throwIfError(res, "DELETE", path);
408
+ }
409
+ async getUserRooms(userId) {
410
+ const path = `/admin/users/${userId}/rooms`;
411
+ const res = await this.adminRequest("GET", `/admin/users/${encodeURIComponent(userId)}/rooms`);
412
+ await this.throwIfError(res, "GET", path);
413
+ const data = await res.json();
414
+ return data.roomIds;
415
+ }
416
+ async exportUser(userId) {
417
+ const path = `/admin/users/${userId}/export`;
418
+ const res = await this.adminRequest("GET", `/admin/users/${encodeURIComponent(userId)}/export`);
419
+ await this.throwIfError(res, "GET", path);
420
+ return await res.json();
421
+ }
422
+ /** Returns the raw response stream so the caller can pipe an arbitrarily large export without buffering it. */
423
+ async exportUserStream(userId) {
424
+ const path = `/admin/users/${userId}/export`;
425
+ const res = await this.adminRequest("GET", `/admin/users/${encodeURIComponent(userId)}/export`);
426
+ await this.throwIfError(res, "GET", path);
427
+ if (!res.body) throw new Error("dotwire export response had no body");
428
+ return res.body;
429
+ }
430
+ async lookupRole(userId) {
431
+ const res = await this.adminRequest("GET", `/admin/users/${encodeURIComponent(userId)}/role`);
432
+ if (res.status === 404) return null;
433
+ await this.throwIfError(res, "GET", `/admin/users/${userId}/role`);
434
+ const data = await res.json();
435
+ return data.role;
436
+ }
437
+ // ---------------------------------------------------------------------
438
+ // Rooms
439
+ // ---------------------------------------------------------------------
440
+ room(roomId) {
441
+ return new DotwireHostRoom(this, roomId);
442
+ }
443
+ async addRoomMembers(roomId, userIds, options = {}) {
444
+ const ensureMemberRole = options.ensureMemberRole ?? true;
445
+ const path = `/admin/rooms/${roomId}/members`;
446
+ const res = await this.adminRequest("POST", path, { body: { userIds, ensureMemberRole } });
447
+ await this.throwIfError(res, "POST", path);
448
+ const data = await res.json();
449
+ return data.members;
450
+ }
451
+ async removeRoomMember(roomId, userId) {
452
+ const path = `/admin/rooms/${roomId}/members/${userId}`;
453
+ const res = await this.adminRequest("DELETE", `/admin/rooms/${roomId}/members/${encodeURIComponent(userId)}`);
454
+ if (res.status === 404) return;
455
+ await this.throwIfError(res, "DELETE", path);
456
+ }
457
+ async getRoomMembers(roomId) {
458
+ const path = `/admin/rooms/${roomId}/members`;
459
+ const res = await this.adminRequest("GET", path);
460
+ await this.throwIfError(res, "GET", path);
461
+ const data = await res.json();
462
+ return data.members;
463
+ }
464
+ /** Reads participants under the user's own room membership, without admin access. */
465
+ async getRoomParticipants(roomId, asUserId) {
466
+ const path = `/rooms/${roomId}/participants`;
467
+ const res = await this.fetchAsUser("GET", path, asUserId);
468
+ await this.throwIfError(res, "GET", path);
469
+ return await res.json();
470
+ }
471
+ // ---------------------------------------------------------------------
472
+ // Sending
473
+ // ---------------------------------------------------------------------
474
+ async sendAsUser(roomId, userId, content) {
475
+ if (this.presend) {
476
+ const result = await this.presend({ roomId, content, senderUserId: userId });
477
+ if (!result.allow) {
478
+ throw new PresendRejectedError(result.rejectionReason ?? "Message rejected by presend hook.");
479
+ }
480
+ if (result.content !== void 0) {
481
+ content = result.content;
482
+ }
483
+ }
484
+ const path = `/rooms/${roomId}/messages`;
485
+ const res = await this.fetchAsUser("POST", `/rooms/${roomId}/messages`, userId, { body: { content } });
486
+ await this.throwIfError(res, "POST", path);
487
+ const ack = await res.json();
488
+ if (this.postSend) {
489
+ try {
490
+ await this.postSend({ roomId, seq: ack.seq, time: ack.time, content, senderUserId: userId });
491
+ } catch (err) {
492
+ throw new PostSendError(ack, err);
493
+ }
494
+ }
495
+ return ack;
496
+ }
497
+ /** Admin message injection (spec §3.3). Presend/postSend hooks do not run for system injections. */
498
+ async sendSystemMessage(roomId, content, options = {}) {
499
+ const senderId = options.senderId ?? "system";
500
+ const path = `/admin/rooms/${roomId}/messages`;
501
+ const res = await this.adminRequest("POST", path, { body: { content, senderId } });
502
+ await this.throwIfError(res, "POST", path);
503
+ return await res.json();
504
+ }
505
+ async getMessages(roomId, options) {
506
+ const path = `/rooms/${roomId}/messages`;
507
+ const res = await this.fetchAsUser("GET", path, options.asUserId, {
508
+ query: { afterSeq: options.afterSeq, beforeSeq: options.beforeSeq, limit: options.limit }
509
+ });
510
+ await this.throwIfError(res, "GET", path);
511
+ return await res.json();
512
+ }
513
+ /** Parses SSE from `GET /rooms/{roomId}/events`, reconnecting on drop when `autoResume` (default true). */
514
+ streamRoomEvents(roomId, options) {
515
+ return this.streamRoomEventsGenerator(roomId, options);
516
+ }
517
+ async *streamRoomEventsGenerator(roomId, options) {
518
+ const autoResume = options.autoResume ?? true;
519
+ const signal = options.signal;
520
+ let lastSeq = options.afterSeq;
521
+ let backoffMs = 1e3;
522
+ const maxBackoffMs = 3e4;
523
+ while (true) {
524
+ if (signal?.aborted) return;
525
+ let response;
526
+ try {
527
+ response = await this.fetchAsUser("GET", `/rooms/${roomId}/events`, options.asUserId, {
528
+ query: lastSeq === void 0 ? {} : { afterSeq: lastSeq },
529
+ extraHeaders: {
530
+ Accept: "text/event-stream",
531
+ ...lastSeq !== void 0 ? { "Last-Event-ID": String(lastSeq) } : {}
532
+ },
533
+ signal,
534
+ timeout: false
535
+ });
536
+ } catch (err) {
537
+ if (signal?.aborted || isAbortError(err)) return;
538
+ if (!autoResume) throw err;
539
+ await delay(backoffMs, signal).catch(() => void 0);
540
+ backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
541
+ continue;
542
+ }
543
+ if (!response.ok) {
544
+ await this.throwIfError(response, "GET", `/rooms/${roomId}/events`);
545
+ }
546
+ if (!response.body) return;
547
+ let revoked = false;
548
+ try {
549
+ for await (const raw of parseSseStream(response.body, signal)) {
550
+ const event = toRoomEvent(raw);
551
+ if (!event) continue;
552
+ if (event.type === "message") lastSeq = event.seq;
553
+ yield event;
554
+ if (event.type === "revoked") {
555
+ revoked = true;
556
+ break;
557
+ }
558
+ }
559
+ } catch (err) {
560
+ if (signal?.aborted || isAbortError(err)) return;
561
+ if (!autoResume) throw err;
562
+ await delay(backoffMs, signal).catch(() => void 0);
563
+ backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
564
+ continue;
565
+ }
566
+ if (revoked || !autoResume || signal?.aborted) return;
567
+ backoffMs = 1e3;
568
+ await delay(backoffMs, signal).catch(() => void 0);
569
+ }
570
+ }
571
+ async redactMessage(roomId, seq) {
572
+ const path = `/admin/rooms/${roomId}/messages/${seq}`;
573
+ const res = await this.adminRequest("DELETE", path);
574
+ await this.throwIfError(res, "DELETE", path);
575
+ return await res.json();
576
+ }
577
+ // ---------------------------------------------------------------------
578
+ // Audit
579
+ // ---------------------------------------------------------------------
580
+ async readAuditLog(afterId = 0, limit = 100) {
581
+ const path = "/audit";
582
+ const res = await this.auditorRequest("GET", path, { query: { afterId, limit } });
583
+ await this.throwIfError(res, "GET", path);
584
+ return await res.json();
585
+ }
586
+ async verifyAuditLog(full = false) {
587
+ const path = "/audit/verify";
588
+ const res = await this.auditorRequest("GET", path, { query: { full } });
589
+ await this.throwIfError(res, "GET", path);
590
+ return await res.json();
591
+ }
592
+ async getAuditCheckpoints(limit = 30) {
593
+ const path = "/audit/checkpoints";
594
+ const res = await this.auditorRequest("GET", path, { query: { limit } });
595
+ await this.throwIfError(res, "GET", path);
596
+ const data = await res.json();
597
+ return data.checkpoints;
598
+ }
599
+ // ---------------------------------------------------------------------
600
+ // Retention
601
+ // ---------------------------------------------------------------------
602
+ async getMessageRetentionDays() {
603
+ const path = "/admin/retention";
604
+ const res = await this.adminRequest("GET", path);
605
+ await this.throwIfError(res, "GET", path);
606
+ return (await res.json()).messagesDays;
607
+ }
608
+ async setMessageRetentionDays(days) {
609
+ const path = "/admin/retention";
610
+ const res = await this.adminRequest("PUT", path, { body: { messagesDays: days } });
611
+ await this.throwIfError(res, "PUT", path);
612
+ return (await res.json()).messagesDays;
613
+ }
614
+ // ---------------------------------------------------------------------
615
+ // Request handlers (web-standard Request/Response)
616
+ // ---------------------------------------------------------------------
617
+ /** Parses `{ roomId, content }`, calls {@link sendAsUser}, and mirrors the server's own send response shape/status. */
618
+ async handleClientSend(request, senderUserId) {
619
+ let parsed;
620
+ try {
621
+ parsed = await request.json();
622
+ } catch {
623
+ return jsonResponse(400, { error: "invalid_request" });
624
+ }
625
+ const result = await this.handleClientSendCore(parsed, senderUserId);
626
+ return jsonResponse(result.status, result.body);
627
+ }
628
+ /** Express-friendly variant: `body` is already-parsed JSON. */
629
+ async handleClientSendBody(body, senderUserId) {
630
+ return this.handleClientSendCore(body, senderUserId);
631
+ }
632
+ async handleClientSendCore(body, senderUserId) {
633
+ const b = body ?? {};
634
+ if (typeof b.roomId !== "string" || typeof b.content !== "string") {
635
+ return { status: 400, body: { error: "invalid_request" } };
636
+ }
637
+ try {
638
+ const ack = await this.sendAsUser(b.roomId, senderUserId, b.content);
639
+ return { status: 202, body: ack };
640
+ } catch (err) {
641
+ if (err instanceof PresendRejectedError) {
642
+ return { status: 422, body: { error: "presend_rejected", reason: err.reason } };
643
+ }
644
+ if (err instanceof DotwireApiError) {
645
+ return { status: err.status, body: { error: err.code ?? "unknown", reason: err.reason } };
646
+ }
647
+ if (err instanceof PostSendError) {
648
+ return { status: 202, body: err.sent };
649
+ }
650
+ throw err;
651
+ }
652
+ }
653
+ /** Verifies `X-Dotwire-Signature` (when `webhookSecret` is set), runs the local `presend` hook, and mirrors the server's presend webhook response shape. */
654
+ async handlePresendWebhook(request) {
655
+ const text = await request.text();
656
+ const signature = request.headers.get("x-dotwire-signature");
657
+ const result = await this.handlePresendWebhookCore(text, signature);
658
+ return jsonResponse(result.status, result.body);
659
+ }
660
+ async handlePresendWebhookBody(body, signatureHeader, rawBody) {
661
+ const text = rawBody ?? JSON.stringify(body);
662
+ return this.handlePresendWebhookCore(text, signatureHeader ?? null, body);
663
+ }
664
+ async handlePresendWebhookCore(rawText, signatureHeader, parsedBody) {
665
+ if (!this.verifySignature(rawText, signatureHeader)) {
666
+ return { status: 401, body: { allow: false, reason: "invalid_signature" } };
667
+ }
668
+ let payload;
669
+ try {
670
+ payload = parsedBody ?? JSON.parse(rawText);
671
+ } catch {
672
+ return { status: 400, body: { allow: false, reason: "invalid_request" } };
673
+ }
674
+ if (typeof payload.roomId !== "string" || typeof payload.senderId !== "string" || typeof payload.content !== "string") {
675
+ return { status: 400, body: { allow: false, reason: "invalid_request" } };
676
+ }
677
+ if (!this.presend) {
678
+ return { status: 200, body: { allow: true } };
679
+ }
680
+ try {
681
+ const result = await this.presend({ roomId: payload.roomId, content: payload.content, senderUserId: payload.senderId });
682
+ if (!result.allow) {
683
+ return { status: 200, body: { allow: false, reason: result.rejectionReason } };
684
+ }
685
+ return { status: 200, body: { allow: true, content: result.content } };
686
+ } catch (err) {
687
+ return { status: 200, body: { allow: false, reason: err instanceof Error ? err.message : "presend hook failed" } };
688
+ }
689
+ }
690
+ /** Verifies the signature, runs the local `postSend` hook, and always answers `{}` (postsend is best-effort). */
691
+ async handlePostSendWebhook(request) {
692
+ const text = await request.text();
693
+ const signature = request.headers.get("x-dotwire-signature");
694
+ const result = await this.handlePostSendWebhookCore(text, signature);
695
+ return jsonResponse(result.status, result.body);
696
+ }
697
+ async handlePostSendWebhookBody(body, signatureHeader, rawBody) {
698
+ const text = rawBody ?? JSON.stringify(body);
699
+ return this.handlePostSendWebhookCore(text, signatureHeader ?? null, body);
700
+ }
701
+ async handlePostSendWebhookCore(rawText, signatureHeader, parsedBody) {
702
+ if (!this.verifySignature(rawText, signatureHeader)) {
703
+ return { status: 401, body: {} };
704
+ }
705
+ let payload;
706
+ try {
707
+ payload = parsedBody ?? JSON.parse(rawText);
708
+ } catch {
709
+ return { status: 400, body: {} };
710
+ }
711
+ if (typeof payload.roomId !== "string" || typeof payload.seq !== "number" || typeof payload.senderId !== "string" || typeof payload.content !== "string" || typeof payload.time !== "string") {
712
+ return { status: 400, body: {} };
713
+ }
714
+ if (this.postSend) {
715
+ try {
716
+ await this.postSend({ roomId: payload.roomId, seq: payload.seq, time: payload.time, content: payload.content, senderUserId: payload.senderId });
717
+ } catch {
718
+ }
719
+ }
720
+ return { status: 200, body: {} };
721
+ }
722
+ verifySignature(rawBody, signatureHeader) {
723
+ if (!this.config.webhookSecret) return true;
724
+ if (!signatureHeader) return false;
725
+ const prefix = "sha256=";
726
+ if (!signatureHeader.startsWith(prefix)) return false;
727
+ const provided = signatureHeader.slice(prefix.length);
728
+ const expected = (0, import_node_crypto.createHmac)("sha256", this.config.webhookSecret).update(rawBody, "utf8").digest("hex");
729
+ const providedBuf = Buffer.from(provided, "hex");
730
+ const expectedBuf = Buffer.from(expected, "hex");
731
+ if (providedBuf.length !== expectedBuf.length) return false;
732
+ return (0, import_node_crypto.timingSafeEqual)(providedBuf, expectedBuf);
733
+ }
734
+ // ---------------------------------------------------------------------
735
+ // HTTP plumbing
736
+ // ---------------------------------------------------------------------
737
+ async adminRequest(method, path, opts = {}) {
738
+ const token = await this.adminToken.get();
739
+ return this.rawFetch(method, path, { ...opts, token });
740
+ }
741
+ async auditorRequest(method, path, opts = {}) {
742
+ const token = await this.auditorToken.get();
743
+ return this.rawFetch(method, path, { ...opts, token });
744
+ }
745
+ /**
746
+ * Role resolution for user-scoped operations (spec §4.3 / §5): try the cached role (or
747
+ * `member` when nothing is cached); on 403, look the role up through the admin API, cache it,
748
+ * and retry once. A second 403 surfaces as a `Forbidden` {@link DotwireApiError}.
749
+ */
750
+ async fetchAsUser(method, path, userId, opts = {}) {
751
+ const cachedRole = this.roleCache.get(userId);
752
+ const firstRole = cachedRole ?? "member";
753
+ const attempt = async (role) => {
754
+ const token = await this.signer.mintToken(userId, { role, ttl: this.defaultTtl });
755
+ return this.rawFetch(method, path, { ...opts, token });
756
+ };
757
+ let res = await attempt(firstRole);
758
+ if (res.status === 403) {
759
+ const looked = await this.lookupRole(userId);
760
+ const resolvedRole = looked ?? "member";
761
+ this.roleCache.set(userId, resolvedRole);
762
+ res = await attempt(resolvedRole);
763
+ }
764
+ return res;
765
+ }
766
+ async rawFetch(method, path, opts) {
767
+ const url = new URL(this.baseUrl + path);
768
+ if (opts.query) {
769
+ for (const [key, value] of Object.entries(opts.query)) {
770
+ if (value !== void 0) url.searchParams.set(key, String(value));
771
+ }
772
+ }
773
+ const headers = { ...opts.extraHeaders };
774
+ if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
775
+ if (opts.body !== void 0) headers["Content-Type"] = "application/json";
776
+ const useTimeout = opts.timeout ?? true;
777
+ let signal = opts.signal;
778
+ if (useTimeout) {
779
+ const timeoutSignal = AbortSignal.timeout(this.requestTimeoutMs);
780
+ signal = opts.signal ? AbortSignal.any([opts.signal, timeoutSignal]) : timeoutSignal;
781
+ }
782
+ return this.fetchImpl(url.toString(), {
783
+ method,
784
+ headers,
785
+ body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0,
786
+ signal
787
+ });
788
+ }
789
+ async throwIfError(res, method, path) {
790
+ if (res.ok) return;
791
+ let code;
792
+ let reason;
793
+ try {
794
+ const clone = res.clone();
795
+ const body = await clone.json();
796
+ code = body?.error;
797
+ reason = body?.reason;
798
+ } catch {
799
+ }
800
+ const retryAfterHeader = res.headers.get("retry-after");
801
+ const retryAfterMs = retryAfterHeader ? Number(retryAfterHeader) * 1e3 : void 0;
802
+ throw new DotwireApiError({
803
+ status: res.status,
804
+ kind: mapErrorKind(res.status, code),
805
+ code,
806
+ reason,
807
+ retryAfterMs,
808
+ method,
809
+ path
810
+ });
811
+ }
812
+ };
813
+ function jsonResponse(status, body) {
814
+ return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
815
+ }
816
+
817
+ // src/moderation.ts
818
+ var ModerationQueue = class {
819
+ items = [];
820
+ waitingReaders = [];
821
+ waitingWriters = [];
822
+ completed = false;
823
+ capacity;
824
+ constructor(capacity = 1e4) {
825
+ this.capacity = capacity;
826
+ }
827
+ async enqueue(item, signal) {
828
+ if (this.completed) throw new Error("ModerationQueue is already completed");
829
+ while (this.items.length >= this.capacity) {
830
+ await this.waitForWriteSlot(signal);
831
+ }
832
+ this.items.push(item);
833
+ this.wake(this.waitingReaders);
834
+ }
835
+ /** Assign directly: `client.postSend = queue.postSendHook`. */
836
+ postSendHook = (ctx) => this.enqueue({ roomId: ctx.roomId, seq: ctx.seq, time: ctx.time, senderUserId: ctx.senderUserId, content: ctx.content });
837
+ /** Signals the worker to drain what is queued and stop. */
838
+ complete() {
839
+ this.completed = true;
840
+ this.wake(this.waitingReaders);
841
+ }
842
+ /** Blocks for an item; resolves null once completed and drained, or if `signal` fires. */
843
+ async dequeue(signal) {
844
+ while (this.items.length === 0) {
845
+ if (this.completed || signal?.aborted) return null;
846
+ await new Promise((resolve) => {
847
+ const onAbort = () => resolve();
848
+ signal?.addEventListener("abort", onAbort, { once: true });
849
+ this.waitingReaders.push(() => {
850
+ signal?.removeEventListener("abort", onAbort);
851
+ resolve();
852
+ });
853
+ });
854
+ }
855
+ return this.take();
856
+ }
857
+ /** Waits up to `timeoutMs` for an item. Resolves 'timeout' on expiry, null on completion/abort. */
858
+ async tryDequeue(timeoutMs, signal) {
859
+ if (this.items.length > 0) return this.take();
860
+ if (this.completed || signal?.aborted) return null;
861
+ return new Promise((resolve) => {
862
+ let settled = false;
863
+ const finish = (value) => {
864
+ if (settled) return;
865
+ settled = true;
866
+ clearTimeout(timer);
867
+ signal?.removeEventListener("abort", onAbort);
868
+ resolve(value);
869
+ };
870
+ const timer = setTimeout(() => finish("timeout"), timeoutMs);
871
+ const onAbort = () => finish(null);
872
+ const wake = () => {
873
+ if (settled) return;
874
+ if (this.items.length > 0) finish(this.take());
875
+ else if (this.completed) finish(null);
876
+ else this.waitingReaders.push(wake);
877
+ };
878
+ signal?.addEventListener("abort", onAbort, { once: true });
879
+ this.waitingReaders.push(wake);
880
+ });
881
+ }
882
+ take() {
883
+ const item = this.items.shift();
884
+ const writer = this.waitingWriters.shift();
885
+ if (writer) writer();
886
+ return item;
887
+ }
888
+ waitForWriteSlot(signal) {
889
+ return new Promise((resolve) => {
890
+ const onAbort = () => resolve();
891
+ signal?.addEventListener("abort", onAbort, { once: true });
892
+ this.waitingWriters.push(() => {
893
+ signal?.removeEventListener("abort", onAbort);
894
+ resolve();
895
+ });
896
+ });
897
+ }
898
+ wake(waiters) {
899
+ const pending = waiters.splice(0);
900
+ for (const w of pending) w();
901
+ }
902
+ };
903
+ var BatchModerationWorker = class {
904
+ queue;
905
+ classifier;
906
+ onFlagged;
907
+ onError;
908
+ maxBatchSize;
909
+ maxBatchDelayMs;
910
+ maxClassifierAttempts;
911
+ constructor(queue, classifier, onFlagged, options = {}, onError) {
912
+ this.queue = queue;
913
+ this.classifier = classifier;
914
+ this.onFlagged = onFlagged;
915
+ this.onError = onError;
916
+ this.maxBatchSize = options.maxBatchSize ?? 100;
917
+ this.maxBatchDelayMs = options.maxBatchDelayMs ?? 3e4;
918
+ this.maxClassifierAttempts = options.maxClassifierAttempts ?? 3;
919
+ }
920
+ /** The usual action: physically redact the flagged message through the admin API, honouring `Retry-After` on 429. */
921
+ static redactWith(host) {
922
+ return async (verdict) => {
923
+ const maxAttempts = 3;
924
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
925
+ try {
926
+ await host.redactMessage(verdict.roomId, verdict.seq);
927
+ return;
928
+ } catch (err) {
929
+ if (err instanceof DotwireApiError && err.status === 429 && attempt < maxAttempts) {
930
+ await delay(err.retryAfterMs ?? 1e3 * attempt);
931
+ continue;
932
+ }
933
+ throw err;
934
+ }
935
+ }
936
+ };
937
+ }
938
+ /** Runs until `signal` fires or the queue is completed and drained. */
939
+ async run(signal) {
940
+ while (true) {
941
+ const batch = await this.fillBatch(signal);
942
+ if (batch.length === 0) return;
943
+ await this.processBatch(batch, signal);
944
+ if (signal.aborted) return;
945
+ }
946
+ }
947
+ async fillBatch(signal) {
948
+ const batch = [];
949
+ const first = await this.queue.dequeue(signal);
950
+ if (first === null) return batch;
951
+ batch.push(first);
952
+ const deadline = Date.now() + this.maxBatchDelayMs;
953
+ while (batch.length < this.maxBatchSize) {
954
+ const remaining = deadline - Date.now();
955
+ if (remaining <= 0) break;
956
+ const item = await this.queue.tryDequeue(remaining, signal);
957
+ if (item === "timeout" || item === null) break;
958
+ batch.push(item);
959
+ }
960
+ return batch;
961
+ }
962
+ async processBatch(batch, signal) {
963
+ let verdicts;
964
+ for (let attempt = 1; attempt <= this.maxClassifierAttempts; attempt++) {
965
+ try {
966
+ verdicts = await this.classifier.classify(batch, signal);
967
+ break;
968
+ } catch (err) {
969
+ if (signal.aborted) return;
970
+ this.onError?.(err);
971
+ if (attempt === this.maxClassifierAttempts) return;
972
+ await delay(1e3 * Math.pow(2, attempt), signal).catch(() => void 0);
973
+ }
974
+ }
975
+ if (!verdicts) return;
976
+ for (const verdict of verdicts) {
977
+ if (!verdict.flagged) continue;
978
+ try {
979
+ await this.onFlagged(verdict, signal);
980
+ } catch (err) {
981
+ if (signal.aborted) return;
982
+ this.onError?.(err);
983
+ }
984
+ }
985
+ }
986
+ };
987
+ // Annotate the CommonJS export names for ESM import in node:
988
+ 0 && (module.exports = {
989
+ BatchModerationWorker,
990
+ DotwireApiError,
991
+ DotwireHostClient,
992
+ DotwireHostRoom,
993
+ DotwireTokenSigner,
994
+ ModerationQueue,
995
+ PostSendError,
996
+ PresendRejectedError,
997
+ PresendResult,
998
+ mapErrorKind
999
+ });
1000
+ //# sourceMappingURL=index.cjs.map