@poppykit/client 0.5.0 → 0.6.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 +9 -1
  2. package/AUTH.md +97 -90
  3. package/CHANGELOG.md +13 -1
  4. package/CONFORMANCE.md +73 -0
  5. package/CONTRIBUTING.md +12 -11
  6. package/DELIVERY.md +19 -3
  7. package/INTEGRATIONS.md +35 -23
  8. package/OPERATIONS.md +120 -0
  9. package/PROVIDER.md +20 -12
  10. package/README.md +6 -17
  11. package/RELEASING.md +2 -0
  12. package/SECURITY.md +2 -0
  13. package/STORAGE.md +14 -7
  14. package/dist/adapter-contract.d.ts +6 -0
  15. package/dist/adapter-contract.js +30 -0
  16. package/dist/auth/challenge.d.ts +2 -0
  17. package/dist/auth/challenge.js +8 -8
  18. package/dist/auth/client-auth.d.ts +20 -7
  19. package/dist/auth/client-auth.js +386 -334
  20. package/dist/auth/sqlite-store.js +13 -4
  21. package/dist/auth/store.d.ts +6 -1
  22. package/dist/conversation.d.ts +6 -0
  23. package/dist/conversation.js +18 -14
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.js +2 -0
  26. package/dist/integrations.d.ts +11 -4
  27. package/dist/integrations.js +30 -38
  28. package/dist/network/discovery.d.ts +31 -13
  29. package/dist/network/discovery.js +103 -66
  30. package/dist/operations.d.ts +103 -0
  31. package/dist/operations.js +261 -0
  32. package/dist/postgres.d.ts +11 -0
  33. package/dist/postgres.js +40 -3
  34. package/dist/protocol/operations.d.ts +51 -0
  35. package/dist/protocol/operations.js +98 -0
  36. package/dist/protocol/session.d.ts +28 -0
  37. package/dist/protocol/session.js +93 -0
  38. package/dist/protocol/sse.d.ts +1 -0
  39. package/dist/protocol/sse.js +1 -1
  40. package/dist/protocol/wire.d.ts +8 -4
  41. package/dist/protocol/wire.js +37 -15
  42. package/dist/provider-postgres.js +3 -3
  43. package/dist/provider-storage.js +4 -4
  44. package/dist/provider.d.ts +26 -3
  45. package/dist/provider.js +94 -13
  46. package/dist/sqlite.d.ts +12 -0
  47. package/dist/sqlite.js +42 -0
  48. package/examples/operations.mjs +25 -0
  49. package/examples/quickstart.mjs +1 -0
  50. package/package.json +5 -2
@@ -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)
@@ -42,15 +42,15 @@ export class PostgresProviderStore {
42
42
  await lock('user', key);
43
43
  await one('user', key);
44
44
  }
