@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,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,209 @@
1
+ import type { MetadataResolver } from '../network/discovery.js';
2
+ import type { IssuerStore, IssuerSelection } from './store.js';
3
+ import type { AuthorityCoordinator, ExternalAuthorityAdapter } from '../auth/authority.js';
4
+ import type { BrowserSessionAuthority } from '../browser.js';
5
+ export type { IssuerStore, IssuerSelection };
6
+ export { createMetadataResolver } from '../network/discovery.js';
7
+ export { createNodeTransport } from '../network/transport.js';
8
+ interface SessionRecord {
9
+ clientId: string;
10
+ userId: string;
11
+ version: number;
12
+ createdAt: number;
13
+ expiresAt: number;
14
+ accountId: string | null;
15
+ grantId: string | null;
16
+ }
17
+ interface GrantRecord {
18
+ clientId: string;
19
+ userId: string;
20
+ accountId: string;
21
+ scopes: string[];
22
+ expiresAt: number;
23
+ revoked: boolean;
24
+ refreshHash: string;
25
+ previousHash: string;
26
+ }
27
+ interface AccessTokenRecord {
28
+ sessionId: string;
29
+ grantId: string | null;
30
+ scopes: string[];
31
+ resource: string | null;
32
+ jkt: string;
33
+ expiresAt: number;
34
+ }
35
+ interface AuthorizationRecord {
36
+ clientId: string;
37
+ redirectUri: string;
38
+ state: string;
39
+ codeChallenge: string;
40
+ scopes: string[];
41
+ browserHash: string;
42
+ expiresAt: number;
43
+ revision: number;
44
+ }
45
+ interface CodeRecord {
46
+ clientId: string;
47
+ redirectUri: string;
48
+ codeChallenge: string;
49
+ accountId: string;
50
+ scopes: string[];
51
+ expiresAt: number;
52
+ grantId: string | null;
53
+ revision: number;
54
+ }
55
+ export type SignInRecord = {
56
+ kind: 'device';
57
+ clientId: string;
58
+ scopes: string[];
59
+ expiresAt: number;
60
+ deviceHash: string;
61
+ status: 'pending' | 'approved' | 'denied' | 'consumed';
62
+ nextPollAt: number;
63
+ intervalMs: number;
64
+ revision: number;
65
+ browserHash?: string;
66
+ accountId?: string;
67
+ } | {
68
+ kind: 'mediated';
69
+ clientId: string;
70
+ userId: string;
71
+ sessionId: string;
72
+ scopes: string[];
73
+ expiresAt: number;
74
+ status: 'verifying' | 'pending' | 'failed' | 'expired' | 'complete';
75
+ attempts: number;
76
+ executionId: string;
77
+ revision: number;
78
+ accountId?: string;
79
+ challengeId?: string;
80
+ sentTo?: string;
81
+ } | {
82
+ kind: 'attempts';
83
+ clientId: string;
84
+ sessionId?: string;
85
+ expiresAt: number;
86
+ count: number;
87
+ } | {
88
+ kind: 'revision';
89
+ clientId: string;
90
+ accountId?: string;
91
+ expiresAt: number;
92
+ revision: number;
93
+ };
94
+ export interface MediatedContext {
95
+ clientId: string;
96
+ userId: string;
97
+ sessionId: string;
98
+ issuer: string;
99
+ }
100
+ export interface MediatedOptions {
101
+ endpoint: string;
102
+ scopes: string[];
103
+ fields: {
104
+ name: string;
105
+ label: string;
106
+ secret: boolean;
107
+ }[];
108
+ authenticate(credentials: Readonly<Record<string, string>>, context: MediatedContext): Promise<{
109
+ accountId: string;
110
+ scopes?: string[];
111
+ challenge?: {
112
+ id: string;
113
+ sentTo: string;
114
+ };
115
+ } | null>;
116
+ verifyCode(input: {
117
+ challengeId: string;
118
+ code: string;
119
+ }, context: MediatedContext): Promise<boolean>;
120
+ maxCodeAttempts?: number;
121
+ maxAttemptsPerMinute?: number;
122
+ maxClientAttemptsPerMinute?: number;
123
+ }
124
+ export interface IssuerState {
125
+ limits?: {
126
+ sessions: number;
127
+ earliestSessionExpiry: number;
128
+ pending: number;
129
+ pendingForClient: number;
130
+ };
131
+ version: 3;
132
+ generation: string;
133
+ sessions: Record<string, SessionRecord>;
134
+ grants: Record<string, GrantRecord>;
135
+ refreshTokens: Record<string, string>;
136
+ authorizations: Record<string, AuthorizationRecord>;
137
+ codes: Record<string, CodeRecord>;
138
+ signIns: Record<string, SignInRecord>;
139
+ accessTokens: Record<string, AccessTokenRecord>;
140
+ replay: Record<string, number>;
141
+ nonces: {
142
+ value: string;
143
+ expiresAt: number;
144
+ }[];
145
+ }
146
+ export interface ReferenceIssuerOptions {
147
+ issuer: string;
148
+ tokenEndpoint: string;
149
+ revocationEndpoint: string;
150
+ authorizationEndpoint?: string;
151
+ poppyDomains: string[];
152
+ directScopes: string[];
153
+ resources?: string[];
154
+ mcpResources?: {
155
+ url: string;
156
+ dpop: boolean;
157
+ }[];
158
+ metadata: MetadataResolver;
159
+ store: IssuerStore;
160
+ coordinator: AuthorityCoordinator;
161
+ now: () => number;
162
+ tokenLifetimeSeconds?: number;
163
+ accountLifetimeSeconds?: number;
164
+ requireDpopNonce?: boolean;
165
+ maxPendingAuthorizations?: number;
166
+ maxPendingAuthorizationsPerClient?: number;
167
+ maxSessionsPerClient?: number;
168
+ device?: {
169
+ endpoint: string;
170
+ verificationUri: string;
171
+ scopes: string[];
172
+ intervalSeconds?: number;
173
+ };
174
+ mediated?: MediatedOptions;
175
+ }
176
+ export interface AuthorizationView {
177
+ id: string;
178
+ clientId: string;
179
+ clientName: string;
180
+ scopes: string[];
181
+ expiresAt: number;
182
+ }
183
+ export type AuthorizationDecision = {
184
+ accountId: string;
185
+ scopes: string[];
186
+ } | {
187
+ denied: true;
188
+ };
189
+ export interface ReferenceIssuer {
190
+ browserSessions: BrowserSessionAuthority;
191
+ token(request: Request): Promise<Response>;
192
+ revoke(request: Request): Promise<Response>;
193
+ beginAuthorization(request: Request, browserBinding: string): Promise<AuthorizationView>;
194
+ completeAuthorization(id: string, browserBinding: string, decision: AuthorizationDecision): Promise<URL>;
195
+ deviceAuthorization(request: Request): Promise<Response>;
196
+ beginDeviceAuthorization(userCode: string, browserBinding: string): Promise<AuthorizationView>;
197
+ completeDeviceAuthorization(userCode: string, browserBinding: string, decision: AuthorizationDecision): Promise<void>;
198
+ mediatedSignIn(request: Request): Promise<Response>;
199
+ authorizationMetadata(): Record<string, unknown>;
200
+ authority: ExternalAuthorityAdapter;
201
+ disconnect(clientId: string, accountId: string): Promise<void>;
202
+ }
203
+ export declare class AuthorizationError extends Error {
204
+ readonly code: string;
205
+ readonly redirectTo?: URL | undefined;
206
+ constructor(code: string, redirectTo?: URL | undefined);
207
+ }
208
+ export declare function initialIssuerState(): IssuerState;
209
+ export declare function createReferenceIssuer(options: ReferenceIssuerOptions): ReferenceIssuer;