@poppykit/client 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 (50) hide show
  1. package/API.md +25 -0
  2. package/AUTH.md +51 -0
  3. package/CHANGELOG.md +18 -0
  4. package/CONTRIBUTING.md +38 -0
  5. package/DELIVERY.md +94 -0
  6. package/INTEGRATIONS.md +87 -0
  7. package/LICENSE +21 -0
  8. package/PROVIDER.md +99 -0
  9. package/README.md +49 -2
  10. package/RELEASING.md +91 -0
  11. package/SECURITY.md +13 -0
  12. package/STORAGE.md +96 -0
  13. package/dist/auth/challenge.d.ts +19 -0
  14. package/dist/auth/challenge.js +68 -0
  15. package/dist/auth/client-auth.d.ts +297 -0
  16. package/dist/auth/client-auth.js +1583 -0
  17. package/dist/auth/ownership.d.ts +9 -0
  18. package/dist/auth/ownership.js +23 -0
  19. package/dist/auth/sqlite-store.d.ts +14 -0
  20. package/dist/auth/sqlite-store.js +123 -0
  21. package/dist/auth/store.d.ts +19 -0
  22. package/dist/auth/store.js +1 -0
  23. package/dist/conversation-store.d.ts +15 -0
  24. package/dist/conversation-store.js +1 -0
  25. package/dist/conversation.d.ts +118 -0
  26. package/dist/conversation.js +404 -0
  27. package/dist/index.d.ts +18 -0
  28. package/dist/index.js +7 -0
  29. package/dist/integrations.d.ts +30 -0
  30. package/dist/integrations.js +58 -0
  31. package/dist/memory.d.ts +15 -0
  32. package/dist/memory.js +32 -0
  33. package/dist/network/discovery.d.ts +101 -0
  34. package/dist/network/discovery.js +276 -0
  35. package/dist/network/transport.d.ts +31 -0
  36. package/dist/network/transport.js +305 -0
  37. package/dist/protocol/sse.d.ts +15 -0
  38. package/dist/protocol/sse.js +90 -0
  39. package/dist/protocol/wire.d.ts +128 -0
  40. package/dist/protocol/wire.js +278 -0
  41. package/dist/provider-sqlite.d.ts +8 -0
  42. package/dist/provider-sqlite.js +30 -0
  43. package/dist/provider.d.ts +121 -0
  44. package/dist/provider.js +342 -0
  45. package/dist/sqlite-init.d.ts +2 -0
  46. package/dist/sqlite-init.js +17 -0
  47. package/dist/sqlite.d.ts +20 -0
  48. package/dist/sqlite.js +80 -0
  49. package/examples/quickstart.mjs +19 -0
  50. package/package.json +72 -4
