@poppykit/server 0.4.0 → 0.5.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 (52) hide show
  1. package/API.md +10 -3
  2. package/AUTH.md +120 -124
  3. package/CHANGELOG.md +13 -1
  4. package/CONFORMANCE.md +73 -0
  5. package/CONTRIBUTING.md +12 -11
  6. package/DELIVERY.md +2 -0
  7. package/INTEGRATIONS.md +74 -81
  8. package/OPERATIONS.md +142 -0
  9. package/README.md +11 -20
  10. package/RELEASING.md +2 -0
  11. package/SECURITY.md +2 -0
  12. package/STORAGE.md +56 -17
  13. package/dist/adapter-contract.d.ts +18 -0
  14. package/dist/adapter-contract.js +79 -0
  15. package/dist/auth/authenticator.d.ts +3 -1
  16. package/dist/auth/authenticator.js +34 -29
  17. package/dist/auth/authority.d.ts +8 -2
  18. package/dist/auth/authority.js +7 -2
  19. package/dist/auth/session.d.ts +47 -0
  20. package/dist/auth/session.js +94 -0
  21. package/dist/browser.d.ts +15 -16
  22. package/dist/browser.js +65 -33
  23. package/dist/conversation/service.d.ts +11 -1
  24. package/dist/conversation/service.js +39 -9
  25. package/dist/feedback.d.ts +1 -1
  26. package/dist/feedback.js +5 -5
  27. package/dist/index.d.ts +5 -1
  28. package/dist/index.js +3 -0
  29. package/dist/mcp.d.ts +1 -1
  30. package/dist/mcp.js +8 -4
  31. package/dist/network/discovery.d.ts +31 -13
  32. package/dist/network/discovery.js +103 -66
  33. package/dist/operations.d.ts +115 -0
  34. package/dist/operations.js +212 -0
  35. package/dist/postgres.d.ts +6 -0
  36. package/dist/postgres.js +72 -4
  37. package/dist/protocol/operations.d.ts +51 -0
  38. package/dist/protocol/operations.js +98 -0
  39. package/dist/protocol/session.d.ts +28 -0
  40. package/dist/protocol/session.js +93 -0
  41. package/dist/protocol/sse.d.ts +1 -0
  42. package/dist/protocol/sse.js +1 -1
  43. package/dist/protocol/wire.d.ts +8 -4
  44. package/dist/protocol/wire.js +37 -15
  45. package/dist/reference-auth/index.d.ts +10 -16
  46. package/dist/reference-auth/index.js +225 -430
  47. package/dist/sqlite.d.ts +11 -0
  48. package/dist/sqlite.js +84 -4
  49. package/examples/operations.mjs +18 -0
  50. package/examples/quickstart.mjs +8 -5
  51. package/migrations/RETIREMENT.md +77 -0
  52. package/package.json +5 -2
