@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.
Files changed (54) hide show
  1. package/API.md +12 -3
  2. package/AUTH.md +97 -65
  3. package/CHANGELOG.md +18 -0
  4. package/CONFORMANCE.md +73 -0
  5. package/CONTRIBUTING.md +13 -10
  6. package/DELIVERY.md +30 -1
  7. package/INTEGRATIONS.md +63 -21
  8. package/OPERATIONS.md +120 -0
  9. package/PROVIDER.md +24 -14
  10. package/README.md +7 -13
  11. package/RELEASING.md +2 -0
  12. package/SECURITY.md +2 -0
  13. package/STORAGE.md +24 -5
  14. package/dist/adapter-contract.d.ts +6 -0
  15. package/dist/adapter-contract.js +31 -1
  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 +29 -13
  19. package/dist/auth/client-auth.js +498 -318
  20. package/dist/auth/sqlite-store.js +27 -4
  21. package/dist/auth/store.d.ts +6 -1
  22. package/dist/conversation.d.ts +6 -0
  23. package/dist/conversation.js +28 -14
  24. package/dist/index.d.ts +6 -1
  25. package/dist/index.js +3 -1
  26. package/dist/integrations.d.ts +16 -4
  27. package/dist/integrations.js +30 -38
  28. package/dist/network/discovery.d.ts +51 -22
  29. package/dist/network/discovery.js +159 -69
  30. package/dist/network/redirect.d.ts +3 -0
  31. package/dist/network/redirect.js +38 -0
  32. package/dist/network/tasks.d.ts +23 -0
  33. package/dist/network/tasks.js +45 -0
  34. package/dist/operations.d.ts +103 -0
  35. package/dist/operations.js +261 -0
  36. package/dist/postgres.d.ts +11 -0
  37. package/dist/postgres.js +52 -2
  38. package/dist/protocol/operations.d.ts +51 -0
  39. package/dist/protocol/operations.js +98 -0
  40. package/dist/protocol/session.d.ts +28 -0
  41. package/dist/protocol/session.js +93 -0
  42. package/dist/protocol/sse.d.ts +1 -0
  43. package/dist/protocol/sse.js +1 -1
  44. package/dist/protocol/wire.d.ts +20 -15
  45. package/dist/protocol/wire.js +70 -22
  46. package/dist/provider-postgres.js +3 -3
  47. package/dist/provider-storage.js +4 -4
  48. package/dist/provider.d.ts +26 -10
  49. package/dist/provider.js +103 -32
  50. package/dist/sqlite.d.ts +12 -0
  51. package/dist/sqlite.js +42 -0
  52. package/examples/operations.mjs +25 -0
  53. package/examples/quickstart.mjs +2 -1
  54. 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 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,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 ('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)
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');
@@ -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)
@@ -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
- return record.response;
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 (!page.events.length || page.cursor === null || page.cursor === cursor)
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';
@@ -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
- assertion: string;
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 declare function protectedResourceMetadataUrl(resource: string): string;
29
- 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
+ }>;
30
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
  }
@@ -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.2';
9
+ protocol_version: '0.3';
5
10
  organization: {
6
11
  name: string;
7
12
  domain: string;
8
13
  };
9
14
  auth?: {
10
- issuer: string;
11
- direct?: {
12
- scopes: string[];
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
- poppy_domains: string[];
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;