@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.
- package/API.md +9 -1
- package/AUTH.md +97 -90
- package/CHANGELOG.md +13 -1
- package/CONFORMANCE.md +73 -0
- package/CONTRIBUTING.md +12 -11
- package/DELIVERY.md +19 -3
- package/INTEGRATIONS.md +35 -23
- package/OPERATIONS.md +120 -0
- package/PROVIDER.md +20 -12
- package/README.md +6 -17
- package/RELEASING.md +2 -0
- package/SECURITY.md +2 -0
- package/STORAGE.md +14 -7
- package/dist/adapter-contract.d.ts +6 -0
- package/dist/adapter-contract.js +30 -0
- package/dist/auth/challenge.d.ts +2 -0
- package/dist/auth/challenge.js +8 -8
- package/dist/auth/client-auth.d.ts +20 -7
- package/dist/auth/client-auth.js +386 -334
- package/dist/auth/sqlite-store.js +13 -4
- package/dist/auth/store.d.ts +6 -1
- package/dist/conversation.d.ts +6 -0
- package/dist/conversation.js +18 -14
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/integrations.d.ts +11 -4
- package/dist/integrations.js +30 -38
- package/dist/network/discovery.d.ts +31 -13
- package/dist/network/discovery.js +103 -66
- package/dist/operations.d.ts +103 -0
- package/dist/operations.js +261 -0
- package/dist/postgres.d.ts +11 -0
- package/dist/postgres.js +40 -3
- package/dist/protocol/operations.d.ts +51 -0
- package/dist/protocol/operations.js +98 -0
- package/dist/protocol/session.d.ts +28 -0
- package/dist/protocol/session.js +93 -0
- package/dist/protocol/sse.d.ts +1 -0
- package/dist/protocol/sse.js +1 -1
- package/dist/protocol/wire.d.ts +8 -4
- package/dist/protocol/wire.js +37 -15
- package/dist/provider-postgres.js +3 -3
- package/dist/provider-storage.js +4 -4
- package/dist/provider.d.ts +26 -3
- package/dist/provider.js +94 -13
- package/dist/sqlite.d.ts +12 -0
- package/dist/sqlite.js +42 -0
- package/examples/operations.mjs +25 -0
- package/examples/quickstart.mjs +1 -0
- package/package.json +5 -2
package/dist/protocol/wire.d.ts
CHANGED
|
@@ -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
|
|
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): {
|
package/dist/protocol/wire.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
|
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
|
-
|
|
189
|
-
|
|
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;
|
package/dist/provider-storage.js
CHANGED
|
@@ -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
|
},
|
package/dist/provider.d.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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:
|
|
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', '
|
|
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 ||
|
|
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
|
|
208
|
-
const
|
|
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(
|
|
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
|
-
|
|
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});}
|
package/examples/quickstart.mjs
CHANGED
|
@@ -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.
|
|
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",
|