@poppykit/client 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/API.md +9 -1
  2. package/AUTH.md +97 -90
  3. package/CHANGELOG.md +20 -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 +20 -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-runtime.js +12 -4
  33. package/dist/postgres.d.ts +11 -0
  34. package/dist/postgres.js +40 -3
  35. package/dist/protocol/operations.d.ts +51 -0
  36. package/dist/protocol/operations.js +98 -0
  37. package/dist/protocol/session.d.ts +28 -0
  38. package/dist/protocol/session.js +93 -0
  39. package/dist/protocol/sse.d.ts +1 -0
  40. package/dist/protocol/sse.js +1 -1
  41. package/dist/protocol/wire.d.ts +8 -4
  42. package/dist/protocol/wire.js +37 -15
  43. package/dist/provider-postgres.js +3 -3
  44. package/dist/provider-storage.js +4 -4
  45. package/dist/provider.d.ts +26 -3
  46. package/dist/provider.js +94 -13
  47. package/dist/sqlite.d.ts +12 -0
  48. package/dist/sqlite.js +42 -0
  49. package/examples/operations.mjs +25 -0
  50. package/examples/quickstart.mjs +1 -0
  51. 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 };
@@ -35,6 +35,9 @@ export async function postgresMigration(schema) {
35
35
  export async function migratePostgres(pool, schema) {
36
36
  schemaName(schema);
37
37
  const client = await pool.connect();
38
+ let connectionError;
39
+ const onError = (error) => { connectionError = error; };
40
+ client.on('error', onError);
38
41
  try {
39
42
  await client.query('BEGIN');
40
43
  await client.query("SET LOCAL statement_timeout='5s'");
@@ -50,11 +53,12 @@ export async function migratePostgres(pool, schema) {
50
53
  await client.query('COMMIT');
51
54
  }
52
55
  catch (error) {
53
- await client.query('ROLLBACK');
56
+ await client.query('ROLLBACK').catch((failure) => { connectionError = failure; });
54
57
  throw error;
55
58
  }
56
59
  finally {
57
- client.release();
60
+ client.release(connectionError);
61
+ client.removeListener('error', onError);
58
62
  }
59
63
  }
60
64
  function scopeActive(context) {
@@ -144,6 +148,9 @@ export class PostgresRuntime {
144
148
  }
145
149
  }
146
150
  const client = await this.pool.connect(), context = { client, active: true, locks: new Set(), state: { serial: 0, active: true }, childActive: false };
151
+ let connectionError;
152
+ const onError = (error) => { connectionError = error; context.state.active = false; };
153
+ client.on('error', onError);
147
154
  try {
148
155
  await client.query('BEGIN');
149
156
  await client.query("SET LOCAL statement_timeout='5s'");
@@ -161,13 +168,14 @@ export class PostgresRuntime {
161
168
  }
162
169
  catch (error) {
163
170
  context.state.active = false;
164
- await client.query('ROLLBACK').catch(() => { });
171
+ await client.query('ROLLBACK').catch((failure) => { connectionError = failure; });
165
172
  throw error;
166
173
  }
167
174
  finally {
168
175
  context.active = false;
169
176
  context.state.active = false;
170
- client.release();
177
+ client.release(connectionError);
178
+ client.removeListener('error', onError);
171
179
  }
172
180
  }
173
181
  connection() {
@@ -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>;