@poppykit/server 0.4.0 → 0.5.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 (52) hide show
  1. package/API.md +10 -3
  2. package/AUTH.md +120 -124
  3. package/CHANGELOG.md +13 -1
  4. package/CONFORMANCE.md +73 -0
  5. package/CONTRIBUTING.md +12 -11
  6. package/DELIVERY.md +2 -0
  7. package/INTEGRATIONS.md +74 -81
  8. package/OPERATIONS.md +142 -0
  9. package/README.md +11 -20
  10. package/RELEASING.md +2 -0
  11. package/SECURITY.md +2 -0
  12. package/STORAGE.md +56 -17
  13. package/dist/adapter-contract.d.ts +18 -0
  14. package/dist/adapter-contract.js +79 -0
  15. package/dist/auth/authenticator.d.ts +3 -1
  16. package/dist/auth/authenticator.js +34 -29
  17. package/dist/auth/authority.d.ts +8 -2
  18. package/dist/auth/authority.js +7 -2
  19. package/dist/auth/session.d.ts +47 -0
  20. package/dist/auth/session.js +94 -0
  21. package/dist/browser.d.ts +15 -16
  22. package/dist/browser.js +65 -33
  23. package/dist/conversation/service.d.ts +11 -1
  24. package/dist/conversation/service.js +39 -9
  25. package/dist/feedback.d.ts +1 -1
  26. package/dist/feedback.js +5 -5
  27. package/dist/index.d.ts +5 -1
  28. package/dist/index.js +3 -0
  29. package/dist/mcp.d.ts +1 -1
  30. package/dist/mcp.js +8 -4
  31. package/dist/network/discovery.d.ts +31 -13
  32. package/dist/network/discovery.js +103 -66
  33. package/dist/operations.d.ts +115 -0
  34. package/dist/operations.js +212 -0
  35. package/dist/postgres.d.ts +6 -0
  36. package/dist/postgres.js +72 -4
  37. package/dist/protocol/operations.d.ts +51 -0
  38. package/dist/protocol/operations.js +98 -0
  39. package/dist/protocol/session.d.ts +28 -0
  40. package/dist/protocol/session.js +93 -0
  41. package/dist/protocol/sse.d.ts +1 -0
  42. package/dist/protocol/sse.js +1 -1
  43. package/dist/protocol/wire.d.ts +8 -4
  44. package/dist/protocol/wire.js +37 -15
  45. package/dist/reference-auth/index.d.ts +10 -16
  46. package/dist/reference-auth/index.js +225 -430
  47. package/dist/sqlite.d.ts +11 -0
  48. package/dist/sqlite.js +84 -4
  49. package/examples/operations.mjs +18 -0
  50. package/examples/quickstart.mjs +8 -5
  51. package/migrations/RETIREMENT.md +77 -0
  52. package/package.json +5 -2
@@ -1,3 +1,4 @@
1
+ import { supportsOperations, parseOperationApproval } from '../protocol/operations.js';
1
2
  import { companyScopes } from '../network/discovery.js';
2
3
  import { randomBytes } from 'node:crypto';
3
4
  import { createConversationNotifications } from './store.js';