@@ -0,0 +1,404 @@
1
+ import { parseEventStream } from './protocol/sse.js';
2
+ import { authChallenge } from './auth/challenge.js';
3
+ import { secureUrl, TransportError } from './network/transport.js';
4
+ import { canonical, id as parseId, parseJson, parseConversationRequest, parseConversationState, parseEventPage, parseMessage, parseProtocolError, retryAfterMilliseconds, ValidationError } from './protocol/wire.js';
5
+ export const initialConversationClientState = () => ({ version: 1, outbound: [], consumers: [] });
6
+ // Only known spec codes (7.13, 6) reach messages and callers; a company-chosen string never does.
7
+ const knownCodes = new Set(['conversation_not_found', 'conversation_closed', 'direct_conversation_open', 'message_id_conflict', 'invalid_cursor', 'cursor_expired', 'invalid_request', 'extension_required', 'rate_limited', 'invalid_token', 'invalid_dpop_proof', 'use_dpop_nonce', 'sign_in_required', 'insufficient_scope']);
8
+ export class ConversationError extends Error {
9
+ code;
10
+ status;
11
+ retryAfterMs;
12
+ scope;
13
+ conversationId;
14
+ extension;
15
+ extensions;
16
+ constructor(code, status, retryAfterMs, scope, conversationId, extension, extensions = extension ? [extension] : []) {
17
+ super(`Conversation ${code}`);
18
+ this.code = code;
19
+ this.status = status;
20
+ this.retryAfterMs = retryAfterMs;
21
+ this.scope = scope;
22
+ this.conversationId = conversationId;
23
+ this.extension = extension;
24
+ this.extensions = extensions;
25
+ this.name = 'ConversationError';
26
+ }
27
+ get requiredScopes() { return this.scope?.split(' ').filter(Boolean) ?? []; }
28
+ }
29
+ // A definite rejection will not change on retry; anything else (auth, 408, 429, 5xx) leaves the operation retryable.
30
+ const definite = (status) => status >= 400 && status < 500 && ![401, 403, 408, 429].includes(status);
31
+ export function createConversationClient(options) {
32
+ const { authorizedRequest, store, clock, connectionId, issuer } = options;
33
+ const endpoint = secureUrl(options.endpoint).href;
34
+ const newId = options.newId ?? (() => `msg_${Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64url')}`); // 128 random bits
35
+ const random = options.random ?? Math.random;
36
+ const sleep = options.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
37
+ const maxAttempts = options.maxAttempts ?? 4, baseDelayMs = options.baseDelayMs ?? 250, maxDelayMs = options.maxDelayMs ?? 60000, maxPages = options.maxPages ?? 1000;
38
+ const key = { connectionId, issuer, endpoint };
39
+ // Validated IDs only: '.' and '..' survive encodeURIComponent and would escape the endpoint.
40
+ const conversationUrl = (id, tail) => { const url = new URL(endpoint); url.pathname = `${url.pathname.replace(/\/+$/, '')}/${parseId(id, 'conversation_id')}/${tail}`; return url.href; };
41
+ // Idempotent by construction: callers reuse the same message ID, so retrying transport failures, 429 and 5xx is safe.
42
+ // Auth failures (including a rate-limited token renewal and its retryAfterMs) are not ours to retry and propagate unchanged.
43
+ async function call(make) {
44
+ for (let attempt = 1;; attempt++) {
45
+ let response;
46
+ const request = make();
47
+ request.signal.throwIfAborted();
48
+ try {
49
+ response = await authorizedRequest(request);
50
+ }
51
+ catch (error) { // A renewal's lost connection is an AuthFailure('transport_error'); it is as retryable as a lost request.
52
+ request.signal.throwIfAborted();
53
+ if (!(error instanceof TransportError || error?.code === 'transport_error'))
54
+ throw error;
55
+ if (attempt >= maxAttempts)
56
+ throw new ConversationError('network_error');
57
+ }
58
+ let delay = Math.min(maxDelayMs, random() * baseDelayMs * 2 ** (attempt - 1)); // full jitter
59
+ if (response) {
60
+ if (response.status !== 429 && response.status < 500)
61
+ return response;
62
+ delay = retryAfterMilliseconds(response.headers.get('retry-after'), clock.now()) ?? delay;
63
+ // A very long Retry-After would stall the caller; surface it instead of waiting.
64
+ if (attempt >= maxAttempts || delay > maxDelayMs)
65
+ return response;
66
+ }
67
+ await response?.body?.cancel().catch(() => { });
68
+ await pause(delay, request.signal);
69
+ }
70
+ }
71
+ async function responseText(response) {
72
+ if (!response.body)
73
+ return '';
74
+ const reader = response.body.getReader(), decoder = new TextDecoder('utf-8', { fatal: true });
75
+ let text = '', bytes = 0;
76
+ try {
77
+ for (;;) {
78
+ const part = await reader.read();
79
+ if (part.done)
80
+ break;
81
+ bytes += part.value.byteLength;
82
+ if (bytes > 1048576)
83
+ throw new ConversationError('body_limit');
84
+ text += decoder.decode(part.value, { stream: true });
85
+ }
86
+ return text + decoder.decode();
87
+ }
88
+ finally {
89
+ await reader.cancel().catch(() => { });
90
+ reader.releaseLock();
91
+ }
92
+ }
93
+ async function failure(response) {
94
+ let body;
95
+ try {
96
+ body = parseJson(await responseText(response));
97
+ }
98
+ catch { /* The header can carry the complete challenge. */ }
99
+ const challenge = authChallenge(response.headers.get('www-authenticate'), body);
100
+ const raw = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
101
+ let code = challenge.error ?? (typeof raw.error === 'string' && knownCodes.has(raw.error) ? raw.error : 'http_error');
102
+ let conversationId;
103
+ try {
104
+ if (challenge.conversationId)
105
+ conversationId = parseId(challenge.conversationId, 'conversation_id');
106
+ }
107
+ catch { /* Invalid peer detail is never used as a destination. */ }
108
+ if (code === 'direct_conversation_open' && !conversationId)
109
+ code = 'http_error';
110
+ return new ConversationError(code, response.status, retryAfterMilliseconds(response.headers.get('retry-after'), clock.now()), challenge.requiredScopes.join(' ') || undefined, conversationId, challenge.extensions[0], challenge.extensions);
111
+ }
112
+ async function pause(ms, signal) {
113
+ signal?.throwIfAborted();
114
+ if (!signal)
115
+ return sleep(ms);
116
+ await new Promise((resolve, reject) => {
117
+ let timer;
118
+ const done = () => { clearTimeout(timer); signal.removeEventListener('abort', abort); resolve(); };
119
+ const abort = () => { clearTimeout(timer); signal.removeEventListener('abort', abort); reject(signal.reason); };
120
+ signal.addEventListener('abort', abort, { once: true });
121
+ if (options.sleep)
122
+ options.sleep(ms).then(done, error => { signal.removeEventListener('abort', abort); reject(error); });
123
+ else
124
+ timer = setTimeout(done, ms);
125
+ if (signal.aborted)
126
+ abort();
127
+ });
128
+ }
129
+ // Pages handed to a consumer in this process; the checkpoint moves only when one of them is acked.
130
+ const delivered = new WeakMap();
131
+ async function checkpoint(conversationId, consumer) {
132
+ parseId(consumer, 'consumer');
133
+ return store.getCheckpoint(key, conversationId, consumer);
134
+ }
135
+ async function dispatch(id, signal) {
136
+ parseId(id, 'message.id');
137
+ const record = await store.getOutbound(key, id);
138
+ if (!record)
139
+ throw new ConversationError('unknown_operation');
140
+ if (record.endpoint !== endpoint)
141
+ throw new ConversationError('invalid_target');
142
+ if (record.phase === 'accepted')
143
+ return record.response;
144
+ if (record.phase === 'rejected')
145
+ throw new ConversationError(record.error.code, record.error.status, undefined, undefined, record.error.conversationId);
146
+ const url = new URL(record.target === null ? endpoint : conversationUrl(record.target, 'messages'));
147
+ if (record.read?.cursor !== undefined)
148
+ url.searchParams.set('cursor', record.read.cursor);
149
+ if (record.read?.wait !== undefined)
150
+ url.searchParams.set('wait', String(record.read.wait));
151
+ const response = await call(() => new Request(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(record.request), signal }));
152
+ if (response.status >= 200 && response.status < 300) {
153
+ let state;
154
+ try {
155
+ const body = parseJson(await responseText(response));
156
+ // A 2xx means accepted; a company that ignores wait answers with a plain state.
157
+ state = body && typeof body === 'object' && ['events', 'cursor', 'has_more'].some(key => key in body) ? parseEventPage(body, record.read?.cursor) : parseConversationState(body);
158
+ }
159
+ catch {
160
+ throw new ConversationError('invalid_response', response.status);
161
+ }
162
+ await store.settleOutbound(key, id, { phase: 'accepted', response: state });
163
+ return state;
164
+ }
165
+ const error = await failure(response);
166
+ // direct_conversation_open lasts only until the Direct Conversation closes (spec 7.10), so the same message stays retryable.
167
+ if (definite(response.status) && error.code !== 'direct_conversation_open')
168
+ await store.settleOutbound(key, id, { phase: 'rejected', error: { code: error.code, status: response.status, ...(error.conversationId === undefined ? {} : { conversationId: error.conversationId }) } });
169
+ throw error;
170
+ }
171
+ // Persist identity, content and target before the first dispatch; a duplicate ID must match exactly.
172
+ async function submit(input, target, read, parent) {
173
+ if (target !== null)
174
+ parseId(target, 'conversation_id');
175
+ if (read.wait !== undefined && (!Number.isSafeInteger(read.wait) || read.wait < 0))
176
+ throw new ConversationError('invalid_request');
177
+ if (read.cursor !== undefined) {
178
+ parseId(read.cursor, 'cursor');
179
+ if (read.wait === undefined)
180
+ throw new ConversationError('invalid_request');
181
+ } // spec 7.3 adds cursor alongside wait
182
+ const message = parseMessage({ ...input, id: input.id ?? newId() });
183
+ const request = parseConversationRequest({ message, ...(parent === undefined ? {} : { parent_conversation_id: parent }) });
184
+ if (Buffer.byteLength(JSON.stringify(request)) > 1048576)
185
+ throw new ConversationError('body_limit');
186
+ const fingerprint = canonical({ target, request });
187
+ const existing = await store.prepareOutbound(key, { connectionId, issuer, endpoint, id: message.id, target, request, fingerprint, phase: 'pending', ...(read.wait === undefined && read.cursor === undefined ? {} : { read: { ...(read.wait === undefined ? {} : { wait: read.wait }), ...(read.cursor === undefined ? {} : { cursor: read.cursor }) } }) });
188
+ if (existing.fingerprint !== fingerprint || existing.endpoint !== endpoint)
189
+ throw new ConversationError('message_id_conflict');
190
+ return { ...(await dispatch(message.id, read.signal)), messageId: message.id };
191
+ }
192
+ async function simplePost(conversationId, tail, signal) {
193
+ parseId(conversationId, 'conversation_id');
194
+ const response = await call(() => new Request(conversationUrl(conversationId, tail), { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', signal }));
195
+ if (!response.ok)
196
+ throw await failure(response);
197
+ try {
198
+ return parseConversationState(parseJson(await responseText(response)));
199
+ }
200
+ catch {
201
+ throw new ConversationError('invalid_response', response.status);
202
+ }
203
+ }
204
+ const client = {
205
+ async authorization(event) {
206
+ if (!(event instanceof ConversationError) && event.type !== 'authorization' || !options.authorization)
207
+ throw new ConversationError('authorization_unavailable');
208
+ return options.authorization(connectionId, event instanceof ConversationError ? event.requiredScopes : typeof event.scope === 'string' ? event.scope.split(' ').filter(Boolean) : []);
209
+ },
210
+ async *watch(conversationId, opts = {}) {
211
+ parseId(conversationId, 'conversation_id');
212
+ if (opts.consumer !== undefined && opts.cursor !== undefined)
213
+ throw new ConversationError('invalid_request');
214
+ let cursor = opts.lastEventId ?? (opts.consumer === undefined ? opts.cursor : await checkpoint(conversationId, opts.consumer));
215
+ let reconnects = 0, attempts = 0, pages = 0, recovered = false;
216
+ const maxReconnects = opts.maxReconnects ?? 4;
217
+ if (!Number.isSafeInteger(maxReconnects) || maxReconnects < 0)
218
+ throw new ConversationError('invalid_request');
219
+ const knownEvents = new Set(['message', 'state', 'authorization', 'user_requested', 'direct_opened', 'direct_closed']);
220
+ const seen = new Set(); // Bounded per watch; durable consumers ack explicitly.
221
+ const eventAck = (event, previous) => ({ event, async ack() { if (opts.consumer !== undefined)
222
+ await store.advanceCheckpoint(key, { connectionId, issuer, conversationId, name: opts.consumer, cursor: event.id }, [previous]); } });
223
+ for (;;) {
224
+ opts.signal?.throwIfAborted();
225
+ const url = new URL(conversationUrl(conversationId, 'events'));
226
+ if (opts.cursor !== undefined)
227
+ url.searchParams.set('cursor', opts.cursor);
228
+ const headers = new Headers({ accept: 'text/event-stream' });
229
+ if (cursor !== undefined)
230
+ headers.set('last-event-id', cursor);
231
+ let response;
232
+ try {
233
+ response = await authorizedRequest(new Request(url, { headers, signal: opts.signal }));
234
+ }
235
+ catch (error) {
236
+ opts.onPartial?.(null);
237
+ opts.signal?.throwIfAborted();
238
+ if (!(error instanceof TransportError) || reconnects++ >= maxReconnects)
239
+ throw error;
240
+ await pause(Math.min(maxDelayMs, baseDelayMs * 2 ** attempts++), opts.signal);
241
+ continue;
242
+ }
243
+ if (!response.ok) {
244
+ const error = await failure(response);
245
+ if (error.code === 'cursor_expired' && !recovered && await opts.onHistoryGap?.()) {
246
+ recovered = true;
247
+ cursor = undefined;
248
+ opts.cursor = undefined;
249
+ seen.clear();
250
+ if (opts.consumer !== undefined)
251
+ await client.resetConsumer(conversationId, opts.consumer);
252
+ continue;
253
+ }
254
+ throw error;
255
+ }
256
+ let closed = false, more = false;
257
+ try {
258
+ if (response.headers.get('content-type')?.split(';')[0].trim() === 'text/event-stream') {
259
+ if (!response.body)
260
+ throw new ConversationError('invalid_response');
261
+ let partialBytes = 0;
262
+ for await (const frame of parseEventStream(response.body, { signal: opts.signal })) {
263
+ if (frame.kind === 'delta') {
264
+ partialBytes += Buffer.byteLength(frame.text);
265
+ if (partialBytes > 1048576)
266
+ throw new ConversationError('body_limit');
267
+ opts.onPartial?.({ messageId: frame.messageId, text: frame.text });
268
+ continue;
269
+ }
270
+ const event = frame.event, previous = cursor;
271
+ if (seen.has(event.id))
272
+ continue;
273
+ if (seen.size >= 10000)
274
+ throw new ConversationError('too_many_events');
275
+ seen.add(event.id);
276
+ if (event.type === 'message')
277
+ opts.onPartial?.(null);
278
+ if (knownEvents.has(event.type))
279
+ yield eventAck(event, previous);
280
+ else
281
+ await eventAck(event, previous).ack();
282
+ cursor = event.id;
283
+ closed = event.type === 'state' && event.status === 'closed';
284
+ }
285
+ }
286
+ else {
287
+ if (++pages > maxPages)
288
+ throw new ConversationError('too_many_pages');
289
+ const beforePage = cursor;
290
+ const page = parseEventPage(parseJson(await responseText(response)), cursor);
291
+ more = page.has_more;
292
+ for (const event of page.events) {
293
+ if (seen.has(event.id))
294
+ continue;
295
+ const previous = cursor;
296
+ if (seen.size >= 10000)
297
+ throw new ConversationError('too_many_events');
298
+ seen.add(event.id);
299
+ if (knownEvents.has(event.type))
300
+ yield eventAck(event, previous);
301
+ else
302
+ await eventAck(event, previous).ack();
303
+ cursor = event.id;
304
+ }
305
+ closed = page.status === 'closed' && !more;
306
+ if (more && (!page.events.length || page.cursor === beforePage))
307
+ throw new ConversationError('invalid_response');
308
+ }
309
+ }
310
+ catch (error) {
311
+ opts.signal?.throwIfAborted();
312
+ if (!(error instanceof TransportError || ['ECONNRESET', 'ERR_STREAM_PREMATURE_CLOSE'].includes(error?.code ?? '')))
313
+ throw error;
314
+ }
315
+ finally {
316
+ opts.onPartial?.(null);
317
+ await response.body?.cancel().catch(() => { });
318
+ }
319
+ if (closed)
320
+ return;
321
+ if (more)
322
+ continue;
323
+ if (reconnects++ >= maxReconnects)
324
+ throw new ConversationError('reconnect_exhausted');
325
+ opts.signal?.throwIfAborted();
326
+ await pause(Math.min(maxDelayMs, baseDelayMs * 2 ** attempts++), opts.signal);
327
+ }
328
+ },
329
+ start: (message, opts = {}) => submit(message, null, opts, opts.parentConversationId),
330
+ send: (conversationId, message, opts = {}) => submit(message, conversationId, opts),
331
+ /** Re-dispatch a pending outbound operation with its original ID and content. */
332
+ retry: dispatch,
333
+ async operation(messageId) { return store.getOutbound(key, messageId); },
334
+ handoff: (conversationId, opts = {}) => simplePost(conversationId, 'handoff', opts.signal),
335
+ close: (conversationId, opts = {}) => simplePost(conversationId, 'close', opts.signal),
336
+ /** Reads one whole page. Explicit `cursor`, or the checkpoint of a named `consumer` (not both). Never acknowledges. */
337
+ async read(conversationId, opts = {}) {
338
+ parseId(conversationId, 'conversation_id');
339
+ if (opts.consumer !== undefined && opts.cursor !== undefined)
340
+ throw new ConversationError('invalid_request'); // the ack would be silently meaningless
341
+ const cursor = opts.consumer === undefined ? opts.cursor : await checkpoint(conversationId, opts.consumer);
342
+ const url = new URL(conversationUrl(conversationId, 'events'));
343
+ if (cursor !== undefined)
344
+ url.searchParams.set('cursor', cursor);
345
+ if (opts.wait !== undefined)
346
+ url.searchParams.set('wait', String(opts.wait));
347
+ const response = await call(() => new Request(url, { method: 'GET', signal: opts.signal }));
348
+ if (!response.ok)
349
+ throw await failure(response); // cursor_expired (410) is the explicit history gap: see resetConsumer
350
+ let page;
351
+ try {
352
+ page = parseEventPage(parseJson(await responseText(response)), cursor);
353
+ }
354
+ catch {
355
+ throw new ConversationError('invalid_response', response.status);
356
+ }
357
+ if (opts.consumer !== undefined)
358
+ delivered.set(page, { conversationId, consumer: opts.consumer, chain: [cursor] });
359
+ return page;
360
+ },
361
+ /** Reads pages until has_more is false. Pages are returned whole, never acknowledged. */
362
+ async drain(conversationId, opts = {}) {
363
+ if (opts.consumer !== undefined && opts.cursor !== undefined)
364
+ throw new ConversationError('invalid_request');
365
+ const pages = [];
366
+ let cursor = opts.consumer === undefined ? opts.cursor : await checkpoint(conversationId, opts.consumer);
367
+ const chain = [cursor]; // checkpoints this page may legitimately advance from
368
+ for (; pages.length < maxPages;) {
369
+ const page = await client.read(conversationId, { cursor, wait: pages.length ? 0 : opts.wait, signal: opts.signal });
370
+ if (opts.consumer !== undefined)
371
+ delivered.set(page, { conversationId, consumer: opts.consumer, chain: [...chain] });
372
+ pages.push(page);
373
+ if (!page.has_more)
374
+ return pages;
375
+ // has_more with no new events or an unchanged cursor can never finish.
376
+ if (!page.events.length || page.cursor === null || page.cursor === cursor)
377
+ throw new ConversationError('invalid_response');
378
+ cursor = page.cursor;
379
+ chain.push(cursor);
380
+ }
381
+ throw new ConversationError('too_many_pages');
382
+ },
383
+ /** Durably advances the consumer to the page's cursor, after the application processed that page. A page never delivered to this consumer by this client instance is refused. */
384
+ async ack(conversationId, consumer, page) {
385
+ parseId(conversationId, 'conversation_id');
386
+ parseId(consumer, 'consumer');
387
+ const from = delivered.get(page);
388
+ if (from?.conversationId !== conversationId || from.consumer !== consumer)
389
+ throw new ConversationError('invalid_request');
390
+ if (page.cursor === null)
391
+ return; // empty foreign page: nothing to advance past
392
+ const cursor = page.cursor;
393
+ // Compare-and-set: cursors are opaque, so a page advances only from a checkpoint it was read from; stale acks do nothing.
394
+ await store.advanceCheckpoint(key, { connectionId, issuer, conversationId, name: consumer, cursor }, from.chain);
395
+ },
396
+ /** After cursor_expired: forget the checkpoint so the consumer rereads retained history from the start (dedupe by event ID; missing events were not processed). */
397
+ async resetConsumer(conversationId, consumer) {
398
+ parseId(conversationId, 'conversation_id');
399
+ parseId(consumer, 'consumer');
400
+ await store.resetCheckpoint(key, conversationId, consumer);
401
+ },
402
+ };
403
+ return client;
404
+ }
@@ -0,0 +1,18 @@
1
+ export { createClientAuth, initialClientAuthState, AuthFailure } from './auth/client-auth.js';
2
+ export type { ClientAuth, ClientAuthOptions, SignOutOutcome, ClientAuthState, AuthStore, AuthSelection, SecretStore, IdentitySigner, Connection, ConnectionStatus, AuthOutcome, OperationStatus, ConversationTarget } from './auth/client-auth.js';
3
+ export { createNodeTransport } from './network/transport.js';
4
+ export type { HttpTransport, NetworkOptions } from './network/transport.js';
5
+ export { createMetadataResolver } from './network/discovery.js';
6
+ export type { MetadataResolver } from './network/discovery.js';
7
+ export { createConversationClient, initialConversationClientState, ConversationError } from './conversation.js';
8
+ export type { ConversationClientOptions, ConsumerCheckpoint, ConversationClientState, OutboundInput, OutboundRecord, ReadOptions, ReadParams } from './conversation.js';
9
+ export type { ConversationStore, ConversationKey } from './conversation-store.js';
10
+ export type { OperationOwner } from './auth/ownership.js';
11
+ export type { WatchOptions, WatchedEvent } from './conversation.js';
12
+ export { parseEventStream } from './protocol/sse.js';
13
+ export type { StreamFrame } from './protocol/sse.js';
14
+ export type { AssertionRequest, AssertionProvider, ProviderIdentity, TrustedInput, SignInMethod, SignInInput, SignInStart, AuthorizationRequirement } from './auth/client-auth.js';
15
+ export { authChallenge } from './auth/challenge.js';
16
+ export type { AuthChallenge } from './auth/challenge.js';
17
+ export type { ApiTarget, AgentBrowserController, FeedbackReport } from './integrations.js';
18
+ export { protectedResourceMetadataUrl } from './integrations.js';
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { createClientAuth, initialClientAuthState, AuthFailure } from './auth/client-auth.js';
2
+ export { createNodeTransport } from './network/transport.js';
3
+ export { createMetadataResolver } from './network/discovery.js';
4
+ export { createConversationClient, initialConversationClientState, ConversationError } from './conversation.js';
5
+ export { parseEventStream } from './protocol/sse.js';
6
+ export { authChallenge } from './auth/challenge.js';
7
+ export { protectedResourceMetadataUrl } from './integrations.js';
@@ -0,0 +1,30 @@
1
+ import type { CompanyMetadata, MetadataResolver } from './network/discovery.js';
2
+ import type { HttpTransport } from './network/transport.js';
3
+ export interface ApiTarget {
4
+ kind: 'openapi' | 'mcp';
5
+ apiUrl: string;
6
+ issuer: string;
7
+ endpoint: string;
8
+ resource?: string;
9
+ scheme: 'DPoP' | 'Bearer';
10
+ }
11
+ export interface AgentBrowserController {
12
+ readonly kind: 'agent-controlled';
13
+ postForm(input: {
14
+ url: string;
15
+ fields: {
16
+ assertion: string;
17
+ };
18
+ validateRedirect(status: number, location: string): string;
19
+ }): Promise<void>;
20
+ }
21
+ export interface FeedbackReport {
22
+ kind: 'missing';
23
+ task: string;
24
+ description: string;
25
+ }
26
+ export declare function validateFeedback(value: FeedbackReport): void;
27
+ export declare function browserDestination(value: string, domain: string): void;
28
+ export declare function protectedResourceMetadataUrl(resource: string): string;
29
+ export declare function mcpScheme(transport: HttpTransport, issuer: string, resource: string): Promise<'DPoP' | 'Bearer'>;
30
+ export declare function resolveApiTarget(metadata: CompanyMetadata, resolver: MetadataResolver, transport: HttpTransport, apiUrl: string, destination?: string): Promise<ApiTarget>;
@@ -0,0 +1,58 @@
1
+ import { secureUrl } from './network/transport.js';
2
+ import { object, parseJson, ValidationError } from './protocol/wire.js';
3
+ export function validateFeedback(value) {
4
+ if (!value || value.kind !== 'missing' || typeof value.task !== 'string' || !value.task.length || [...value.task].length > 200 || typeof value.description !== 'string' || !value.description.length || [...value.description].length > 2000)
5
+ throw new ValidationError('feedback');
6
+ }
7
+ export function browserDestination(value, domain) {
8
+ const url = secureUrl(value);
9
+ if (url.hostname !== domain.toLowerCase() && !url.hostname.endsWith(`.${domain.toLowerCase()}`))
10
+ throw new ValidationError('return_to');
11
+ }
12
+ export function protectedResourceMetadataUrl(resource) {
13
+ const url = secureUrl(resource);
14
+ if (url.search)
15
+ throw new ValidationError('resource');
16
+ return `${url.origin}/.well-known/oauth-protected-resource${url.pathname === '/' ? '' : url.pathname}`;
17
+ }
18
+ export async function mcpScheme(transport, issuer, resource) {
19
+ const response = await transport.fetch(new Request(protectedResourceMetadataUrl(resource)), { mode: 'discovery' });
20
+ if (response.status !== 200)
21
+ throw new ValidationError('resource_metadata');
22
+ const metadata = object(parseJson(await response.text()));
23
+ if (metadata.resource !== resource || !Array.isArray(metadata.authorization_servers) || !metadata.authorization_servers.includes(issuer))
24
+ throw new ValidationError('resource_issuer');
25
+ for (const server of metadata.authorization_servers)
26
+ secureUrl(server);
27
+ if ('bearer_methods_supported' in metadata && (!Array.isArray(metadata.bearer_methods_supported) || !metadata.bearer_methods_supported.includes('header')))
28
+ throw new ValidationError('bearer_methods_supported');
29
+ if (!Object.hasOwn(metadata, 'dpop_signing_alg_values_supported'))
30
+ return 'Bearer';
31
+ if (!Array.isArray(metadata.dpop_signing_alg_values_supported) || !metadata.dpop_signing_alg_values_supported.includes('ES256') || !metadata.dpop_signing_alg_values_supported.every(value => typeof value === 'string'))
32
+ throw new ValidationError('dpop_signing_alg_values_supported');
33
+ return 'DPoP';
34
+ }
35
+ export async function resolveApiTarget(metadata, resolver, transport, apiUrl, destination) {
36
+ const api = metadata.document.apis?.find(entry => entry.url === apiUrl);
37
+ const issuer = metadata.authorization?.issuer;
38
+ if (!api || !issuer || !['openapi', 'mcp'].includes(api.type))
39
+ throw new ValidationError('api');
40
+ if (api.type === 'mcp') {
41
+ if ((api.resource !== undefined && api.resource !== api.url) || (destination !== undefined && destination !== api.url))
42
+ throw new ValidationError('resource');
43
+ const scheme = await mcpScheme(transport, issuer, api.url);
44
+ if (scheme === 'Bearer' && metadata.document.agent?.protocols.some(protocol => protocol.resource === api.url))
45
+ throw new ValidationError('resource');
46
+ return { kind: 'mcp', apiUrl, issuer, endpoint: api.url, resource: api.url, scheme };
47
+ }
48
+ // An OpenAPI servers entry is data. The destination host must independently vouch for this issuer.
49
+ if (!destination)
50
+ throw new ValidationError('destination');
51
+ const url = secureUrl(destination);
52
+ if (url.search)
53
+ throw new ValidationError('destination');
54
+ const serving = await resolver.resolveCompany(url.host);
55
+ if (serving.authorization?.issuer !== issuer || serving.document.organization.domain !== url.hostname)
56
+ throw new ValidationError('destination_issuer');
57
+ return { kind: 'openapi', apiUrl, issuer, endpoint: destination, ...(api.resource ? { resource: api.resource } : {}), scheme: 'DPoP' };
58
+ }
@@ -0,0 +1,15 @@
1
+ import type { ConversationKey, ConversationStore } from './conversation-store.js';
2
+ import type { ConsumerCheckpoint, OutboundRecord } from './conversation.js';
3
+ /** Non-durable, single-process test/development adapter. */
4
+ export declare class MemoryConversationStore implements ConversationStore {
5
+ private readonly outbound;
6
+ private readonly checkpoints;
7
+ private outKey;
8
+ private checkKey;
9
+ getOutbound(key: ConversationKey, id: string): Promise<OutboundRecord | undefined>;
10
+ prepareOutbound(key: ConversationKey, row: OutboundRecord): Promise<OutboundRecord>;
11
+ settleOutbound(key: ConversationKey, id: string, result: Pick<OutboundRecord, 'phase' | 'response' | 'error'>): Promise<void>;
12
+ getCheckpoint(key: ConversationKey, conversation: string, name: string): Promise<string | undefined>;
13
+ advanceCheckpoint(key: ConversationKey, row: ConsumerCheckpoint, from: readonly (string | undefined)[]): Promise<void>;
14
+ resetCheckpoint(key: ConversationKey, conversation: string, name: string): Promise<void>;
15
+ }
package/dist/memory.js ADDED
@@ -0,0 +1,32 @@
1
+ /** Non-durable, single-process test/development adapter. */
2
+ export class MemoryConversationStore {
3
+ outbound = new Map();
4
+ checkpoints = new Map();
5
+ outKey(key, id) { return JSON.stringify([key.connectionId, key.issuer, id]); }
6
+ checkKey(key, conversation, name) { return JSON.stringify([key.connectionId, key.issuer, key.endpoint, conversation, name]); }
7
+ async getOutbound(key, id) { return structuredClone(this.outbound.get(this.outKey(key, id))); }
8
+ async prepareOutbound(key, row) {
9
+ if (row.connectionId !== key.connectionId || row.issuer !== key.issuer || row.endpoint !== key.endpoint)
10
+ throw new TypeError('outbound binding');
11
+ const id = this.outKey(key, row.id);
12
+ if (!this.outbound.has(id))
13
+ this.outbound.set(id, structuredClone(row));
14
+ return structuredClone(this.outbound.get(id));
15
+ }
16
+ async settleOutbound(key, id, result) {
17
+ const row = this.outbound.get(this.outKey(key, id));
18
+ if (!row || row.endpoint !== key.endpoint)
19
+ throw new TypeError('outbound binding');
20
+ if (row.phase === 'pending')
21
+ Object.assign(row, structuredClone(result));
22
+ }
23
+ async getCheckpoint(key, conversation, name) { return this.checkpoints.get(this.checkKey(key, conversation, name)); }
24
+ async advanceCheckpoint(key, row, from) {
25
+ if (row.connectionId !== key.connectionId || row.issuer !== key.issuer)
26
+ throw new TypeError('checkpoint binding');
27
+ const id = this.checkKey(key, row.conversationId, row.name);
28
+ if (from.includes(this.checkpoints.get(id)))
29
+ this.checkpoints.set(id, row.cursor);
30
+ }
31
+ async resetCheckpoint(key, conversation, name) { this.checkpoints.delete(this.checkKey(key, conversation, name)); }
32
+ }