@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.
- package/API.md +9 -1
- package/AUTH.md +97 -90
- package/CHANGELOG.md +20 -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 +20 -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-runtime.js +12 -4
- 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
|
@@ -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,7 +44,7 @@ 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: {} };
|
|
45
|
-
if (state.version === 1) {
|
|
47
|
+
if (state.version === 1 || state.version === 2) {
|
|
46
48
|
for (const table of tables)
|
|
47
49
|
for (const row of this.db.prepare(`SELECT id,body FROM auth_${table}`).all())
|
|
48
50
|
state[table][row.id] = JSON.parse(row.body);
|
|
@@ -54,7 +56,7 @@ export class SqliteAuthStore {
|
|
|
54
56
|
for (const table of tables)
|
|
55
57
|
state[table] = {};
|
|
56
58
|
}
|
|
57
|
-
if (state.version !==
|
|
59
|
+
if (state.version !== 3)
|
|
58
60
|
throw new Error('unsupported auth state version');
|
|
59
61
|
const get = (table, id) => {
|
|
60
62
|
const row = this.db.prepare(`SELECT body FROM auth_${table} WHERE id=?`).get(id);
|
|
@@ -74,7 +76,11 @@ export class SqliteAuthStore {
|
|
|
74
76
|
history();
|
|
75
77
|
return state;
|
|
76
78
|
}
|
|
77
|
-
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)
|
|
78
84
|
get('connections', selection.connectionId);
|
|
79
85
|
else if ('operationId' in selection)
|
|
80
86
|
get('operations', selection.operationId);
|
|
@@ -84,6 +90,9 @@ export class SqliteAuthStore {
|
|
|
84
90
|
rows('connections', '');
|
|
85
91
|
for (const operation of Object.values(state.operations))
|
|
86
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);
|
|
87
96
|
for (const connection of Object.values(state.connections)) {
|
|
88
97
|
if (connection.grantId)
|
|
89
98
|
get('grants', connection.grantId);
|
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)
|
|
@@ -141,7 +146,7 @@ export function createConversationClient(options) {
|
|
|
141
146
|
throw new ConversationError('invalid_target');
|
|
142
147
|
if (record.phase === 'accepted') {
|
|
143
148
|
try {
|
|
144
|
-
return record.response && 'events' in record.response ? parseEventPage(record.response, record.read?.cursor) : parseConversationState(record.response);
|
|
149
|
+
return record.response && 'events' in record.response ? visiblePage(parseEventPage(record.response, record.read?.cursor, operations)) : parseConversationState(record.response);
|
|
145
150
|
}
|
|
146
151
|
catch {
|
|
147
152
|
throw new ConversationError('stored_operation_reset_required');
|
|
@@ -152,7 +157,7 @@ export function createConversationClient(options) {
|
|
|
152
157
|
// A legacy pending body may already be accepted remotely. Never rewrite or replay it as a new contract.
|
|
153
158
|
if ('sender' in record.request.message)
|
|
154
159
|
throw new ConversationError('legacy_message_uncertain');
|
|
155
|
-
parseConversationRequest(record.request);
|
|
160
|
+
parseConversationRequest(record.request, operations);
|
|
156
161
|
const url = new URL(record.target === null ? endpoint : conversationUrl(record.target, 'messages'));
|
|
157
162
|
if (record.read?.cursor !== undefined)
|
|
158
163
|
url.searchParams.set('cursor', record.read.cursor);
|
|
@@ -164,13 +169,13 @@ export function createConversationClient(options) {
|
|
|
164
169
|
try {
|
|
165
170
|
const body = parseJson(await responseText(response));
|
|
166
171
|
// A 2xx means accepted; a company that ignores wait answers with a plain state.
|
|
167
|
-
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);
|
|
168
173
|
}
|
|
169
174
|
catch {
|
|
170
175
|
throw new ConversationError('invalid_response', response.status);
|
|
171
176
|
}
|
|
172
177
|
await store.settleOutbound(key, id, { phase: 'accepted', response: state });
|
|
173
|
-
return state;
|
|
178
|
+
return 'events' in state ? visiblePage(state) : state;
|
|
174
179
|
}
|
|
175
180
|
const error = await failure(response);
|
|
176
181
|
// direct_conversation_open lasts only until the Direct Conversation closes (spec 7.10), so the same message stays retryable.
|
|
@@ -189,8 +194,8 @@ export function createConversationClient(options) {
|
|
|
189
194
|
if (read.wait === undefined)
|
|
190
195
|
throw new ConversationError('invalid_request');
|
|
191
196
|
} // spec 7.3 adds cursor alongside wait
|
|
192
|
-
const message = parseMessage({ ...input, id: input.id ?? newId() });
|
|
193
|
-
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);
|
|
194
199
|
if (Buffer.byteLength(JSON.stringify(request)) > 1048576)
|
|
195
200
|
throw new ConversationError('body_limit');
|
|
196
201
|
const fingerprint = canonical({ target, request });
|
|
@@ -226,7 +231,6 @@ export function createConversationClient(options) {
|
|
|
226
231
|
const maxReconnects = opts.maxReconnects ?? 4;
|
|
227
232
|
if (!Number.isSafeInteger(maxReconnects) || maxReconnects < 0)
|
|
228
233
|
throw new ConversationError('invalid_request');
|
|
229
|
-
const knownEvents = new Set(['message', 'state', 'authorization', 'user_requested', 'direct_opened', 'direct_closed']);
|
|
230
234
|
const seen = new Set(); // Bounded per watch; durable consumers ack explicitly.
|
|
231
235
|
const eventAck = (event, previous) => ({ event, async ack() { if (opts.consumer !== undefined)
|
|
232
236
|
await store.advanceCheckpoint(key, { connectionId, issuer, conversationId, name: opts.consumer, cursor: event.id }, [previous]); } });
|
|
@@ -269,7 +273,7 @@ export function createConversationClient(options) {
|
|
|
269
273
|
if (!response.body)
|
|
270
274
|
throw new ConversationError('invalid_response');
|
|
271
275
|
let partialBytes = 0;
|
|
272
|
-
for await (const frame of parseEventStream(response.body, { signal: opts.signal })) {
|
|
276
|
+
for await (const frame of parseEventStream(response.body, { signal: opts.signal, operations })) {
|
|
273
277
|
if (frame.kind === 'delta') {
|
|
274
278
|
partialBytes += Buffer.byteLength(frame.text);
|
|
275
279
|
if (partialBytes > 1048576)
|
|
@@ -297,7 +301,7 @@ export function createConversationClient(options) {
|
|
|
297
301
|
if (++pages > maxPages)
|
|
298
302
|
throw new ConversationError('too_many_pages');
|
|
299
303
|
const beforePage = cursor;
|
|
300
|
-
const page = parseEventPage(parseJson(await responseText(response)), cursor);
|
|
304
|
+
const page = parseEventPage(parseJson(await responseText(response)), cursor, operations);
|
|
301
305
|
more = page.has_more;
|
|
302
306
|
for (const event of page.events) {
|
|
303
307
|
if (seen.has(event.id))
|
|
@@ -359,7 +363,7 @@ export function createConversationClient(options) {
|
|
|
359
363
|
throw await failure(response); // cursor_expired (410) is the explicit history gap: see resetConsumer
|
|
360
364
|
let page;
|
|
361
365
|
try {
|
|
362
|
-
page = parseEventPage(parseJson(await responseText(response)), cursor);
|
|
366
|
+
page = visiblePage(parseEventPage(parseJson(await responseText(response)), cursor, operations));
|
|
363
367
|
}
|
|
364
368
|
catch {
|
|
365
369
|
throw new ConversationError('invalid_response', response.status);
|
|
@@ -383,7 +387,7 @@ export function createConversationClient(options) {
|
|
|
383
387
|
if (!page.has_more)
|
|
384
388
|
return pages;
|
|
385
389
|
// has_more with no new events or an unchanged cursor can never finish.
|
|
386
|
-
if (
|
|
390
|
+
if (page.cursor === null || page.cursor === cursor)
|
|
387
391
|
throw new ConversationError('invalid_response');
|
|
388
392
|
cursor = page.cursor;
|
|
389
393
|
chain.push(cursor);
|
package/dist/index.d.ts
CHANGED
|
@@ -19,3 +19,5 @@ export { protectedResourceMetadataUrl } from './integrations.js';
|
|
|
19
19
|
export type { Author, AuthorType, Message, RecordedMessage, ConversationRequest, ConversationState, ConversationEvent, KnownEvent, EventPage } from './protocol/wire.js';
|
|
20
20
|
export type { Alternative, AlternativeReference, ExcludedTask } from './network/tasks.js';
|
|
21
21
|
export type { ScopeHint } from './network/discovery.js';
|
|
22
|
+
export * from './operations.js';
|
|
23
|
+
export * from './protocol/operations.js';
|
package/dist/index.js
CHANGED
|
@@ -5,3 +5,5 @@ export { createConversationClient, initialConversationClientState, ConversationE
|
|
|
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 {
|
|
@@ -17,19 +18,25 @@ export interface AgentBrowserController {
|
|
|
17
18
|
url: string;
|
|
18
19
|
fields: {
|
|
19
20
|
code: string;
|
|
20
|
-
proof: string;
|
|
21
21
|
};
|
|
22
22
|
validateRedirect(status: number, location: string): string;
|
|
23
23
|
}): Promise<void>;
|
|
24
24
|
navigate(url: string): Promise<void>;
|
|
25
25
|
}
|
|
26
26
|
export interface FeedbackReport {
|
|
27
|
-
kind: 'missing';
|
|
28
27
|
task: string;
|
|
29
28
|
description: string;
|
|
29
|
+
[key: string]: unknown;
|
|
30
30
|
}
|
|
31
31
|
export declare function validateFeedback(value: FeedbackReport): void;
|
|
32
32
|
export declare function browserDestination(value: string, domain: string): void;
|
|
33
|
-
export
|
|
34
|
-
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
|
+
}>;
|
|
35
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
|
}
|
|
@@ -12,16 +12,7 @@ export interface PoppyDocument {
|
|
|
12
12
|
domain: string;
|
|
13
13
|
};
|
|
14
14
|
auth?: {
|
|
15
|
-
issuer
|
|
16
|
-
mediated?: {
|
|
17
|
-
endpoint: string;
|
|
18
|
-
fields: {
|
|
19
|
-
name: string;
|
|
20
|
-
label: string;
|
|
21
|
-
secret: boolean;
|
|
22
|
-
}[];
|
|
23
|
-
scopes: string[];
|
|
24
|
-
};
|
|
15
|
+
issuer?: string;
|
|
25
16
|
scopes?: Record<string, string>;
|
|
26
17
|
scope_hints?: Record<string, ScopeHint>;
|
|
27
18
|
[key: string]: unknown;
|
|
@@ -30,7 +21,6 @@ export interface PoppyDocument {
|
|
|
30
21
|
protocols: {
|
|
31
22
|
type: string;
|
|
32
23
|
endpoint: string;
|
|
33
|
-
resource?: string;
|
|
34
24
|
[key: string]: unknown;
|
|
35
25
|
}[];
|
|
36
26
|
};
|
|
@@ -38,7 +28,6 @@ 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?: {
|
|
@@ -78,6 +67,10 @@ export interface ClientMetadata {
|
|
|
78
67
|
jwks_uri: string;
|
|
79
68
|
redirect_uris: string[];
|
|
80
69
|
token_endpoint_auth_method: 'private_key_jwt';
|
|
70
|
+
extensions?: Record<string, {
|
|
71
|
+
version: string;
|
|
72
|
+
[key: string]: unknown;
|
|
73
|
+
}>;
|
|
81
74
|
[key: string]: unknown;
|
|
82
75
|
}
|
|
83
76
|
export interface CompanyMetadata {
|
|
@@ -87,6 +80,10 @@ export interface CompanyMetadata {
|
|
|
87
80
|
}
|
|
88
81
|
export interface MetadataResolver {
|
|
89
82
|
resolveCompany(domain: string): Promise<CompanyMetadata>;
|
|
83
|
+
resolveResource?(resource: string, options?: {
|
|
84
|
+
metadataUrl?: string;
|
|
85
|
+
refresh?: boolean;
|
|
86
|
+
}): Promise<ProtectedResourceMetadata | null>;
|
|
90
87
|
resolveClient(clientId: string): Promise<ClientMetadata>;
|
|
91
88
|
resolveJwks(uri: string, options?: {
|
|
92
89
|
refresh?: boolean;
|
|
@@ -98,11 +95,32 @@ export declare function companyScopes(value: unknown, allowEmpty?: boolean): str
|
|
|
98
95
|
export declare function authorizationMetadataUrl(issuer: string): string;
|
|
99
96
|
export declare function validateClientId(clientId: string): URL;
|
|
100
97
|
export declare function parsePoppyDocument(value: unknown, servingUrl: string): PoppyDocument;
|
|
101
|
-
export declare function parseAuthorizationMetadata(value: unknown, document: PoppyDocument): AuthorizationMetadata;
|
|
98
|
+
export declare function parseAuthorizationMetadata(value: unknown, document: PoppyDocument, issuer?: string | undefined): AuthorizationMetadata;
|
|
102
99
|
export declare function signInMethods(metadata: CompanyMetadata): {
|
|
103
100
|
method: 'direct' | 'device' | 'mediated';
|
|
104
101
|
scopes: string[];
|
|
105
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;
|
|
106
124
|
export declare function parseClientMetadata(value: unknown, clientId: string): ClientMetadata;
|
|
107
125
|
export declare function createMetadataResolver(transport: HttpTransport, options?: {
|
|
108
126
|
now?: () => number;
|