@@ -215,7 +216,7 @@ export function createConversationService(options) {
215
216
  const events = [];
216
217
  let bytes = 1024; // Worst-case IDs, status, responder, cursor and JSON envelope.
217
218
  for (const stored of conversation.events.slice(start, start + pageSize)) {
218
- const event = parseEvent(stored);
219
+ const event = parseEvent(stored, !!options.operations);
219
220
  const size = Buffer.byteLength(JSON.stringify(event)) + 1;
220
221
  if (bytes + size > maxPageBytes)
221
222
  break;
@@ -247,7 +248,7 @@ export function createConversationService(options) {
247
248
  return conversation;
248
249
  // Spec 7.11: the same User not signed in to the bound account (signed out or another account) is told to sign in; anyone else learns nothing.
249
250
  if (accountId !== undefined && conversation.userId === principal.userId)
250
- throw new AuthorityError('insufficient_scope', { scope: historyScopes.join(' ') });
251
+ throw new AuthorityError(principal.accountId ? 'insufficient_scope' : 'account_required', { scope: historyScopes.join(' ') });
251
252
  throw notFound();
252
253
  }
253
254
  // Runs change under withCurrent; target() and history() decide the scopes from the state being checked.
@@ -272,7 +273,9 @@ export function createConversationService(options) {
272
273
  async function accept(request, principal, url, conversationId) {
273
274
  const read = parseReadOptions(url, maxWaitSeconds);
274
275
  const waiting = url.searchParams.has('wait');
275
- const body = parseConversationRequest(await readJson(request));
276
+ const body = parseConversationRequest(await readJson(request), !!options.operations);
277
+ if (body.message.approval && conversationId === undefined)
278
+ throw new ValidationError('message.approval');
276
279
  const parentId = conversationId === undefined ? body.parent_conversation_id : undefined;
277
280
  // A Direct Conversation carries the User's own words (7.10).
278
281
  if (parentId !== undefined && directWords(body.message))
@@ -337,6 +340,16 @@ export function createConversationService(options) {
337
340
  page(conversation, read.cursor, draft.cursorStatus);
338
341
  conversation.authority = authority;
339
342
  }
343
+ if (body.message.approval) {
344
+ const approved = parseOperationApproval(body.message.approval);
345
+ const approvalKey = canonical([approved.operation_id, approved.revision]);
346
+ if (conversation.operationApprovals?.includes(approvalKey)) {
347
+ const result = { status: 202, body: ack(conversation) };
348
+ draft.messages[key] = { conversationId: conversation.id, fingerprint, receipt: result };
349
+ return result;
350
+ }
351
+ conversation.operationApprovals = [...(conversation.operationApprovals ?? []), approvalKey];
352
+ }
340
353
  pruneWork(draft);
341
354
  const message = body.message;
342
355
  conversation.lastUserMessage = message;
@@ -345,7 +358,7 @@ export function createConversationService(options) {
345
358
  addEvent(conversation, { type: 'message', message: { ...message, role: 'user' } });
346
359
  // A context-only message asks nothing. Work always records this message's own accepted authority; while queued
347
360
  // for or answered by a person it is held for them and reaches automation only if they never answer it.
348
- if ('text' in message || 'data' in message) {
361
+ if ('text' in message || 'data' in message || 'approval' in message) {
349
362
  const held = conversation.responder === 'human' || conversation.status === 'queued';
350
363
  draft.work.push({ id: randomId('wrk_'), conversationId: conversation.id, message, authority, phase: held ? 'held' : 'pending', outputs: {} });
351
364
  if (!held && conversation.status === 'idle')
@@ -498,6 +511,8 @@ export function createConversationService(options) {
498
511
  if (request.method !== method)
499
512
  throw new HttpError(405, { error: 'method_not_allowed' }, { Allow: method });
500
513
  const principal = await authenticator.authenticate(request);
514
+ if (options.operations && !supportsOperations(await options.operations.resolveClient(principal.clientId)))
515
+ throw new HttpError(403, { error: 'extension_required', extension: 'operations' }, { 'WWW-Authenticate': 'Bearer error="extension_required", extension="operations"' });
501
516
  if (!match)
502
517
  return accept(request, principal, url);
503
518
  const [, id, action] = match;
@@ -603,7 +618,7 @@ export function createConversationService(options) {
603
618
  return json(400, { error: 'invalid_request' });
604
619
  // Status/code/headers only, never the raw message.
605
620
  if (error instanceof AuthorityError)
606
- return json(error.status, { error: error.code }, error.headers);
621
+ return error.code === 'account_required' || error.code === 'invalid_session' ? error.response() : json(error.status, { error: error.code }, error.headers);
607
622
  throw error;
608
623
  }
609
624
  },
@@ -625,7 +640,7 @@ export function createConversationService(options) {
625
640
  work.phase = 'claimed';
626
641
  work.fence = newFence(draft);
627
642
  work.leaseUntil = now + leaseMs;
628
- return { workId: work.id, fence: work.fence, conversationId: work.conversationId, message: parseMessage(work.message), context: conversation.context };
643
+ return { workId: work.id, fence: work.fence, conversationId: work.conversationId, message: parseMessage(work.message, { operations: !!options.operations }), context: conversation.context };
629
644
  }
630
645
  return null;
631
646
  });
@@ -642,7 +657,7 @@ export function createConversationService(options) {
642
657
  const accountId = boundAccount(draft, work);
643
658
  if (accountId !== undefined && work.authority.principal.accountId !== accountId)
644
659
  throw new AuthorityError('insufficient_scope', { scope: historyScopes.join(' ') });
645
- return linkedHistory(draft, conversation).map(parseEvent);
660
+ return linkedHistory(draft, conversation).map(event => parseEvent(event, !!options.operations));
646
661
  }, never, true);
647
662
  },
648
663
  requireAccount(ref, scopes) {
@@ -656,8 +671,23 @@ export function createConversationService(options) {
656
671
  return { accountId };
657
672
  });
658
673
  },
674
+ operationContext(ref) {
675
+ if (!options.operations)
676
+ throw new TypeError('operations not configured');
677
+ return publish(ref, noScopes, (_draft, work, conversation) => ({
678
+ principal: structuredClone(work.authority.principal),
679
+ request: { channel: 'conversation', endpoint: options.endpoint, conversationId: conversation.id },
680
+ ...(work.message.approval ? { approval: parseOperationApproval(work.message.approval) } : {}),
681
+ }));
682
+ },
659
683
  async reply(ref, outputId, message) {
660
- const parsed = parseMessage(message, { company: true });
684
+ if (message.operation) {
685
+ const state = await store.read({ workId: ref.workId });
686
+ const work = state.work.find(row => row.id === ref.workId);
687
+ if (!work || !options.operations || !supportsOperations(await options.operations.resolveClient(work.authority.principal.clientId)))
688
+ throw new ValidationError('message.operation');
689
+ }
690
+ const parsed = parseMessage(message, { company: true, operations: !!options.operations });
661
691
  const payload = canonical({ reply: parsed });
662
692
  await publish(ref, noScopes, (draft, work, conversation) => {
663
693
  if (parsed.author.type !== conversation.responder)
@@ -729,7 +759,7 @@ export function createConversationService(options) {
729
759
  const source = conversation.lastUserMessage ?? last?.message;
730
760
  if (!source)
731
761
  throw new PublicationError('stale_work');
732
- const { role: _role, ...message } = parseMessage(source);
762
+ const { role: _role, ...message } = parseMessage(source, { operations: !!options.operations });
733
763
  const work = { id: randomId('wrk_'), conversationId, message, authority, phase: 'claimed', fence: newFence(draft), leaseUntil: clock.now() + operatorLeaseMs, human: true, outputs: {} };
734
764
  conversation.humanUntil = work.leaseUntil;
735
765
  draft.work.push(work);
@@ -1,8 +1,8 @@
1
1
  import type { RequestAuthenticator, AuthorityPolicy, Principal } from './auth/authority.js';
2
2
  export interface FeedbackReport {
3
- kind: string;
4
3
  task: string;
5
4
  description: string;
5
+ [key: string]: unknown;
6
6
  }
7
7
  export declare function createFeedbackHandler(options: {
8
8
  endpoint: string;
package/dist/feedback.js CHANGED
@@ -12,7 +12,7 @@ export function createFeedbackHandler(options) {
12
12
  }
13
13
  catch (error) {
14
14
  if (error instanceof AuthorityError)
15
- return new Response(null, { status: error.status, headers: error.headers });
15
+ return error.response();
16
16
  throw error;
17
17
  }
18
18
  if (request.url !== options.endpoint || request.method !== 'POST' || request.headers.get('content-type')?.split(';')[0] !== 'application/json')
@@ -20,9 +20,9 @@ export function createFeedbackHandler(options) {
20
20
  let report;
21
21
  try {
22
22
  const value = object(parseJson(await boundedText(request, 16384)));
23
- if (typeof value.kind !== 'string' || !value.kind.length || typeof value.task !== 'string' || !value.task.length || [...value.task].length > 200 || typeof value.description !== 'string' || !value.description.length || [...value.description].length > 2000)
23
+ if (typeof value.task !== 'string' || !value.task.length || [...value.task].length > 200 || typeof value.description !== 'string' || !value.description.length || [...value.description].length > 2000)
24
24
  throw 0;
25
- report = { kind: value.kind, task: value.task, description: value.description };
25
+ report = { ...value, task: value.task, description: value.description };
26
26
  }
27
27
  catch {
28
28
  return fail(400, 'invalid_request');
@@ -30,14 +30,14 @@ export function createFeedbackHandler(options) {
30
30
  try {
31
31
  return await options.policy.withCurrent(principal, [], async () => {
32
32
  if (options.allow && !await options.allow(principal))
33
- return fail(429, 'rate_limited');
33
+ return Response.json({ error: 'temporarily_unavailable' }, { status: 429, headers: { 'Cache-Control': 'no-store', 'Retry-After': '60' } });
34
34
  await options.accept(Object.freeze(report), principal);
35
35
  return new Response(null, { status: 202 });
36
36
  });
37
37
  }
38
38
  catch (error) {
39
39
  if (error instanceof AuthorityError)
40
- return new Response(null, { status: error.status, headers: error.headers });
40
+ return error.response();
41
41
  throw error;
42
42
  }
43
43
  };
package/dist/index.d.ts CHANGED
@@ -5,8 +5,12 @@ export * from './conversation/service.js';
5
5
  export type { ConversationStore, ConversationSelection, ConversationNotifications } from './conversation/store.js';
6
6
  export { createConversationNotifications } from './conversation/store.js';
7
7
  export { createBrowserSessions } from './browser.js';
8
- export type { BrowserBinding, BrowserSession, BrowserSessionAuthority, BrowserCookieStore } from './browser.js';
8
+ export type { BrowserBinding, BrowserCookieStore, BrowserJoin } from './browser.js';
9
9
  export { createFeedbackHandler } from './feedback.js';
10
10
  export type { FeedbackReport } from './feedback.js';
11
11
  export { mcpResourceMetadata, mcpAuthorizationError } from './mcp.js';
12
12
  export type { Author, AuthorType, Message, RecordedMessage, ConversationRequest, ConversationState, ConversationEvent, KnownEvent, EventPage } from './protocol/wire.js';
13
+ export { createSessionAuthority } from './auth/session.js';
14
+ export type { SessionRecord, SessionState, SessionAdmission, SessionStore, SessionAuthority } from './auth/session.js';
15
+ export * from './operations.js';
16
+ export * from './protocol/operations.js';
package/dist/index.js CHANGED
@@ -5,3 +5,6 @@ export { createConversationNotifications } from './conversation/store.js';
5
5
  export { createBrowserSessions } from './browser.js';
6
6
  export { createFeedbackHandler } from './feedback.js';
7
7
  export { mcpResourceMetadata, mcpAuthorizationError } from './mcp.js';
8
+ export { createSessionAuthority } from './auth/session.js';
9
+ export * from './operations.js';
10
+ export * from './protocol/operations.js';
package/dist/mcp.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { AuthorityError } from './auth/authority.js';
2
2
  export declare function mcpResourceMetadata(input: {
3
3
  resource: string;
4
- issuer: string;
4
+ issuer?: string;
5
5
  dpop: boolean;
6
6
  }): Record<string, unknown>;
7
7
  export declare function mcpAuthorizationError(error: AuthorityError, metadataUrl: string, dpop: boolean): Response;
package/dist/mcp.js CHANGED
@@ -1,14 +1,18 @@
1
1
  import { secureUrl } from './network/transport.js';
2
2
  export function mcpResourceMetadata(input) {
3
3
  secureUrl(input.resource);
4
- secureUrl(input.issuer);
5
- return { resource: input.resource, authorization_servers: [input.issuer], bearer_methods_supported: ['header'], ...(input.dpop ? { dpop_signing_alg_values_supported: ['ES256'] } : {}) };
4
+ if (input.issuer)
5
+ secureUrl(input.issuer);
6
+ return { resource: input.resource, authorization_servers: input.issuer ? [input.issuer] : [], bearer_methods_supported: ['header'], ...(input.dpop ? { dpop_signing_alg_values_supported: ['ES256'] } : {}) };
6
7
  }
7
8
  export function mcpAuthorizationError(error, metadataUrl, dpop) {
9
+ if (error.code === 'invalid_session' || error.code === 'rate_limited')
10
+ return error.response();
8
11
  secureUrl(metadataUrl);
9
12
  if (/["\x00-\x20\\]/.test(metadataUrl))
10
13
  throw new TypeError('metadataUrl');
11
- const scope = error.headers.get('www-authenticate')?.match(/, scope="[^"]*"/)?.[0] ?? '';
12
- const challenge = (scheme) => `${scheme} error="${error.code}"${scope}, resource_metadata="${metadataUrl}"`;
14
+ const scope = error.headers.get('www-authenticate')?.match(/(?:^|[ ,])scope="([^"]*)"/)?.[1];
15
+ const attributes = [...(error.code === 'account_required' ? [] : [`error="${error.code}"`]), ...(scope === undefined ? [] : [`scope="${scope}"`]), `resource_metadata="${metadataUrl}"`];
16
+ const challenge = (scheme) => `${scheme} ${attributes.join(', ')}`;
13
17
  return new Response(null, { status: error.status, headers: { 'WWW-Authenticate': [challenge('Bearer'), ...(dpop ? [challenge('DPoP')] : [])].join(', '), 'Cache-Control': 'no-store' } });
14
18
  }
@@ -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;
@@ -1,5 +1,5 @@
1
1
  import { validRedirect } from './redirect.js';
2
- import { object, string, member, canonical, parseJson, ValidationError } from '../protocol/wire.js';
2
+ import { object, string, member, parseJson, ValidationError } from '../protocol/wire.js';
3
3
  import { secureUrl } from './transport.js';
4
4
  import { parseExcludedTasks } from './tasks.js';
5
5
  function url(value, field) { const valueString = string(value, field); secureUrl(valueString); return valueString; }
@@ -8,7 +8,7 @@ function strings(value, field, allowEmpty = false) { if (!Array.isArray(value) |
8
8
  export function companyScopes(value, allowEmpty = false) {
9
9
  const result = strings(value, 'scopes', allowEmpty);
10
10
  for (const scope of result)
11
- if (!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope) || scope.startsWith('poppy:'))
11
+ if (!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope))
12
12
  throw new ValidationError('scopes');
13
13
  return result;
14
14
  }
@@ -40,24 +40,10 @@ export function parsePoppyDocument(value, servingUrl) {
40
40
  const web = 'web' in doc ? object(doc.web, 'web') : undefined;
41
41
  if (web && 'browser_session_endpoint' in web)
42
42
  url(web.browser_session_endpoint, 'browser_session_endpoint');
43
- if (['agent', 'apis', 'feedback'].some(key => key in doc) || (web && 'browser_session_endpoint' in web))
44
- object(doc.auth, 'auth');
45
43
  if ('auth' in doc) {
46
44
  const auth = object(doc.auth, 'auth');
47
- authorizationMetadataUrl(url(auth.issuer, 'issuer'));
48
- if ('mediated' in auth) {
49
- const mediated = object(auth.mediated, 'mediated');
50
- url(mediated.endpoint, 'mediated.endpoint');
51
- companyScopes(mediated.scopes);
52
- if (!Array.isArray(mediated.fields) || !mediated.fields.length)
53
- throw new ValidationError('mediated.fields');
54
- for (const field of mediated.fields) {
55
- const item = object(field);
56
- string(item.name, 'field.name');
57
- string(item.label, 'field.label');
58
- member(item.secret, [true, false], 'field.secret');
59
- }
60
- }
45
+ if ('issuer' in auth)
46
+ authorizationMetadataUrl(url(auth.issuer, 'issuer'));
61
47
  if ('scope_hints' in auth) {
62
48
  const hints = object(auth.scope_hints, 'scope_hints');
63
49
  for (const [scope, value] of Object.entries(hints)) {
@@ -82,8 +68,8 @@ export function parsePoppyDocument(value, servingUrl) {
82
68
  const item = object(protocol);
83
69
  string(item.type, 'protocol.type');
84
70
  url(item.endpoint, 'protocol.endpoint');
85
- if ('resource' in item)
86
- url(item.resource, 'protocol.resource');
71
+ if (item.type === 'poppy' && 'access' in item)
72
+ string(item.access, 'protocol.access');
87
73
  }
88
74
  }
89
75
  if ('apis' in doc) {
@@ -94,8 +80,6 @@ export function parsePoppyDocument(value, servingUrl) {
94
80
  string(item.type, 'api.type');
95
81
  string(item.description, 'api.description');
96
82
  url(item.url, 'api.url');
97
- if ('resource' in item)
98
- url(item.resource, 'api.resource');
99
83
  }
100
84
  }
101
85
  if ('excluded_tasks' in doc)
@@ -109,21 +93,22 @@ export function parsePoppyDocument(value, servingUrl) {
109
93
  if ('endpoint' in item)
110
94
  url(item.endpoint, 'extension.endpoint');
111
95
  }
96
+ const mediated = doc.extensions?.['mediated-sign-in'];
97
+ if (mediated && object(mediated).version === '1')
98
+ parseMediatedSignIn(mediated);
112
99
  return doc;
113
100
  }
114
- export function parseAuthorizationMetadata(value, document) {
101
+ export function parseAuthorizationMetadata(value, document, issuer = document.auth?.issuer) {
115
102
  const metadata = object(value);
116
- const auth = document.auth;
117
- if (metadata.issuer !== auth.issuer)
103
+ if (!issuer || metadata.issuer !== issuer)
118
104
  throw new ValidationError('issuer');
119
105
  const resources = strings(metadata.protected_resources, 'protected_resources');
120
106
  resources.forEach(value => url(value, 'protected_resources'));
121
107
  if (!resources.some(value => /^https:\/\/[^/:?#]+$/.test(value) && value.slice(8).toLowerCase() === document.organization.domain.toLowerCase()))
122
108
  throw new ValidationError('protected_resources');
123
109
  const grants = strings(metadata.grant_types_supported, 'grant_types_supported');
124
- for (const grant of ['urn:ietf:params:oauth:grant-type:jwt-bearer', 'refresh_token'])
125
- if (!grants.includes(grant))
126
- throw new ValidationError('grant_types_supported');
110
+ if (!grants.includes('refresh_token'))
111
+ throw new ValidationError('grant_types_supported');
127
112
  for (const key of ['token_endpoint', 'revocation_endpoint'])
128
113
  url(metadata[key], key);
129
114
  for (const [key, grant] of [['authorization_endpoint', 'authorization_code'], ['device_authorization_endpoint', 'urn:ietf:params:oauth:grant-type:device_code']])
@@ -131,15 +116,13 @@ export function parseAuthorizationMetadata(value, document) {
131
116
  url(metadata[key], key);
132
117
  if ('pushed_authorization_request_endpoint' in metadata)
133
118
  url(metadata.pushed_authorization_request_endpoint, 'pushed_authorization_request_endpoint');
134
- const signIn = grants.includes('authorization_code') || grants.includes('urn:ietf:params:oauth:grant-type:device_code') || !!auth.mediated;
135
- const supported = signIn || 'scopes_supported' in metadata ? companyScopes(metadata.scopes_supported, true) : [];
136
- if (auth.mediated && !auth.mediated.scopes.every(scope => supported.includes(scope)))
137
- throw new ValidationError('mediated.scopes');
138
- // Scope descriptions may instead live in a listed OpenAPI document.
139
- if (!document.apis?.some(api => api.type === 'openapi') && supported.some(scope => !Object.hasOwn(auth.scopes ?? {}, scope)))
140
- throw new ValidationError('auth.scopes');
141
- if ('dpop_signing_alg_values_supported' in metadata && !strings(metadata.dpop_signing_alg_values_supported, 'dpop_signing_alg_values_supported').includes('ES256'))
142
- throw new ValidationError('dpop_signing_alg_values_supported');
119
+ if ('scopes_supported' in metadata)
120
+ companyScopes(metadata.scopes_supported, true);
121
+ const mediated = mediatedSignIn(document);
122
+ if (mediated && !grants.includes(MEDIATED_GRANT))
123
+ throw new ValidationError('mediated.grant');
124
+ if ('dpop_signing_alg_values_supported' in metadata)
125
+ strings(metadata.dpop_signing_alg_values_supported, 'dpop_signing_alg_values_supported');
143
126
  if ('client_id_metadata_document_supported' in metadata)
144
127
  member(metadata.client_id_metadata_document_supported, [true, false], 'client_id_metadata_document_supported');
145
128
  return metadata;
@@ -152,10 +135,53 @@ export function signInMethods(metadata) {
152
135
  methods.push({ method: 'direct', scopes: [...auth.scopes_supported ?? []] });
153
136
  if (auth.grant_types_supported.includes('urn:ietf:params:oauth:grant-type:device_code') && auth.device_authorization_endpoint)
154
137
  methods.push({ method: 'device', scopes: [...auth.scopes_supported ?? []] });
155
- if (metadata.document.auth?.mediated)
156
- methods.push({ method: 'mediated', scopes: [...metadata.document.auth.mediated.scopes] });
138
+ const mediated = mediatedSignIn(metadata.document);
139
+ if (mediated && auth.grant_types_supported.includes(MEDIATED_GRANT))
140
+ methods.push({ method: 'mediated', scopes: [...mediated.scopes] });
157
141
  return methods;
158
142
  }
143
+ export const MEDIATED_GRANT = 'https://personalagentprotocol.org/grant/mediated-sign-in';
144
+ export function parseMediatedSignIn(value) {
145
+ const extension = object(value, 'mediated-sign-in');
146
+ member(extension.version, ['1'], 'mediated.version');
147
+ companyScopes(extension.scopes);
148
+ if (!Array.isArray(extension.fields) || !extension.fields.length)
149
+ throw new ValidationError('mediated.fields');
150
+ const names = new Set();
151
+ const reserved = new Set(['grant_type', 'scope', 'resource', 'client_id', 'client_secret', 'client_assertion', 'client_assertion_type', 'sign_in_id', 'one_time_code']);
152
+ for (const value of extension.fields) {
153
+ const field = object(value);
154
+ const name = string(field.name, 'field.name');
155
+ if (reserved.has(name) || names.has(name))
156
+ throw new ValidationError('field.name');
157
+ names.add(name);
158
+ string(field.label, 'field.label');
159
+ member(field.secret, [true, false], 'field.secret');
160
+ }
161
+ return extension;
162
+ }
163
+ export function mediatedSignIn(document) {
164
+ const extension = document.extensions?.['mediated-sign-in'];
165
+ return extension?.version === '1' ? parseMediatedSignIn(extension) : undefined;
166
+ }
167
+ export function companyIdentifier(metadata) {
168
+ return metadata.authorization?.issuer ?? `https://${metadata.document.organization.domain.toLowerCase()}`;
169
+ }
170
+ export function protectedResourceMetadataUrl(resource) {
171
+ const parsed = secureUrl(resource);
172
+ return `${parsed.origin}/.well-known/oauth-protected-resource${parsed.pathname === '/' ? '' : parsed.pathname}${parsed.search}`;
173
+ }
174
+ export function parseProtectedResourceMetadata(value) {
175
+ const metadata = object(value, 'resource_metadata');
176
+ url(metadata.resource, 'resource');
177
+ if ('authorization_servers' in metadata)
178
+ strings(metadata.authorization_servers, 'authorization_servers', true).forEach(value => authorizationMetadataUrl(url(value, 'authorization_servers')));
179
+ if ('bearer_methods_supported' in metadata)
180
+ strings(metadata.bearer_methods_supported, 'bearer_methods_supported', true);
181
+ if ('dpop_signing_alg_values_supported' in metadata)
182
+ strings(metadata.dpop_signing_alg_values_supported, 'dpop_signing_alg_values_supported', true);
183
+ return metadata;
184
+ }
159
185
  export function parseClientMetadata(value, clientId) {
160
186
  const parsed = validateClientId(clientId);
161
187
  const metadata = object(value);
@@ -166,6 +192,9 @@ export function parseClientMetadata(value, clientId) {
166
192
  const redirects = strings(metadata.redirect_uris, 'redirect_uris', true);
167
193
  if (secureUrl(url(metadata.jwks_uri, 'jwks_uri')).hostname !== parsed.hostname || redirects.some(endpoint => !validRedirect(endpoint, clientId)))
168
194
  throw new ValidationError('client_endpoint_domain');
195
+ if ('extensions' in metadata)
196
+ for (const extension of Object.values(object(metadata.extensions)))
197
+ string(object(extension).version, 'extension.version');
169
198
  if ('logo_uri' in metadata)
170
199
  url(metadata.logo_uri, 'logo_uri');
171
200
  return metadata;
@@ -186,7 +215,7 @@ function remember(map, key, value, max, expired) {
186
215
  }
187
216
  }
188
217
  // Defaults: 1000 entries per map; at most 16 cold fetch operations in flight and 120 started per 60 s (excess is rejected, not queued).
189
- // Invariant: maxFetchesPerMinute * 5 (300 s TTL cap) must stay below maxEntries, or a flood can evict live authDocuments/refreshes entries.
218
+ // Refresh throttling entries share the same bound as metadata cache entries.
190
219
  // shortcut: limits are global per resolver, not per caller, and cap total cold-fetch throughput; move per-caller limiting to the server HTTP layer before production use.
191
220
  export function createMetadataResolver(transport, options = {}) {
192
221
  const now = options.now ?? Date.now;
@@ -198,13 +227,12 @@ export function createMetadataResolver(transport, options = {}) {
198
227
  const cache = new Map();
199
228
  const pending = new Map();
200
229
  const refreshes = new Map();
201
- const authDocuments = new Map();
202
- async function get(target, validate, mode, refresh = false) {
230
+ async function get(target, validate, mode, refresh = false, allowMissing = false) {
203
231
  secureUrl(target);
204
232
  const key = `${mode}:${target}`;
205
233
  const cached = cache.get(key);
206
234
  if (!refresh && cached && cached.expires > now()) {
207
- validate(cached.value, cached.url);
235
+ validate(cached.value, cached.url, cached.missing);
208
236
  return structuredClone(cached);
209
237
  }
210
238
  let task = pending.get(key);
@@ -225,24 +253,25 @@ export function createMetadataResolver(transport, options = {}) {
225
253
  else if (cached?.modified)
226
254
  headers.set('if-modified-since', cached.modified);
227
255
  const response = await transport.fetch(new Request(target, { headers }), { mode });
228
- if (response.status !== 200 && !(response.status === 304 && cached))
256
+ if (response.status !== 200 && !(allowMissing && response.status === 404) && !(response.status === 304 && cached))
229
257
  throw new ValidationError('metadata.status');
230
258
  let value;
231
259
  try {
232
- value = response.status === 304 ? cached.value : parseJson(await response.text());
260
+ value = response.status === 304 ? cached.value : response.status === 404 ? null : parseJson(await response.text());
233
261
  }
234
262
  catch {
235
263
  throw new ValidationError('metadata.json');
236
264
  }
265
+ const missing = response.status === 404 || (response.status === 304 && cached?.missing === true);
237
266
  const servedUrl = response.url || (response.status === 304 ? cached.url : target);
238
- validate(value, servedUrl);
267
+ validate(value, servedUrl, missing);
239
268
  if (response.status === 304)
240
- validate(value, cached.url);
269
+ validate(value, cached.url, missing);
241
270
  const control = response.headers.get('cache-control') ?? (response.status === 304 ? cached?.control : undefined) ?? '';
242
271
  const age = Number(response.headers.get('age') ?? 0);
243
272
  const seconds = Number(/(?:^|,)\s*max-age=(\d+)/i.exec(control)?.[1] ?? 60); // no max-age: 60 s floor (guide: at least a minute)
244
273
  const expires = now() + (/\b(?:no-store|no-cache)\b/i.test(control) ? 0 : Math.max(0, Math.min(300, seconds - (Number.isFinite(age) ? age : 0)))) * 1000;
245
- const result = { value, control, url: servedUrl, expires, etag: response.headers.get('etag') ?? cached?.etag, modified: response.headers.get('last-modified') ?? cached?.modified };
274
+ const result = { value, control, missing, url: servedUrl, expires, etag: response.headers.get('etag') ?? cached?.etag, modified: response.headers.get('last-modified') ?? cached?.modified };
246
275
  if (startedEpoch !== epoch)
247
276
  return result;
248
277
  if (!/\bno-store\b/i.test(control))
@@ -255,7 +284,7 @@ export function createMetadataResolver(transport, options = {}) {
255
284
  }
256
285
  try {
257
286
  const result = await task;
258
- validate(result.value, result.url);
287
+ validate(result.value, result.url, result.missing);
259
288
  return structuredClone(result);
260
289
  }
261
290
  finally {
@@ -266,36 +295,44 @@ export function createMetadataResolver(transport, options = {}) {
266
295
  return {
267
296
  invalidate(target) { epoch++; pending.clear(); if (target === undefined) {
268
297
  cache.clear();
269
- authDocuments.clear();
270
298
  }
271
299
  else {
272
300
  for (const key of cache.keys())
273
301
  if (key.endsWith(`:${target}`))
274
302
  cache.delete(key);
275
- authDocuments.clear();
276
303
  } },
277
304
  async resolveCompany(input) {
278
- const startedEpoch = epoch;
279
305
  if (!/^[a-z0-9.-]+(?::\d{1,5})?$/i.test(input))
280
306
  throw new ValidationError('domain');
281
307
  const target = `https://${input}/.well-known/poppy.json`;
282
308
  const found = await get(target, parsePoppyDocument, 'discovery');
283
309
  const document = parsePoppyDocument(found.value, found.url);
284
- if (!document.auth)
310
+ const resource = `https://${document.organization.domain.toLowerCase()}`;
311
+ const protectedMetadata = await get(protectedResourceMetadataUrl(resource), (value, _servedUrl, missing) => {
312
+ if (!missing) {
313
+ const metadata = parseProtectedResourceMetadata(value);
314
+ if (!/^https:\/\/[^/:?#]+$/.test(metadata.resource) || metadata.resource.slice(8).toLowerCase() !== document.organization.domain.toLowerCase())
315
+ throw new ValidationError('resource');
316
+ }
317
+ }, 'discovery', false, true);
318
+ const servers = protectedMetadata.missing ? [] : parseProtectedResourceMetadata(protectedMetadata.value).authorization_servers ?? [];
319
+ const selected = document.auth?.issuer;
320
+ if (!servers.length) {
321
+ if (selected || mediatedSignIn(document) || document.agent?.protocols.some(item => item.type === 'poppy' && item.access === 'signed_in'))
322
+ throw new ValidationError('issuer');
285
323
  return { document, url: found.url };
286
- const auth = await get(authorizationMetadataUrl(document.auth.issuer), value => parseAuthorizationMetadata(value, document), 'discovery');
287
- if (startedEpoch !== epoch)
288
- return { document, authorization: parseAuthorizationMetadata(auth.value, document), url: found.url };
289
- const entries = authDocuments.get(document.auth.issuer) ?? new Map();
290
- const spelling = canonical(document.auth);
291
- const currentDomain = document.organization.domain.toLowerCase();
292
- for (const [otherDomain, entry] of entries)
293
- if (otherDomain !== currentDomain && entry.expires > now() && entry.auth !== spelling)
294
- throw new ValidationError('auth.consistency');
295
- const expiredEntry = (entry) => entry.expires <= now();
296
- remember(entries, currentDomain, { auth: spelling, expires: found.expires }, maxEntries, expiredEntry);
297
- remember(authDocuments, document.auth.issuer, entries, maxEntries, inner => [...inner.values()].every(expiredEntry));
298
- return { document, authorization: parseAuthorizationMetadata(auth.value, document), url: found.url };
324
+ }
325
+ const issuer = selected ?? (servers.length === 1 ? servers[0] : undefined);
326
+ if (!issuer || !servers.includes(issuer))
327
+ throw new ValidationError('issuer');
328
+ const auth = await get(authorizationMetadataUrl(issuer), value => parseAuthorizationMetadata(value, document, issuer), 'discovery');
329
+ return { document, authorization: parseAuthorizationMetadata(auth.value, document, issuer), url: found.url };
330
+ },
331
+ async resolveResource(resource, settings = {}) {
332
+ const target = settings.metadataUrl ?? protectedResourceMetadataUrl(resource);
333
+ const found = await get(target, (value, _servedUrl, missing) => { if (!missing)
334
+ parseProtectedResourceMetadata(value); }, 'discovery', settings.refresh, true);
335
+ return found.missing ? null : parseProtectedResourceMetadata(found.value);
299
336
  },
300
337
  async resolveExcludedTasks(document) {
301
338
  if (!document.excluded_tasks)