@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
@@ -0,0 +1,261 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { canonical, id as parseId, object, parseJson, retryAfterMilliseconds, validateJson } from './protocol/wire.js';
3
+ import { approvalHeader, approvalMetaKey, captureApiOperationRequest, operationRequest, operationExpiry, parseBusinessOperation, requestFingerprint, supportsOperations } from './protocol/operations.js';
4
+ export class OperationClientError extends Error {
5
+ code;
6
+ constructor(code) {
7
+ super('Business operation ' + code);
8
+ this.code = code;
9
+ }
10
+ }
11
+ export function createOperationClient(options) {
12
+ const now = options.now ?? Date.now;
13
+ const sending = new Map();
14
+ const token = () => randomBytes(24).toString('base64url');
15
+ function sameContext(a, b) {
16
+ return a.companyId === b.companyId && a.clientId === b.clientId && a.userId === b.userId && a.authority === b.authority;
17
+ }
18
+ async function context(requireSupport = false) {
19
+ const value = await options.context();
20
+ if (requireSupport && !value.operations)
21
+ throw new OperationClientError('not_negotiated');
22
+ return value;
23
+ }
24
+ async function withAttempt(id, action) {
25
+ const current = await context();
26
+ return options.store.transaction(id, slot => {
27
+ if (!slot.record || slot.record.version !== 1)
28
+ throw new OperationClientError('unknown_attempt');
29
+ if (!sameContext(slot.record.context, current))
30
+ throw new OperationClientError('owner_changed');
31
+ return action(slot.record);
32
+ });
33
+ }
34
+ async function receive(id, request, value, headers) {
35
+ parseId(id, 'attempt_id');
36
+ const current = await context(true), operation = parseBusinessOperation(value), binding = operationRequest(request);
37
+ return options.store.transaction(id, slot => {
38
+ if (slot.record) {
39
+ const old = slot.record;
40
+ if (old.version !== 1)
41
+ throw new OperationClientError('unsupported_version');
42
+ if (!sameContext(old.context, current))
43
+ throw new OperationClientError('owner_changed');
44
+ if (canonical(old.operation) === canonical(operation) && requestFingerprint(old.request) === requestFingerprint(binding))
45
+ return structuredClone(old);
46
+ if (old.operation.operation_id === operation.operation_id && old.operation.revision === operation.revision)
47
+ throw new OperationClientError('revision_changed');
48
+ if (['uncertain', 'completed'].includes(old.phase))
49
+ throw new OperationClientError('attempt_uncertain');
50
+ }
51
+ const record = { version: 1, id, context: current, request: binding, operation, phase: operationExpiry(operation.expires_at) <= now() ? 'expired' : 'pending_consent', messageId: token(), ...(headers ? { headers } : {}) };
52
+ if (slot.record)
53
+ record.previousDecisions = [...(slot.record.previousDecisions ?? []), { revision: slot.record.operation.revision, phase: slot.record.phase }];
54
+ slot.record = record;
55
+ return structuredClone(record);
56
+ });
57
+ }
58
+ return {
59
+ receive,
60
+ async receiveApi(id, request, response) {
61
+ if (response.status !== 403)
62
+ throw new OperationClientError('not_operation');
63
+ const body = object(parseJson(await response.clone().text()));
64
+ if (body.error !== 'approval_required')
65
+ throw new OperationClientError('not_operation');
66
+ const headers = [...request.headers].filter(([key]) => !['authorization', 'poppy-session', 'poppy-approval', 'dpop', 'cookie', 'idempotency-key'].includes(key));
67
+ return receive(id, await captureApiOperationRequest(request), body.operation, headers);
68
+ },
69
+ async receiveMcp(id, request, result) {
70
+ const body = object(result);
71
+ if (body.isError !== true)
72
+ throw new OperationClientError('not_operation');
73
+ return receive(id, request, object(body.structuredContent).operation);
74
+ },
75
+ async receiveConversation(id, request, message) {
76
+ const body = object(message);
77
+ if (body.role !== 'company')
78
+ throw new OperationClientError('not_operation');
79
+ return receive(id, request, body.operation);
80
+ },
81
+ async recover(after, limit = 100) {
82
+ const result = [];
83
+ for (const id of await options.store.pending(after, limit)) {
84
+ try {
85
+ result.push(await withAttempt(id, record => structuredClone(record)));
86
+ }
87
+ catch (error) {
88
+ if (!(error instanceof OperationClientError))
89
+ throw error;
90
+ result.push({ id, error: error.code });
91
+ }
92
+ }
93
+ return result;
94
+ },
95
+ get(id) { return withAttempt(id, record => structuredClone(record)); },
96
+ async requestConsent(id) {
97
+ await context(true);
98
+ const selected = await withAttempt(id, record => {
99
+ if (record.phase !== 'pending_consent')
100
+ throw new OperationClientError('consent_not_pending');
101
+ if (operationExpiry(record.operation.expires_at) <= now()) {
102
+ record.phase = 'expired';
103
+ return structuredClone(record);
104
+ }
105
+ record.phase = 'consenting';
106
+ record.consentToken = token();
107
+ return structuredClone(record);
108
+ });
109
+ if (selected.phase === 'expired')
110
+ return selected;
111
+ let answer;
112
+ try {
113
+ const differences = await options.checkTerms(structuredClone(selected.request), structuredClone(selected.operation));
114
+ if (!Array.isArray(differences) || differences.some(value => typeof value !== 'string'))
115
+ throw new OperationClientError('invalid_terms_check');
116
+ answer = await options.consent.confirm({ attemptId: id, companyId: selected.context.companyId, companyName: selected.context.companyName, summary: selected.operation.summary, ...(selected.operation.terms ? { terms: structuredClone(selected.operation.terms) } : {}), differences });
117
+ if (answer !== 'approve' && answer !== 'decline')
118
+ throw new OperationClientError('invalid_consent');
119
+ }
120
+ catch {
121
+ throw new OperationClientError('consent_interrupted');
122
+ }
123
+ return withAttempt(id, record => {
124
+ if (record.phase !== 'consenting' || record.consentToken !== selected.consentToken)
125
+ throw new OperationClientError('consent_withdrawn');
126
+ delete record.consentToken;
127
+ record.phase = operationExpiry(record.operation.expires_at) <= now() ? 'expired' : answer === 'approve' ? 'approved' : 'declined';
128
+ return structuredClone(record);
129
+ });
130
+ },
131
+ /** Explicitly dismiss the old UI first. Its late callback is fenced; a fresh answer is required. */
132
+ resumeConsent(id) {
133
+ return withAttempt(id, record => { if (record.phase !== 'consenting')
134
+ throw new OperationClientError('consent_not_interrupted'); delete record.consentToken; record.phase = 'pending_consent'; return structuredClone(record); });
135
+ },
136
+ withdraw(id) {
137
+ for (const controller of sending.get(id) ?? [])
138
+ controller.abort();
139
+ return withAttempt(id, record => {
140
+ if (record.phase === 'uncertain') {
141
+ record.withdrawalRequested = true;
142
+ return structuredClone(record);
143
+ }
144
+ if (!['pending_consent', 'consenting', 'approved'].includes(record.phase))
145
+ throw new OperationClientError('already_sent');
146
+ delete record.consentToken;
147
+ record.phase = 'withdrawn';
148
+ return structuredClone(record);
149
+ });
150
+ },
151
+ async retry(id) {
152
+ await context(true);
153
+ const controller = new AbortController(), active = sending.get(id) ?? new Set();
154
+ active.add(controller);
155
+ sending.set(id, active);
156
+ try {
157
+ const selected = await withAttempt(id, record => {
158
+ if (record.withdrawalRequested)
159
+ throw new OperationClientError('approval_withdrawn');
160
+ if (!['approved', 'uncertain'].includes(record.phase))
161
+ throw new OperationClientError('not_approved');
162
+ if (record.retryAt && record.retryAt > now())
163
+ throw new OperationClientError('retry_later');
164
+ if (record.phase === 'approved' && operationExpiry(record.operation.expires_at) <= now()) {
165
+ record.phase = 'expired';
166
+ return structuredClone(record);
167
+ }
168
+ // Commit before dispatch. Crash/timeout recovery retains this exact approval, request and message ID.
169
+ record.phase = 'uncertain';
170
+ return structuredClone(record);
171
+ });
172
+ if (selected.phase === 'expired')
173
+ return selected;
174
+ let outcome;
175
+ try {
176
+ controller.signal.throwIfAborted();
177
+ await withAttempt(id, record => { if (record.withdrawalRequested)
178
+ controller.abort(); });
179
+ controller.signal.throwIfAborted();
180
+ outcome = await options.send(selected, { operation_id: selected.operation.operation_id, revision: selected.operation.revision }, controller.signal);
181
+ }
182
+ catch {
183
+ return withAttempt(id, record => structuredClone(record));
184
+ }
185
+ return withAttempt(id, record => {
186
+ if (record.phase === 'completed')
187
+ return structuredClone(record);
188
+ if (outcome.kind === 'completed') {
189
+ validateJson(outcome.result);
190
+ record.result = structuredClone(outcome.result);
191
+ record.phase = 'completed';
192
+ }
193
+ else if (outcome.retryAfterMs !== undefined)
194
+ record.retryAt = Math.min(Number.MAX_SAFE_INTEGER, now() + outcome.retryAfterMs);
195
+ return structuredClone(record);
196
+ });
197
+ }
198
+ finally {
199
+ active.delete(controller);
200
+ if (!active.size)
201
+ sending.delete(id);
202
+ }
203
+ },
204
+ /** Trusted host lookup of actual business state; an operation returned on retry is not reconciliation. */
205
+ async reconcile(id, lookup) {
206
+ const selected = await withAttempt(id, record => { if (record.phase !== 'uncertain')
207
+ throw new OperationClientError('not_uncertain'); return structuredClone(record); });
208
+ const outcome = await lookup(selected);
209
+ return withAttempt(id, record => {
210
+ if (record.phase === 'completed')
211
+ return structuredClone(record);
212
+ if (outcome.kind === 'completed') {
213
+ validateJson(outcome.result);
214
+ record.result = structuredClone(outcome.result);
215
+ record.phase = 'completed';
216
+ }
217
+ return structuredClone(record);
218
+ });
219
+ },
220
+ };
221
+ }
222
+ export function approvedApiRequest(attempt, approval, signal) {
223
+ if (attempt.request.channel !== 'api')
224
+ throw new OperationClientError('wrong_channel');
225
+ const headers = new Headers(attempt.headers);
226
+ headers.delete('idempotency-key');
227
+ headers.set('poppy-approval', approvalHeader(approval));
228
+ return new Request(attempt.request.url, { method: attempt.request.method, headers, signal, body: attempt.request.body === null ? null : Buffer.from(attempt.request.body, 'base64'), redirect: 'manual' });
229
+ }
230
+ export function approvedMcpParams(attempt, approval) {
231
+ if (attempt.request.channel !== 'mcp')
232
+ throw new OperationClientError('wrong_channel');
233
+ return { name: attempt.request.name, arguments: structuredClone(attempt.request.arguments), _meta: { [approvalMetaKey]: approval } };
234
+ }
235
+ export function approvedConversationMessage(attempt, approval) {
236
+ if (attempt.request.channel !== 'conversation')
237
+ throw new OperationClientError('wrong_channel');
238
+ return { id: attempt.messageId, author: { type: 'agent' }, approval };
239
+ }
240
+ /** Adapters must use verified SDK channel targets and current authorization; a conversation acceptance is still pending work. */
241
+ export function createOperationSender(channels) {
242
+ return async (attempt, approval, signal) => {
243
+ if (attempt.request.channel === 'api') {
244
+ const response = await channels.api(approvedApiRequest(attempt, approval, signal), attempt.context);
245
+ if (response.status === 202 || !response.ok) {
246
+ await response.body?.cancel();
247
+ return { kind: 'pending', retryAfterMs: retryAfterMilliseconds(response.headers.get('retry-after'), Date.now()) };
248
+ }
249
+ return { kind: 'completed', result: { status: response.status, headers: [...response.headers].filter(([name]) => !['set-cookie', 'authorization', 'poppy-session', 'dpop'].includes(name)), body: Buffer.from(await response.arrayBuffer()).toString('base64') } };
250
+ }
251
+ if (attempt.request.channel === 'mcp') {
252
+ const result = await channels.mcp(attempt.request.url, approvedMcpParams(attempt, approval), signal, attempt.context);
253
+ if (result.isError === true)
254
+ return { kind: 'pending' };
255
+ return { kind: 'completed', result };
256
+ }
257
+ await channels.conversation(attempt.request, approvedConversationMessage(attempt, approval), signal, attempt.context);
258
+ return { kind: 'pending' };
259
+ };
260
+ }
261
+ export { supportsOperations };
@@ -1,3 +1,4 @@
1
+ import type { BusinessAttemptStore } from './operations.js';
1
2
  import type { Pool } from 'pg';
