@dotwire/host 0.0.0-stage → 0.2.1

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,464 @@
1
+ type DotwireRole = 'member' | 'auditor' | 'admin';
2
+ interface DotwireHostConfig {
3
+ baseUrl: string;
4
+ issuer: string;
5
+ audience?: string;
6
+ keyId: string;
7
+ privateKeyPem: string;
8
+ adminUserId?: string;
9
+ auditorUserId?: string;
10
+ /** jose-style duration string (e.g. "15m"), passed straight through to `SignJWT#setExpirationTime`. */
11
+ defaultTokenTtl?: string;
12
+ requestTimeoutMs?: number;
13
+ /** HMAC-SHA256 secret used to verify `X-Dotwire-Signature` on incoming webhook calls. */
14
+ webhookSecret?: string;
15
+ roleCacheTtlMs?: number;
16
+ /** Every request goes through this so tests can inject a fake. Defaults to global `fetch`. */
17
+ fetch?: typeof fetch;
18
+ /**
19
+ * Runs in-process before {@link DotwireHostClient.sendAsUser} and {@link DotwireHostClient.handleClientSend},
20
+ * and inside {@link DotwireHostClient.handlePresendWebhook} - one place for moderation logic that
21
+ * covers both host-initiated sends and messages sent directly by `@dotwire/client`.
22
+ */
23
+ presend?: (ctx: PresendContext) => Promise<PresendResult> | PresendResult;
24
+ /** Same idea as {@link presend}, run after a message is accepted; see {@link PostSendContext}. */
25
+ postSend?: (ctx: PostSendContext) => Promise<void> | void;
26
+ }
27
+ interface MintTokenOptions {
28
+ role?: DotwireRole;
29
+ displayName?: string;
30
+ /** jose-style duration string, e.g. "15m". Defaults to `config.defaultTokenTtl`. */
31
+ ttl?: string;
32
+ }
33
+ interface PresendContext {
34
+ roomId: string;
35
+ content: string;
36
+ senderUserId: string;
37
+ }
38
+ interface PresendResult {
39
+ allow: boolean;
40
+ /** Rewritten content; only meaningful when `allow` is true. */
41
+ content?: string;
42
+ rejectionReason?: string;
43
+ }
44
+ declare const PresendResult: {
45
+ accept(content?: string): PresendResult;
46
+ reject(reason: string): PresendResult;
47
+ };
48
+ /** What a postSend hook sees: the accepted message plus its ack, so it can be found again by (roomId, seq). */
49
+ interface PostSendContext {
50
+ roomId: string;
51
+ seq: number;
52
+ time: string;
53
+ content: string;
54
+ senderUserId: string;
55
+ }
56
+ interface SendMessageResult {
57
+ seq: number;
58
+ time: string;
59
+ }
60
+ interface HistoryMessage {
61
+ seq: number;
62
+ time: string;
63
+ senderId: string;
64
+ content: string;
65
+ }
66
+ interface RoomHistoryResponse {
67
+ roomId: string;
68
+ messages: HistoryMessage[];
69
+ hasMore: boolean;
70
+ }
71
+ interface UserRoleResponse {
72
+ userId: string;
73
+ role: DotwireRole;
74
+ }
75
+ interface RoomMembersResponse {
76
+ roomId: string;
77
+ members: string[];
78
+ }
79
+ interface UserRoomsResponse {
80
+ userId: string;
81
+ roomIds: string[];
82
+ }
83
+ interface RedactMessageResponse {
84
+ roomId: string;
85
+ seq: number;
86
+ removedFromHistory: boolean;
87
+ removedFromStream: boolean;
88
+ }
89
+ interface RetentionResponse {
90
+ messagesDays: number;
91
+ }
92
+ interface AuditEntry {
93
+ id: number;
94
+ eventType: string;
95
+ roomId: string | null;
96
+ messageSeq: number | null;
97
+ actorId: string | null;
98
+ subjectId: string | null;
99
+ value: number | null;
100
+ time: string;
101
+ hash: string;
102
+ }
103
+ interface AuditPageResponse {
104
+ events: AuditEntry[];
105
+ hasMore: boolean;
106
+ }
107
+ interface AuditVerificationResponse {
108
+ ok: boolean;
109
+ fromId: number;
110
+ toId: number;
111
+ checked: number;
112
+ firstInvalidId: number | null;
113
+ anchor: string | null;
114
+ }
115
+ interface AuditCheckpointEntry {
116
+ day: string;
117
+ lastId: number;
118
+ hash: string;
119
+ createdAt: string;
120
+ }
121
+ interface AuditCheckpointsResponse {
122
+ checkpoints: AuditCheckpointEntry[];
123
+ }
124
+ interface UserExportMessage {
125
+ roomId: string;
126
+ seq: number;
127
+ time: string;
128
+ content: string | null;
129
+ undecryptable?: boolean;
130
+ }
131
+ interface UserExportAuditEvent {
132
+ id: number;
133
+ eventType: string;
134
+ roomId: string | null;
135
+ messageSeq: number | null;
136
+ actorId: string | null;
137
+ subjectId: string | null;
138
+ value: number | null;
139
+ time: string;
140
+ }
141
+ interface UserExport {
142
+ userId: string;
143
+ exportedAt: string;
144
+ role: DotwireRole | null;
145
+ roomIds: string[];
146
+ messages: UserExportMessage[];
147
+ auditEvents: UserExportAuditEvent[];
148
+ }
149
+ interface GetMessagesOptions {
150
+ asUserId: string;
151
+ afterSeq?: number;
152
+ beforeSeq?: number;
153
+ limit?: number;
154
+ }
155
+ interface AddRoomMembersOptions {
156
+ /** Grants `member` to any listed user with no existing role, without touching an existing auditor/admin. Default true. */
157
+ ensureMemberRole?: boolean;
158
+ }
159
+ interface SendSystemMessageOptions {
160
+ /** Defaults to "system". */
161
+ senderId?: string;
162
+ }
163
+ interface StreamRoomEventsOptions {
164
+ asUserId: string;
165
+ afterSeq?: number;
166
+ /** Reconnect on drop with `Last-Event-ID`, exponential backoff 1s..30s, until cancelled or revoked. Default true. */
167
+ autoResume?: boolean;
168
+ signal?: AbortSignal;
169
+ }
170
+ interface TokenResponseOptions {
171
+ userId: string;
172
+ role?: DotwireRole;
173
+ displayName?: string;
174
+ ttl?: string;
175
+ }
176
+ /** Discriminated union of everything `GET /rooms/{roomId}/events` can emit (spec §3.9). */
177
+ type RoomEvent = {
178
+ type: 'message';
179
+ roomId: string;
180
+ seq: number;
181
+ senderId: string;
182
+ time: string;
183
+ content: string;
184
+ replayed?: boolean;
185
+ } | {
186
+ type: 'retracted';
187
+ roomId: string;
188
+ seq: number;
189
+ } | {
190
+ type: 'typing';
191
+ roomId: string;
192
+ userId: string;
193
+ isTyping: boolean;
194
+ } | {
195
+ type: 'presence';
196
+ roomId: string;
197
+ joined: string[];
198
+ left: string[];
199
+ } | {
200
+ type: 'revoked';
201
+ roomId: string;
202
+ };
203
+ /** Result of {@link DotwireHostClient.handleClientSendBody} / the Request-based `handleClientSend`. */
204
+ interface ClientSendResult {
205
+ status: number;
206
+ body: {
207
+ seq: number;
208
+ time: string;
209
+ } | {
210
+ error: string;
211
+ reason?: string;
212
+ };
213
+ }
214
+ /** Result of the two webhook handlers' body-based variants. */
215
+ interface WebhookResult {
216
+ status: number;
217
+ body: {
218
+ allow: boolean;
219
+ content?: string;
220
+ reason?: string;
221
+ } | Record<string, never>;
222
+ }
223
+
224
+ /**
225
+ * Mirrors `Dotwire.Host.DotwireApiException.Kind` (.NET) / the wire error contract in spec §3.14.
226
+ * Mapped from HTTP status first, then the `error` code in the body.
227
+ */
228
+ type DotwireApiErrorKind = 'BadRequest' | 'Unauthorized' | 'Forbidden' | 'NotFound' | 'PresendRejected' | 'RateLimited' | 'Unavailable' | 'Unknown';
229
+ interface DotwireApiErrorInit {
230
+ status: number;
231
+ kind: DotwireApiErrorKind;
232
+ code?: string;
233
+ reason?: string;
234
+ retryAfterMs?: number;
235
+ method: string;
236
+ path: string;
237
+ }
238
+ /** Thrown when the dotwire server answers a request with a non-success status. */
239
+ declare class DotwireApiError extends Error {
240
+ readonly status: number;
241
+ readonly kind: DotwireApiErrorKind;
242
+ readonly code?: string;
243
+ readonly reason?: string;
244
+ readonly retryAfterMs?: number;
245
+ readonly method: string;
246
+ readonly path: string;
247
+ constructor(init: DotwireApiErrorInit);
248
+ }
249
+ /**
250
+ * Maps an HTTP status (and, where ambiguous, the wire error code from spec §3.14) to a
251
+ * {@link DotwireApiErrorKind}. Status is authoritative; code only disambiguates 422/503/429/400.
252
+ */
253
+ declare function mapErrorKind(status: number, code?: string): DotwireApiErrorKind;
254
+ /**
255
+ * Thrown when the host's own {@link DotwireHostConfig.presend} hook rejects a message, evaluated
256
+ * in-process before the message ever reaches the wire (no network round-trip). Distinct from a
257
+ * {@link DotwireApiError} with kind `PresendRejected`, which is the *server's* configured presend
258
+ * webhook rejecting a message on `POST /rooms/{roomId}/messages` (a different, server-side hook).
259
+ */
260
+ declare class PresendRejectedError extends Error {
261
+ readonly reason: string;
262
+ constructor(reason: string);
263
+ }
264
+ /**
265
+ * The message WAS sent (see {@link sent}); only the local `postSend` hook failed afterwards.
266
+ * Kept distinct from a send failure so callers never retry a send that already succeeded.
267
+ */
268
+ declare class PostSendError extends Error {
269
+ readonly sent: SendMessageResult;
270
+ constructor(sent: SendMessageResult, cause: unknown);
271
+ }
272
+
273
+ declare class DotwireTokenSigner {
274
+ private readonly config;
275
+ private readonly keyPromise;
276
+ private readonly defaultTtl;
277
+ constructor(config: DotwireHostConfig);
278
+ mintToken(userId: string, options?: MintTokenOptions): Promise<string>;
279
+ }
280
+
281
+ /**
282
+ * Node/TypeScript counterpart of `Dotwire.Host.DotwireHostClient` (.NET). Mints its own tokens
283
+ * and talks to a dotwire server entirely over its frozen HTTP surface - see spec §3 for the
284
+ * server's wire shapes and §4/§5 for the identity and role-resolution rules this class follows.
285
+ */
286
+ declare class DotwireHostClient {
287
+ private readonly config;
288
+ private readonly signer;
289
+ private readonly baseUrl;
290
+ private readonly fetchImpl;
291
+ private readonly requestTimeoutMs;
292
+ private readonly adminUserId;
293
+ private readonly auditorUserId;
294
+ private readonly defaultTtl;
295
+ private readonly roleCache;
296
+ private readonly adminToken;
297
+ private readonly auditorToken;
298
+ presend?: (ctx: PresendContext) => Promise<PresendResult> | PresendResult;
299
+ postSend?: (ctx: PostSendContext) => Promise<void> | void;
300
+ constructor(config: DotwireHostConfig);
301
+ /** Mints a token directly. An explicit `role` bypasses all role resolution. */
302
+ mintToken(userId: string, options?: MintTokenOptions): Promise<string>;
303
+ /**
304
+ * Returns a `Response` with body `{ token }`. When `role` is omitted, resolves it through the
305
+ * cached admin lookup and falls back to `member` for an unknown user (404 on the lookup) since
306
+ * there is no request of its own to fail here.
307
+ */
308
+ tokenResponse(options: TokenResponseOptions): Promise<Response>;
309
+ private resolveRoleForToken;
310
+ setUserRole(userId: string, role: DotwireRole): Promise<UserRoleResponse>;
311
+ getUserRole(userId: string): Promise<UserRoleResponse | null>;
312
+ deleteUserRole(userId: string): Promise<void>;
313
+ getUserRooms(userId: string): Promise<string[]>;
314
+ exportUser(userId: string): Promise<UserExport>;
315
+ /** Returns the raw response stream so the caller can pipe an arbitrarily large export without buffering it. */
316
+ exportUserStream(userId: string): Promise<ReadableStream<Uint8Array>>;
317
+ private lookupRole;
318
+ room(roomId: string): DotwireHostRoom;
319
+ addRoomMembers(roomId: string, userIds: string[], options?: AddRoomMembersOptions): Promise<string[]>;
320
+ removeRoomMember(roomId: string, userId: string): Promise<void>;
321
+ getRoomMembers(roomId: string): Promise<string[]>;
322
+ /** Reads participants under the user's own room membership, without admin access. */
323
+ getRoomParticipants(roomId: string, asUserId: string): Promise<string[]>;
324
+ sendAsUser(roomId: string, userId: string, content: string): Promise<SendMessageResult>;
325
+ /** Admin message injection (spec §3.3). Presend/postSend hooks do not run for system injections. */
326
+ sendSystemMessage(roomId: string, content: string, options?: SendSystemMessageOptions): Promise<SendMessageResult>;
327
+ getMessages(roomId: string, options: GetMessagesOptions): Promise<RoomHistoryResponse>;
328
+ /** Parses SSE from `GET /rooms/{roomId}/events`, reconnecting on drop when `autoResume` (default true). */
329
+ streamRoomEvents(roomId: string, options: StreamRoomEventsOptions): AsyncIterable<RoomEvent>;
330
+ private streamRoomEventsGenerator;
331
+ redactMessage(roomId: string, seq: number): Promise<RedactMessageResponse>;
332
+ readAuditLog(afterId?: number, limit?: number): Promise<AuditPageResponse>;
333
+ verifyAuditLog(full?: boolean): Promise<AuditVerificationResponse>;
334
+ getAuditCheckpoints(limit?: number): Promise<AuditCheckpointsResponse['checkpoints']>;
335
+ getMessageRetentionDays(): Promise<number>;
336
+ setMessageRetentionDays(days: number): Promise<number>;
337
+ /** Parses `{ roomId, content }`, calls {@link sendAsUser}, and mirrors the server's own send response shape/status. */
338
+ handleClientSend(request: Request, senderUserId: string): Promise<Response>;
339
+ /** Express-friendly variant: `body` is already-parsed JSON. */
340
+ handleClientSendBody(body: unknown, senderUserId: string): Promise<ClientSendResult>;
341
+ private handleClientSendCore;
342
+ /** Verifies `X-Dotwire-Signature` (when `webhookSecret` is set), runs the local `presend` hook, and mirrors the server's presend webhook response shape. */
343
+ handlePresendWebhook(request: Request): Promise<Response>;
344
+ handlePresendWebhookBody(body: unknown, signatureHeader?: string | null, rawBody?: string): Promise<WebhookResult>;
345
+ private handlePresendWebhookCore;
346
+ /** Verifies the signature, runs the local `postSend` hook, and always answers `{}` (postsend is best-effort). */
347
+ handlePostSendWebhook(request: Request): Promise<Response>;
348
+ handlePostSendWebhookBody(body: unknown, signatureHeader?: string | null, rawBody?: string): Promise<WebhookResult>;
349
+ private handlePostSendWebhookCore;
350
+ private verifySignature;
351
+ private adminRequest;
352
+ private auditorRequest;
353
+ /**
354
+ * Role resolution for user-scoped operations (spec §4.3 / §5): try the cached role (or
355
+ * `member` when nothing is cached); on 403, look the role up through the admin API, cache it,
356
+ * and retry once. A second 403 surfaces as a `Forbidden` {@link DotwireApiError}.
357
+ */
358
+ private fetchAsUser;
359
+ private rawFetch;
360
+ private throwIfError;
361
+ }
362
+
363
+ /** Mirrors {@link DotwireHostClient}'s room-scoped methods without the repeated `roomId` argument. */
364
+ declare class DotwireHostRoom {
365
+ private readonly client;
366
+ readonly roomId: string;
367
+ constructor(client: DotwireHostClient, roomId: string);
368
+ addMembers(userIds: string[], options?: AddRoomMembersOptions): Promise<string[]>;
369
+ removeMember(userId: string): Promise<void>;
370
+ getMembers(): Promise<string[]>;
371
+ getParticipants(asUserId: string): Promise<string[]>;
372
+ sendAsUser(userId: string, content: string): Promise<SendMessageResult>;
373
+ sendSystemMessage(content: string, options?: SendSystemMessageOptions): Promise<SendMessageResult>;
374
+ getMessages(options: GetMessagesOptions): Promise<RoomHistoryResponse>;
375
+ events(options: StreamRoomEventsOptions): AsyncIterable<RoomEvent>;
376
+ redact(seq: number): Promise<RedactMessageResponse>;
377
+ }
378
+
379
+ /** An accepted message awaiting asynchronous review. (roomId, seq) is the redaction target. */
380
+ interface PendingModeration {
381
+ roomId: string;
382
+ seq: number;
383
+ time: string;
384
+ senderUserId: string;
385
+ content: string;
386
+ }
387
+ /** One classifier decision. Only flagged verdicts trigger an action. */
388
+ interface ModerationVerdict {
389
+ roomId: string;
390
+ seq: number;
391
+ flagged: boolean;
392
+ category?: string;
393
+ reason?: string;
394
+ }
395
+ /**
396
+ * The expensive judgment call, run off the send path over a whole batch at once. Implementations
397
+ * call an LLM (see `samples/moderation` for a Claude Message Batches API reference implementation),
398
+ * a third-party moderation API, or anything else too slow to sit inside a send. Return one verdict
399
+ * per input; inputs with no verdict are treated as not flagged.
400
+ */
401
+ interface ModerationClassifier {
402
+ classify(batch: readonly PendingModeration[], signal?: AbortSignal): Promise<readonly ModerationVerdict[]>;
403
+ }
404
+ /**
405
+ * In-process hand-off between the send path and the {@link BatchModerationWorker}. Enqueueing
406
+ * (via {@link postSendHook}) is a microtask-cheap array push, so wiring it into
407
+ * `DotwireHostConfig.postSend` adds no meaningful latency to a send. The verdict itself is
408
+ * computed later, in batches, by the worker.
409
+ *
410
+ * Bounded at `capacity`: once full, {@link enqueue} (and therefore `postSendHook`) waits for
411
+ * room rather than silently dropping - the queue applies back-pressure to sends, mirroring the
412
+ * .NET port's `BoundedChannelFullMode.Wait` default.
413
+ */
414
+ declare class ModerationQueue {
415
+ private readonly items;
416
+ private readonly waitingReaders;
417
+ private readonly waitingWriters;
418
+ private completed;
419
+ private readonly capacity;
420
+ constructor(capacity?: number);
421
+ enqueue(item: PendingModeration, signal?: AbortSignal): Promise<void>;
422
+ /** Assign directly: `client.postSend = queue.postSendHook`. */
423
+ postSendHook: (ctx: PostSendContext) => Promise<void>;
424
+ /** Signals the worker to drain what is queued and stop. */
425
+ complete(): void;
426
+ /** Blocks for an item; resolves null once completed and drained, or if `signal` fires. */
427
+ dequeue(signal?: AbortSignal): Promise<PendingModeration | null>;
428
+ /** Waits up to `timeoutMs` for an item. Resolves 'timeout' on expiry, null on completion/abort. */
429
+ tryDequeue(timeoutMs: number, signal?: AbortSignal): Promise<PendingModeration | null | 'timeout'>;
430
+ private take;
431
+ private waitForWriteSlot;
432
+ private wake;
433
+ }
434
+ interface BatchModerationOptions {
435
+ /** Flush when this many messages are queued... Default 100. */
436
+ maxBatchSize?: number;
437
+ /** ...or when the oldest queued message has waited this long, whichever comes first. Default 30000. */
438
+ maxBatchDelayMs?: number;
439
+ /** Classifier attempts per batch (exponential backoff between them) before the batch is dropped. Default 3. */
440
+ maxClassifierAttempts?: number;
441
+ }
442
+ /**
443
+ * Drains a {@link ModerationQueue} in size-or-time bounded batches, hands each batch to the
444
+ * {@link ModerationClassifier}, and runs `onFlagged` for every flagged verdict (typically
445
+ * {@link BatchModerationWorker.redactWith}). Runs entirely off the send path.
446
+ */
447
+ declare class BatchModerationWorker {
448
+ private readonly queue;
449
+ private readonly classifier;
450
+ private readonly onFlagged;
451
+ private readonly onError?;
452
+ private readonly maxBatchSize;
453
+ private readonly maxBatchDelayMs;
454
+ private readonly maxClassifierAttempts;
455
+ constructor(queue: ModerationQueue, classifier: ModerationClassifier, onFlagged: (verdict: ModerationVerdict, signal?: AbortSignal) => Promise<void> | void, options?: BatchModerationOptions, onError?: (err: unknown) => void);
456
+ /** The usual action: physically redact the flagged message through the admin API, honouring `Retry-After` on 429. */
457
+ static redactWith(host: DotwireHostClient): (verdict: ModerationVerdict) => Promise<void>;
458
+ /** Runs until `signal` fires or the queue is completed and drained. */
459
+ run(signal: AbortSignal): Promise<void>;
460
+ private fillBatch;
461
+ private processBatch;
462
+ }
463
+
464
+ export { type AddRoomMembersOptions, type AuditCheckpointEntry, type AuditCheckpointsResponse, type AuditEntry, type AuditPageResponse, type AuditVerificationResponse, type BatchModerationOptions, BatchModerationWorker, type ClientSendResult, DotwireApiError, type DotwireApiErrorInit, type DotwireApiErrorKind, DotwireHostClient, type DotwireHostConfig, DotwireHostRoom, type DotwireRole, DotwireTokenSigner, type GetMessagesOptions, type HistoryMessage, type MintTokenOptions, type ModerationClassifier, ModerationQueue, type ModerationVerdict, type PendingModeration, type PostSendContext, PostSendError, type PresendContext, PresendRejectedError, PresendResult, type RedactMessageResponse, type RetentionResponse, type RoomEvent, type RoomHistoryResponse, type RoomMembersResponse, type SendMessageResult, type SendSystemMessageOptions, type StreamRoomEventsOptions, type TokenResponseOptions, type UserExport, type UserExportAuditEvent, type UserExportMessage, type UserRoleResponse, type UserRoomsResponse, type WebhookResult, mapErrorKind };