45
- if (selection.kind === 'begin' || selection.kind === 'revoke') {
45
+ if (selection.kind === 'begin' || selection.kind === 'delegate' || selection.kind === 'revoke') {
46
46
  const operations = await this.db.query("SELECT id,body FROM poppy_provider.records WHERE ns=$1 AND kind='operation' AND split_part(id,':',1)=$2 ORDER BY id LIMIT 1001", [this.namespace, enrollmentId]);
47
47
  for (const row of operations)
48
48
  rows.set(JSON.stringify(['operation', row.id]), JSON.parse(row.body));
49
49
  if (operations.length > 1000)
50
50
  throw new Error('provider aggregate exceeds retained operation limit');
51
- if (selection.kind === 'begin') {
51
+ if (selection.kind === 'begin' || selection.kind === 'delegate') {
52
52
  const states = new Set();
53
- if (selection.stateHash)
53
+ if ('stateHash' in selection && selection.stateHash)
54
54
  states.add(selection.stateHash);
55
55
  for (const row of operations) {
56
56
  const hash = JSON.parse(row.body).binding?.stateHash;
@@ -14,8 +14,8 @@ export function scopedProviderTransaction(selection, raw) {
14
14
  const states = new Set();
15
15
  if (selection.kind === 'callback')
16
16
  states.add(selection.stateHash);
17
- if (selection.kind === 'begin') {
18
- if (selection.stateHash)
17
+ if (selection.kind === 'begin' || selection.kind === 'delegate') {
18
+ if ('stateHash' in selection && selection.stateHash)
19
19
  states.add(selection.stateHash);
20
20
  for (const row of raw.list('operation', `${enrollmentId}:`, 1001))
21
21
  if (row.value.binding?.stateHash)
@@ -34,7 +34,7 @@ export function scopedProviderTransaction(selection, raw) {
34
34
  else {
35
35
  allowed = (kind === 'enrollment' && id === enrollmentId) || (kind === 'user' && id === userKey) || (kind === 'state' && states.has(id));
36
36
  if (kind === 'operation')
37
- allowed = (selection.kind === 'begin' || selection.kind === 'revoke') ? id.startsWith(`${enrollmentId}:`) : id === `${enrollmentId}:${operationId}`;
37
+ allowed = (selection.kind === 'begin' || selection.kind === 'delegate' || selection.kind === 'revoke') ? id.startsWith(`${enrollmentId}:`) : id === `${enrollmentId}:${operationId}`;
38
38
  }
39
39
  if (!allowed)
40
40
  throw new TypeError('provider access outside selection');
@@ -44,7 +44,7 @@ export function scopedProviderTransaction(selection, raw) {
44
44
  put(kind, id, value) { check(kind, id, true); raw.put(kind, id, value); },
45
45
  remove(kind, id) { check(kind, id); raw.remove(kind, id); },
46
46
  list(kind, prefix, limit) {
47
- if (!['begin', 'revoke'].includes(selection.kind) || kind !== 'operation' || prefix !== `${enrollmentId}:` || !Number.isSafeInteger(limit) || limit < 1 || limit > 1001)
47
+ if (!['begin', 'delegate', 'revoke'].includes(selection.kind) || kind !== 'operation' || prefix !== `${enrollmentId}:` || !Number.isSafeInteger(limit) || limit < 1 || limit > 1001)
48
48
  throw new TypeError('provider list outside selection');
49
49
  return raw.list(kind, prefix, limit);
50
50
  },
@@ -23,6 +23,14 @@ export type ProviderSelection = {
23
23
  kind: 'operation';
24
24
  enrollmentId: string;
25
25
  operationId: string;
26
+ } | {
27
+ kind: 'delegate';
28
+ enrollmentId: string;
29
+ operationId: string;
30
+ at: number;
31
+ } | {
32
+ kind: 'metadata';
33
+ enrollmentId: string;
26
34
  } | {
27
35
  kind: 'begin';
28
36
  enrollmentId: string;
@@ -64,6 +72,10 @@ export interface ProviderOptions {
64
72
  store: ProviderStore;
65
73
  cipher: ResultCipher;
66
74
  metadata: MetadataResolver;
75
+ /** Publish only for enrollments whose agent has all Operations channels, trusted consent and durable recovery configured. */
76
+ operations?: {
77
+ ready(enrollmentId: string): Promise<boolean>;
78
+ };
67
79
  signer: IdentitySigner;
68
80
  /** Publish new keys before use and retain retired public keys through assertion expiry + skew. */
69
81
  publishedKeys?: () => Promise<Record<string, any>[]>;
@@ -83,7 +95,7 @@ export declare function createProvider(options: ProviderOptions): {
83
95
  callbackUrl: string;
84
96
  }>;
85
97
  identity(credential: string, input: Row): Promise<{
86
- clientId: string;
98
+ clientId: any;
87
99
  userId: string;
88
100
  }>;
89
101
  begin(credential: string, input: Row): Promise<{
@@ -93,6 +105,10 @@ export declare function createProvider(options: ProviderOptions): {
93
105
  assertion: string;
94
106
  expiresAt: number;
95
107
  }>;
108
+ delegate(credential: string, input: Row): Promise<{
109
+ delegation: string;
110
+ expiresAt: number;
111
+ }>;
96
112
  callback(callbackUrl: string): Promise<{
97
113
  status: any;
98
114
  }>;
@@ -127,16 +143,23 @@ export declare function createProviderClient(options: {
127
143
  assertion: string;
128
144
  expiresAt: number;
129
145
  }>;
146
+ delegate: (request: {
147
+ operationId: string;
148
+ jwk: import("./protocol/wire.js").JsonObject;
149
+ }) => Promise<{
150
+ delegation: string;
151
+ expiresAt: number;
152
+ }>;
130
153
  beginDirect: (input: {
131
154
  operationId: string;
132
155
  issuer: string;
133
- sessionKey: string;
156
+ sessionKey: string | null;
134
157
  state: string;
135
158
  }) => Promise<Row>;
136
159
  takeResult: (input: {
137
160
  operationId: string;
138
161
  issuer: string;
139
- sessionKey: string;
162
+ sessionKey: string | null;
140
163
  }) => Promise<Row>;
141
164
  cancel: (operationId: string) => Promise<Row>;
142
165
  revokeEnrollment: () => Promise<Row>;
package/dist/provider.js CHANGED
@@ -1,6 +1,9 @@
1
+ import { importJWK } from 'jose';
2
+ import { parseJson } from './protocol/wire.js';
1
3
  /** Optional PoppyKit provider v1. Hosting, human authentication and key custody belong to the operator. */
2
4
  import { createHash, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
3
5
  import { secureUrl } from './network/transport.js';
6
+ import { companyIdentifier } from './network/discovery.js';
4
7
  /** Explicit migration aid for existing local synchronous stores; unsuitable for remote I/O. */
5
8
  export function adaptLegacyProviderStore(store) {
6
9
  return { async transaction(_selection, action) {
@@ -53,6 +56,7 @@ function publicKey(value) {
53
56
  export function createProvider(options) {
54
57
  const { store, cipher } = options, now = options.now ?? Date.now;
55
58
  const client = https(options.clientId);
59
+ const enrolledClientId = (enrollmentId) => { const url = new URL(client); url.pathname = url.pathname.replace(/\/$/, '') + '/enrollments/' + enrollmentId; return url.href; };
56
60
  if ([options.callbackUrl, options.jwksUri].some(value => https(value).hostname !== client.hostname) || https(options.callbackUrl).search)
57
61
  throw new Error('provider metadata endpoints must share client domain; callback cannot contain query');
58
62
  function auth(tx, credential, installationId) {
@@ -66,7 +70,7 @@ export function createProvider(options) {
66
70
  const row = tx.get('operation', `${enrollment.enrollmentId}:${operationId}`);
67
71
  if (!row)
68
72
  fail('binding_conflict');
69
- if (row.binding.purpose === 'direct' && (typeof row.binding.sessionKey !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(row.binding.sessionKey)))
73
+ if (row.binding.purpose === 'direct' && row.binding.sessionKey !== null && (typeof row.binding.sessionKey !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(row.binding.sessionKey)))
70
74
  fail('binding_conflict');
71
75
  if (row.expiresAt <= now())
72
76
  fail('expired');
@@ -78,7 +82,7 @@ export function createProvider(options) {
78
82
  }
79
83
  async function verified(domain, issuer) {
80
84
  const metadata = await options.metadata.resolveCompany(domain);
81
- if (metadata.authorization?.issuer !== issuer)
85
+ if (companyIdentifier(metadata) !== issuer)
82
86
  fail('binding_conflict');
83
87
  return metadata;
84
88
  }
@@ -122,9 +126,9 @@ export function createProvider(options) {
122
126
  tx.put('capacity', 'enrollments', { count: count + 1 });
123
127
  tx.put('visitor', subjectKey, { id: visitor });
124
128
  const enrollmentId = id(), secret = id();
125
- tx.put('enrollment', enrollmentId, { installationId, visitor, verifier: hash(secret), allowedCompanies: [...new Set(human.allowedCompanies)], revoked: false, starts: [] });
129
+ tx.put('enrollment', enrollmentId, { clientId: enrolledClientId(enrollmentId), installationId, visitor, verifier: hash(secret), allowedCompanies: [...new Set(human.allowedCompanies)], revoked: false, starts: [] });
126
130
  tx.put('enrollmentRequest', key, { enrollmentId });
127
- return { enrollmentId, credential: `${enrollmentId}.${secret}`, clientId: options.clientId, callbackUrl: options.callbackUrl };
131
+ return { enrollmentId, credential: `${enrollmentId}.${secret}`, clientId: enrolledClientId(enrollmentId), callbackUrl: options.callbackUrl };
128
132
  });
129
133
  },
130
134
  async identity(credential, input) {
@@ -135,12 +139,12 @@ export function createProvider(options) {
135
139
  const enrollment = auth(tx, credential, text(input.installationId));
136
140
  if (!enrollment.allowedCompanies.includes(metadata.document.organization.domain))
137
141
  fail('unauthorized');
138
- return { clientId: options.clientId, userId: user(tx, enrollment.visitor, input.issuer) };
142
+ return { clientId: enrollment.clientId ?? options.clientId, userId: user(tx, enrollment.visitor, input.issuer) };
139
143
  });
140
144
  },
141
145
  async begin(credential, input) {
142
146
  exact(input, ['version', 'installationId', 'operationId', 'domain', 'issuer', 'purpose', 'sessionId', 'sessionKey', 'state']);
143
- if (!['client-authentication', 'session', 'direct'].includes(input.purpose))
147
+ if (!['client-authentication', 'direct'].includes(input.purpose))
144
148
  fail('binding_conflict');
145
149
  text(input.operationId);
146
150
  if (input.sessionId !== undefined)
@@ -150,7 +154,7 @@ export function createProvider(options) {
150
154
  fail('binding_conflict');
151
155
  if (input.purpose !== 'direct' && input.state !== undefined)
152
156
  fail('binding_conflict');
153
- if (input.purpose === 'direct' && (typeof input.state !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(input.state) || typeof input.sessionKey !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(input.sessionKey)))
157
+ if (input.purpose === 'direct' && (typeof input.state !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(input.state) || (input.sessionKey !== null && (typeof input.sessionKey !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(input.sessionKey)))))
154
158
  fail('binding_conflict');
155
159
  if (input.purpose !== 'direct' && input.sessionKey !== undefined)
156
160
  fail('binding_conflict');
@@ -196,7 +200,7 @@ export function createProvider(options) {
196
200
  exact(input, ['version', 'installationId', 'operationId', 'purpose']);
197
201
  const snapshot = await store.transaction(selectedOperation(credential, input), tx => {
198
202
  const enrollment = auth(tx, credential, text(input.installationId)), op = operation(tx, enrollment, text(input.operationId));
199
- if (op.binding.purpose !== input.purpose || !['client-authentication', 'session'].includes(input.purpose))
203
+ if (op.binding.purpose !== input.purpose || input.purpose !== 'client-authentication')
200
204
  fail('binding_conflict');
201
205
  throttle(tx, enrollment);
202
206
  return { enrollment, op };
@@ -204,8 +208,9 @@ export function createProvider(options) {
204
208
  const b = snapshot.op.binding;
205
209
  await verified(b.domain, b.issuer);
206
210
  const iat = Math.floor(now() / 1000), exp = iat + 60;
207
- const claims = { iss: options.clientId, sub: b.purpose === 'client-authentication' ? options.clientId : b.userId, aud: b.issuer, iat, exp, jti: id() };
208
- const header = { alg: 'ES256', kid: options.signer.kid, typ: b.purpose === 'client-authentication' ? 'client-authentication+jwt' : 'JWT' };
211
+ const signingClientId = snapshot.enrollment.clientId ?? options.clientId;
212
+ const claims = { iss: signingClientId, sub: signingClientId, aud: b.issuer, iat, exp, jti: id() };
213
+ const header = { alg: 'ES256', kid: options.signer.kid, typ: 'client-authentication+jwt' };
209
214
  const unsigned = `${Buffer.from(JSON.stringify(header)).toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}`;
210
215
  const signature = await options.signer.sign(new TextEncoder().encode(unsigned));
211
216
  if (signature.length !== 64)
@@ -214,6 +219,64 @@ export function createProvider(options) {
214
219
  await store.transaction(selectedOperation(credential, input), tx => { const enrollment = auth(tx, credential, input.installationId), op = operation(tx, enrollment, input.operationId); tx.put('operation', `${enrollment.enrollmentId}:${input.operationId}`, { ...op, status: 'signed' }); });
215
220
  return { assertion: `${unsigned}.${Buffer.from(signature).toString('base64url')}`, expiresAt: exp };
216
221
  },
222
+ async delegate(credential, input) {
223
+ exact(input, ['version', 'installationId', 'operationId', 'jwk']);
224
+ const source = object(input.jwk);
225
+ if (source.kty !== 'EC' || source.crv !== 'P-256' || typeof source.x !== 'string' || typeof source.y !== 'string' || ['d', 'p', 'q', 'dp', 'dq', 'qi', 'oth', 'k'].some(name => name in source))
226
+ fail('binding_conflict');
227
+ const jwk = { kty: 'EC', crv: 'P-256', x: source.x, y: source.y };
228
+ try {
229
+ await importJWK(jwk, 'ES256');
230
+ }
231
+ catch {
232
+ fail('binding_conflict');
233
+ }
234
+ const operationId = text(input.operationId), installationId = text(input.installationId);
235
+ const selection = { kind: 'delegate', enrollmentId: enrollmentId(credential), operationId, at: now() };
236
+ const claim = await store.transaction(selection, tx => {
237
+ const enrollment = auth(tx, credential, installationId);
238
+ if (!enrollment.clientId || enrollment.allowedCompanies.length !== 1)
239
+ fail('binding_conflict'); // Legacy shared-client enrollments cannot release delegated authority.
240
+ const key = `${enrollment.enrollmentId}:${operationId}`, existing = tx.get('operation', key);
241
+ const binding = { purpose: 'delegation', jwk };
242
+ if (existing) {
243
+ if (!same(existing.binding, binding) || existing.expiresAt <= now() || existing.status === 'canceled')
244
+ fail('binding_conflict');
245
+ return { clientId: enrollment.clientId, existing: existing.result ? cipher.open(existing.result) : null, expiresAt: existing.expiresAt };
246
+ }
247
+ throttle(tx, enrollment);
248
+ const rows = tx.list('operation', `${enrollment.enrollmentId}:`, 1001);
249
+ for (const row of rows)
250
+ if (row.value.expiresAt + 30000 <= now()) {
251
+ tx.remove('operation', row.id);
252
+ if (row.value.binding.stateHash)
253
+ tx.remove('state', row.value.binding.stateHash);
254
+ }
255
+ if (rows.filter(row => row.value.expiresAt + 30000 > now()).length >= 1000)
256
+ throw new ProviderError('rate_limited', now() + 60000);
257
+ const expiresAt = (Math.floor(now() / 1800000) * 1800 + 3600) * 1000;
258
+ tx.put('operation', key, { binding, expiresAt, status: 'pending', result: null, resultHash: null });
259
+ return { clientId: enrollment.clientId, existing: null, expiresAt };
260
+ });
261
+ if (claim.existing)
262
+ return { delegation: claim.existing, expiresAt: Math.floor(claim.expiresAt / 1000) };
263
+ const exp = Math.floor(claim.expiresAt / 1000), iat = exp - 3600;
264
+ const header = { alg: 'ES256', typ: 'pap-delegation+jwt', kid: options.signer.kid };
265
+ const payload = { iss: claim.clientId, cnf: { jwk }, iat, exp, jti: id() };
266
+ const unsigned = Buffer.from(JSON.stringify(header)).toString('base64url') + '.' + Buffer.from(JSON.stringify(payload)).toString('base64url');
267
+ const signature = await options.signer.sign(new TextEncoder().encode(unsigned));
268
+ if (!(signature instanceof Uint8Array) || signature.length !== 64)
269
+ fail('unavailable');
270
+ const delegation = unsigned + '.' + Buffer.from(signature).toString('base64url');
271
+ return store.transaction({ kind: 'operation', enrollmentId: enrollmentId(credential), operationId }, tx => {
272
+ const enrollment = auth(tx, credential, installationId), op = operation(tx, enrollment, operationId);
273
+ if (op.binding.purpose !== 'delegation' || !same(op.binding.jwk, jwk))
274
+ fail('binding_conflict');
275
+ const result = op.result ? cipher.open(op.result) : delegation;
276
+ tx.put('operation', `${enrollment.enrollmentId}:${operationId}`, { ...op, status: 'signed', result: cipher.seal(result) });
277
+ return { delegation: result, expiresAt: exp };
278
+ });
279
+ },
217
280
  async callback(callbackUrl) {
218
281
  const url = new URL(callbackUrl), base = new URL(options.callbackUrl);
219
282
  if (url.origin !== base.origin || url.pathname !== base.pathname || url.username || url.password || url.hash || ['state', 'iss', 'code', 'error', 'error_description', 'error_uri'].some(key => url.searchParams.getAll(key).length > 1))
@@ -279,6 +342,18 @@ export function createProvider(options) {
279
342
  try {
280
343
  if (request.url === options.clientId && request.method === 'GET')
281
344
  return Response.json({ client_id: options.clientId, client_name: options.clientName, jwks_uri: options.jwksUri, redirect_uris: [options.callbackUrl], token_endpoint_auth_method: 'private_key_jwt' }, { headers });
345
+ if (request.method === 'GET') {
346
+ const prefix = client.pathname.replace(/\/$/, '') + '/enrollments/', requested = new URL(request.url);
347
+ if (requested.origin === client.origin && requested.pathname.startsWith(prefix)) {
348
+ const enrollmentId = requested.pathname.slice(prefix.length);
349
+ if (!/^[A-Za-z0-9_-]{43}$/.test(enrollmentId) || request.url !== enrolledClientId(enrollmentId))
350
+ return new Response(null, { status: 404 });
351
+ const enrollment = await store.transaction({ kind: 'metadata', enrollmentId }, tx => tx.get('enrollment', enrollmentId));
352
+ if (!enrollment || enrollment.revoked || enrollment.clientId !== request.url)
353
+ return new Response(null, { status: 404 });
354
+ return Response.json({ client_id: request.url, client_name: options.clientName, jwks_uri: options.jwksUri, redirect_uris: [options.callbackUrl], token_endpoint_auth_method: 'private_key_jwt', ...(await options.operations?.ready(enrollmentId) ? { extensions: { operations: { version: '2' } } } : {}) }, { headers });
355
+ }
356
+ }
282
357
  if (request.url === options.jwksUri && request.method === 'GET')
283
358
  return Response.json({ keys: (options.publishedKeys ? await options.publishedKeys() : [{ ...await options.signer.publicJwk(), kid: options.signer.kid }]).map(publicKey) }, { headers });
284
359
  const url = new URL(request.url);
@@ -310,11 +385,11 @@ export function createProvider(options) {
310
385
  finally {
311
386
  await reader.cancel().catch(() => { });
312
387
  }
313
- const input = object(JSON.parse(body));
388
+ const input = object(parseJson(body));
314
389
  if (input.version !== 1)
315
390
  fail('binding_conflict');
316
391
  const action = url.pathname.slice(url.pathname.lastIndexOf('/') + 1), credential = request.headers.get('authorization')?.match(/^Bearer (\S+)$/)?.[1] ?? '';
317
- const allowed = ['enroll', 'identity', 'begin', 'assert', 'takeResult', 'cancel', 'revokeEnrollment'];
392
+ const allowed = ['enroll', 'identity', 'begin', 'assert', 'delegate', 'takeResult', 'cancel', 'revokeEnrollment'];
318
393
  if (!allowed.includes(action))
319
394
  fail('binding_conflict');
320
395
  const result = action === 'enroll' ? await api.enroll(request, input) : await api[action](credential, input);
@@ -352,7 +427,13 @@ export function createProviderClient(options) {
352
427
  const value = await call('assert', { operationId: request.operationId, purpose: request.purpose });
353
428
  return { assertion: text(value.assertion), expiresAt: value.expiresAt };
354
429
  };
355
- return { identity, assert, beginDirect: (input) => call('begin', { ...input, domain: options.domain, purpose: 'direct' }),
430
+ const delegate = async (request) => {
431
+ const value = await call('delegate', request);
432
+ if (typeof value.expiresAt !== 'number')
433
+ throw new ProviderError('invalid_response');
434
+ return { delegation: text(value.delegation), expiresAt: value.expiresAt };
435
+ };
436
+ return { identity, assert, delegate, beginDirect: (input) => call('begin', { ...input, domain: options.domain, purpose: 'direct' }),
356
437
  takeResult: (input) => call('takeResult', input),
357
438
  cancel: (operationId) => call('cancel', { operationId }), revokeEnrollment: () => call('revokeEnrollment', {}) };
358
439
  }
package/dist/sqlite.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { BusinessAttemptStore } from './operations.js';
1
2
  export { SqliteAuthStore } from './auth/sqlite-store.js';
2
3
  import type { ConversationKey, ConversationStore } from './conversation-store.js';
3
4
  import type { ConsumerCheckpoint, OutboundRecord } from './conversation.js';
@@ -18,3 +19,14 @@ export declare class SqliteConversationStore implements ConversationStore {
18
19
  advanceCheckpoint(key: ConversationKey, row: ConsumerCheckpoint, from: readonly (string | undefined)[]): Promise<void>;
19
20
  resetCheckpoint(key: ConversationKey, conversation: string, name: string): Promise<void>;
20
21
  }
22
+ /** Separate from authentication lifecycle operations. Never discard uncertain business attempts. */
23
+ export declare class SqliteBusinessAttemptStore implements BusinessAttemptStore {
24
+ readonly namespace: string;
25
+ private readonly db;
26
+ constructor(path: string, namespace: string);
27
+ close(): void;
28
+ pending(after: string | undefined, limit: number): Promise<string[]>;
29
+ transaction<T>(id: string, action: (slot: {
30
+ record?: import('./operations.js').BusinessAttempt;
31
+ }) => T): Promise<T>;
32
+ }
package/dist/sqlite.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { validateJson } from './protocol/wire.js';
1
2
  import { DatabaseSync } from 'node:sqlite';
2
3
  import { initializeSqlite } from './sqlite-init.js';
3
4
  export { SqliteAuthStore } from './auth/sqlite-store.js';
@@ -78,3 +79,44 @@ export class SqliteConversationStore {
78
79
  this.db.prepare('DELETE FROM checkpoints WHERE connection=? AND issuer=? AND endpoint=? AND conversation=? AND name=?').run(key.connectionId, key.issuer, key.endpoint, conversation, name);
79
80
  }
80
81
  }
82
+ /** Separate from authentication lifecycle operations. Never discard uncertain business attempts. */
83
+ export class SqliteBusinessAttemptStore {
84
+ namespace;
85
+ db;
86
+ constructor(path, namespace) {
87
+ this.namespace = namespace;
88
+ this.db = new DatabaseSync(path);
89
+ initializeSqlite(this.db, () => this.db.exec(`PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
90
+ CREATE TABLE IF NOT EXISTS business_attempts (ns TEXT NOT NULL, id TEXT NOT NULL, body TEXT NOT NULL, PRIMARY KEY(ns,id));`));
91
+ }
92
+ close() { this.db.close(); }
93
+ async pending(after, limit) {
94
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
95
+ throw new TypeError('business recovery limit');
96
+ return this.db.prepare("SELECT id FROM business_attempts WHERE ns=? AND id>? AND json_extract(body,'$.phase') IN ('pending_consent','consenting','approved','uncertain') ORDER BY id LIMIT ?").all(this.namespace, after ?? '', limit).map(row => row.id);
97
+ }
98
+ async transaction(id, action) {
99
+ this.db.exec('BEGIN IMMEDIATE');
100
+ try {
101
+ const row = this.db.prepare('SELECT body FROM business_attempts WHERE ns=? AND id=?').get(this.namespace, id);
102
+ const slot = { record: row ? JSON.parse(row.body) : undefined };
103
+ if (slot.record && slot.record.version !== 1)
104
+ throw new Error('unsupported business attempt version');
105
+ const result = action(slot);
106
+ if (result && typeof result.then === 'function')
107
+ throw new TypeError('async business attempt transaction');
108
+ if (slot.record) {
109
+ if (slot.record.version !== 1 || slot.record.id !== id)
110
+ throw new TypeError('business attempt binding');
111
+ validateJson(slot.record);
112
+ this.db.prepare('INSERT INTO business_attempts VALUES (?,?,?) ON CONFLICT(ns,id) DO UPDATE SET body=excluded.body').run(this.namespace, id, JSON.stringify(slot.record));
113
+ }
114
+ this.db.exec('COMMIT');
115
+ return structuredClone(result);
116
+ }
117
+ catch (error) {
118
+ this.db.exec('ROLLBACK');
119
+ throw error;
120
+ }
121
+ }
122
+ }
@@ -0,0 +1,25 @@
1
+ // Synthetic Company flow. The trusted terminal asks each time; --self-check always declines.
2
+ import assert from 'node:assert/strict';
3
+ import {mkdtemp,rm} from 'node:fs/promises';
4
+ import {tmpdir} from 'node:os';
5
+ import {join} from 'node:path';
6
+ import {createInterface} from 'node:readline/promises';
7
+ import {createOperationClient} from '../dist/index.js';
8
+ import {SqliteBusinessAttemptStore} from '../dist/sqlite.js';
9
+ const dir=await mkdtemp(join(tmpdir(),'poppy-operation-example-')),store=new SqliteBusinessAttemptStore(join(dir,'attempts.db'),'synthetic-user');
10
+ const selfCheck=process.argv.includes('--self-check');let sends=0;
11
+ try{
12
+ const client=createOperationClient({store,context:async()=>({companyId:'https://example.com',companyName:'Example Company',clientId:'https://agent.example/client',userId:'synthetic',authority:'session:synthetic',operations:true}),
13
+ checkTerms:async(request,operation)=>operation.terms?.order_id===request.arguments?.order_id?[]:['Order differs from your request.'],
14
+ consent:{confirm:async({attemptId,companyId,companyName,summary,terms,differences})=>{
15
+ if(selfCheck)return 'decline';
16
+ if(!process.stdin.isTTY||!process.stderr.isTTY)throw new Error('Trusted terminal required');
17
+ const saved=await client.get(attemptId),display=JSON.stringify({companyId,companyName,summary,terms,differences,request:saved.request,expiresAt:saved.operation.expires_at},null,2).replace(/[\u007f-\u009f\u2028-\u202e\u2066-\u2069]/g,c=>'\\u'+c.charCodeAt(0).toString(16).padStart(4,'0'));
18
+ const terminal=createInterface({input:process.stdin,output:process.stderr});
19
+ try{const answer=await terminal.question(`${display}\nApprove these exact terms? Type yes: `);return answer==='yes'?'approve':'decline';}finally{terminal.close();}
20
+ }},send:async()=>{sends++;return {kind:'completed',result:{synthetic:true,done:true}};}});
21
+ await client.receiveMcp('exchange-order-one',{channel:'mcp',url:'https://example.com/mcp',name:'exchange',arguments:{order_id:'one'}},{isError:true,structuredContent:{operation:{operation_id:'exchange_one',revision:'revision_one',summary:'Exchange order one for the blue jacket for $70.',terms:{order_id:'one',amount:'70.00'},expires_at:new Date(Date.now()+900000).toISOString()}}});
22
+ const decision=await client.requestConsent('exchange-order-one');
23
+ if(decision.phase==='approved')console.log((await client.retry(decision.id)).phase);else console.log(decision.phase);
24
+ if(selfCheck){assert.equal(decision.phase,'declined');assert.equal(sends,0);}
25
+ }finally{store.close();await rm(dir,{recursive:true,force:true});}
@@ -6,6 +6,7 @@ const domain = selfCheck ? 'company.example' : process.argv[2];
6
6
  if (!domain) throw new Error('usage: node examples/quickstart.mjs DOMAIN|--self-check');
7
7
  const transport = selfCheck ? {
8
8
  async fetch(request) {
9
+ if (request.url === 'https://company.example/.well-known/oauth-protected-resource') return new Response(null, {status:404});
9
10
  assert.equal(request.url, 'https://company.example/.well-known/poppy.json');
10
11
  return Response.json({ protocol_version: '0.3', organization: { name: 'Example Company', domain }, web: {} });
11
12
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poppykit/client",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "description": "Personal Agent Protocol client SDK",
6
6
  "repository": {
@@ -43,7 +43,9 @@
43
43
  "RELEASING.md",
44
44
  "examples",
45
45
  "LICENSE",
46
- "migrations"
46
+ "migrations",
47
+ "CONFORMANCE.md",
48
+ "OPERATIONS.md"
47
49
  ],
48
50
  "exports": {
49
51
  ".": {
@@ -80,6 +82,7 @@
80
82
  }
81
83
  },
82
84
  "dependencies": {
85
+ "@agentsig/structured-fields": "0.1.1",
83
86
  "jose": "6.2.12"
84
87
  },
85
88
  "license": "MIT",