@poppykit/client 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/API.md +25 -0
  2. package/AUTH.md +51 -0
  3. package/CHANGELOG.md +22 -0
  4. package/CONTRIBUTING.md +50 -0
  5. package/DELIVERY.md +113 -0
  6. package/INTEGRATIONS.md +87 -0
  7. package/LICENSE +21 -0
  8. package/PROVIDER.md +99 -0
  9. package/README.md +123 -2
  10. package/RELEASING.md +193 -0
  11. package/SECURITY.md +13 -0
  12. package/STORAGE.md +99 -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,101 @@
1
+ import type { JsonObject } from '../protocol/wire.js';
2
+ import type { HttpTransport } from './transport.js';
3
+ export interface PoppyDocument {
4
+ protocol_version: '0.2';
5
+ organization: {
6
+ name: string;
7
+ domain: string;
8
+ };
9
+ auth?: {
10
+ issuer: string;
11
+ direct?: {
12
+ scopes: string[];
13
+ };
14
+ device?: {
15
+ scopes: string[];
16
+ };
17
+ mediated?: {
18
+ endpoint: string;
19
+ fields: {
20
+ name: string;
21
+ label: string;
22
+ secret: boolean;
23
+ }[];
24
+ scopes: string[];
25
+ };
26
+ custom_scopes?: Record<string, string>;
27
+ [key: string]: unknown;
28
+ };
29
+ agent?: {
30
+ protocols: {
31
+ type: string;
32
+ endpoint: string;
33
+ resource?: string;
34
+ [key: string]: unknown;
35
+ }[];
36
+ };
37
+ apis?: {
38
+ type: string;
39
+ url: string;
40
+ description: string;
41
+ resource?: string;
42
+ [key: string]: unknown;
43
+ }[];
44
+ web?: {
45
+ browser_session_endpoint?: string;
46
+ [key: string]: unknown;
47
+ };
48
+ feedback?: {
49
+ endpoint: string;
50
+ [key: string]: unknown;
51
+ };
52
+ extensions?: Record<string, {
53
+ version: string;
54
+ endpoint?: string;
55
+ [key: string]: unknown;
56
+ }>;
57
+ [key: string]: unknown;
58
+ }
59
+ export interface AuthorizationMetadata {
60
+ issuer: string;
61
+ token_endpoint: string;
62
+ revocation_endpoint: string;
63
+ poppy_domains: string[];
64
+ authorization_endpoint?: string;
65
+ device_authorization_endpoint?: string;
66
+ dpop_signing_alg_values_supported?: string[];
67
+ client_id_metadata_document_supported?: boolean;
68
+ [key: string]: unknown;
69
+ }
70
+ export interface ClientMetadata {
71
+ client_id: string;
72
+ client_name: string;
73
+ jwks_uri: string;
74
+ redirect_uris: string[];
75
+ token_endpoint_auth_method: 'private_key_jwt';
76
+ [key: string]: unknown;
77
+ }
78
+ export interface CompanyMetadata {
79
+ document: PoppyDocument;
80
+ authorization?: AuthorizationMetadata;
81
+ url: string;
82
+ }
83
+ export interface MetadataResolver {
84
+ resolveCompany(domain: string): Promise<CompanyMetadata>;
85
+ resolveClient(clientId: string): Promise<ClientMetadata>;
86
+ resolveJwks(uri: string, options?: {
87
+ refresh?: boolean;
88
+ }): Promise<JsonObject>;
89
+ invalidate?(target?: string): void;
90
+ }
91
+ export declare function authorizationMetadataUrl(issuer: string): string;
92
+ export declare function validateClientId(clientId: string): URL;
93
+ export declare function parsePoppyDocument(value: unknown, servingUrl: string): PoppyDocument;
94
+ export declare function parseAuthorizationMetadata(value: unknown, document: PoppyDocument): AuthorizationMetadata;
95
+ export declare function parseClientMetadata(value: unknown, clientId: string): ClientMetadata;
96
+ export declare function createMetadataResolver(transport: HttpTransport, options?: {
97
+ now?: () => number;
98
+ maxEntries?: number;
99
+ maxConcurrentFetches?: number;
100
+ maxFetchesPerMinute?: number;
101
+ }): MetadataResolver;
@@ -0,0 +1,276 @@
1
+ import { object, string, member, canonical, parseJson, ValidationError } from '../protocol/wire.js';
2
+ import { secureUrl } from './transport.js';
3
+ function url(value, field) { const valueString = string(value, field); secureUrl(valueString); return valueString; }
4
+ function strings(value, field, allowEmpty = false) { if (!Array.isArray(value) || (!allowEmpty && !value.length))
5
+ throw new ValidationError(field); value.forEach(item => string(item, field)); return value; }
6
+ function scopes(value) { for (const scope of strings(value, 'scopes'))
7
+ if (!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope))
8
+ throw new ValidationError('scopes'); }
9
+ function domain(value) { const text = string(value, 'domain'); if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i.test(text) || text.includes('..'))
10
+ throw new ValidationError('domain'); return text; }
11
+ export function authorizationMetadataUrl(issuer) { const parsed = secureUrl(issuer); if (parsed.search)
12
+ throw new ValidationError('issuer'); return `${parsed.origin}/.well-known/oauth-authorization-server${parsed.pathname === '/' ? '' : parsed.pathname}`; }
13
+ export function validateClientId(clientId) {
14
+ // Backslashes, whitespace and control characters are normalized away by URL parsing, hiding dot segments.
15
+ if (/[\\\s\x00-\x1f]/.test(clientId))
16
+ throw new ValidationError('client_id');
17
+ const raw = /^https:\/\/[^/?#]+(\/[^?#]*)(?:\?[^#]*)?$/.exec(clientId);
18
+ if (!raw || raw[1].split('/').some(segment => /^(?:\.|%2e){1,2}$/i.test(segment)))
19
+ throw new ValidationError('client_id');
20
+ return secureUrl(clientId);
21
+ }
22
+ export function parsePoppyDocument(value, servingUrl) {
23
+ const doc = object(value);
24
+ member(doc.protocol_version, ['0.2'], 'protocol_version');
25
+ const organization = object(doc.organization, 'organization');
26
+ string(organization.name, 'organization.name');
27
+ if (domain(organization.domain).toLowerCase() !== secureUrl(servingUrl).hostname.toLowerCase())
28
+ throw new ValidationError('organization.domain');
29
+ if (!['agent', 'apis', 'web'].some(key => key in doc))
30
+ throw new ValidationError('capabilities');
31
+ const web = 'web' in doc ? object(doc.web, 'web') : undefined;
32
+ if (web && 'browser_session_endpoint' in web)
33
+ url(web.browser_session_endpoint, 'browser_session_endpoint');
34
+ if (['agent', 'apis', 'feedback'].some(key => key in doc) || (web && 'browser_session_endpoint' in web))
35
+ object(doc.auth, 'auth');
36
+ if ('auth' in doc) {
37
+ const auth = object(doc.auth, 'auth');
38
+ authorizationMetadataUrl(url(auth.issuer, 'issuer'));
39
+ for (const key of ['direct', 'device'])
40
+ if (key in auth)
41
+ scopes(object(auth[key], key).scopes);
42
+ if ('mediated' in auth) {
43
+ const mediated = object(auth.mediated, 'mediated');
44
+ url(mediated.endpoint, 'mediated.endpoint');
45
+ scopes(mediated.scopes);
46
+ if (!Array.isArray(mediated.fields) || !mediated.fields.length)
47
+ throw new ValidationError('mediated.fields');
48
+ for (const field of mediated.fields) {
49
+ const item = object(field);
50
+ string(item.name, 'field.name');
51
+ string(item.label, 'field.label');
52
+ member(item.secret, [true, false], 'field.secret');
53
+ }
54
+ }
55
+ if ('custom_scopes' in auth)
56
+ Object.values(object(auth.custom_scopes)).forEach(value => string(value, 'custom_scopes', false));
57
+ }
58
+ if ('agent' in doc) {
59
+ const protocols = object(doc.agent, 'agent').protocols;
60
+ if (!Array.isArray(protocols) || !protocols.length)
61
+ throw new ValidationError('protocols');
62
+ for (const protocol of protocols) {
63
+ const item = object(protocol);
64
+ string(item.type, 'protocol.type');
65
+ url(item.endpoint, 'protocol.endpoint');
66
+ if ('resource' in item)
67
+ url(item.resource, 'protocol.resource');
68
+ }
69
+ }
70
+ if ('apis' in doc) {
71
+ if (!Array.isArray(doc.apis))
72
+ throw new ValidationError('apis');
73
+ for (const api of doc.apis) {
74
+ const item = object(api);
75
+ string(item.type, 'api.type');
76
+ string(item.description, 'api.description');
77
+ url(item.url, 'api.url');
78
+ if ('resource' in item)
79
+ url(item.resource, 'api.resource');
80
+ }
81
+ }
82
+ if ('feedback' in doc)
83
+ url(object(doc.feedback).endpoint, 'feedback.endpoint');
84
+ if ('extensions' in doc)
85
+ for (const extension of Object.values(object(doc.extensions))) {
86
+ const item = object(extension);
87
+ string(item.version, 'extension.version');
88
+ if ('endpoint' in item)
89
+ url(item.endpoint, 'extension.endpoint');
90
+ }
91
+ return doc;
92
+ }
93
+ export function parseAuthorizationMetadata(value, document) {
94
+ const metadata = object(value);
95
+ const auth = document.auth;
96
+ if (metadata.issuer !== auth.issuer)
97
+ throw new ValidationError('issuer');
98
+ const domains = strings(metadata.poppy_domains, 'poppy_domains');
99
+ domains.forEach(domain);
100
+ if (!domains.some(item => item.toLowerCase() === document.organization.domain.toLowerCase()))
101
+ throw new ValidationError('poppy_domains');
102
+ for (const key of ['token_endpoint', 'revocation_endpoint'])
103
+ url(metadata[key], key);
104
+ for (const [key, required] of [['authorization_endpoint', !!auth.direct], ['device_authorization_endpoint', !!auth.device]])
105
+ if (required || key in metadata)
106
+ url(metadata[key], key);
107
+ if ('dpop_signing_alg_values_supported' in metadata && !strings(metadata.dpop_signing_alg_values_supported, 'dpop_signing_alg_values_supported').includes('ES256'))
108
+ throw new ValidationError('dpop_signing_alg_values_supported');
109
+ if ('client_id_metadata_document_supported' in metadata)
110
+ member(metadata.client_id_metadata_document_supported, [true, false], 'client_id_metadata_document_supported');
111
+ return metadata;
112
+ }
113
+ export function parseClientMetadata(value, clientId) {
114
+ const parsed = validateClientId(clientId);
115
+ const metadata = object(value);
116
+ if (metadata.client_id !== clientId)
117
+ throw new ValidationError('client_id');
118
+ string(metadata.client_name, 'client_name');
119
+ member(metadata.token_endpoint_auth_method, ['private_key_jwt'], 'token_endpoint_auth_method');
120
+ const redirects = strings(metadata.redirect_uris, 'redirect_uris', true);
121
+ for (const endpoint of [url(metadata.jwks_uri, 'jwks_uri'), ...redirects])
122
+ if (secureUrl(endpoint).hostname.toLowerCase() !== parsed.hostname.toLowerCase())
123
+ throw new ValidationError('client_endpoint_domain');
124
+ if ('logo_uri' in metadata)
125
+ url(metadata.logo_uri, 'logo_uri');
126
+ return metadata;
127
+ }
128
+ // Re-insert so Map order is recency, then drop expired entries and finally the oldest until at most `max` remain.
129
+ function remember(map, key, value, max, expired) {
130
+ map.delete(key);
131
+ map.set(key, value);
132
+ if (map.size <= max)
133
+ return;
134
+ for (const [k, v] of map)
135
+ if (expired(v))
136
+ map.delete(k);
137
+ for (const k of map.keys()) {
138
+ if (map.size <= max)
139
+ break;
140
+ map.delete(k);
141
+ }
142
+ }
143
+ // Defaults: 1000 entries per map; at most 16 cold fetch operations in flight and 120 started per 60 s (excess is rejected, not queued).
144
+ // Invariant: maxFetchesPerMinute * 5 (300 s TTL cap) must stay below maxEntries, or a flood can evict live authDocuments/refreshes entries.
145
+ // shortcut: limits are global per resolver, not per caller, and cap total cold-fetch throughput; move per-caller limiting to the server HTTP layer before production use.
146
+ export function createMetadataResolver(transport, options = {}) {
147
+ const now = options.now ?? Date.now;
148
+ const maxEntries = options.maxEntries ?? 1000;
149
+ const maxConcurrent = options.maxConcurrentFetches ?? 16;
150
+ const maxPerMinute = options.maxFetchesPerMinute ?? 120;
151
+ let epoch = 0, fetching = 0;
152
+ const starts = [];
153
+ const cache = new Map();
154
+ const pending = new Map();
155
+ const refreshes = new Map();
156
+ const authDocuments = new Map();
157
+ async function get(target, validate, mode, refresh = false) {
158
+ secureUrl(target);
159
+ const key = `${mode}:${target}`;
160
+ const cached = cache.get(key);
161
+ if (!refresh && cached && cached.expires > now()) {
162
+ validate(cached.value, cached.url);
163
+ return structuredClone(cached);
164
+ }
165
+ let task = pending.get(key);
166
+ if (!task) {
167
+ while (starts.length && now() - starts[0] >= 60000)
168
+ starts.shift();
169
+ if (fetching >= maxConcurrent || starts.length >= maxPerMinute)
170
+ throw new ValidationError('metadata.rate_limited');
171
+ starts.push(now());
172
+ if (refresh)
173
+ remember(refreshes, target, now(), maxEntries, at => now() - at >= 60000);
174
+ fetching++;
175
+ const startedEpoch = epoch;
176
+ task = (async () => {
177
+ const headers = new Headers();
178
+ if (cached?.etag)
179
+ headers.set('if-none-match', cached.etag);
180
+ else if (cached?.modified)
181
+ headers.set('if-modified-since', cached.modified);
182
+ const response = await transport.fetch(new Request(target, { headers }), { mode });
183
+ if (response.status !== 200 && !(response.status === 304 && cached))
184
+ throw new ValidationError('metadata.status');
185
+ let value;
186
+ try {
187
+ value = response.status === 304 ? cached.value : parseJson(await response.text());
188
+ }
189
+ catch {
190
+ throw new ValidationError('metadata.json');
191
+ }
192
+ const servedUrl = response.status === 304 ? cached.url : response.url || target;
193
+ validate(value, servedUrl);
194
+ const control = response.headers.get('cache-control') ?? (response.status === 304 ? cached?.control : undefined) ?? '';
195
+ const age = Number(response.headers.get('age') ?? 0);
196
+ const seconds = Number(/(?:^|,)\s*max-age=(\d+)/i.exec(control)?.[1] ?? 60); // no max-age: 60 s floor (guide: at least a minute)
197
+ const expires = now() + (/\b(?:no-store|no-cache)\b/i.test(control) ? 0 : Math.max(0, Math.min(300, seconds - (Number.isFinite(age) ? age : 0)))) * 1000;
198
+ const result = { value, control, url: response.status === 304 ? cached.url : servedUrl, expires, etag: response.headers.get('etag') ?? cached?.etag, modified: response.headers.get('last-modified') ?? cached?.modified };
199
+ if (startedEpoch !== epoch)
200
+ return result;
201
+ if (!/\bno-store\b/i.test(control))
202
+ remember(cache, key, result, maxEntries, entry => entry.expires <= now());
203
+ else
204
+ cache.delete(key);
205
+ return result;
206
+ })().finally(() => { fetching--; });
207
+ pending.set(key, task);
208
+ }
209
+ try {
210
+ const result = await task;
211
+ validate(result.value, result.url);
212
+ return structuredClone(result);
213
+ }
214
+ finally {
215
+ if (pending.get(key) === task)
216
+ pending.delete(key);
217
+ }
218
+ }
219
+ return {
220
+ invalidate(target) { epoch++; pending.clear(); if (target === undefined) {
221
+ cache.clear();
222
+ authDocuments.clear();
223
+ }
224
+ else {
225
+ for (const key of cache.keys())
226
+ if (key.endsWith(`:${target}`))
227
+ cache.delete(key);
228
+ authDocuments.clear();
229
+ } },
230
+ async resolveCompany(input) {
231
+ const startedEpoch = epoch;
232
+ if (!/^[a-z0-9.-]+(?::\d{1,5})?$/i.test(input))
233
+ throw new ValidationError('domain');
234
+ const target = `https://${input}/.well-known/poppy.json`;
235
+ const found = await get(target, parsePoppyDocument, 'discovery');
236
+ const document = parsePoppyDocument(found.value, found.url);
237
+ if (!document.auth)
238
+ return { document, url: found.url };
239
+ const auth = await get(authorizationMetadataUrl(document.auth.issuer), value => parseAuthorizationMetadata(value, document), 'discovery');
240
+ if (startedEpoch !== epoch)
241
+ return { document, authorization: parseAuthorizationMetadata(auth.value, document), url: found.url };
242
+ const entries = authDocuments.get(document.auth.issuer) ?? new Map();
243
+ const spelling = canonical(document.auth);
244
+ const currentDomain = document.organization.domain.toLowerCase();
245
+ for (const [otherDomain, entry] of entries)
246
+ if (otherDomain !== currentDomain && entry.expires > now() && entry.auth !== spelling)
247
+ throw new ValidationError('auth.consistency');
248
+ const expiredEntry = (entry) => entry.expires <= now();
249
+ remember(entries, currentDomain, { auth: spelling, expires: found.expires }, maxEntries, expiredEntry);
250
+ remember(authDocuments, document.auth.issuer, entries, maxEntries, inner => [...inner.values()].every(expiredEntry));
251
+ return { document, authorization: parseAuthorizationMetadata(auth.value, document), url: found.url };
252
+ },
253
+ async resolveClient(clientId) { validateClientId(clientId); const found = await get(clientId, value => parseClientMetadata(value, clientId), 'endpoint'); return parseClientMetadata(found.value, clientId); },
254
+ async resolveJwks(uri, settings = {}) {
255
+ const validate = (value) => {
256
+ const jwks = object(value);
257
+ if (!Array.isArray(jwks.keys) || jwks.keys.length > 100)
258
+ throw new ValidationError('jwks.keys');
259
+ for (const key of jwks.keys) {
260
+ const item = object(key);
261
+ string(item.kty, 'jwk.kty');
262
+ for (const secret of ['d', 'p', 'q', 'dp', 'dq', 'qi', 'oth', 'k'])
263
+ if (secret in item)
264
+ throw new ValidationError('jwk.private');
265
+ }
266
+ };
267
+ if (settings.refresh) {
268
+ const last = refreshes.get(uri);
269
+ if (last !== undefined && now() - last < 60000)
270
+ throw new ValidationError('jwks.refresh_limited');
271
+ }
272
+ const found = await get(uri, validate, 'discovery', settings.refresh);
273
+ return found.value;
274
+ }
275
+ };
276
+ }
@@ -0,0 +1,31 @@
1
+ export interface HttpTransport {
2
+ fetch(request: Request, options?: {
3
+ mode?: 'endpoint' | 'discovery';
4
+ waitMs?: number;
5
+ }): Promise<Response>;
6
+ stream?(request: Request): Promise<Response>;
7
+ close?(): void;
8
+ }
9
+ export interface NetworkOptions {
10
+ timeoutMs?: number;
11
+ maxResponseBytes?: number;
12
+ maxRequestBytes?: number;
13
+ maxRedirects?: number;
14
+ maxPools?: number;
15
+ maxSockets?: number;
16
+ maxRequests?: number;
17
+ maxStreamBytes?: number;
18
+ maxStreamMs?: number;
19
+ localExceptions?: readonly {
20
+ hostname: string;
21
+ addresses: readonly string[];
22
+ }[];
23
+ ca?: string;
24
+ }
25
+ export declare class TransportError extends Error {
26
+ readonly code: 'invalid_url' | 'blocked_address' | 'redirect_limit' | 'timeout' | 'body_limit' | 'network_error' | 'overloaded' | 'closed';
27
+ constructor(code: 'invalid_url' | 'blocked_address' | 'redirect_limit' | 'timeout' | 'body_limit' | 'network_error' | 'overloaded' | 'closed');
28
+ }
29
+ export declare function secureUrl(value: string): URL;
30
+ export declare function isPublicAddress(value: string): boolean;
31
+ export declare function createNodeTransport(options?: NetworkOptions): HttpTransport;