2
3
  import type { ClientAuthState } from './auth/client-auth.js';
3
4
  import type { AuthSelection, AuthStore } from './auth/store.js';
@@ -37,3 +38,13 @@ export declare class PostgresConversationStore implements ConversationStore {
37
38
  advanceCheckpoint(key: ConversationKey, row: ConsumerCheckpoint, from: readonly (string | undefined)[]): Promise<void>;
38
39
  resetCheckpoint(key: ConversationKey, conversation: string, name: string): Promise<void>;
39
40
  }
41
+ export declare class PostgresBusinessAttemptStore implements BusinessAttemptStore {
42
+ readonly namespace: string;
43
+ private readonly db;
44
+ constructor(pool: Pool, namespace: string);
45
+ close(): Promise<void>;
46
+ pending(after: string | undefined, limit: number): Promise<string[]>;
47
+ transaction<T>(id: string, action: (slot: {
48
+ record?: import('./operations.js').BusinessAttempt;
49
+ }) => T): Promise<T>;
50
+ }
package/dist/postgres.js CHANGED
@@ -27,7 +27,7 @@ export class PostgresAuthStore {
27
27
  for (const row of await this.db.query(`SELECT id,body FROM poppy_client.records WHERE ns=$1 AND kind=$2 ${where}`, [this.namespace, table, ...values]))
28
28
  state[table][row.id] = JSON.parse(row.body);
29
29
  };
