@poppykit/client 0.4.1 → 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 +12 -3
- package/AUTH.md +97 -65
- package/CHANGELOG.md +18 -0
- package/CONFORMANCE.md +73 -0
- package/CONTRIBUTING.md +13 -10
- package/DELIVERY.md +30 -1
- package/INTEGRATIONS.md +63 -21
- package/OPERATIONS.md +120 -0
- package/PROVIDER.md +24 -14
- package/README.md +7 -13
- package/RELEASING.md +2 -0
- package/SECURITY.md +2 -0
- package/STORAGE.md +24 -5
- package/dist/adapter-contract.d.ts +6 -0
- package/dist/adapter-contract.js +31 -1
- package/dist/auth/challenge.d.ts +2 -0
- package/dist/auth/challenge.js +8 -8
- package/dist/auth/client-auth.d.ts +29 -13
- package/dist/auth/client-auth.js +498 -318
- package/dist/auth/sqlite-store.js +27 -4
- package/dist/auth/store.d.ts +6 -1
- package/dist/conversation.d.ts +6 -0
- package/dist/conversation.js +28 -14
- package/dist/index.d.ts +6 -1
- package/dist/index.js +3 -1
- package/dist/integrations.d.ts +16 -4
- package/dist/integrations.js +30 -38
- package/dist/network/discovery.d.ts +51 -22
- package/dist/network/discovery.js +159 -69
- package/dist/network/redirect.d.ts +3 -0
- package/dist/network/redirect.js +38 -0
- package/dist/network/tasks.d.ts +23 -0
- package/dist/network/tasks.js +45 -0
- package/dist/operations.d.ts +103 -0
- package/dist/operations.js +261 -0
- package/dist/postgres.d.ts +11 -0
- package/dist/postgres.js +52 -2
- 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 +20 -15
- package/dist/protocol/wire.js +70 -22
- package/dist/provider-postgres.js +3 -3
- package/dist/provider-storage.js +4 -4
- package/dist/provider.d.ts +26 -10
- package/dist/provider.js +103 -32
- package/dist/sqlite.d.ts +12 -0
- package/dist/sqlite.js +42 -0
- package/examples/operations.mjs +25 -0
- package/examples/quickstart.mjs +2 -1
- package/package.json +5 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
2
|
import { initializeSqlite } from '../sqlite-init.js';
|
|
3
|
-
import { initialClientAuthState } from './client-auth.js';
|
|
3
|
+
import { initialClientAuthState, upgradeClientAuthState } from './client-auth.js';
|
|
4
4
|
const tables = ['identities', 'connections', 'grants', 'operations', 'cleanup'];
|
|
5
5
|
function json(value) {
|
|
6
6
|
return JSON.stringify(value, (_key, item) => {
|
|
@@ -19,7 +19,9 @@ export class SqliteAuthStore {
|
|
|
19
19
|
this.db.exec('CREATE TABLE IF NOT EXISTS auth_meta (id INTEGER PRIMARY KEY CHECK(id=1), body TEXT NOT NULL)');
|
|
20
20
|
for (const table of tables)
|
|
21
21
|
this.db.exec(`CREATE TABLE IF NOT EXISTS auth_${table} (id TEXT PRIMARY KEY, body TEXT NOT NULL)`);
|
|
22
|
-
this.db.exec(`CREATE INDEX IF NOT EXISTS
|
|
22
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS connections_grant ON auth_connections(json_extract(body,'$.grantId'));
|
|
23
|
+
CREATE INDEX IF NOT EXISTS connections_account ON auth_connections(json_extract(body,'$.issuer'),json_extract(body,'$.userId'));
|
|
24
|
+
CREATE INDEX IF NOT EXISTS operations_connection ON auth_operations(json_extract(body,'$.connectionId'));
|
|
23
25
|
CREATE UNIQUE INDEX IF NOT EXISTS operations_callback ON auth_operations(json_extract(body,'$.direct.stateHash'));
|
|
24
26
|
CREATE INDEX IF NOT EXISTS operations_pending ON auth_operations(id) WHERE json_extract(body,'$.phase')!='terminal';
|
|
25
27
|
CREATE INDEX IF NOT EXISTS operations_cleanup ON auth_operations(json_extract(body,'$.createdAt')) WHERE json_extract(body,'$.phase')='terminal' AND json_extract(body,'$.status')!='uncertain';
|
|
@@ -42,6 +44,20 @@ export class SqliteAuthStore {
|
|
|
42
44
|
}
|
|
43
45
|
select(selection) {
|
|
44
46
|
const state = { ...JSON.parse(this.db.prepare('SELECT body FROM auth_meta WHERE id=1').get().body), identities: {}, connections: {}, grants: {}, operations: {}, cleanup: {} };
|
|
47
|
+
if (state.version === 1 || state.version === 2) {
|
|
48
|
+
for (const table of tables)
|
|
49
|
+
for (const row of this.db.prepare(`SELECT id,body FROM auth_${table}`).all())
|
|
50
|
+
state[table][row.id] = JSON.parse(row.body);
|
|
51
|
+
upgradeClientAuthState(state);
|
|
52
|
+
for (const table of tables)
|
|
53
|
+
for (const [id, value] of Object.entries(state[table]))
|
|
54
|
+
this.db.prepare(`UPDATE auth_${table} SET body=? WHERE id=?`).run(json(value), id);
|
|
55
|
+
this.db.prepare('UPDATE auth_meta SET body=? WHERE id=1').run(json({ version: state.version, epoch: state.epoch, owner: state.owner }));
|
|
56
|
+
for (const table of tables)
|
|
57
|
+
state[table] = {};
|
|
58
|
+
}
|
|
59
|
+
if (state.version !== 3)
|
|
60
|
+
throw new Error('unsupported auth state version');
|
|
45
61
|
const get = (table, id) => {
|
|
46
62
|
const row = this.db.prepare(`SELECT body FROM auth_${table} WHERE id=?`).get(id);
|
|
47
63
|
if (row)
|
|
@@ -60,7 +76,11 @@ export class SqliteAuthStore {
|
|
|
60
76
|
history();
|
|
61
77
|
return state;
|
|
62
78
|
}
|
|
63
|
-
if ('
|
|
79
|
+
if ('account' in selection) {
|
|
80
|
+
get('identities', selection.account.issuer);
|
|
81
|
+
rows('connections', "WHERE json_extract(body,'$.issuer')=? AND json_extract(body,'$.userId')=?", selection.account.issuer, selection.account.userId);
|
|
82
|
+
}
|
|
83
|
+
else if ('connectionId' in selection)
|
|
64
84
|
get('connections', selection.connectionId);
|
|
65
85
|
else if ('operationId' in selection)
|
|
66
86
|
get('operations', selection.operationId);
|
|
@@ -70,6 +90,9 @@ export class SqliteAuthStore {
|
|
|
70
90
|
rows('connections', '');
|
|
71
91
|
for (const operation of Object.values(state.operations))
|
|
72
92
|
get('connections', operation.connectionId);
|
|
93
|
+
const linked = new Set([...Object.values(state.connections).map(row => row.grantId), ...Object.values(state.operations).map(row => row.grantId)].filter((id) => id !== null));
|
|
94
|
+
for (const grantId of linked)
|
|
95
|
+
rows('connections', "WHERE json_extract(body,'$.grantId')=?", grantId);
|
|
73
96
|
for (const connection of Object.values(state.connections)) {
|
|
74
97
|
if (connection.grantId)
|
|
75
98
|
get('grants', connection.grantId);
|
|
@@ -85,7 +108,7 @@ export class SqliteAuthStore {
|
|
|
85
108
|
return state;
|
|
86
109
|
}
|
|
87
110
|
async read(selection) {
|
|
88
|
-
this.db.exec('BEGIN');
|
|
111
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
89
112
|
try {
|
|
90
113
|
const state = this.select(selection);
|
|
91
114
|
this.db.exec('COMMIT');
|
package/dist/auth/store.d.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import type { ClientAuthState } from './client-auth.js';
|
|
2
|
-
/**
|
|
2
|
+
/** Include the selected connections, shared-grant peers, and their grants, operations and cleanup. */
|
|
3
3
|
export type AuthSelection = ({
|
|
4
4
|
connectionId: string;
|
|
5
5
|
} | {
|
|
6
6
|
operationId: string;
|
|
7
7
|
} | {
|
|
8
8
|
issuer: string;
|
|
9
|
+
} | {
|
|
10
|
+
account: {
|
|
11
|
+
issuer: string;
|
|
12
|
+
userId: string;
|
|
13
|
+
};
|
|
9
14
|
} | {
|
|
10
15
|
callbackHash: string;
|
|
11
16
|
} | {
|
package/dist/conversation.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ClientMetadata, PoppyDocument } from './network/discovery.js';
|
|
1
2
|
import type { AuthorizationRequirement } from './auth/client-auth.js';
|
|
2
3
|
import type { ConversationEvent } from './protocol/wire.js';
|
|
3
4
|
import type { ConversationStore } from './conversation-store.js';
|
|
@@ -49,6 +50,11 @@ export declare class ConversationError extends Error {
|
|
|
49
50
|
get requiredScopes(): string[];
|
|
50
51
|
}
|
|
51
52
|
export interface ConversationClientOptions {
|
|
53
|
+
/** Verified metadata for both peers; omit until trusted Operations consent/recovery is configured. */
|
|
54
|
+
operations?: {
|
|
55
|
+
client: ClientMetadata;
|
|
56
|
+
company: PoppyDocument;
|
|
57
|
+
};
|
|
52
58
|
authorizedRequest(request: Request): Promise<Response>;
|
|
53
59
|
endpoint: string;
|
|
54
60
|
connectionId: string;
|
package/dist/conversation.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { supportsOperations } from './protocol/operations.js';
|
|
1
2
|
import { parseEventStream } from './protocol/sse.js';
|
|
2
|
-
import { authChallenge } from './auth/challenge.js';
|
|
3
|
+
import { authChallenge, schemeChallenge, dpopChallenge } from './auth/challenge.js';
|
|
3
4
|
import { secureUrl, TransportError } from './network/transport.js';
|
|
4
5
|
import { canonical, id as parseId, parseJson, parseConversationRequest, parseConversationState, parseEventPage, parseMessage, parseProtocolError, retryAfterMilliseconds, ValidationError } from './protocol/wire.js';
|
|
5
6
|
export const initialConversationClientState = () => ({ version: 1, outbound: [], consumers: [] });
|
|
6
7
|
// Only known spec codes (7.13, 6) reach messages and callers; a company-chosen string never does.
|
|
7
|
-
const knownCodes = new Set(['conversation_not_found', 'conversation_closed', 'direct_conversation_open', 'message_id_conflict', 'invalid_cursor', 'cursor_expired', 'invalid_request', 'extension_required', 'rate_limited', 'invalid_token', 'invalid_dpop_proof', 'use_dpop_nonce', '
|
|
8
|
+
const knownCodes = new Set(['conversation_not_found', 'conversation_closed', 'direct_conversation_open', 'message_id_conflict', 'invalid_cursor', 'cursor_expired', 'invalid_request', 'extension_required', 'rate_limited', 'invalid_token', 'invalid_dpop_proof', 'use_dpop_nonce', 'invalid_session', 'insufficient_scope']);
|
|
9
|
+
const knownEvents = new Set(['message', 'state', 'authorization', 'user_requested', 'direct_opened', 'direct_closed']);
|
|
10
|
+
const visiblePage = (page) => ({ ...page, events: page.events.filter(event => knownEvents.has(event.type)) });
|
|
8
11
|
export class ConversationError extends Error {
|
|
9
12
|
code;
|
|
10
13
|
status;
|
|
@@ -30,6 +33,7 @@ export class ConversationError extends Error {
|
|
|
30
33
|
const definite = (status) => status >= 400 && status < 500 && ![401, 403, 408, 429].includes(status);
|
|
31
34
|
export function createConversationClient(options) {
|
|
32
35
|
const { authorizedRequest, store, clock, connectionId, issuer } = options;
|
|
36
|
+
const operations = !!options.operations && supportsOperations(options.operations.client) && supportsOperations(options.operations.company);
|
|
33
37
|
const endpoint = secureUrl(options.endpoint).href;
|
|
34
38
|
const newId = options.newId ?? (() => `msg_${Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64url')}`); // 128 random bits
|
|
35
39
|
const random = options.random ?? Math.random;
|
|
@@ -98,7 +102,8 @@ export function createConversationClient(options) {
|
|
|
98
102
|
catch { /* The header can carry the complete challenge. */ }
|
|
99
103
|
const challenge = authChallenge(response.headers.get('www-authenticate'), body);
|
|
100
104
|
const raw = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
|
|
101
|
-
|
|
105
|
+
const offered = dpopChallenge(response.headers.get('www-authenticate')) ?? schemeChallenge(response.headers.get('www-authenticate'), 'Bearer');
|
|
106
|
+
let code = challenge.error ?? (response.status === 401 && offered !== undefined && offered.error === undefined ? 'insufficient_scope' : undefined) ?? (typeof raw.error === 'string' && knownCodes.has(raw.error) ? raw.error : 'http_error');
|
|
102
107
|
let conversationId;
|
|
103
108
|
try {
|
|
104
109
|
if (challenge.conversationId)
|
|
@@ -139,10 +144,20 @@ export function createConversationClient(options) {
|
|
|
139
144
|
throw new ConversationError('unknown_operation');
|
|
140
145
|
if (record.endpoint !== endpoint)
|
|
141
146
|
throw new ConversationError('invalid_target');
|
|
142
|
-
if (record.phase === 'accepted')
|
|
143
|
-
|
|
147
|
+
if (record.phase === 'accepted') {
|
|
148
|
+
try {
|
|
149
|
+
return record.response && 'events' in record.response ? visiblePage(parseEventPage(record.response, record.read?.cursor, operations)) : parseConversationState(record.response);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
throw new ConversationError('stored_operation_reset_required');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
144
155
|
if (record.phase === 'rejected')
|
|
145
156
|
throw new ConversationError(record.error.code, record.error.status, undefined, undefined, record.error.conversationId);
|
|
157
|
+
// A legacy pending body may already be accepted remotely. Never rewrite or replay it as a new contract.
|
|
158
|
+
if ('sender' in record.request.message)
|
|
159
|
+
throw new ConversationError('legacy_message_uncertain');
|
|
160
|
+
parseConversationRequest(record.request, operations);
|
|
146
161
|
const url = new URL(record.target === null ? endpoint : conversationUrl(record.target, 'messages'));
|
|
147
162
|
if (record.read?.cursor !== undefined)
|
|
148
163
|
url.searchParams.set('cursor', record.read.cursor);
|
|
@@ -154,13 +169,13 @@ export function createConversationClient(options) {
|
|
|
154
169
|
try {
|
|
155
170
|
const body = parseJson(await responseText(response));
|
|
156
171
|
// A 2xx means accepted; a company that ignores wait answers with a plain state.
|
|
157
|
-
state = body && typeof body === 'object' && ['events', 'cursor', 'has_more'].some(key => key in body) ? parseEventPage(body, record.read?.cursor) : parseConversationState(body);
|
|
172
|
+
state = body && typeof body === 'object' && ['events', 'cursor', 'has_more'].some(key => key in body) ? parseEventPage(body, record.read?.cursor, operations) : parseConversationState(body);
|
|
158
173
|
}
|
|
159
174
|
catch {
|
|
160
175
|
throw new ConversationError('invalid_response', response.status);
|
|
161
176
|
}
|
|
162
177
|
await store.settleOutbound(key, id, { phase: 'accepted', response: state });
|
|
163
|
-
return state;
|
|
178
|
+
return 'events' in state ? visiblePage(state) : state;
|
|
164
179
|
}
|
|
165
180
|
const error = await failure(response);
|
|
166
181
|
// direct_conversation_open lasts only until the Direct Conversation closes (spec 7.10), so the same message stays retryable.
|
|
@@ -179,8 +194,8 @@ export function createConversationClient(options) {
|
|
|
179
194
|
if (read.wait === undefined)
|
|
180
195
|
throw new ConversationError('invalid_request');
|
|
181
196
|
} // spec 7.3 adds cursor alongside wait
|
|
182
|
-
const message = parseMessage({ ...input, id: input.id ?? newId() });
|
|
183
|
-
const request = parseConversationRequest({ message, ...(parent === undefined ? {} : { parent_conversation_id: parent }) });
|
|
197
|
+
const message = parseMessage({ ...input, id: input.id ?? newId() }, { operations });
|
|
198
|
+
const request = parseConversationRequest({ message, ...(parent === undefined ? {} : { parent_conversation_id: parent }) }, operations);
|
|
184
199
|
if (Buffer.byteLength(JSON.stringify(request)) > 1048576)
|
|
185
200
|
throw new ConversationError('body_limit');
|
|
186
201
|
const fingerprint = canonical({ target, request });
|
|
@@ -216,7 +231,6 @@ export function createConversationClient(options) {
|
|
|
216
231
|
const maxReconnects = opts.maxReconnects ?? 4;
|
|
217
232
|
if (!Number.isSafeInteger(maxReconnects) || maxReconnects < 0)
|
|
218
233
|
throw new ConversationError('invalid_request');
|
|
219
|
-
const knownEvents = new Set(['message', 'state', 'authorization', 'user_requested', 'direct_opened', 'direct_closed']);
|
|
220
234
|
const seen = new Set(); // Bounded per watch; durable consumers ack explicitly.
|
|
221
235
|
const eventAck = (event, previous) => ({ event, async ack() { if (opts.consumer !== undefined)
|
|
222
236
|
await store.advanceCheckpoint(key, { connectionId, issuer, conversationId, name: opts.consumer, cursor: event.id }, [previous]); } });
|
|
@@ -259,7 +273,7 @@ export function createConversationClient(options) {
|
|
|
259
273
|
if (!response.body)
|
|
260
274
|
throw new ConversationError('invalid_response');
|
|
261
275
|
let partialBytes = 0;
|
|
262
|
-
for await (const frame of parseEventStream(response.body, { signal: opts.signal })) {
|
|
276
|
+
for await (const frame of parseEventStream(response.body, { signal: opts.signal, operations })) {
|
|
263
277
|
if (frame.kind === 'delta') {
|
|
264
278
|
partialBytes += Buffer.byteLength(frame.text);
|
|
265
279
|
if (partialBytes > 1048576)
|
|
@@ -287,7 +301,7 @@ export function createConversationClient(options) {
|
|
|
287
301
|
if (++pages > maxPages)
|
|
288
302
|
throw new ConversationError('too_many_pages');
|
|
289
303
|
const beforePage = cursor;
|
|
290
|
-
const page = parseEventPage(parseJson(await responseText(response)), cursor);
|
|
304
|
+
const page = parseEventPage(parseJson(await responseText(response)), cursor, operations);
|
|
291
305
|
more = page.has_more;
|
|
292
306
|
for (const event of page.events) {
|
|
293
307
|
if (seen.has(event.id))
|
|
@@ -349,7 +363,7 @@ export function createConversationClient(options) {
|
|
|
349
363
|
throw await failure(response); // cursor_expired (410) is the explicit history gap: see resetConsumer
|
|
350
364
|
let page;
|
|
351
365
|
try {
|
|
352
|
-
page = parseEventPage(parseJson(await responseText(response)), cursor);
|
|
366
|
+
page = visiblePage(parseEventPage(parseJson(await responseText(response)), cursor, operations));
|
|
353
367
|
}
|
|
354
368
|
catch {
|
|
355
369
|
throw new ConversationError('invalid_response', response.status);
|
|
@@ -373,7 +387,7 @@ export function createConversationClient(options) {
|
|
|
373
387
|
if (!page.has_more)
|
|
374
388
|
return pages;
|
|
375
389
|
// has_more with no new events or an unchanged cursor can never finish.
|
|
376
|
-
if (
|
|
390
|
+
if (page.cursor === null || page.cursor === cursor)
|
|
377
391
|
throw new ConversationError('invalid_response');
|
|
378
392
|
cursor = page.cursor;
|
|
379
393
|
chain.push(cursor);
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export { createClientAuth, initialClientAuthState, AuthFailure } from './auth/cl
|
|
|
2
2
|
export type { ClientAuth, ClientAuthOptions, SignOutOutcome, ClientAuthState, AuthStore, AuthSelection, SecretStore, IdentitySigner, Connection, ConnectionStatus, AuthOutcome, OperationStatus, ConversationTarget } from './auth/client-auth.js';
|
|
3
3
|
export { createNodeTransport } from './network/transport.js';
|
|
4
4
|
export type { HttpTransport, NetworkOptions } from './network/transport.js';
|
|
5
|
-
export { createMetadataResolver } from './network/discovery.js';
|
|
5
|
+
export { createMetadataResolver, signInMethods } from './network/discovery.js';
|
|
6
6
|
export type { MetadataResolver } from './network/discovery.js';
|
|
7
7
|
export { createConversationClient, initialConversationClientState, ConversationError } from './conversation.js';
|
|
8
8
|
export type { ConversationClientOptions, ConsumerCheckpoint, ConversationClientState, OutboundInput, OutboundRecord, ReadOptions, ReadParams } from './conversation.js';
|
|
@@ -16,3 +16,8 @@ export { authChallenge } from './auth/challenge.js';
|
|
|
16
16
|
export type { AuthChallenge } from './auth/challenge.js';
|
|
17
17
|
export type { ApiTarget, AgentBrowserController, FeedbackReport } from './integrations.js';
|
|
18
18
|
export { protectedResourceMetadataUrl } from './integrations.js';
|
|
19
|
+
export type { Author, AuthorType, Message, RecordedMessage, ConversationRequest, ConversationState, ConversationEvent, KnownEvent, EventPage } from './protocol/wire.js';
|
|
20
|
+
export type { Alternative, AlternativeReference, ExcludedTask } from './network/tasks.js';
|
|
21
|
+
export type { ScopeHint } from './network/discovery.js';
|
|
22
|
+
export * from './operations.js';
|
|
23
|
+
export * from './protocol/operations.js';
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export { createClientAuth, initialClientAuthState, AuthFailure } from './auth/client-auth.js';
|
|
2
2
|
export { createNodeTransport } from './network/transport.js';
|
|
3
|
-
export { createMetadataResolver } from './network/discovery.js';
|
|
3
|
+
export { createMetadataResolver, signInMethods } from './network/discovery.js';
|
|
4
4
|
export { createConversationClient, initialConversationClientState, ConversationError } from './conversation.js';
|
|
5
5
|
export { parseEventStream } from './protocol/sse.js';
|
|
6
6
|
export { authChallenge } from './auth/challenge.js';
|
|
7
7
|
export { protectedResourceMetadataUrl } from './integrations.js';
|
|
8
|
+
export * from './operations.js';
|
|
9
|
+
export * from './protocol/operations.js';
|
package/dist/integrations.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { protectedResourceMetadataUrl } from './network/discovery.js';
|
|
1
2
|
import type { CompanyMetadata, MetadataResolver } from './network/discovery.js';
|
|
2
3
|
import type { HttpTransport } from './network/transport.js';
|
|
3
4
|
export interface ApiTarget {
|
|
@@ -10,21 +11,32 @@ export interface ApiTarget {
|
|
|
10
11
|
}
|
|
11
12
|
export interface AgentBrowserController {
|
|
12
13
|
readonly kind: 'agent-controlled';
|
|
14
|
+
loadJoinForm(url: string): Promise<{
|
|
15
|
+
code: string;
|
|
16
|
+
}>;
|
|
13
17
|
postForm(input: {
|
|
14
18
|
url: string;
|
|
15
19
|
fields: {
|
|
16
|
-
|
|
20
|
+
code: string;
|
|
17
21
|
};
|
|
18
22
|
validateRedirect(status: number, location: string): string;
|
|
19
23
|
}): Promise<void>;
|
|
24
|
+
navigate(url: string): Promise<void>;
|
|
20
25
|
}
|
|
21
26
|
export interface FeedbackReport {
|
|
22
|
-
kind: 'missing';
|
|
23
27
|
task: string;
|
|
24
28
|
description: string;
|
|
29
|
+
[key: string]: unknown;
|
|
25
30
|
}
|
|
26
31
|
export declare function validateFeedback(value: FeedbackReport): void;
|
|
27
32
|
export declare function browserDestination(value: string, domain: string): void;
|
|
28
|
-
export
|
|
29
|
-
export declare function
|
|
33
|
+
export { protectedResourceMetadataUrl };
|
|
34
|
+
export declare function serviceAuthorization(metadata: CompanyMetadata, resolver: MetadataResolver, transport: HttpTransport, endpoint: string, options?: {
|
|
35
|
+
mcp?: boolean;
|
|
36
|
+
metadataUrl?: string;
|
|
37
|
+
refresh?: boolean;
|
|
38
|
+
}): Promise<{
|
|
39
|
+
resource?: string;
|
|
40
|
+
scheme: 'DPoP' | 'Bearer';
|
|
41
|
+
}>;
|
|
30
42
|
export declare function resolveApiTarget(metadata: CompanyMetadata, resolver: MetadataResolver, transport: HttpTransport, apiUrl: string, destination?: string): Promise<ApiTarget>;
|
package/dist/integrations.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { companyIdentifier, createMetadataResolver, protectedResourceMetadataUrl } from './network/discovery.js';
|
|
1
2
|
import { secureUrl } from './network/transport.js';
|
|
2
|
-
import {
|
|
3
|
+
import { ValidationError } from './protocol/wire.js';
|
|
3
4
|
export function validateFeedback(value) {
|
|
4
|
-
if (!value ||
|
|
5
|
+
if (!value || typeof value.task !== 'string' || !value.task.length || [...value.task].length > 200 || typeof value.description !== 'string' || !value.description.length || [...value.description].length > 2000)
|
|
5
6
|
throw new ValidationError('feedback');
|
|
6
7
|
}
|
|
7
8
|
export function browserDestination(value, domain) {
|
|
@@ -9,50 +10,41 @@ export function browserDestination(value, domain) {
|
|
|
9
10
|
if (url.hostname !== domain.toLowerCase() && !url.hostname.endsWith(`.${domain.toLowerCase()}`))
|
|
10
11
|
throw new ValidationError('return_to');
|
|
11
12
|
}
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
export { protectedResourceMetadataUrl };
|
|
14
|
+
// Discovery is credential-free; a network or malformed-metadata failure never means default authority.
|
|
15
|
+
export async function serviceAuthorization(metadata, resolver, transport, endpoint, options = {}) {
|
|
16
|
+
secureUrl(endpoint);
|
|
17
|
+
const found = await (resolver.resolveResource ? resolver : createMetadataResolver(transport)).resolveResource(endpoint, options);
|
|
18
|
+
const domain = `https://${metadata.document.organization.domain.toLowerCase()}`, issuer = metadata.authorization?.issuer;
|
|
19
|
+
if (found) {
|
|
20
|
+
if (found.resource !== endpoint && found.resource !== domain)
|
|
21
|
+
throw new ValidationError('resource');
|
|
22
|
+
if (issuer && (options.mcp || found.authorization_servers?.length) && !found.authorization_servers?.includes(issuer))
|
|
23
|
+
throw new ValidationError('resource_issuer');
|
|
24
|
+
if (!issuer && found.authorization_servers?.length)
|
|
25
|
+
throw new ValidationError('resource_issuer');
|
|
26
|
+
if (Array.isArray(found.bearer_methods_supported) && !found.bearer_methods_supported.includes('header'))
|
|
27
|
+
throw new ValidationError('bearer_methods_supported');
|
|
28
|
+
}
|
|
29
|
+
if (options.mcp && issuer && (!found || found.resource !== endpoint))
|
|
15
30
|
throw new ValidationError('resource');
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
export async function mcpScheme(transport, issuer, resource) {
|
|
19
|
-
const response = await transport.fetch(new Request(protectedResourceMetadataUrl(resource)), { mode: 'discovery' });
|
|
20
|
-
if (response.status !== 200)
|
|
21
|
-
throw new ValidationError('resource_metadata');
|
|
22
|
-
const metadata = object(parseJson(await response.text()));
|
|
23
|
-
if (metadata.resource !== resource || !Array.isArray(metadata.authorization_servers) || !metadata.authorization_servers.includes(issuer))
|
|
24
|
-
throw new ValidationError('resource_issuer');
|
|
25
|
-
for (const server of metadata.authorization_servers)
|
|
26
|
-
secureUrl(server);
|
|
27
|
-
if ('bearer_methods_supported' in metadata && (!Array.isArray(metadata.bearer_methods_supported) || !metadata.bearer_methods_supported.includes('header')))
|
|
28
|
-
throw new ValidationError('bearer_methods_supported');
|
|
29
|
-
if (!Object.hasOwn(metadata, 'dpop_signing_alg_values_supported'))
|
|
30
|
-
return 'Bearer';
|
|
31
|
-
if (!Array.isArray(metadata.dpop_signing_alg_values_supported) || !metadata.dpop_signing_alg_values_supported.includes('ES256') || !metadata.dpop_signing_alg_values_supported.every(value => typeof value === 'string'))
|
|
32
|
-
throw new ValidationError('dpop_signing_alg_values_supported');
|
|
33
|
-
return 'DPoP';
|
|
31
|
+
const algorithms = options.mcp ? found?.dpop_signing_alg_values_supported : metadata.authorization?.dpop_signing_alg_values_supported;
|
|
32
|
+
return { ...(options.mcp ? { resource: endpoint } : found && found.resource !== domain ? { resource: found.resource } : {}), scheme: algorithms?.includes('ES256') ? 'DPoP' : 'Bearer' };
|
|
34
33
|
}
|
|
35
34
|
export async function resolveApiTarget(metadata, resolver, transport, apiUrl, destination) {
|
|
36
|
-
const api = metadata.document.apis?.find(entry => entry.url === apiUrl);
|
|
37
|
-
|
|
38
|
-
if (!api || !issuer || !['openapi', 'mcp'].includes(api.type))
|
|
35
|
+
const api = metadata.document.apis?.find(entry => entry.url === apiUrl), issuer = companyIdentifier(metadata);
|
|
36
|
+
if (!api || !['openapi', 'mcp'].includes(api.type))
|
|
39
37
|
throw new ValidationError('api');
|
|
40
38
|
if (api.type === 'mcp') {
|
|
41
|
-
if (
|
|
39
|
+
if (destination !== undefined && destination !== api.url)
|
|
42
40
|
throw new ValidationError('resource');
|
|
43
|
-
|
|
44
|
-
if (scheme === 'Bearer' && metadata.document.agent?.protocols.some(protocol => protocol.resource === api.url))
|
|
45
|
-
throw new ValidationError('resource');
|
|
46
|
-
return { kind: 'mcp', apiUrl, issuer, endpoint: api.url, resource: api.url, scheme };
|
|
41
|
+
return { kind: 'mcp', apiUrl, issuer, endpoint: api.url, ...await serviceAuthorization(metadata, resolver, transport, api.url, { mcp: true }) };
|
|
47
42
|
}
|
|
48
|
-
//
|
|
43
|
+
// A description is data. The destination host independently vouches for the same Company.
|
|
49
44
|
if (!destination)
|
|
50
45
|
throw new ValidationError('destination');
|
|
51
|
-
const url = secureUrl(destination);
|
|
52
|
-
if (url.
|
|
53
|
-
throw new ValidationError('destination');
|
|
54
|
-
const serving = await resolver.resolveCompany(url.host);
|
|
55
|
-
if (serving.authorization?.issuer !== issuer || serving.document.organization.domain !== url.hostname)
|
|
46
|
+
const url = secureUrl(destination), serving = await resolver.resolveCompany(url.host);
|
|
47
|
+
if (companyIdentifier(serving) !== issuer || serving.document.organization.domain.toLowerCase() !== url.hostname)
|
|
56
48
|
throw new ValidationError('destination_issuer');
|
|
57
|
-
return { kind: 'openapi', apiUrl, issuer, endpoint: destination, ...(
|
|
49
|
+
return { kind: 'openapi', apiUrl, issuer, endpoint: destination, ...await serviceAuthorization(metadata, resolver, transport, destination) };
|
|
58
50
|
}
|
|
@@ -1,36 +1,26 @@
|
|
|
1
1
|
import type { JsonObject } from '../protocol/wire.js';
|
|
2
2
|
import type { HttpTransport } from './transport.js';
|
|
3
|
+
import type { ExcludedTask } from './tasks.js';
|
|
4
|
+
export interface ScopeHint {
|
|
5
|
+
changes: 'none' | 'reversible' | 'irreversible';
|
|
6
|
+
personal_data: boolean;
|
|
7
|
+
}
|
|
3
8
|
export interface PoppyDocument {
|
|
4
|
-
protocol_version: '0.
|
|
9
|
+
protocol_version: '0.3';
|
|
5
10
|
organization: {
|
|
6
11
|
name: string;
|
|
7
12
|
domain: string;
|
|
8
13
|
};
|
|
9
14
|
auth?: {
|
|
10
|
-
issuer
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
};
|
|
14
|
-
device?: {
|
|
15
|
-
scopes: string[];
|
|
16
|
-
};
|
|
17
|
-
mediated?: {
|
|
18
|
-
endpoint: string;
|
|
19
|
-
fields: {
|
|
20
|
-
name: string;
|
|
21
|
-
label: string;
|
|
22
|
-
secret: boolean;
|
|
23
|
-
}[];
|
|
24
|
-
scopes: string[];
|
|
25
|
-
};
|
|
26
|
-
custom_scopes?: Record<string, string>;
|
|
15
|
+
issuer?: string;
|
|
16
|
+
scopes?: Record<string, string>;
|
|
17
|
+
scope_hints?: Record<string, ScopeHint>;
|
|
27
18
|
[key: string]: unknown;
|
|
28
19
|
};
|
|
29
20
|
agent?: {
|
|
30
21
|
protocols: {
|
|
31
22
|
type: string;
|
|
32
23
|
endpoint: string;
|
|
33
|
-
resource?: string;
|
|
34
24
|
[key: string]: unknown;
|
|
35
25
|
}[];
|
|
36
26
|
};
|
|
@@ -38,13 +28,15 @@ export interface PoppyDocument {
|
|
|
38
28
|
type: string;
|
|
39
29
|
url: string;
|
|
40
30
|
description: string;
|
|
41
|
-
resource?: string;
|
|
42
31
|
[key: string]: unknown;
|
|
43
32
|
}[];
|
|
44
33
|
web?: {
|
|
45
34
|
browser_session_endpoint?: string;
|
|
46
35
|
[key: string]: unknown;
|
|
47
36
|
};
|
|
37
|
+
excluded_tasks?: {
|
|
38
|
+
url: string;
|
|
39
|
+
};
|
|
48
40
|
feedback?: {
|
|
49
41
|
endpoint: string;
|
|
50
42
|
[key: string]: unknown;
|
|
@@ -60,7 +52,9 @@ export interface AuthorizationMetadata {
|
|
|
60
52
|
issuer: string;
|
|
61
53
|
token_endpoint: string;
|
|
62
54
|
revocation_endpoint: string;
|
|
63
|
-
|
|
55
|
+
protected_resources: string[];
|
|
56
|
+
grant_types_supported: string[];
|
|
57
|
+
scopes_supported?: string[];
|
|
64
58
|
authorization_endpoint?: string;
|
|
65
59
|
device_authorization_endpoint?: string;
|
|
66
60
|
dpop_signing_alg_values_supported?: string[];
|
|
@@ -73,6 +67,10 @@ export interface ClientMetadata {
|
|
|
73
67
|
jwks_uri: string;
|
|
74
68
|
redirect_uris: string[];
|
|
75
69
|
token_endpoint_auth_method: 'private_key_jwt';
|
|
70
|
+
extensions?: Record<string, {
|
|
71
|
+
version: string;
|
|
72
|
+
[key: string]: unknown;
|
|
73
|
+
}>;
|
|
76
74
|
[key: string]: unknown;
|
|
77
75
|
}
|
|
78
76
|
export interface CompanyMetadata {
|
|
@@ -82,16 +80,47 @@ export interface CompanyMetadata {
|
|
|
82
80
|
}
|
|
83
81
|
export interface MetadataResolver {
|
|
84
82
|
resolveCompany(domain: string): Promise<CompanyMetadata>;
|
|
83
|
+
resolveResource?(resource: string, options?: {
|
|
84
|
+
metadataUrl?: string;
|
|
85
|
+
refresh?: boolean;
|
|
86
|
+
}): Promise<ProtectedResourceMetadata | null>;
|
|
85
87
|
resolveClient(clientId: string): Promise<ClientMetadata>;
|
|
86
88
|
resolveJwks(uri: string, options?: {
|
|
87
89
|
refresh?: boolean;
|
|
88
90
|
}): Promise<JsonObject>;
|
|
91
|
+
resolveExcludedTasks?(document: PoppyDocument): Promise<ExcludedTask[]>;
|
|
89
92
|
invalidate?(target?: string): void;
|
|
90
93
|
}
|
|
94
|
+
export declare function companyScopes(value: unknown, allowEmpty?: boolean): string[];
|
|
91
95
|
export declare function authorizationMetadataUrl(issuer: string): string;
|
|
92
96
|
export declare function validateClientId(clientId: string): URL;
|
|
93
97
|
export declare function parsePoppyDocument(value: unknown, servingUrl: string): PoppyDocument;
|
|
94
|
-
export declare function parseAuthorizationMetadata(value: unknown, document: PoppyDocument): AuthorizationMetadata;
|
|
98
|
+
export declare function parseAuthorizationMetadata(value: unknown, document: PoppyDocument, issuer?: string | undefined): AuthorizationMetadata;
|
|
99
|
+
export declare function signInMethods(metadata: CompanyMetadata): {
|
|
100
|
+
method: 'direct' | 'device' | 'mediated';
|
|
101
|
+
scopes: string[];
|
|
102
|
+
}[];
|
|
103
|
+
export declare const MEDIATED_GRANT = "https://personalagentprotocol.org/grant/mediated-sign-in";
|
|
104
|
+
export interface MediatedSignIn {
|
|
105
|
+
version: '1';
|
|
106
|
+
fields: {
|
|
107
|
+
name: string;
|
|
108
|
+
label: string;
|
|
109
|
+
secret: boolean;
|
|
110
|
+
}[];
|
|
111
|
+
scopes: string[];
|
|
112
|
+
}
|
|
113
|
+
export declare function parseMediatedSignIn(value: unknown): MediatedSignIn;
|
|
114
|
+
export declare function mediatedSignIn(document: PoppyDocument): MediatedSignIn | undefined;
|
|
115
|
+
export declare function companyIdentifier(metadata: CompanyMetadata): string;
|
|
116
|
+
export interface ProtectedResourceMetadata {
|
|
117
|
+
resource: string;
|
|
118
|
+
authorization_servers?: string[];
|
|
119
|
+
dpop_signing_alg_values_supported?: string[];
|
|
120
|
+
[key: string]: unknown;
|
|
121
|
+
}
|
|
122
|
+
export declare function protectedResourceMetadataUrl(resource: string): string;
|
|
123
|
+
export declare function parseProtectedResourceMetadata(value: unknown): ProtectedResourceMetadata;
|
|
95
124
|
export declare function parseClientMetadata(value: unknown, clientId: string): ClientMetadata;
|
|
96
125
|
export declare function createMetadataResolver(transport: HttpTransport, options?: {
|
|
97
126
|
now?: () => number;
|