@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,278 @@
1
+ export class ValidationError extends Error {
2
+ field;
3
+ constructor(field) {
4
+ super(`Invalid protocol field: ${field}`);
5
+ this.field = field;
6
+ this.name = 'ValidationError';
7
+ }
8
+ }
9
+ export function validateJson(value) {
10
+ const stack = [{ value, depth: 0 }];
11
+ while (stack.length) {
12
+ const current = stack.pop();
13
+ if (current.depth > 64)
14
+ throw new ValidationError('json.depth');
15
+ if (current.value === null || typeof current.value === 'string' || typeof current.value === 'boolean')
16
+ continue;
17
+ if (typeof current.value === 'number' && Number.isFinite(current.value))
18
+ continue;
19
+ if (typeof current.value !== 'object' || (Object.getPrototypeOf(current.value) !== Object.prototype && !Array.isArray(current.value)))
20
+ throw new ValidationError('json');
21
+ for (const child of Object.values(current.value))
22
+ stack.push({ value: child, depth: current.depth + 1 });
23
+ }
24
+ }
25
+ export function object(value, field = 'document') {
26
+ if (!value || typeof value !== 'object' || Array.isArray(value))
27
+ throw new ValidationError(field);
28
+ validateJson(value);
29
+ return value;
30
+ }
31
+ export function string(value, field, nonempty = true) {
32
+ if (typeof value !== 'string' || (nonempty && !value.length))
33
+ throw new ValidationError(field);
34
+ return value;
35
+ }
36
+ export function member(value, values, field) {
37
+ if (!values.includes(value))
38
+ throw new ValidationError(field);
39
+ }
40
+ export function id(value, field) {
41
+ const result = string(value, field);
42
+ if (!/^[A-Za-z0-9_-]{1,256}$/.test(result))
43
+ throw new ValidationError(field);
44
+ return result;
45
+ }
46
+ function positive(value, field) {
47
+ if (!Number.isSafeInteger(value) || value <= 0)
48
+ throw new ValidationError(field);
49
+ }
50
+ export function parseMessage(value, options = {}) {
51
+ const message = { ...object(value, 'message') };
52
+ id(message.id, 'message.id');
53
+ member(message.sender, ['agent', 'human'], 'message.sender');
54
+ if (!['text', 'data', 'context'].some(key => key in message))
55
+ throw new ValidationError('message.content');
56
+ if ('text' in message)
57
+ string(message.text, 'message.text', false);
58
+ if ('data' in message)
59
+ object(message.data, 'message.data');
60
+ if (options.recorded)
61
+ member(message.role, ['user', 'company'], 'message.role');
62
+ else
63
+ delete message.role;
64
+ if ('context' in message) {
65
+ if (options.company || message.role === 'company')
66
+ throw new ValidationError('message.context');
67
+ const context = object(message.context, 'message.context');
68
+ if ('user_available' in context)
69
+ member(context.user_available, [true, false], 'message.context.user_available');
70
+ if ('locale' in context) {
71
+ const locale = string(context.locale, 'message.context.locale');
72
+ try {
73
+ new Intl.Locale(locale);
74
+ }
75
+ catch {
76
+ throw new ValidationError('message.context.locale');
77
+ }
78
+ }
79
+ if ('time_zone' in context) {
80
+ const zone = string(context.time_zone, 'message.context.time_zone');
81
+ if (/^[+-]/.test(zone))
82
+ throw new ValidationError('message.context.time_zone');
83
+ try {
84
+ new Intl.DateTimeFormat('en', { timeZone: zone });
85
+ }
86
+ catch {
87
+ throw new ValidationError('message.context.time_zone');
88
+ }
89
+ }
90
+ }
91
+ return message;
92
+ }
93
+ export function parseConversationRequest(value) {
94
+ const request = object(value);
95
+ if ('parent_conversation_id' in request)
96
+ id(request.parent_conversation_id, 'parent_conversation_id');
97
+ return { ...request, message: parseMessage(request.message) };
98
+ }
99
+ export function parseConversationState(value) {
100
+ const state = object(value);
101
+ id(state.conversation_id, 'conversation_id');
102
+ member(state.status, ['working', 'idle', 'queued', 'closed'], 'status');
103
+ member(state.responder, ['agent', 'human'], 'responder');
104
+ return state;
105
+ }
106
+ export function parseEvent(value) {
107
+ const event = object(value, 'event');
108
+ id(event.id, 'event.id');
109
+ string(event.type, 'event.type');
110
+ const date = string(event.created_at, 'event.created_at');
111
+ const parts = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|([+-])(\d{2}):(\d{2}))$/.exec(date);
112
+ if (!parts)
113
+ throw new ValidationError('event.created_at');
114
+ const [, year, month, day, hour, minute, second, , offsetHour = '0', offsetMinute = '0'] = parts;
115
+ const days = new Date(`${year}-${month}-01T00:00:00Z`);
116
+ days.setUTCMonth(days.getUTCMonth() + 1);
117
+ days.setUTCDate(0);
118
+ if (+month < 1 || +month > 12 || +day < 1 || +day > days.getUTCDate() || +hour > 23 || +minute > 59 || +second > 60 || +offsetHour > 23 || +offsetMinute > 59)
119
+ throw new ValidationError('event.created_at');
120
+ switch (event.type) {
121
+ case 'message':
122
+ parseMessage(event.message, { recorded: true });
123
+ break;
124
+ case 'state':
125
+ member(event.status, ['working', 'idle', 'queued', 'closed'], 'event.status');
126
+ member(event.responder, ['agent', 'human'], 'event.responder');
127
+ break;
128
+ case 'authorization':
129
+ member(event.error, ['sign_in_required', 'insufficient_scope'], 'event.error');
130
+ if (event.error === 'insufficient_scope' || 'scope' in event)
131
+ string(event.scope, 'event.scope', false);
132
+ break;
133
+ case 'user_requested':
134
+ string(event.reason, 'event.reason', false);
135
+ break;
136
+ case 'direct_opened':
137
+ case 'direct_closed':
138
+ id(event.conversation_id, 'event.conversation_id');
139
+ break;
140
+ }
141
+ return event;
142
+ }
143
+ export function parseEventPage(value, requestCursor) {
144
+ const page = parseConversationState(value);
145
+ if (!Array.isArray(page.events))
146
+ throw new ValidationError('events');
147
+ page.events.forEach(parseEvent);
148
+ member(page.has_more, [true, false], 'has_more');
149
+ if (page.cursor === null) {
150
+ if (requestCursor !== undefined || page.events.length || page.has_more)
151
+ throw new ValidationError('cursor');
152
+ }
153
+ else {
154
+ string(page.cursor, 'cursor');
155
+ const expected = page.events.length ? page.events.at(-1).id : requestCursor;
156
+ if (expected !== undefined && page.cursor !== expected)
157
+ throw new ValidationError('cursor');
158
+ }
159
+ return page;
160
+ }
161
+ export function parseTokenResponse(value) {
162
+ const token = object(value);
163
+ string(token.access_token, 'access_token');
164
+ string(token.session_id, 'session_id');
165
+ string(token.scope, 'scope', false);
166
+ member(token.token_type, ['DPoP', 'Bearer'], 'token_type');
167
+ member(token.signed_in, [true, false], 'signed_in');
168
+ positive(token.expires_in, 'expires_in');
169
+ if ('refresh_token' in token)
170
+ string(token.refresh_token, 'refresh_token');
171
+ if ('refresh_token_expires_in' in token)
172
+ positive(token.refresh_token_expires_in, 'refresh_token_expires_in');
173
+ return token;
174
+ }
175
+ export function parseProtocolError(value) {
176
+ const error = object(value);
177
+ string(error.error, 'error');
178
+ for (const key of ['error_description', 'error_uri'])
179
+ if (key in error)
180
+ string(error[key], key, false);
181
+ if (error.error === 'extension_required' || 'extension' in error)
182
+ string(error.extension, 'extension');
183
+ if (error.error === 'direct_conversation_open' || 'conversation_id' in error)
184
+ id(error.conversation_id, 'conversation_id');
185
+ return error;
186
+ }
187
+ export function parseReadOptions(url, maxWaitSeconds) {
188
+ if (!Number.isSafeInteger(maxWaitSeconds) || maxWaitSeconds < 0)
189
+ throw new ValidationError('maxWaitSeconds');
190
+ for (const key of ['wait', 'cursor'])
191
+ if (url.searchParams.getAll(key).length > 1)
192
+ throw new ValidationError(key);
193
+ const wait = url.searchParams.get('wait') ?? '0';
194
+ if (!/^\d+$/.test(wait))
195
+ throw new ValidationError('wait');
196
+ const cursor = url.searchParams.get('cursor');
197
+ if (cursor !== null)
198
+ string(cursor, 'cursor');
199
+ return { ...(cursor === null ? {} : { cursor }), waitSeconds: Number(BigInt(wait) > BigInt(maxWaitSeconds) ? maxWaitSeconds : BigInt(wait)) };
200
+ }
201
+ export function retryAfterMilliseconds(value, nowMs) {
202
+ if (!value)
203
+ return undefined;
204
+ if (/^\d+$/.test(value)) {
205
+ const ms = Number(value) * 1000;
206
+ return Number.isSafeInteger(ms) ? ms : Infinity;
207
+ } // too large to wait: let the caller's maxDelay check surface it
208
+ if (!/^[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(value))
209
+ return undefined;
210
+ const date = Date.parse(value);
211
+ return Number.isFinite(date) ? Math.max(0, date - nowMs) : undefined;
212
+ }
213
+ // Session and client assertions (spec 4.1/4.2): aud is the issuer as a single string, exp - iat <= 300s.
214
+ export function parseAssertionClaims(value) {
215
+ const claims = object(value, 'claims');
216
+ for (const key of ['iss', 'sub', 'aud', 'jti'])
217
+ string(claims[key], key);
218
+ // 128 random bits is at least 16 bytes; cap length since jti lands in the replay store. Encoding is not constrained.
219
+ if (claims.jti.length < 16 || claims.jti.length > 256)
220
+ throw new ValidationError('jti');
221
+ positive(claims.iat, 'iat');
222
+ positive(claims.exp, 'exp');
223
+ if (claims.exp <= claims.iat || claims.exp - claims.iat > 300)
224
+ throw new ValidationError('exp');
225
+ return claims;
226
+ }
227
+ // DPoP proof payload (spec 4.3); htu carries no query or fragment.
228
+ export function parseDpopClaims(value) {
229
+ const claims = object(value, 'claims');
230
+ string(claims.jti, 'jti');
231
+ string(claims.htm, 'htm');
232
+ positive(claims.iat, 'iat');
233
+ const htu = string(claims.htu, 'htu');
234
+ try {
235
+ const url = new URL(htu);
236
+ if (url.protocol !== 'https:' || url.search || url.hash || htu.includes('?') || htu.includes('#'))
237
+ throw 0;
238
+ }
239
+ catch {
240
+ throw new ValidationError('htu');
241
+ }
242
+ for (const key of ['ath', 'nonce'])
243
+ if (key in claims)
244
+ string(claims[key], key);
245
+ return claims;
246
+ }
247
+ // Sorted keys so structural equality ignores key order but keeps arrays, text and unknown members exact.
248
+ export function canonical(value) {
249
+ const walk = (item) => {
250
+ if (Array.isArray(item))
251
+ return `[${item.map(walk).join(',')}]`;
252
+ if (item && typeof item === 'object')
253
+ return `{${Object.keys(item).sort().map(key => `${JSON.stringify(key)}:${walk(item[key])}`).join(',')}}`;
254
+ return JSON.stringify(item);
255
+ };
256
+ return walk(JSON.parse(JSON.stringify(value))); // same shape as the wire (drops undefined members)
257
+ }
258
+ // JSON.parse that rejects duplicate member names (guide "Transport and JSON"); JSON.parse alone keeps the last one.
259
+ export function parseJson(text) {
260
+ const value = JSON.parse(text); // validates syntax, so the scan below only sees well-formed tokens
261
+ const stack = [];
262
+ const tokens = text.match(/"(?:[^"\\]|\\.)*"|[{}[\],:]|[^\s"{}[\],:]+/g) ?? [];
263
+ tokens.forEach((token, index) => {
264
+ if (token === '{')
265
+ stack.push(new Set());
266
+ else if (token === '[')
267
+ stack.push(null);
268
+ else if (token === '}' || token === ']')
269
+ stack.pop();
270
+ else if (token[0] === '"' && tokens[index + 1] === ':') {
271
+ const names = stack.at(-1), name = JSON.parse(token);
272
+ if (names?.has(name))
273
+ throw new ValidationError('json.duplicate_member');
274
+ names?.add(name);
275
+ }
276
+ });
277
+ return value;
278
+ }
@@ -0,0 +1,8 @@
1
+ import type { ProviderStore, ProviderTransaction } from './provider.js';
2
+ /** Optional durable provider records. Callback results must be encrypted by the separately configured cipher. */
3
+ export declare class SqliteProviderStore implements ProviderStore {
4
+ private readonly db;
5
+ constructor(path: string);
6
+ close(): void;
7
+ transaction<T>(action: (tx: ProviderTransaction) => T): T;
8
+ }
@@ -0,0 +1,30 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { initializeSqlite } from './sqlite-init.js';
3
+ /** Optional durable provider records. Callback results must be encrypted by the separately configured cipher. */
4
+ export class SqliteProviderStore {
5
+ db;
6
+ constructor(path) {
7
+ this.db = new DatabaseSync(path);
8
+ initializeSqlite(this.db, () => this.db.exec('PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; CREATE TABLE IF NOT EXISTS provider_records(kind TEXT NOT NULL,id TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(kind,id));'));
9
+ }
10
+ close() { this.db.close(); }
11
+ transaction(action) {
12
+ this.db.exec('BEGIN IMMEDIATE');
13
+ try {
14
+ const result = action({
15
+ get: (kind, id) => { const row = this.db.prepare('SELECT body FROM provider_records WHERE kind=? AND id=?').get(kind, id); return row ? JSON.parse(row.body) : undefined; },
16
+ put: (kind, id, value) => { this.db.prepare('INSERT OR REPLACE INTO provider_records VALUES (?,?,?)').run(kind, id, JSON.stringify(value)); },
17
+ remove: (kind, id) => { this.db.prepare('DELETE FROM provider_records WHERE kind=? AND id=?').run(kind, id); },
18
+ list: (kind, prefix, limit) => this.db.prepare('SELECT id,body FROM provider_records WHERE kind=? AND id>=? AND id<? ORDER BY id LIMIT ?').all(kind, prefix, `${prefix}\uffff`, limit).map(row => ({ id: row.id, value: JSON.parse(row.body) })),
19
+ });
20
+ if (result instanceof Promise)
21
+ throw new TypeError('provider transaction must be synchronous');
22
+ this.db.exec('COMMIT');
23
+ return result;
24
+ }
25
+ catch (error) {
26
+ this.db.exec('ROLLBACK');
27
+ throw error;
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,121 @@
1
+ import type { IdentitySigner } from './auth/client-auth.js';
2
+ import type { MetadataResolver } from './network/discovery.js';
3
+ import type { HttpTransport } from './network/transport.js';
4
+ type Row = Record<string, any>;
5
+ export interface ProviderTransaction {
6
+ get(kind: string, id: string): Row | undefined;
7
+ put(kind: string, id: string, value: Row): void;
8
+ remove(kind: string, id: string): void;
9
+ list(kind: string, prefix: string, limit: number): {
10
+ id: string;
11
+ value: Row;
12
+ }[];
13
+ }
14
+ /** Must commit synchronously, atomically across processes; callback must not perform I/O. */
15
+ export interface ProviderStore {
16
+ transaction<T>(action: (tx: ProviderTransaction) => T): T;
17
+ }
18
+ export interface ResultCipher {
19
+ seal(value: string): string;
20
+ open(value: string): string;
21
+ }
22
+ export declare function createResultCipher(key: Uint8Array): ResultCipher;
23
+ export type ProviderErrorCode = 'unauthorized' | 'binding_conflict' | 'expired' | 'consumed' | 'canceled' | 'rate_limited' | 'unavailable';
24
+ export declare class ProviderError extends Error {
25
+ readonly code: ProviderErrorCode;
26
+ readonly retryAt?: number | undefined;
27
+ constructor(code: ProviderErrorCode, retryAt?: number | undefined);
28
+ }
29
+ export interface ProviderOptions {
30
+ clientId: string;
31
+ callbackUrl: string;
32
+ jwksUri: string;
33
+ clientName: string;
34
+ store: ProviderStore;
35
+ cipher: ResultCipher;
36
+ metadata: MetadataResolver;
37
+ signer: IdentitySigner;
38
+ /** Publish new keys before use and retain retired public keys through assertion expiry + skew. */
39
+ publishedKeys?: () => Promise<Record<string, any>[]>;
40
+ /** Validate operator login and CSRF; caller IDs and public setup URLs are never authentication. */
41
+ authenticateEnrollment(request: Request): Promise<{
42
+ subject: string;
43
+ allowedCompanies: string[];
44
+ } | null>;
45
+ /** Required for browser assertions: independently prove session belongs to this client AND derived user. */
46
+ verifySession?(binding: {
47
+ clientId: string;
48
+ userId: string;
49
+ issuer: string;
50
+ sessionId: string;
51
+ }): Promise<boolean>;
52
+ now?: () => number;
53
+ maxEnrollments?: number;
54
+ }
55
+ export declare function createProvider(options: ProviderOptions): {
56
+ enroll(request: Request, input: Row): Promise<{
57
+ enrollmentId: string;
58
+ credential: string;
59
+ clientId: string;
60
+ callbackUrl: string;
61
+ }>;
62
+ identity(credential: string, input: Row): Promise<{
63
+ clientId: string;
64
+ userId: string;
65
+ }>;
66
+ begin(credential: string, input: Row): Promise<{
67
+ expiresAt: any;
68
+ }>;
69
+ assert(credential: string, input: Row): Promise<{
70
+ assertion: string;
71
+ expiresAt: number;
72
+ }>;
73
+ callback(callbackUrl: string): {
74
+ status: string;
75
+ };
76
+ takeResult(credential: string, input: Row): {
77
+ status: string;
78
+ callbackUrl?: undefined;
79
+ } | {
80
+ status: string;
81
+ callbackUrl: string;
82
+ };
83
+ cancel(credential: string, input: Row): {
84
+ status: string;
85
+ };
86
+ revokeEnrollment(credential: string, input: Row): {
87
+ status: string;
88
+ };
89
+ handle(request: Request): Promise<Response>;
90
+ };
91
+ /** Bound trusted adapter: credentials/assertions/results never belong in harness output. */
92
+ export declare function createProviderClient(options: {
93
+ url: string;
94
+ installationId: string;
95
+ credential: () => Promise<string>;
96
+ transport: HttpTransport;
97
+ domain: string;
98
+ }): {
99
+ identity: (issuer: string) => Promise<{
100
+ clientId: string;
101
+ userId: string;
102
+ }>;
103
+ assert: (request: import("./auth/client-auth.js").AssertionRequest) => Promise<{
104
+ assertion: string;
105
+ expiresAt: number;
106
+ }>;
107
+ beginDirect: (input: {
108
+ operationId: string;
109
+ issuer: string;
110
+ sessionId: string;
111
+ state: string;
112
+ }) => Promise<Row>;
113
+ takeResult: (input: {
114
+ operationId: string;
115
+ issuer: string;
116
+ sessionId: string;
117
+ }) => Promise<Row>;
118
+ cancel: (operationId: string) => Promise<Row>;
119
+ revokeEnrollment: () => Promise<Row>;
120
+ };
121
+ export {};