@poppykit/client 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/API.md +9 -1
  2. package/AUTH.md +97 -90
  3. package/CHANGELOG.md +13 -1
  4. package/CONFORMANCE.md +73 -0
  5. package/CONTRIBUTING.md +12 -11
  6. package/DELIVERY.md +19 -3
  7. package/INTEGRATIONS.md +35 -23
  8. package/OPERATIONS.md +120 -0
  9. package/PROVIDER.md +20 -12
  10. package/README.md +6 -17
  11. package/RELEASING.md +2 -0
  12. package/SECURITY.md +2 -0
  13. package/STORAGE.md +14 -7
  14. package/dist/adapter-contract.d.ts +6 -0
  15. package/dist/adapter-contract.js +30 -0
  16. package/dist/auth/challenge.d.ts +2 -0
  17. package/dist/auth/challenge.js +8 -8
  18. package/dist/auth/client-auth.d.ts +20 -7
  19. package/dist/auth/client-auth.js +386 -334
  20. package/dist/auth/sqlite-store.js +13 -4
  21. package/dist/auth/store.d.ts +6 -1
  22. package/dist/conversation.d.ts +6 -0
  23. package/dist/conversation.js +18 -14
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.js +2 -0
  26. package/dist/integrations.d.ts +11 -4
  27. package/dist/integrations.js +30 -38
  28. package/dist/network/discovery.d.ts +31 -13
  29. package/dist/network/discovery.js +103 -66
  30. package/dist/operations.d.ts +103 -0
  31. package/dist/operations.js +261 -0
  32. package/dist/postgres.d.ts +11 -0
  33. package/dist/postgres.js +40 -3
  34. package/dist/protocol/operations.d.ts +51 -0
  35. package/dist/protocol/operations.js +98 -0
  36. package/dist/protocol/session.d.ts +28 -0
  37. package/dist/protocol/session.js +93 -0
  38. package/dist/protocol/sse.d.ts +1 -0
  39. package/dist/protocol/sse.js +1 -1
  40. package/dist/protocol/wire.d.ts +8 -4
  41. package/dist/protocol/wire.js +37 -15
  42. package/dist/provider-postgres.js +3 -3
  43. package/dist/provider-storage.js +4 -4
  44. package/dist/provider.d.ts +26 -3
  45. package/dist/provider.js +94 -13
  46. package/dist/sqlite.d.ts +12 -0
  47. package/dist/sqlite.js +42 -0
  48. package/examples/operations.mjs +25 -0
  49. package/examples/quickstart.mjs +1 -0
  50. package/package.json +5 -2
@@ -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 operations_connection ON auth_operations(json_extract(body,'$.connectionId'));
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 !== 2)
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 ('connectionId' in selection)
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);
@@ -1,11 +1,16 @@
1
1
  import type { ClientAuthState } from './client-auth.js';
2
- /** A connection aggregate includes its grants, operations and cleanup; never other connections. */
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
  } | {
@@ -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;
@@ -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', 'sign_in_required', 'insufficient_scope']);
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
- let code = challenge.error ?? (typeof raw.error === 'string' && knownCodes.has(raw.error) ? raw.error : 'http_error');
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 (!page.events.length || page.cursor === null || page.cursor === cursor)
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';
@@ -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 declare function protectedResourceMetadataUrl(resource: string): string;
34
- export declare function mcpScheme(transport: HttpTransport, issuer: string, resource: string): Promise<'DPoP' | 'Bearer'>;
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>;
@@ -1,7 +1,8 @@
1
+ import { companyIdentifier, createMetadataResolver, protectedResourceMetadataUrl } from './network/discovery.js';
1
2
  import { secureUrl } from './network/transport.js';
2
- import { object, parseJson, ValidationError } from './protocol/wire.js';
3
+ import { ValidationError } from './protocol/wire.js';
3
4
  export function validateFeedback(value) {
4
- if (!value || value.kind !== 'missing' || typeof value.task !== 'string' || !value.task.length || [...value.task].length > 200 || typeof value.description !== 'string' || !value.description.length || [...value.description].length > 2000)
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 function protectedResourceMetadataUrl(resource) {
13
- const url = secureUrl(resource);
14
- if (url.search)
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
- return `${url.origin}/.well-known/oauth-protected-resource${url.pathname === '/' ? '' : url.pathname}`;
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
- const issuer = metadata.authorization?.issuer;
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 ((api.resource !== undefined && api.resource !== api.url) || (destination !== undefined && destination !== api.url))
39
+ if (destination !== undefined && destination !== api.url)
42
40
  throw new ValidationError('resource');
43
- const scheme = await mcpScheme(transport, issuer, api.url);
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
- // An OpenAPI servers entry is data. The destination host must independently vouch for this issuer.
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.search)
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, ...(api.resource ? { resource: api.resource } : {}), scheme: 'DPoP' };
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: string;
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;