@@ -0,0 +1,93 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { calculateJwkThumbprint, compactVerify, createLocalJWKSet, decodeProtectedHeader, errors, importJWK } from 'jose';
3
+ import { id, object, parseJson, string, ValidationError } from './wire.js';
4
+ import { validateClientId } from '../network/discovery.js';
5
+ import { secureUrl } from '../network/transport.js';
6
+ const COMPACT = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
7
+ const PRIVATE = ['d', 'p', 'q', 'dp', 'dq', 'qi', 'oth', 'k'];
8
+ const hash = (value) => createHash('sha256').update(value).digest('base64url');
9
+ function unique(jwt) {
10
+ if (!COMPACT.test(jwt))
11
+ throw new ValidationError('session.jwt');
12
+ for (const segment of jwt.split('.').slice(0, 2))
13
+ parseJson(Buffer.from(segment, 'base64url').toString('utf8'));
14
+ }
15
+ function timestamp(value) {
16
+ if (!Number.isSafeInteger(value) || value < 0)
17
+ throw new ValidationError('session.time');
18
+ return value;
19
+ }
20
+ function bounded(value, field) {
21
+ const result = string(value, field);
22
+ if (result.length > 256)
23
+ throw new ValidationError(field);
24
+ return result;
25
+ }
26
+ export function sessionRequestUrl(value) {
27
+ const url = secureUrl(value);
28
+ return `${url.origin}${url.pathname}`;
29
+ }
30
+ export function parseSessionClaims(value) {
31
+ const claims = object(value, 'session');
32
+ validateClientId(string(claims.iss, 'session.iss'));
33
+ secureUrl(string(claims.aud, 'session.aud'));
34
+ id(claims.sid, 'session.sid');
35
+ bounded(claims.sub, 'session.sub');
36
+ if ('agent_id' in claims)
37
+ bounded(claims.agent_id, 'session.agent_id');
38
+ string(claims.htm, 'session.htm');
39
+ const htu = string(claims.htu, 'session.htu');
40
+ if (htu.includes('?') || htu.includes('#'))
41
+ throw new ValidationError('session.htu');
42
+ secureUrl(htu);
43
+ timestamp(claims.iat);
44
+ const jti = bounded(claims.jti, 'session.jti');
45
+ if (jti.length < 16)
46
+ throw new ValidationError('session.jti');
47
+ if ('ath' in claims)
48
+ string(claims.ath, 'session.ath');
49
+ return claims;
50
+ }
51
+ /** Verifies identity only. The resource must separately admit/continue the client_id + sid under its store fence. */
52
+ export async function verifySessionJwt(jwt, expected) {
53
+ if (!jwt || jwt.length > 32768)
54
+ throw new ValidationError('session.jwt');
55
+ unique(jwt);
56
+ const header = decodeProtectedHeader(jwt);
57
+ if (header.typ !== 'poppy-session+jwt' || header.alg !== 'ES256' || typeof header.kid !== 'string' || typeof header.pap_dlg !== 'string')
58
+ throw new ValidationError('session.header');
59
+ const claims = parseSessionClaims(parseJson(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8')));
60
+ const delegation = header.pap_dlg;
61
+ unique(delegation);
62
+ const delegatedHeader = decodeProtectedHeader(delegation);
63
+ if (delegatedHeader.typ !== 'pap-delegation+jwt' || typeof delegatedHeader.alg !== 'string' || !['ES256', 'PS384'].includes(delegatedHeader.alg) || typeof delegatedHeader.kid !== 'string')
64
+ throw new ValidationError('delegation.header');
65
+ const client = await expected.metadata.resolveClient(claims.iss);
66
+ let jwks = await expected.metadata.resolveJwks(client.jwks_uri);
67
+ let verified;
68
+ try {
69
+ verified = await compactVerify(delegation, createLocalJWKSet(jwks), { algorithms: ['ES256', 'PS384'] });
70
+ }
71
+ catch (error) {
72
+ if (!(error instanceof errors.JWKSNoMatchingKey))
73
+ throw error;
74
+ jwks = await expected.metadata.resolveJwks(client.jwks_uri, { refresh: true });
75
+ verified = await compactVerify(delegation, createLocalJWKSet(jwks), { algorithms: ['ES256', 'PS384'] });
76
+ }
77
+ const delegated = object(parseJson(new TextDecoder().decode(verified.payload)), 'delegation');
78
+ const issued = timestamp(delegated.iat), expires = timestamp(delegated.exp), at = expected.nowMs / 1000;
79
+ bounded(delegated.jti, 'delegation.jti');
80
+ if (delegated.jti.length < 16 || delegated.iss !== claims.iss || expires <= issued || expires - issued > 86400 || expires <= at || issued > at + 30)
81
+ throw new ValidationError('delegation.claims');
82
+ const key = object(object(delegated.cnf, 'delegation.cnf').jwk, 'delegation.key');
83
+ if (key.kty !== 'EC' || key.crv !== 'P-256' || PRIVATE.some(name => name in key) || typeof key.x !== 'string' || typeof key.y !== 'string')
84
+ throw new ValidationError('delegation.key');
85
+ if (header.kid !== await calculateJwkThumbprint(key) || claims.iat > expires)
86
+ throw new ValidationError('session.key');
87
+ await compactVerify(jwt, await importJWK(key, 'ES256'), { algorithms: ['ES256'] });
88
+ if (claims.aud !== expected.companyId || claims.htm !== expected.method || sessionRequestUrl(claims.htu) !== sessionRequestUrl(expected.url) || claims.iat < at - 60 || claims.iat > at + 30)
89
+ throw new ValidationError('session.binding');
90
+ if (expected.accessToken === undefined ? 'ath' in claims : claims.ath !== hash(expected.accessToken) || expected.tokenClientId !== claims.iss)
91
+ throw new ValidationError('session.token');
92
+ return { ...claims, delegationExpiresAt: expires * 1000 };
93
+ }
@@ -12,4 +12,5 @@ export declare function parseEventStream(body: ReadableStream<Uint8Array>, optio
12
12
  maxFrameBytes?: number;
13
13
  maxStreamBytes?: number;
14
14
  signal?: AbortSignal;
15
+ operations?: boolean;
15
16
  }): AsyncGenerator<StreamFrame>;
@@ -18,7 +18,7 @@ export async function* parseEventStream(body, options = {}) {
18
18
  const delta = object(value);
19
19
  return { kind: 'delta', messageId: id(delta.message_id, 'message_id'), text: string(delta.text, 'text', false) };
20
20
  }
21
- const event = parseEvent(value);
21
+ const event = parseEvent(value, options.operations);
22
22
  if (eventId !== event.id)
23
23
  throw new ValidationError('sse.id');
24
24
  return { kind: 'event', event };
@@ -1,3 +1,4 @@
1
+ import type { BusinessOperation, OperationApproval } from './operations.js';
1
2
  export type Json = null | boolean | number | string | Json[] | {
2
3
  [key: string]: Json;
3
4
  };
@@ -19,6 +20,8 @@ export interface Message {
19
20
  text?: string;
20
21
  data?: JsonObject;
21
22
  context?: JsonObject;
23
+ operation?: BusinessOperation;
24
+ approval?: OperationApproval;
22
25
  [key: string]: unknown;
23
26
  }
24
27
  export interface RecordedMessage extends Message {
@@ -67,7 +70,7 @@ export interface EventPage extends ConversationState {
67
70
  export interface TokenResponse {
68
71
  access_token: string;
69
72
  token_type: 'DPoP' | 'Bearer';
70
- expires_in: number;
73
+ expires_in?: number;
71
74
  scope?: string;
72
75
  refresh_token?: string;
73
76
  refresh_token_expires_in?: number;
@@ -93,11 +96,12 @@ export declare function id(value: unknown, field: string): string;
93
96
  export declare function parseMessage(value: unknown, options?: {
94
97
  recorded?: boolean;
95
98
  company?: boolean;
99
+ operations?: boolean;
96
100
  }): Message | RecordedMessage;
97
- export declare function parseConversationRequest(value: unknown): ConversationRequest;
101
+ export declare function parseConversationRequest(value: unknown, operations?: boolean): ConversationRequest;
98
102
  export declare function parseConversationState(value: unknown): ConversationState;
99
- export declare function parseEvent(value: unknown): ConversationEvent;
100
- export declare function parseEventPage(value: unknown, requestCursor?: string): EventPage;
103
+ export declare function parseEvent(value: unknown, operations?: boolean): ConversationEvent;
104
+ export declare function parseEventPage(value: unknown, requestCursor?: string, operations?: boolean): EventPage;
101
105
  export declare function parseTokenResponse(value: unknown): TokenResponse;
102
106
  export declare function parseProtocolError(value: unknown): ProtocolErrorBody;
103
107
  export declare function parseReadOptions(url: URL, maxWaitSeconds: number): {
@@ -1,3 +1,4 @@
1
+ import { parseBusinessOperation, parseOperationApproval } from './operations.js';
1
2
  export class ValidationError extends Error {
2
3
  field;
3
4
  constructor(field) {
@@ -70,7 +71,18 @@ export function parseMessage(value, options = {}) {
70
71
  throw new ValidationError('message.author.image');
71
72
  }
72
73
  }
73
- if (!['text', 'data', 'context'].some(key => key in message))
74
+ const company = options.company || (options.recorded && message.role === 'company');
75
+ if ('operation' in message) {
76
+ if (!company || !options.operations)
77
+ throw new ValidationError('message.operation');
78
+ message.operation = parseBusinessOperation(message.operation);
79
+ }
80
+ if ('approval' in message) {
81
+ if (company || !options.operations)
82
+ throw new ValidationError('message.approval');
83
+ message.approval = parseOperationApproval(message.approval);
84
+ }
85
+ if (!['text', 'data', 'context', ...(options.operations ? [company ? 'operation' : 'approval'] : [])].some(key => key in message))
74
86
  throw new ValidationError('message.content');
75
87
  if ('text' in message)
76
88
  string(message.text, 'message.text', false);
@@ -109,20 +121,24 @@ export function parseMessage(value, options = {}) {
109
121
  }
110
122
  return message;
111
123
  }
112
- export function parseConversationRequest(value) {
124
+ export function parseConversationRequest(value, operations = false) {
113
125
  const request = object(value);
114
126
  if ('parent_conversation_id' in request)
115
127
  id(request.parent_conversation_id, 'parent_conversation_id');
116
- return { ...request, message: parseMessage(request.message) };
128
+ return { ...request, message: parseMessage(request.message, { operations }) };
117
129
  }
118
130
  export function parseConversationState(value) {
119
131
  const state = object(value);
120
132
  id(state.conversation_id, 'conversation_id');
121
- member(state.status, ['working', 'idle', 'queued', 'closed'], 'status');
122
- member(state.responder, ['agent', 'human'], 'responder');
133
+ string(state.status, 'status');
134
+ string(state.responder, 'responder');
135
+ if (!['working', 'idle', 'queued', 'closed'].includes(state.status))
136
+ state.status = 'working';
137
+ if (!['agent', 'human'].includes(state.responder))
138
+ state.responder = 'human';
123
139
  return state;
124
140
  }
125
- export function parseEvent(value) {
141
+ export function parseEvent(value, operations = false) {
126
142
  const event = object(value, 'event');
127
143
  id(event.id, 'event.id');
128
144
  string(event.type, 'event.type');
@@ -138,12 +154,14 @@ export function parseEvent(value) {
138
154
  throw new ValidationError('event.created_at');
139
155
  switch (event.type) {
140
156
  case 'message':
141
- parseMessage(event.message, { recorded: true });
157
+ parseMessage(event.message, { recorded: true, operations });
142
158
  break;
143
- case 'state':
144
- member(event.status, ['working', 'idle', 'queued', 'closed'], 'event.status');
145
- member(event.responder, ['agent', 'human'], 'event.responder');
159
+ case 'state': {
160
+ const state = parseConversationState({ conversation_id: 'state', status: event.status, responder: event.responder });
161
+ event.status = state.status;
162
+ event.responder = state.responder;
146
163
  break;
164
+ }
147
165
  case 'authorization':
148
166
  member(event.error, ['insufficient_scope'], 'event.error');
149
167
  if (!/^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/.test(string(event.scope, 'event.scope')))
@@ -159,11 +177,11 @@ export function parseEvent(value) {
159
177
  }
160
178
  return event;
161
179
  }
162
- export function parseEventPage(value, requestCursor) {
180
+ export function parseEventPage(value, requestCursor, operations = false) {
163
181
  const page = parseConversationState(value);
164
182
  if (!Array.isArray(page.events))
165
183
  throw new ValidationError('events');
166
- page.events.forEach(parseEvent);
184
+ page.events.forEach(event => parseEvent(event, operations));
167
185
  member(page.has_more, [true, false], 'has_more');
168
186
  if (page.cursor === null) {
169
187
  if (requestCursor !== undefined || page.events.length || page.has_more)
@@ -172,7 +190,7 @@ export function parseEventPage(value, requestCursor) {
172
190
  else {
173
191
  string(page.cursor, 'cursor');
174
192
  const expected = page.events.length ? page.events.at(-1).id : requestCursor;
175
- if (expected !== undefined && page.cursor !== expected)
193
+ if (expected === undefined || page.cursor !== expected)
176
194
  throw new ValidationError('cursor');
177
195
  }
178
196
  return page;
@@ -185,8 +203,12 @@ export function parseTokenResponse(value) {
185
203
  if (scope !== '' && (!/^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/.test(scope) || new Set(scope.split(' ')).size !== scope.split(' ').length))
186
204
  throw new ValidationError('scope');
187
205
  }
188
- member(token.token_type, ['DPoP', 'Bearer'], 'token_type');
189
- positive(token.expires_in, 'expires_in');
206
+ const type = string(token.token_type, 'token_type').toLowerCase();
207
+ if (type !== 'bearer' && type !== 'dpop')
208
+ throw new ValidationError('token_type');
209
+ token.token_type = type === 'bearer' ? 'Bearer' : 'DPoP';
210
+ if ('expires_in' in token && (!Number.isSafeInteger(token.expires_in) || token.expires_in < 0))
211
+ throw new ValidationError('expires_in');
190
212
  if ('refresh_token' in token)
191
213
  string(token.refresh_token, 'refresh_token');
192
214
  if ('refresh_token_expires_in' in token)
@@ -1,7 +1,6 @@
1
1
  import type { MetadataResolver } from '../network/discovery.js';
2
2
  import type { IssuerStore, IssuerSelection } from './store.js';
3
3
  import type { AuthorityCoordinator, ExternalAuthorityAdapter } from '../auth/authority.js';
4
- import type { BrowserSession, BrowserSessionAuthority } from '../browser.js';
5
4
  export type { IssuerStore, IssuerSelection };
6
5
  export { createMetadataResolver } from '../network/discovery.js';
7
6
  export { createNodeTransport } from '../network/transport.js';
@@ -26,6 +25,8 @@ interface GrantRecord {
26
25
  previousHash: string;
27
26
  }
28
27
  interface AccessTokenRecord {
28
+ clientId?: string;
29
+ accountId?: string;
29
30
  sessionId: string;
30
31
  grantId: string | null;
31
32
  scopes: string[];
@@ -34,7 +35,7 @@ interface AccessTokenRecord {
34
35
  expiresAt: number;
35
36
  }
36
37
  interface AuthorizationRecord {
37
- sessionKey: string;
38
+ sessionKey: string | null;
38
39
  clientId: string;
39
40
  redirectUri: string;
40
41
  state: string;
@@ -49,7 +50,7 @@ interface AuthorizationRecord {
49
50
  };
50
51
  }
51
52
  interface CodeRecord {
52
- sessionKey: string;
53
+ sessionKey: string | null;
53
54
  clientId: string;
54
55
  redirectUri: string;
55
56
  codeChallenge: string;
@@ -74,9 +75,11 @@ export type SignInRecord = {
74
75
  accountId?: string;
75
76
  } | {
76
77
  kind: 'mediated';
78
+ resource?: string | null;
79
+ grantId?: string;
77
80
  clientId: string;
78
- userId: string;
79
- sessionId: string;
81
+ userId?: string;
82
+ sessionId?: string;
80
83
  scopes: string[];
81
84
  expiresAt: number;
82
85
  status: 'verifying' | 'pending' | 'failed' | 'expired' | 'complete';
@@ -101,12 +104,10 @@ export type SignInRecord = {
101
104
  };
102
105
  export interface MediatedContext {
103
106
  clientId: string;
104
- userId: string;
105
- sessionId: string;
106
107
  issuer: string;
107
108
  }
108
109
  export interface MediatedOptions {
109
- endpoint: string;
110
+ endpoint?: string;
110
111
  scopes: string[];
111
112
  fields: {
112
113
  name: string;
@@ -136,7 +137,7 @@ export interface IssuerState {
136
137
  pending: number;
137
138
  pendingForClient: number;
138
139
  };
139
- version: 3 | 4;
140
+ version: 3 | 4 | 5;
140
141
  generation: string;
141
142
  sessions: Record<string, SessionRecord>;
142
143
  sessionKeys: Record<string, string>;
@@ -200,12 +201,6 @@ export type AuthorizationDecision = {
200
201
  denied: true;
201
202
  };
202
203
  export interface ReferenceIssuer {
203
- browserSessions: BrowserSessionAuthority;
204
- sessionForKey(binding: {
205
- clientId: string;
206
- userId: string;
207
- sessionKey: string;
208
- }): Promise<BrowserSession>;
209
204
  token(request: Request): Promise<Response>;
210
205
  revoke(request: Request): Promise<Response>;
211
206
  beginAuthorization(request: Request, browserBinding: string): Promise<AuthorizationView>;
@@ -213,7 +208,6 @@ export interface ReferenceIssuer {
213
208
  deviceAuthorization(request: Request): Promise<Response>;
214
209
  beginDeviceAuthorization(userCode: string, browserBinding: string): Promise<AuthorizationView>;
215
210
  completeDeviceAuthorization(userCode: string, browserBinding: string, decision: AuthorizationDecision): Promise<void>;
216
- mediatedSignIn(request: Request): Promise<Response>;
217
211
  authorizationMetadata(): Record<string, unknown>;
218
212
  authority: ExternalAuthorityAdapter;
219
213
  disconnect(clientId: string, accountId: string): Promise<void>;