@poppykit/server 0.0.0-stage → 0.1.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.
Files changed (51) hide show
  1. package/API.md +21 -0
  2. package/AUTH.md +60 -0
  3. package/CHANGELOG.md +18 -0
  4. package/CONTRIBUTING.md +38 -0
  5. package/DELIVERY.md +77 -0
  6. package/INTEGRATIONS.md +84 -0
  7. package/LICENSE +21 -0
  8. package/README.md +67 -2
  9. package/RELEASING.md +91 -0
  10. package/SECURITY.md +13 -0
  11. package/STORAGE.md +112 -0
  12. package/dist/adapter-contract.d.ts +12 -0
  13. package/dist/adapter-contract.js +140 -0
  14. package/dist/auth/authenticator.d.ts +6 -0
  15. package/dist/auth/authenticator.js +51 -0
  16. package/dist/auth/authority.d.ts +55 -0
  17. package/dist/auth/authority.js +16 -0
  18. package/dist/auth/dpop.d.ts +15 -0
  19. package/dist/auth/dpop.js +49 -0
  20. package/dist/browser.d.ts +44 -0
  21. package/dist/browser.js +110 -0
  22. package/dist/conversation/service.d.ts +126 -0
  23. package/dist/conversation/service.js +754 -0
  24. package/dist/conversation/store.d.ts +26 -0
  25. package/dist/conversation/store.js +14 -0
  26. package/dist/feedback.d.ts +13 -0
  27. package/dist/feedback.js +44 -0
  28. package/dist/index.d.ts +11 -0
  29. package/dist/index.js +7 -0
  30. package/dist/mcp.d.ts +7 -0
  31. package/dist/mcp.js +14 -0
  32. package/dist/network/discovery.d.ts +101 -0
  33. package/dist/network/discovery.js +276 -0
  34. package/dist/network/transport.d.ts +31 -0
  35. package/dist/network/transport.js +305 -0
  36. package/dist/protocol/sse.d.ts +15 -0
  37. package/dist/protocol/sse.js +90 -0
  38. package/dist/protocol/wire.d.ts +128 -0
  39. package/dist/protocol/wire.js +278 -0
  40. package/dist/reference-auth/index.d.ts +209 -0
  41. package/dist/reference-auth/index.js +999 -0
  42. package/dist/reference-auth/store.d.ts +53 -0
  43. package/dist/reference-auth/store.js +2 -0
  44. package/dist/sqlite-init.d.ts +2 -0
  45. package/dist/sqlite-init.js +17 -0
  46. package/dist/sqlite.d.ts +27 -0
  47. package/dist/sqlite.js +369 -0
  48. package/dist/store.d.ts +1 -0
  49. package/dist/store.js +1 -0
  50. package/examples/quickstart.mjs +29 -0
  51. package/package.json +67 -4