30
- if (state.version === 1) {
30
+ if (state.version === 1 || state.version === 2) {
31
31
  for (const table of tables)
32
32
  await rows(table, '');
33
33
  upgradeClientAuthState(state);
@@ -38,12 +38,16 @@ export class PostgresAuthStore {
38
38
  for (const table of tables)
39
39
  state[table] = {};
40
40
  }
41
- if (state.version !== 2)
41
+ if (state.version !== 3)
42
42
  throw new Error('unsupported auth state version');
43
43
  if ('issuer' in selection)
44
44
  await one('identities', selection.issuer);
45
45
  else {
46
- if ('connectionId' in selection)
46
+ if ('account' in selection) {
47
+ await one('identities', selection.account.issuer);
48
+ await rows('connections', "AND body::jsonb->>'issuer'=$3 AND body::jsonb->>'userId'=$4", [selection.account.issuer, selection.account.userId]);
49
+ }
50
+ else if ('connectionId' in selection)
47
51
  await one('connections', selection.connectionId);
48
52
  else if ('operationId' in selection)
49
53
  await one('operations', selection.operationId);
@@ -53,6 +57,9 @@ export class PostgresAuthStore {
53
57
  await rows('connections', '');
54
58
  for (const operation of Object.values(state.operations))
55
59
  await one('connections', operation.connectionId);
60
+ const linked = new Set([...Object.values(state.connections).map(row => row.grantId), ...Object.values(state.operations).map(row => row.grantId)].filter((id) => id !== null));
61
+ for (const grantId of linked)
62
+ await rows('connections', "AND body::jsonb->>'grantId'=$3", [grantId]);
56
63
  for (const connection of Object.values(state.connections)) {
57
64
  if (connection.grantId)
58
65
  await one('grants', connection.grantId);
@@ -156,3 +163,33 @@ export class PostgresConversationStore {
156
163
  }
157
164
  resetCheckpoint(key, conversation, name) { return this.db.run(async () => { const values = this.checkpointValues(key, conversation, name); await this.db.lock(JSON.stringify(['checkpoint', ...values])); await this.db.query('DELETE FROM poppy_client.checkpoints WHERE ns=$1 AND connection=$2 AND issuer=$3 AND endpoint=$4 AND conversation=$5 AND name=$6', values); }); }
158
165
  }
166
+ export class PostgresBusinessAttemptStore {
167
+ namespace;
168
+ db;
169
+ constructor(pool, namespace) {
170
+ this.namespace = namespace;
171
+ this.db = new PostgresRuntime(pool, 'poppy_client');
172
+ }
173
+ close() { return this.db.close(); }
174
+ pending(after, limit) {
175
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
176
+ throw new TypeError('business recovery limit');
177
+ return this.db.run(async () => (await this.db.query("SELECT id FROM poppy_client.records WHERE ns=$1 AND kind='business_attempts' AND id COLLATE \"C\">$2 AND body::jsonb->>'phase' IN ('pending_consent','consenting','approved','uncertain') ORDER BY id COLLATE \"C\" LIMIT $3", [this.namespace, after ?? '', limit])).map(row => row.id));
178
+ }
179
+ transaction(id, action) {
180
+ return this.db.run(async () => {
181
+ await this.db.lock(JSON.stringify([this.namespace, 'business_attempts', id]));
182
+ const row = (await this.db.query("SELECT body FROM poppy_client.records WHERE ns=$1 AND kind='business_attempts' AND id=$2", [this.namespace, id]))[0];
183
+ const slot = { record: row ? JSON.parse(row.body) : undefined };
184
+ if (slot.record && slot.record.version !== 1)
185
+ throw new Error('unsupported business attempt version');
186
+ const result = synchronous(action(slot));
187
+ if (slot.record) {
188
+ if (slot.record.version !== 1 || slot.record.id !== id)
189
+ throw new TypeError('business attempt binding');
190
+ await this.db.query("INSERT INTO poppy_client.records(ns,kind,id,body) VALUES($1,'business_attempts',$2,$3) ON CONFLICT(ns,kind,id) DO UPDATE SET body=excluded.body", [this.namespace, id, json(slot.record)]);
191
+ }
192
+ return result;
193
+ });
194
+ }
195
+ }
@@ -0,0 +1,51 @@
1
+ import type { JsonObject } from './wire.js';
2
+ export interface BusinessOperation {
3
+ operation_id: string;
4
+ revision: string;
5
+ summary: string;
6
+ terms?: JsonObject;
7
+ expires_at: string;
8
+ [key: string]: unknown;
9
+ }
10
+ export interface OperationApproval {
11
+ operation_id: string;
12
+ revision: string;
13
+ [key: string]: unknown;
14
+ }
15
+ export declare const operationsV2: {
16
+ readonly version: "2";
17
+ };
18
+ export declare function supportsOperations(document: {
19
+ extensions?: unknown;
20
+ }): boolean;
21
+ export declare function parseBusinessOperation(value: unknown): BusinessOperation;
22
+ /** RFC3339 permits a leap second; JavaScript Date.parse does not. */
23
+ export declare function operationExpiry(value: string): number;
24
+ export declare function parseOperationApproval(value: unknown): OperationApproval;
25
+ export declare function parseApprovalHeader(value: string | null): OperationApproval | undefined;
26
+ export declare function approvalHeader(value: OperationApproval): string;
27
+ export declare const approvalMetaKey = "personalagentprotocol.org/approval";
28
+ export declare function mcpApproval(params: unknown): OperationApproval | undefined;
29
+ export type OperationRequest = {
30
+ channel: 'api';
31
+ method: string;
32
+ url: string;
33
+ body: string | null;
34
+ } | {
35
+ channel: 'mcp';
36
+ url: string;
37
+ name: string;
38
+ arguments: JsonObject;
39
+ } | {
40
+ channel: 'conversation';
41
+ endpoint: string;
42
+ conversationId: string;
43
+ };
44
+ export declare function operationRequest(value: OperationRequest): OperationRequest;
45
+ export declare function requestFingerprint(value: OperationRequest): string;
46
+ export declare function operationRequiredResponse(operation: BusinessOperation): Response;
47
+ export declare function operationToolError(operation: BusinessOperation): JsonObject;
48
+ /** Base64 preserves exact API body bytes. Bind the complete URL, including its query. */
49
+ export declare function captureApiOperationRequest(request: Request): Promise<Extract<OperationRequest, {
50
+ channel: 'api';
51
+ }>>;
@@ -0,0 +1,98 @@
1
+ import { parse, serialize, getMember } from '@agentsig/structured-fields';
2
+ import { canonical, id, object, string, ValidationError } from './wire.js';
3
+ import { secureUrl } from '../network/transport.js';
4
+ export const operationsV2 = { version: '2' };
5
+ export function supportsOperations(document) {
6
+ const extensions = document.extensions;
7
+ return !!extensions && typeof extensions === 'object' && !Array.isArray(extensions)
8
+ && extensions.operations?.version === '2';
9
+ }
10
+ export function parseBusinessOperation(value) {
11
+ const operation = object(value, 'operation');
12
+ id(operation.operation_id, 'operation.operation_id');
13
+ id(operation.revision, 'operation.revision');
14
+ string(operation.summary, 'operation.summary');
15
+ if ('terms' in operation)
16
+ object(operation.terms, 'operation.terms');
17
+ const date = string(operation.expires_at, 'operation.expires_at');
18
+ const match = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|[+-](\d{2}):(\d{2}))$/.exec(date);
19
+ if (!match)
20
+ throw new ValidationError('operation.expires_at');
21
+ const [, y, m, d, h, min, s, oh = '0', om = '0'] = match;
22
+ const monthEnd = new Date(0);
23
+ monthEnd.setUTCFullYear(+y, +m, 0);
24
+ const days = monthEnd.getUTCDate();
25
+ if (+m < 1 || +m > 12 || +d < 1 || +d > days || +h > 23 || +min > 59 || +s > 60 || +oh > 23 || +om > 59 || !Number.isFinite(operationExpiry(date)))
26
+ throw new ValidationError('operation.expires_at');
27
+ return structuredClone(operation);
28
+ }
29
+ /** RFC3339 permits a leap second; JavaScript Date.parse does not. */
30
+ export function operationExpiry(value) {
31
+ return /:60(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/.test(value)
32
+ ? Date.parse(value.replace(/:60(?=\.\d+|[Zz]|[+-])/, ':59')) + 1000 : Date.parse(value);
33
+ }
34
+ export function parseOperationApproval(value) {
35
+ const approval = object(value, 'approval');
36
+ id(approval.operation_id, 'approval.operation_id');
37
+ id(approval.revision, 'approval.revision');
38
+ return { ...approval };
39
+ }
40
+ export function parseApprovalHeader(value) {
41
+ if (value === null)
42
+ return;
43
+ try {
44
+ const dictionary = parse(value, 'dictionary');
45
+ const text = (name) => { const member = getMember(dictionary, name); return member?.kind === 'item' && member.bare.kind === 'string' ? member.bare.value : undefined; };
46
+ return parseOperationApproval({ operation_id: text('operation_id'), revision: text('revision') });
47
+ }
48
+ catch {
49
+ return;
50
+ } // Invalid Structured Fields are ignored as a whole (RFC 9651 section 2.2).
51
+ }
52
+ export function approvalHeader(value) {
53
+ const approval = parseOperationApproval(value);
54
+ return serialize({ kind: 'dictionary', entries: [
55
+ ['operation_id', { kind: 'item', bare: { kind: 'string', value: approval.operation_id }, parameters: [] }],
56
+ ['revision', { kind: 'item', bare: { kind: 'string', value: approval.revision }, parameters: [] }],
57
+ ] });
58
+ }
59
+ export const approvalMetaKey = 'personalagentprotocol.org/approval';
60
+ export function mcpApproval(params) {
61
+ const meta = object(params, 'params')._meta;
62
+ if (meta === undefined)
63
+ return;
64
+ const value = object(meta, '_meta')[approvalMetaKey];
65
+ return value === undefined ? undefined : parseOperationApproval(value);
66
+ }
67
+ export function operationRequest(value) {
68
+ object(value, 'operation.request');
69
+ if (value.channel === 'api') {
70
+ secureUrl(value.url);
71
+ if (!/^[A-Z]+$/.test(value.method) || !(value.body === null || typeof value.body === 'string'))
72
+ throw new ValidationError('operation.request');
73
+ }
74
+ else if (value.channel === 'mcp') {
75
+ secureUrl(value.url);
76
+ string(value.name, 'tool.name');
77
+ object(value.arguments, 'tool.arguments');
78
+ }
79
+ else if (value.channel === 'conversation') {
80
+ secureUrl(value.endpoint);
81
+ id(value.conversationId, 'conversation_id');
82
+ }
83
+ else
84
+ throw new ValidationError('operation.channel');
85
+ return structuredClone(value);
86
+ }
87
+ export function requestFingerprint(value) { return canonical(operationRequest(value)); }
88
+ export function operationRequiredResponse(operation) {
89
+ return Response.json({ error: 'approval_required', operation: parseBusinessOperation(operation) }, { status: 403, headers: { 'Cache-Control': 'no-store' } });
90
+ }
91
+ export function operationToolError(operation) {
92
+ const parsed = parseBusinessOperation(operation);
93
+ return { isError: true, content: [{ type: 'text', text: parsed.summary }], structuredContent: { operation: parsed } };
94
+ }
95
+ /** Base64 preserves exact API body bytes. Bind the complete URL, including its query. */
96
+ export async function captureApiOperationRequest(request) {
97
+ return { channel: 'api', method: request.method, url: request.url, body: request.body === null ? null : Buffer.from(await request.clone().arrayBuffer()).toString('base64') };
98
+ }
@@ -0,0 +1,28 @@
1
+ import type { MetadataResolver } from '../network/discovery.js';
2
+ export interface SessionClaims {
3
+ iss: string;
4
+ aud: string;
5
+ sid: string;
6
+ sub: string;
7
+ agent_id?: string;
8
+ htm: string;
9
+ htu: string;
10
+ iat: number;
11
+ jti: string;
12
+ ath?: string;
13
+ }
14
+ export interface VerifiedSession extends SessionClaims {
15
+ delegationExpiresAt: number;
16
+ }
17
+ export declare function sessionRequestUrl(value: string): string;
18
+ export declare function parseSessionClaims(value: unknown): SessionClaims;
19
+ /** Verifies identity only. The resource must separately admit/continue the client_id + sid under its store fence. */
20
+ export declare function verifySessionJwt(jwt: string | null, expected: {
21
+ companyId: string;
22
+ method: string;
23
+ url: string;
24
+ nowMs: number;
25
+ metadata: MetadataResolver;
26
+ accessToken?: string;
27
+ tokenClientId?: string;
28
+ }): Promise<VerifiedSession>;
@@ -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 };