@@ -0,0 +1,754 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { createConversationNotifications } from './store.js';
3
+ import { ValidationError, canonical, parseJson, object, parseConversationRequest, parseMessage, parseReadOptions } from '../protocol/wire.js';
4
+ import { secureUrl } from '../network/transport.js';
5
+ import { own } from '../store.js';
6
+ import { AuthorityError } from '../auth/authority.js';
7
+ export class PublicationError extends Error {
8
+ code;
9
+ constructor(code) {
10
+ super(`Publication rejected: ${code}`);
11
+ this.code = code;
12
+ this.name = 'PublicationError';
13
+ }
14
+ }
15
+ // Spec 7.10: text or data in a Direct Conversation is the User's own words; context-only messages may come from the agent.
16
+ const directWords = (message) => message.sender !== 'human' && (message.text !== undefined || message.data !== undefined);
17
+ const maxBodyBytes = 1048576;
18
+ const maxWaitSeconds = 30;
19
+ const randomId = (prefix) => prefix + randomBytes(16).toString('base64url');
20
+ export function initialConversationState() {
21
+ return { version: 1, generation: randomId(''), conversations: {}, bindings: {}, messages: {}, work: [] };
22
+ }
23
+ /** For the host's reset transaction: a fresh state with a new generation, so every old fence is stale. */
24
+ export function resetConversationState(draft) {
25
+ Object.assign(draft, initialConversationState());
26
+ }
27
+ class HttpError extends Error {
28
+ status;
29
+ body;
30
+ headers;
31
+ constructor(status, body, headers = {}) {
32
+ super(String(body.error));
33
+ this.status = status;
34
+ this.body = body;
35
+ this.headers = headers;
36
+ }
37
+ }
38
+ class Rescope extends Error {
39
+ }
40
+ const notFound = () => new HttpError(404, { error: 'conversation_not_found' });
41
+ const closed = () => new HttpError(409, { error: 'conversation_closed' });
42
+ const directOpen = (childId) => new HttpError(409, { error: 'direct_conversation_open', conversation_id: childId });
43
+ const never = () => false;
44
+ const always = () => true;
45
+ const unfinished = (work) => work.phase === 'pending' || work.phase === 'claimed';
46
+ const finished = (work) => !unfinished(work) && work.phase !== 'held';
47
+ function json(status, body, headers = {}) {
48
+ const response = Response.json(body, { status, headers });
49
+ response.headers.set('Cache-Control', 'no-store');
50
+ return response;
51
+ }
52
+ async function readJson(request) {
53
+ const chunks = [];
54
+ let size = 0;
55
+ const reader = request.body?.getReader();
56
+ for (let part = await reader?.read(); part && !part.done; part = await reader.read()) {
57
+ size += part.value.byteLength;
58
+ if (size > maxBodyBytes) {
59
+ await reader.cancel();
60
+ throw new ValidationError('body');
61
+ }
62
+ chunks.push(part.value);
63
+ }
64
+ try {
65
+ return parseJson(Buffer.concat(chunks).toString('utf8'));
66
+ }
67
+ catch {
68
+ throw new ValidationError('body');
69
+ }
70
+ }
71
+ export function createConversationService(options) {
72
+ const { store, authenticator, policy, clock } = options;
73
+ const pageSize = options.pageSize ?? 100;
74
+ const historyScopes = [...(options.historyScopes ?? ['poppy:read'])];
75
+ const leaseMs = options.leaseMs ?? 60000;
76
+ const operatorLeaseMs = options.operatorLeaseMs ?? 900000;
77
+ const pollIntervalMs = options.pollIntervalMs ?? 1000;
78
+ const finishedWorkLimit = options.finishedWorkLimit ?? 1000;
79
+ const revocationPolicy = options.revocationPolicy ?? 'stop-account-work';
80
+ for (const value of [pageSize, leaseMs, operatorLeaseMs, pollIntervalMs, finishedWorkLimit])
81
+ if (!Number.isSafeInteger(value) || value <= 0)
82
+ throw new TypeError('Invalid conversation option');
83
+ const maxPageBytes = options.maxPageBytes ?? 1048576;
84
+ const maxFrameBytes = options.maxFrameBytes ?? 1048576;
85
+ const maxEventBytes = options.maxEventBytes ?? Math.min(maxPageBytes - 1025, maxFrameBytes - 512);
86
+ const streamMs = options.streamMs ?? 30000;
87
+ if (![maxPageBytes, maxFrameBytes, maxEventBytes, streamMs].every(Number.isSafeInteger) || maxPageBytes < 2048 || maxFrameBytes < 1024 || maxEventBytes < 512 || maxEventBytes > maxPageBytes - 1025 || maxEventBytes > maxFrameBytes - 512 || streamMs < 1 || streamMs > 300000)
88
+ throw new TypeError('Invalid delivery budget');
89
+ const notifications = options.notifications ?? createConversationNotifications();
90
+ const hints = (ids) => { for (const id of ids) {
91
+ try {
92
+ notifications.publish(id);
93
+ }
94
+ catch { /* Durable reconciliation recovers lost hints. */ }
95
+ } };
96
+ function changes(draft, action) { const before = new Map(Object.values(draft.conversations).map(c => [c.id, c.events.at(-1)?.id])); const value = action(); return { value, ids: Object.values(draft.conversations).filter(c => c.events.at(-1)?.id !== before.get(c.id)).map(c => c.id) }; }
97
+ async function committed(task) { const result = await task; hints(result.ids); return result.value; }
98
+ const endpoint = secureUrl(options.endpoint);
99
+ const basePath = endpoint.pathname.replace(/\/$/, '');
100
+ const timestamp = () => new Date(clock.now()).toISOString();
101
+ function addEvent(conversation, event) {
102
+ const record = { id: randomId('evt_'), created_at: timestamp(), ...event };
103
+ if (Buffer.byteLength(JSON.stringify(record)) > maxEventBytes)
104
+ throw new ValidationError('event.size');
105
+ conversation.events.push(record);
106
+ }
107
+ function setState(conversation, status, responder = conversation.responder) {
108
+ if (conversation.status === status && conversation.responder === responder)
109
+ return;
110
+ conversation.status = status;
111
+ conversation.responder = responder;
112
+ addEvent(conversation, { type: 'state', status, responder });
113
+ }
114
+ const newFence = (draft) => `${draft.generation}.${randomId('')}`;
115
+ // Finished work is never read again: publication needs a claimed record (else stale_work), and status, claims and
116
+ // handback look only at held, pending and claimed work. Output dedupe lives on the claimed record. So the oldest go first.
117
+ function pruneWork(draft) {
118
+ let excess = draft.work.filter(finished).length - finishedWorkLimit;
119
+ if (excess > 0)
120
+ draft.work = draft.work.filter(work => !finished(work) || excess-- <= 0);
121
+ }
122
+ // Serial per conversation: only the oldest unfinished work item is eligible.
123
+ function claimable(state, now) {
124
+ const seen = new Set();
125
+ for (const work of state.work) {
126
+ const conversation = state.conversations[work.conversationId];
127
+ // A person who left, with or without finishing their claim: once the operator lease passes, anything awaiting
128
+ // them (their claim or held messages) is handed back. Never while a Direct Conversation is open: that would cancel parent work.
129
+ if (conversation.responder === 'human') {
130
+ if ((work.phase === 'held' || unfinished(work)) && (conversation.humanUntil ?? 0) <= now && conversation.childId === undefined)
131
+ return work;
132
+ continue;
133
+ }
134
+ if (!unfinished(work) || seen.has(work.conversationId))
135
+ continue;
136
+ seen.add(work.conversationId);
137
+ // Automation pauses while queued for a person and while a Direct Conversation is open.
138
+ if (conversation.status === 'queued' || conversation.childId !== undefined)
139
+ continue;
140
+ if (work.phase === 'claimed' && (work.leaseUntil ?? 0) > now)
141
+ continue;
142
+ return work;
143
+ }
144
+ }
145
+ const idleOrWorking = (draft, id) => draft.work.some(work => work.conversationId === id && unfinished(work)) ? 'working' : 'idle';
146
+ // Invalidates every current claim on a conversation: automated work returns to pending, or all of it is cancelled.
147
+ // Staff attempts are always cancelled; a person rejoins explicitly.
148
+ function fence(draft, id, outcome) {
149
+ for (const work of draft.work) {
150
+ if (work.conversationId !== id || !unfinished(work))
151
+ continue;
152
+ work.phase = outcome === 'cancelled' || work.human ? 'cancelled' : 'pending';
153
+ delete work.fence;
154
+ delete work.leaseUntil;
155
+ }
156
+ }
157
+ function requestHandoff(draft, conversation) {
158
+ if (conversation.status === 'queued' || conversation.responder === 'human')
159
+ return;
160
+ // Deliberately cancels the whole pre-handoff backlog: the user asked for a person (7.9), so automation drops it rather than retrying it.
161
+ fence(draft, conversation.id, 'cancelled');
162
+ setState(conversation, 'queued');
163
+ }
164
+ const release = (draft, id) => {
165
+ for (const work of draft.work)
166
+ if (work.conversationId === id && work.phase === 'held')
167
+ work.phase = 'pending';
168
+ };
169
+ // The person's claim stops working and automation answers what they left unanswered.
170
+ function handBack(draft, conversation) {
171
+ fence(draft, conversation.id, 'cancelled');
172
+ release(draft, conversation.id);
173
+ delete conversation.humanUntil;
174
+ setState(conversation, idleOrWorking(draft, conversation.id), 'agent');
175
+ }
176
+ // Closing a parent closes its open Direct Conversation first; closing a child reopens its parent for messages.
177
+ function closeConversation(draft, conversation) {
178
+ if (conversation.status === 'closed')
179
+ return;
180
+ if (conversation.childId !== undefined)
181
+ closeConversation(draft, draft.conversations[conversation.childId]);
182
+ fence(draft, conversation.id, 'cancelled');
183
+ for (const work of draft.work)
184
+ if (work.conversationId === conversation.id && work.phase === 'held')
185
+ work.phase = 'cancelled';
186
+ setState(conversation, 'closed');
187
+ const parent = conversation.parentId === undefined ? undefined : draft.conversations[conversation.parentId];
188
+ if (parent?.childId === conversation.id) {
189
+ delete parent.childId;
190
+ addEvent(parent, { type: 'direct_closed', conversation_id: conversation.id });
191
+ }
192
+ }
193
+ // Each Direct Conversation ran while its parent was suspended, so its events belong right after direct_opened.
194
+ function linkedHistory(draft, conversation) {
195
+ const root = conversation.parentId === undefined ? conversation : draft.conversations[conversation.parentId];
196
+ return root.events.flatMap(event => event.type === 'direct_opened' ? [event, ...draft.conversations[event.conversation_id].events] : [event]);
197
+ }
198
+ function page(conversation, cursor, cursorStatus) {
199
+ let start = 0;
200
+ if (cursorStatus === 'expired' || conversation.eventWindow?.expired)
201
+ throw new HttpError(410, { error: 'cursor_expired' });
202
+ if (cursorStatus === 'invalid' || conversation.eventWindow?.invalid)
203
+ throw new HttpError(400, { error: 'invalid_cursor' });
204
+ if (cursor !== undefined && !conversation.eventWindow) {
205
+ const index = conversation.events.findIndex(event => event.id === cursor);
206
+ if (index < 0)
207
+ throw new HttpError(400, { error: 'invalid_cursor' });
208
+ start = index + 1;
209
+ }
210
+ const events = [];
211
+ let bytes = 1024; // Worst-case IDs, status, responder, cursor and JSON envelope.
212
+ for (const event of conversation.events.slice(start, start + pageSize)) {
213
+ const size = Buffer.byteLength(JSON.stringify(event)) + 1;
214
+ if (bytes + size > maxPageBytes)
215
+ break;
216
+ events.push(event);
217
+ bytes += size;
218
+ }
219
+ if (!events.length && (conversation.events.length > start || conversation.eventWindow?.hasMore))
220
+ throw new ValidationError('page.size');
221
+ return {
222
+ conversation_id: conversation.id, events: events, cursor: events.at(-1)?.id ?? cursor ?? null,
223
+ has_more: !!conversation.eventWindow?.hasMore || start + events.length < conversation.events.length, status: conversation.status, responder: conversation.responder,
224
+ };
225
+ }
226
+ function ack(conversation) {
227
+ return { conversation_id: conversation.id, status: conversation.status, responder: conversation.responder };
228
+ }
229
+ // Account-bound history needs poppy:read; sending alone does not read history. Other readers fail ownership first.
230
+ const scopesFor = (state, principal, conversation, history) =>
231
+ // Only an owner may see a scope error; for anyone else ownership fails first with 404, so existence never leaks.
232
+ conversation && history && conversation.issuer === principal.issuer && conversation.clientId === principal.clientId && principal.accountId !== undefined
233
+ && own(state.bindings, conversation.bindingId)?.accountId === principal.accountId ? historyScopes : [];
234
+ // Spec 7.2: owned by client_id + User ID until account use, then by client_id + account instead.
235
+ function owned(state, principal, id) {
236
+ const conversation = own(state.conversations, id);
237
+ if (!conversation || conversation.issuer !== principal.issuer || conversation.clientId !== principal.clientId)
238
+ throw notFound();
239
+ const accountId = own(state.bindings, conversation.bindingId)?.accountId;
240
+ if (accountId === undefined ? conversation.userId === principal.userId : principal.accountId === accountId)
241
+ return conversation;
242
+ // Spec 7.11: the same User not signed in to the bound account (signed out or another account) is told to sign in; anyone else learns nothing.
243
+ if (accountId !== undefined && conversation.userId === principal.userId)
244
+ throw new AuthorityError('sign_in_required');
245
+ throw notFound();
246
+ }
247
+ // Runs change under withCurrent; target() and history() decide the scopes from the state being checked.
248
+ async function current(principal, selection, target, history, change) {
249
+ for (;;) {
250
+ const before = await store.read(selection);
251
+ const id = target(before);
252
+ const scopes = scopesFor(before, principal, id === undefined ? undefined : own(before.conversations, id), history(before));
253
+ const result = await policy.withCurrent(principal, scopes, () => store.transaction(selection, draft => {
254
+ const now = target(draft);
255
+ // Binding only ever gains an account, so a rerun with the wider scopes settles it.
256
+ if (scopesFor(draft, principal, now === undefined ? undefined : own(draft.conversations, now), history(draft)).length > scopes.length)
257
+ return { rescope: true };
258
+ return changes(draft, () => change(draft));
259
+ }));
260
+ if (!('rescope' in result)) {
261
+ hints(result.ids);
262
+ return result.value;
263
+ }
264
+ }
265
+ }
266
+ async function accept(request, principal, url, conversationId) {
267
+ const read = parseReadOptions(url, maxWaitSeconds);
268
+ const waiting = url.searchParams.has('wait');
269
+ const body = parseConversationRequest(await readJson(request));
270
+ const parentId = conversationId === undefined ? body.parent_conversation_id : undefined;
271
+ // A Direct Conversation carries the User's own words (7.10).
272
+ if (parentId !== undefined && directWords(body.message))
273
+ throw new ValidationError('message.sender');
274
+ const key = JSON.stringify([principal.issuer, principal.clientId, principal.userId, body.message.id]);
275
+ const fingerprint = canonical({ target: conversationId ?? null, request: body });
276
+ const authority = JSON.parse(JSON.stringify({ principal, acceptedAt: clock.now(), revocationPolicy }));
277
+ // A new Direct Conversation shares its parent's binding, so the parent decides the scopes.
278
+ const target = (state) => conversationId ?? own(state.messages, key)?.conversationId ?? parentId;
279
+ // A retry returns the saved receipt, which is a history page if the original request waited.
280
+ const history = (state) => waiting || 'events' in (own(state.messages, key)?.receipt.body ?? {});
281
+ const receipt = await current(principal, { ...(conversationId ? { conversationId } : parentId ? { conversationId: parentId } : {}), messageKey: key, cursorCheck: read.cursor, append: !waiting && read.cursor === undefined }, target, history, draft => {
282
+ if (conversationId !== undefined)
283
+ owned(draft, principal, conversationId);
284
+ const existing = own(draft.messages, key);
285
+ if (existing) {
286
+ // Ownership first, so a foreign account learns nothing from a conflict.
287
+ owned(draft, principal, existing.conversationId);
288
+ if (existing.fingerprint !== fingerprint)
289
+ throw new HttpError(409, { error: 'message_id_conflict' });
290
+ return existing.receipt;
291
+ }
292
+ let conversation;
293
+ if (conversationId === undefined) {
294
+ if (read.cursor !== undefined)
295
+ throw new HttpError(400, { error: 'invalid_cursor' });
296
+ const id = randomId('cnv_');
297
+ let bindingId = id;
298
+ if (parentId === undefined)
299
+ draft.bindings[id] = {};
300
+ else {
301
+ // Same serialized transition as account binding: the child joins whatever binding the parent has now.
302
+ const parent = owned(draft, principal, parentId);
303
+ if (parent.parentId !== undefined)
304
+ throw notFound();
305
+ if (parent.status === 'closed')
306
+ throw closed();
307
+ if (parent.childId !== undefined)
308
+ throw directOpen(parent.childId);
309
+ // Parent automation pauses while the Direct Conversation is open; its current attempt can no longer publish.
310
+ // A person handling the parent loses their claim but stays responder: staff hosts rejoin or returnToAgent after the child closes.
311
+ fence(draft, parent.id, 'pending');
312
+ parent.childId = id;
313
+ bindingId = parent.bindingId;
314
+ addEvent(parent, { type: 'direct_opened', conversation_id: id });
315
+ }
316
+ conversation = {
317
+ id, issuer: principal.issuer, clientId: principal.clientId, userId: principal.userId, bindingId,
318
+ status: 'idle', responder: 'agent', context: {}, events: [], authority, ...(parentId === undefined ? {} : { parentId }),
319
+ };
320
+ draft.conversations[id] = conversation;
321
+ }
322
+ else {
323
+ conversation = owned(draft, principal, conversationId);
324
+ if (conversation.status === 'closed')
325
+ throw closed();
326
+ if (conversation.childId !== undefined)
327
+ throw directOpen(conversation.childId);
328
+ if (conversation.parentId !== undefined && directWords(body.message))
329
+ throw new ValidationError('message.sender');
330
+ if (read.cursor !== undefined)
331
+ page(conversation, read.cursor, draft.cursorStatus);
332
+ conversation.authority = authority;
333
+ }
334
+ pruneWork(draft);
335
+ const message = body.message;
336
+ conversation.lastUserMessage = message;
337
+ if (message.context)
338
+ conversation.context = { ...conversation.context, ...message.context };
339
+ addEvent(conversation, { type: 'message', message: { ...message, role: 'user' } });
340
+ // A context-only message asks nothing. Work always records this message's own accepted authority; while queued
341
+ // for or answered by a person it is held for them and reaches automation only if they never answer it.
342
+ if ('text' in message || 'data' in message) {
343
+ const held = conversation.responder === 'human' || conversation.status === 'queued';
344
+ draft.work.push({ id: randomId('wrk_'), conversationId: conversation.id, message, authority, phase: held ? 'held' : 'pending', outputs: {} });
345
+ if (!held && conversation.status === 'idle')
346
+ setState(conversation, 'working');
347
+ }
348
+ // A read from the cursor always finds the message just accepted, so 7.5 returns at once and the receipt
349
+ // commits with the acceptance: there is no accepted-but-unanswered state for a crash to leave behind.
350
+ const result = { status: conversationId === undefined ? 201 : 202, body: waiting ? page(conversation, read.cursor) : ack(conversation) };
351
+ draft.messages[key] = { conversationId: conversation.id, fingerprint, receipt: result };
352
+ return result;
353
+ });
354
+ return json(receipt.status, receipt.body);
355
+ }
356
+ async function events(request, principal, url, conversationId) {
357
+ const streaming = request.headers.get('accept')?.split(',').some(value => value.trim().split(';')[0] === 'text/event-stream') ?? false;
358
+ const last = request.headers.get('last-event-id');
359
+ if (last !== null)
360
+ url.searchParams.set('cursor', last);
361
+ const read = parseReadOptions(url, maxWaitSeconds);
362
+ let cursor = read.cursor;
363
+ const deliver = () => current(principal, { conversationId, events: { cursor, limit: pageSize, maxBytes: maxPageBytes - 1024 } }, () => conversationId, () => true, draft => page(owned(draft, principal, conversationId), cursor));
364
+ // Install before the first durable read. Revision catches a wake arriving during that read.
365
+ let revision = 0, wake;
366
+ const unsubscribe = notifications.subscribe(conversationId, () => { revision++; wake?.(); });
367
+ const stop = new AbortController(), signal = AbortSignal.any([request.signal, stop.signal]);
368
+ let cleaned = false;
369
+ let lifetime;
370
+ const cleanup = () => { if (!cleaned) {
371
+ cleaned = true;
372
+ clearTimeout(lifetime);
373
+ unsubscribe();
374
+ stop.abort();
375
+ } };
376
+ async function wait(ms, seen) {
377
+ if (revision !== seen || signal.aborted || ms <= 0)
378
+ return;
379
+ await new Promise(resolve => {
380
+ const done = () => { clearTimeout(timer); signal.removeEventListener('abort', done); wake = undefined; resolve(); };
381
+ const timer = setTimeout(done, ms);
382
+ wake = done;
383
+ signal.addEventListener('abort', done, { once: true });
384
+ if (revision !== seen || signal.aborted)
385
+ done();
386
+ });
387
+ }
388
+ try {
389
+ let seen = revision, first = await deliver();
390
+ signal.throwIfAborted();
391
+ if (!streaming) {
392
+ const deadline = Date.now() + read.waitSeconds * 1000, logicalDeadline = clock.now() + read.waitSeconds * 1000;
393
+ while (!first.events.length && Date.now() < deadline && clock.now() < logicalDeadline && !signal.aborted) {
394
+ await wait(Math.min(pollIntervalMs, deadline - Date.now(), logicalDeadline - clock.now()), seen);
395
+ seen = revision;
396
+ if (signal.aborted)
397
+ throw signal.reason;
398
+ first = await deliver();
399
+ }
400
+ signal.throwIfAborted();
401
+ cleanup();
402
+ return json(200, first);
403
+ }
404
+ lifetime = setTimeout(cleanup, streamMs);
405
+ const encoder = new TextEncoder(), deadline = Date.now() + streamMs;
406
+ let pending = first, index = 0, bytes = 0, cancelled = false;
407
+ const stream = new ReadableStream({
408
+ async pull(target) {
409
+ try {
410
+ signal.throwIfAborted();
411
+ while (index >= pending.events.length) {
412
+ if (Date.now() >= deadline || pending.status === 'closed' && !pending.has_more) {
413
+ cleanup();
414
+ target.close();
415
+ return;
416
+ }
417
+ if (!pending.has_more)
418
+ await wait(Math.min(pollIntervalMs, deadline - Date.now()), seen);
419
+ signal.throwIfAborted();
420
+ seen = revision;
421
+ pending = await deliver();
422
+ index = 0;
423
+ }
424
+ const event = pending.events[index++];
425
+ const frame = encoder.encode('id: ' + event.id + '\ndata: ' + JSON.stringify(event) + '\n\n');
426
+ bytes += frame.byteLength;
427
+ if (frame.byteLength > maxFrameBytes || bytes > 16777216) {
428
+ cleanup();
429
+ target.close();
430
+ return;
431
+ }
432
+ cursor = event.id;
433
+ target.enqueue(frame);
434
+ }
435
+ catch (error) {
436
+ const expired = stop.signal.aborted && !request.signal.aborted;
437
+ cleanup();
438
+ if (cancelled)
439
+ return;
440
+ if (expired)
441
+ target.close();
442
+ else
443
+ target.error(error);
444
+ }
445
+ },
446
+ cancel() { cancelled = true; cleanup(); },
447
+ }, { highWaterMark: 0 });
448
+ return new Response(stream, { headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-store' } });
449
+ }
450
+ catch (error) {
451
+ cleanup();
452
+ throw error;
453
+ }
454
+ }
455
+ async function lifecycle(request, principal, conversationId, action) {
456
+ object(await readJson(request));
457
+ const body = await current(principal, { conversationId, append: true }, () => conversationId, () => false, draft => {
458
+ const conversation = owned(draft, principal, conversationId);
459
+ if (action === 'close')
460
+ closeConversation(draft, conversation);
461
+ else {
462
+ if (conversation.status === 'closed')
463
+ throw closed();
464
+ if (conversation.childId !== undefined)
465
+ throw directOpen(conversation.childId);
466
+ requestHandoff(draft, conversation);
467
+ }
468
+ return ack(conversation);
469
+ });
470
+ return json(action === 'close' ? 200 : 202, body);
471
+ }
472
+ async function route(request) {
473
+ const url = new URL(request.url);
474
+ if (url.origin !== endpoint.origin || !(url.pathname === basePath || url.pathname.startsWith(basePath + '/')))
475
+ throw new HttpError(404, { error: 'not_found' });
476
+ const rest = url.pathname.slice(basePath.length);
477
+ const match = /^\/([A-Za-z0-9_-]{1,256})\/(messages|events|handoff|close)$/.exec(rest);
478
+ if (rest !== '' && !match) {
479
+ // Spec 7.13: a conversation path whose ID fails the pattern is a bad request, not a missing resource.
480
+ if (/^\/[^/]+\/(messages|events|handoff|close)$/.test(rest))
481
+ throw new HttpError(400, { error: 'invalid_request' });
482
+ throw new HttpError(404, { error: 'not_found' });
483
+ }
484
+ const method = match?.[2] === 'events' ? 'GET' : 'POST';
485
+ if (request.method !== method)
486
+ throw new HttpError(405, { error: 'method_not_allowed' }, { Allow: method });
487
+ const principal = await authenticator.authenticate(request);
488
+ if (!match)
489
+ return accept(request, principal, url);
490
+ const [, id, action] = match;
491
+ if (action === 'messages')
492
+ return accept(request, principal, url, id);
493
+ return action === 'events' ? events(request, principal, url, id) : lifecycle(request, principal, id, action);
494
+ }
495
+ // Resolves the durable authority, then revalidates fence, lease and phase inside the transaction.
496
+ // scopes() is evaluated on the read and again on the draft; a binding gained in between reruns with wider scopes.
497
+ // change() gets live: whether the accepted sign-in still holds. Only account-free publications check it; others pass true.
498
+ async function publish(ref, scopes, change, accountFree = never, loadHistory = false) {
499
+ const selection = { workId: ref.workId, append: !loadHistory };
500
+ for (;;) {
501
+ const before = await store.read(selection);
502
+ const stored = before.work.find(work => work.id === ref.workId);
503
+ if (!stored)
504
+ throw new PublicationError('stale_work');
505
+ const granted = scopes(before, stored);
506
+ const run = (authority, live) => policy.withAccepted(authority, granted, () => store.transaction(selection, draft => {
507
+ const work = draft.work.find(item => item.id === ref.workId);
508
+ if (!work || work.phase !== 'claimed' || work.fence !== ref.fence || (work.leaseUntil ?? 0) <= clock.now())
509
+ throw new PublicationError('stale_work');
510
+ if (scopes(draft, work).length > granted.length)
511
+ throw new Rescope(); // nothing changed yet, so the rollback is free
512
+ return changes(draft, () => change(draft, work, draft.conversations[work.conversationId], live));
513
+ }));
514
+ try {
515
+ if (!accountFree(stored))
516
+ return await committed(run(stored.authority, true));
517
+ // A publication that uses no account (failing, suspending, asking to sign in, a person's words) must stay possible after revocation,
518
+ // or the work is reclaimed forever. Try it as if revocation stopped work first, to learn whether the sign-in is live.
519
+ try {
520
+ return await committed(run({ ...stored.authority, revocationPolicy: 'stop-account-work' }, true));
521
+ }
522
+ catch (error) {
523
+ if (!(error instanceof AuthorityError && error.code === 'invalid_token'))
524
+ throw error;
525
+ return await committed(run({ ...stored.authority, revocationPolicy: 'allow-accepted-work-to-finish' }, false));
526
+ }
527
+ }
528
+ catch (error) {
529
+ if (!(error instanceof Rescope))
530
+ throw error;
531
+ }
532
+ }
533
+ }
534
+ const noScopes = () => [];
535
+ // A person's own words and their claim's upkeep use no account (7.8); their account reads still go through requireAccount.
536
+ const byPerson = (work) => work.human === true;
537
+ // Staff operations: the host authorizes the operator, then the conversation's stored customer authority applies.
538
+ async function staff(operator, conversationId, accountFree, change) {
539
+ if (!options.authorizeOperator)
540
+ throw new TypeError('authorizeOperator is not configured');
541
+ await options.authorizeOperator(operator, conversationId);
542
+ const selection = { conversationId, append: true };
543
+ for (;;) {
544
+ const stored = own((await store.read({ conversationId, append: true })).conversations, conversationId);
545
+ if (!stored)
546
+ throw new PublicationError('stale_work');
547
+ // Conversations stored before staff support have none until the customer's next accepted message.
548
+ if (stored.authority === undefined)
549
+ throw new Error('Conversation has no stored customer authority');
550
+ // Leaving the queue or handing back uses no account, so it stays possible after revocation.
551
+ const authority = accountFree ? { ...stored.authority, revocationPolicy: 'allow-accepted-work-to-finish' } : stored.authority;
552
+ try {
553
+ return await committed(policy.withAccepted(authority, [], () => store.transaction(selection, draft => {
554
+ const conversation = own(draft.conversations, conversationId);
555
+ if (!conversation)
556
+ throw new PublicationError('stale_work');
557
+ // A message accepted in between replaced the authority that was checked.
558
+ if (canonical(conversation.authority) !== canonical(stored.authority))
559
+ throw new Rescope();
560
+ return changes(draft, () => change(draft, conversation, stored.authority));
561
+ })));
562
+ }
563
+ catch (error) {
564
+ if (!(error instanceof Rescope))
565
+ throw error;
566
+ }
567
+ }
568
+ }
569
+ // Stable output IDs: true the first time, false for an identical repeat, a conflict for changed content.
570
+ function firstOutput(work, outputId, payload) {
571
+ const previous = own(work.outputs, outputId);
572
+ if (previous === undefined) {
573
+ work.outputs[outputId] = payload;
574
+ return true;
575
+ }
576
+ if (previous !== payload)
577
+ throw new PublicationError('output_conflict');
578
+ return false;
579
+ }
580
+ const boundAccount = (state, work) => own(state.bindings, state.conversations[work.conversationId].bindingId)?.accountId;
581
+ return {
582
+ async handle(request) {
583
+ try {
584
+ return await route(request);
585
+ }
586
+ catch (error) {
587
+ if (error instanceof HttpError)
588
+ return json(error.status, error.body, error.headers);
589
+ if (error instanceof ValidationError)
590
+ return json(400, { error: 'invalid_request' });
591
+ // Status/code/headers only, never the raw message.
592
+ if (error instanceof AuthorityError)
593
+ return json(error.status, { error: error.code }, error.headers);
594
+ throw error;
595
+ }
596
+ },
597
+ async claim() {
598
+ // Idle pollers only read; the transaction rechecks, since another claimer may have won in between.
599
+ const selection = { claimAt: clock.now(), append: true };
600
+ if (!claimable(await store.read(selection), clock.now()))
601
+ return null;
602
+ return store.transaction(selection, draft => {
603
+ const now = clock.now();
604
+ pruneWork(draft);
605
+ for (let work = claimable(draft, now); work; work = claimable(draft, now)) {
606
+ const conversation = draft.conversations[work.conversationId];
607
+ // An abandoned person's claim pins responder human no longer; this uses no account, like returnToAgent.
608
+ if (conversation.responder === 'human') {
609
+ handBack(draft, conversation);
610
+ continue;
611
+ }
612
+ work.phase = 'claimed';
613
+ work.fence = newFence(draft);
614
+ work.leaseUntil = now + leaseMs;
615
+ return { workId: work.id, fence: work.fence, conversationId: work.conversationId, message: work.message, context: conversation.context };
616
+ }
617
+ return null;
618
+ });
619
+ },
620
+ renew(ref) {
621
+ return publish(ref, noScopes, (_draft, work, conversation) => {
622
+ work.leaseUntil = clock.now() + (work.human ? operatorLeaseMs : leaseMs);
623
+ if (work.human)
624
+ conversation.humanUntil = work.leaseUntil;
625
+ }, byPerson);
626
+ },
627
+ history(ref) {
628
+ return publish(ref, (state, work) => boundAccount(state, work) === undefined ? [] : historyScopes, (draft, work, conversation) => {
629
+ const accountId = boundAccount(draft, work);
630
+ if (accountId !== undefined && work.authority.principal.accountId !== accountId)
631
+ throw new AuthorityError('sign_in_required');
632
+ return linkedHistory(draft, conversation);
633
+ }, never, true);
634
+ },
635
+ requireAccount(ref, scopes) {
636
+ return publish(ref, () => [...scopes], (draft, work, conversation) => {
637
+ const accountId = work.authority.principal.accountId;
638
+ const binding = draft.bindings[conversation.bindingId];
639
+ // Parent and child share this record, so the first account use wins for both and no other account can.
640
+ if (accountId === undefined || (binding.accountId ?? accountId) !== accountId)
641
+ throw new AuthorityError('sign_in_required');
642
+ binding.accountId = accountId;
643
+ return { accountId };
644
+ });
645
+ },
646
+ async reply(ref, outputId, message) {
647
+ const parsed = parseMessage(message, { company: true });
648
+ const payload = canonical({ reply: parsed });
649
+ await publish(ref, noScopes, (draft, work, conversation) => {
650
+ if (parsed.sender !== conversation.responder)
651
+ throw new ValidationError('message.sender');
652
+ if (!firstOutput(work, outputId, payload))
653
+ return;
654
+ // A person's reply answers the messages held for them so far.
655
+ if (work.human)
656
+ for (const item of draft.work)
657
+ if (item.conversationId === conversation.id && item.phase === 'held')
658
+ item.phase = 'cancelled';
659
+ addEvent(conversation, { type: 'message', message: { ...parsed, role: 'company' } });
660
+ }, byPerson);
661
+ },
662
+ async requestAuthorization(ref, outputId, scopes) {
663
+ const direct = options.supportedScopes ?? options.directScopes;
664
+ if (!direct)
665
+ throw new TypeError('supportedScopes is not configured');
666
+ if (!Array.isArray(scopes) || !scopes.length || !scopes.every(scope => typeof scope === 'string' && direct.includes(scope)))
667
+ throw new TypeError('scopes');
668
+ const scope = [...new Set(scopes)].join(' ');
669
+ await publish(ref, noScopes, (draft, work, conversation, live) => {
670
+ if (!firstOutput(work, outputId, canonical({ authorization: scope })))
671
+ return;
672
+ const { accountId, scopes: granted } = work.authority.principal;
673
+ const bound = boundAccount(draft, work);
674
+ // More scopes only helps a live sign-in on the right account; signed out, another account or revoked all need sign-in.
675
+ const narrow = live && accountId !== undefined && (bound ?? accountId) === accountId && !scopes.every(item => granted.includes(item));
676
+ addEvent(conversation, { type: 'authorization', error: narrow ? 'insufficient_scope' : 'sign_in_required', scope });
677
+ }, always);
678
+ },
679
+ async requestUser(ref, outputId, reason) {
680
+ if (typeof reason !== 'string' || reason === '')
681
+ throw new TypeError('reason');
682
+ await publish(ref, noScopes, (_draft, work, conversation) => {
683
+ // The User is already the one talking in a Direct Conversation (7.10).
684
+ if (conversation.parentId !== undefined)
685
+ throw new TypeError('requestUser in a Direct Conversation');
686
+ if (firstOutput(work, outputId, canonical({ user_requested: reason })))
687
+ addEvent(conversation, { type: 'user_requested', reason });
688
+ });
689
+ },
690
+ finish(ref, outcome) {
691
+ return publish(ref, noScopes, (draft, work, conversation) => {
692
+ work.phase = outcome;
693
+ delete work.fence;
694
+ delete work.leaseUntil;
695
+ // Spec 7.8: 'human' means a person's own words, so a person's failed work gets no canned apology.
696
+ if (outcome === 'failed' && !work.human) {
697
+ addEvent(conversation, { type: 'message', message: { id: randomId('msg_'), role: 'company', sender: 'agent', text: 'Sorry, something went wrong and this request could not be completed.' } });
698
+ }
699
+ if (conversation.status === 'working')
700
+ setState(conversation, idleOrWorking(draft, conversation.id));
701
+ }, work => outcome !== 'completed' || byPerson(work));
702
+ },
703
+ handoff(ref) {
704
+ return publish(ref, noScopes, (draft, work, conversation) => {
705
+ work.phase = 'completed';
706
+ delete work.fence;
707
+ delete work.leaseUntil;
708
+ requestHandoff(draft, conversation);
709
+ }, always);
710
+ },
711
+ close(ref) {
712
+ return publish(ref, noScopes, (draft, _work, conversation) => closeConversation(draft, conversation), always);
713
+ },
714
+ joinHuman(operator, conversationId) {
715
+ return staff(operator, conversationId, false, (draft, conversation, authority) => {
716
+ if (conversation.status === 'closed' || conversation.childId !== undefined)
717
+ throw new PublicationError('stale_work');
718
+ fence(draft, conversationId, 'cancelled');
719
+ const last = conversation.events.findLast(event => event.type === 'message' && event.message.role === 'user');
720
+ const source = conversation.lastUserMessage ?? last?.message;
721
+ if (!source)
722
+ throw new PublicationError('stale_work');
723
+ const { role: _role, ...message } = source;
724
+ const work = { id: randomId('wrk_'), conversationId, message, authority, phase: 'claimed', fence: newFence(draft), leaseUntil: clock.now() + operatorLeaseMs, human: true, outputs: {} };
725
+ conversation.humanUntil = work.leaseUntil;
726
+ draft.work.push(work);
727
+ setState(conversation, 'idle', 'human');
728
+ return { workId: work.id, fence: work.fence, conversationId, message, context: conversation.context };
729
+ });
730
+ },
731
+ noHumanAvailable(operator, conversationId, reason) {
732
+ if (typeof reason !== 'string' || reason === '')
733
+ return Promise.reject(new TypeError('reason'));
734
+ return staff(operator, conversationId, true, (draft, conversation) => {
735
+ if (conversation.status !== 'queued')
736
+ return;
737
+ // Parent publication waits until its Direct Conversation closes.
738
+ if (conversation.childId !== undefined)
739
+ throw new PublicationError('stale_work');
740
+ // Messages sent while queued were meant for a person; the agent answers them now.
741
+ release(draft, conversationId);
742
+ addEvent(conversation, { type: 'message', message: { id: randomId('msg_'), role: 'company', sender: 'agent', text: reason } });
743
+ setState(conversation, idleOrWorking(draft, conversationId), 'agent');
744
+ });
745
+ },
746
+ returnToAgent(operator, conversationId) {
747
+ return staff(operator, conversationId, true, (draft, conversation) => {
748
+ if (conversation.responder !== 'human' || conversation.status === 'closed')
749
+ return;
750
+ handBack(draft, conversation);
751
+ });
752
+ },
753
+ };
754
+ }