@poppykit/server 0.3.1 → 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 (57) hide show
  1. package/API.md +11 -3
  2. package/AUTH.md +120 -95
  3. package/CHANGELOG.md +18 -0
  4. package/CONFORMANCE.md +73 -0
  5. package/CONTRIBUTING.md +13 -10
  6. package/DELIVERY.md +21 -1
  7. package/INTEGRATIONS.md +72 -69
  8. package/OPERATIONS.md +142 -0
  9. package/README.md +14 -17
  10. package/RELEASING.md +2 -0
  11. package/SECURITY.md +2 -0
  12. package/STORAGE.md +57 -2
  13. package/dist/adapter-contract.d.ts +18 -0
  14. package/dist/adapter-contract.js +90 -11
  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 -13
  22. package/dist/browser.js +80 -46
  23. package/dist/conversation/service.d.ts +18 -8
  24. package/dist/conversation/service.js +60 -28
  25. package/dist/feedback.d.ts +1 -1
  26. package/dist/feedback.js +5 -5
  27. package/dist/index.d.ts +6 -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 +51 -22
  32. package/dist/network/discovery.js +159 -69
  33. package/dist/network/redirect.d.ts +3 -0
  34. package/dist/network/redirect.js +38 -0
  35. package/dist/network/tasks.d.ts +23 -0
  36. package/dist/network/tasks.js +45 -0
  37. package/dist/operations.d.ts +115 -0
  38. package/dist/operations.js +212 -0
  39. package/dist/postgres.d.ts +6 -0
  40. package/dist/postgres.js +92 -6
  41. package/dist/protocol/operations.d.ts +51 -0
  42. package/dist/protocol/operations.js +98 -0
  43. package/dist/protocol/session.d.ts +28 -0
  44. package/dist/protocol/session.js +93 -0
  45. package/dist/protocol/sse.d.ts +1 -0
  46. package/dist/protocol/sse.js +1 -1
  47. package/dist/protocol/wire.d.ts +20 -15
  48. package/dist/protocol/wire.js +70 -22
  49. package/dist/reference-auth/index.d.ts +17 -12
  50. package/dist/reference-auth/index.js +252 -386
  51. package/dist/reference-auth/store.d.ts +2 -1
  52. package/dist/sqlite.d.ts +11 -0
  53. package/dist/sqlite.js +104 -6
  54. package/examples/operations.mjs +18 -0
  55. package/examples/quickstart.mjs +10 -6
  56. package/migrations/RETIREMENT.md +77 -0
  57. package/package.json +5 -2
@@ -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;
@@ -1,11 +1,17 @@
1
- import { object, string, member, canonical, parseJson, ValidationError } from '../protocol/wire.js';
1
+ import { validRedirect } from './redirect.js';
2
+ import { object, string, member, parseJson, ValidationError } from '../protocol/wire.js';
2
3
  import { secureUrl } from './transport.js';
4
+ import { parseExcludedTasks } from './tasks.js';
3
5
  function url(value, field) { const valueString = string(value, field); secureUrl(valueString); return valueString; }
4
6
  function strings(value, field, allowEmpty = false) { if (!Array.isArray(value) || (!allowEmpty && !value.length))
5
7
  throw new ValidationError(field); value.forEach(item => string(item, field)); return value; }
6
- function scopes(value) { for (const scope of strings(value, 'scopes'))
7
- if (!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope))
8
- throw new ValidationError('scopes'); }
8
+ export function companyScopes(value, allowEmpty = false) {
9
+ const result = strings(value, 'scopes', allowEmpty);
10
+ for (const scope of result)
11
+ if (!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope))
12
+ throw new ValidationError('scopes');
13
+ return result;
14
+ }
9
15
  function domain(value) { const text = string(value, 'domain'); if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i.test(text) || text.includes('..'))
10
16
  throw new ValidationError('domain'); return text; }
11
17
  export function authorizationMetadataUrl(issuer) { const parsed = secureUrl(issuer); if (parsed.search)
@@ -21,7 +27,10 @@ export function validateClientId(clientId) {
21
27
  }
22
28
  export function parsePoppyDocument(value, servingUrl) {
23
29
  const doc = object(value);
24
- member(doc.protocol_version, ['0.2'], 'protocol_version');
30
+ member(doc.protocol_version, ['0.3'], 'protocol_version');
31
+ const served = secureUrl(servingUrl);
32
+ if (served.pathname !== '/.well-known/poppy.json' || servingUrl.includes('?'))
33
+ throw new ValidationError('discovery.url');
25
34
  const organization = object(doc.organization, 'organization');
26
35
  string(organization.name, 'organization.name');
27
36
  if (domain(organization.domain).toLowerCase() !== secureUrl(servingUrl).hostname.toLowerCase())
@@ -31,29 +40,25 @@ export function parsePoppyDocument(value, servingUrl) {
31
40
  const web = 'web' in doc ? object(doc.web, 'web') : undefined;
32
41
  if (web && 'browser_session_endpoint' in web)
33
42
  url(web.browser_session_endpoint, 'browser_session_endpoint');
34
- if (['agent', 'apis', 'feedback'].some(key => key in doc) || (web && 'browser_session_endpoint' in web))
35
- object(doc.auth, 'auth');
36
43
  if ('auth' in doc) {
37
44
  const auth = object(doc.auth, 'auth');
38
- authorizationMetadataUrl(url(auth.issuer, 'issuer'));
39
- for (const key of ['direct', 'device'])
40
- if (key in auth)
41
- scopes(object(auth[key], key).scopes);
42
- if ('mediated' in auth) {
43
- const mediated = object(auth.mediated, 'mediated');
44
- url(mediated.endpoint, 'mediated.endpoint');
45
- scopes(mediated.scopes);
46
- if (!Array.isArray(mediated.fields) || !mediated.fields.length)
47
- throw new ValidationError('mediated.fields');
48
- for (const field of mediated.fields) {
49
- const item = object(field);
50
- string(item.name, 'field.name');
51
- string(item.label, 'field.label');
52
- member(item.secret, [true, false], 'field.secret');
45
+ if ('issuer' in auth)
46
+ authorizationMetadataUrl(url(auth.issuer, 'issuer'));
47
+ if ('scope_hints' in auth) {
48
+ const hints = object(auth.scope_hints, 'scope_hints');
49
+ for (const [scope, value] of Object.entries(hints)) {
50
+ companyScopes([scope]);
51
+ const hint = object(value, 'scope_hint');
52
+ string(hint.changes, 'changes');
53
+ member(hint.personal_data, [true, false], 'personal_data');
54
+ hints[scope] = { changes: ['none', 'reversible', 'irreversible'].includes(hint.changes) ? hint.changes : 'irreversible', personal_data: hint.personal_data };
53
55
  }
54
56
  }
55
- if ('custom_scopes' in auth)
56
- Object.values(object(auth.custom_scopes)).forEach(value => string(value, 'custom_scopes', false));
57
+ if ('scopes' in auth)
58
+ for (const [scope, description] of Object.entries(object(auth.scopes, 'auth.scopes'))) {
59
+ companyScopes([scope]);
60
+ string(description, 'auth.scopes');
61
+ }
57
62
  }
58
63
  if ('agent' in doc) {
59
64
  const protocols = object(doc.agent, 'agent').protocols;
@@ -63,8 +68,8 @@ export function parsePoppyDocument(value, servingUrl) {
63
68
  const item = object(protocol);
64
69
  string(item.type, 'protocol.type');
65
70
  url(item.endpoint, 'protocol.endpoint');
66
- if ('resource' in item)
67
- url(item.resource, 'protocol.resource');
71
+ if (item.type === 'poppy' && 'access' in item)
72
+ string(item.access, 'protocol.access');
68
73
  }
69
74
  }
70
75
  if ('apis' in doc) {
@@ -75,10 +80,10 @@ export function parsePoppyDocument(value, servingUrl) {
75
80
  string(item.type, 'api.type');
76
81
  string(item.description, 'api.description');
77
82
  url(item.url, 'api.url');
78
- if ('resource' in item)
79
- url(item.resource, 'api.resource');
80
83
  }
81
84
  }
85
+ if ('excluded_tasks' in doc)
86
+ url(object(doc.excluded_tasks).url, 'excluded_tasks.url');
82
87
  if ('feedback' in doc)
83
88
  url(object(doc.feedback).endpoint, 'feedback.endpoint');
84
89
  if ('extensions' in doc)
@@ -88,28 +93,95 @@ export function parsePoppyDocument(value, servingUrl) {
88
93
  if ('endpoint' in item)
89
94
  url(item.endpoint, 'extension.endpoint');
90
95
  }
96
+ const mediated = doc.extensions?.['mediated-sign-in'];
97
+ if (mediated && object(mediated).version === '1')
98
+ parseMediatedSignIn(mediated);
91
99
  return doc;
92
100
  }
93
- export function parseAuthorizationMetadata(value, document) {
101
+ export function parseAuthorizationMetadata(value, document, issuer = document.auth?.issuer) {
94
102
  const metadata = object(value);
95
- const auth = document.auth;
96
- if (metadata.issuer !== auth.issuer)
103
+ if (!issuer || metadata.issuer !== issuer)
97
104
  throw new ValidationError('issuer');
98
- const domains = strings(metadata.poppy_domains, 'poppy_domains');
99
- domains.forEach(domain);
100
- if (!domains.some(item => item.toLowerCase() === document.organization.domain.toLowerCase()))
101
- throw new ValidationError('poppy_domains');
105
+ const resources = strings(metadata.protected_resources, 'protected_resources');
106
+ resources.forEach(value => url(value, 'protected_resources'));
107
+ if (!resources.some(value => /^https:\/\/[^/:?#]+$/.test(value) && value.slice(8).toLowerCase() === document.organization.domain.toLowerCase()))
108
+ throw new ValidationError('protected_resources');
109
+ const grants = strings(metadata.grant_types_supported, 'grant_types_supported');
110
+ if (!grants.includes('refresh_token'))
111
+ throw new ValidationError('grant_types_supported');
102
112
  for (const key of ['token_endpoint', 'revocation_endpoint'])
103
113
  url(metadata[key], key);
104
- for (const [key, required] of [['authorization_endpoint', !!auth.direct], ['device_authorization_endpoint', !!auth.device]])
105
- if (required || key in metadata)
114
+ for (const [key, grant] of [['authorization_endpoint', 'authorization_code'], ['device_authorization_endpoint', 'urn:ietf:params:oauth:grant-type:device_code']])
115
+ if (grants.includes(grant) || key in metadata)
106
116
  url(metadata[key], key);
107
- if ('dpop_signing_alg_values_supported' in metadata && !strings(metadata.dpop_signing_alg_values_supported, 'dpop_signing_alg_values_supported').includes('ES256'))
108
- throw new ValidationError('dpop_signing_alg_values_supported');
117
+ if ('pushed_authorization_request_endpoint' in metadata)
118
+ url(metadata.pushed_authorization_request_endpoint, 'pushed_authorization_request_endpoint');
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');
109
126
  if ('client_id_metadata_document_supported' in metadata)
110
127
  member(metadata.client_id_metadata_document_supported, [true, false], 'client_id_metadata_document_supported');
111
128
  return metadata;
112
129
  }
130
+ export function signInMethods(metadata) {
131
+ const auth = metadata.authorization, methods = [];
132
+ if (!auth)
133
+ return methods;
134
+ if (auth.grant_types_supported.includes('authorization_code') && auth.authorization_endpoint)
135
+ methods.push({ method: 'direct', scopes: [...auth.scopes_supported ?? []] });
136
+ if (auth.grant_types_supported.includes('urn:ietf:params:oauth:grant-type:device_code') && auth.device_authorization_endpoint)
137
+ methods.push({ method: 'device', scopes: [...auth.scopes_supported ?? []] });
138
+ const mediated = mediatedSignIn(metadata.document);
139
+ if (mediated && auth.grant_types_supported.includes(MEDIATED_GRANT))
140
+ methods.push({ method: 'mediated', scopes: [...mediated.scopes] });
141
+ return methods;
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
+ }
113
185
  export function parseClientMetadata(value, clientId) {
114
186
  const parsed = validateClientId(clientId);
115
187
  const metadata = object(value);
@@ -118,9 +190,11 @@ export function parseClientMetadata(value, clientId) {
118
190
  string(metadata.client_name, 'client_name');
119
191
  member(metadata.token_endpoint_auth_method, ['private_key_jwt'], 'token_endpoint_auth_method');
120
192
  const redirects = strings(metadata.redirect_uris, 'redirect_uris', true);
121
- for (const endpoint of [url(metadata.jwks_uri, 'jwks_uri'), ...redirects])
122
- if (secureUrl(endpoint).hostname.toLowerCase() !== parsed.hostname.toLowerCase())
123
- throw new ValidationError('client_endpoint_domain');
193
+ if (secureUrl(url(metadata.jwks_uri, 'jwks_uri')).hostname !== parsed.hostname || redirects.some(endpoint => !validRedirect(endpoint, clientId)))
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');
124
198
  if ('logo_uri' in metadata)
125
199
  url(metadata.logo_uri, 'logo_uri');
126
200
  return metadata;
@@ -141,7 +215,7 @@ function remember(map, key, value, max, expired) {
141
215
  }
142
216
  }
143
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).
144
- // 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.
145
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.
146
220
  export function createMetadataResolver(transport, options = {}) {
147
221
  const now = options.now ?? Date.now;
@@ -153,13 +227,12 @@ export function createMetadataResolver(transport, options = {}) {
153
227
  const cache = new Map();
154
228
  const pending = new Map();
155
229
  const refreshes = new Map();
156
- const authDocuments = new Map();
157
- async function get(target, validate, mode, refresh = false) {
230
+ async function get(target, validate, mode, refresh = false, allowMissing = false) {
158
231
  secureUrl(target);
159
232
  const key = `${mode}:${target}`;
160
233
  const cached = cache.get(key);
161
234
  if (!refresh && cached && cached.expires > now()) {
162
- validate(cached.value, cached.url);
235
+ validate(cached.value, cached.url, cached.missing);
163
236
  return structuredClone(cached);
164
237
  }
165
238
  let task = pending.get(key);
@@ -180,22 +253,25 @@ export function createMetadataResolver(transport, options = {}) {
180
253
  else if (cached?.modified)
181
254
  headers.set('if-modified-since', cached.modified);
182
255
  const response = await transport.fetch(new Request(target, { headers }), { mode });
183
- if (response.status !== 200 && !(response.status === 304 && cached))
256
+ if (response.status !== 200 && !(allowMissing && response.status === 404) && !(response.status === 304 && cached))
184
257
  throw new ValidationError('metadata.status');
185
258
  let value;
186
259
  try {
187
- 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());
188
261
  }
189
262
  catch {
190
263
  throw new ValidationError('metadata.json');
191
264
  }
192
- const servedUrl = response.status === 304 ? cached.url : response.url || target;
193
- validate(value, servedUrl);
265
+ const missing = response.status === 404 || (response.status === 304 && cached?.missing === true);
266
+ const servedUrl = response.url || (response.status === 304 ? cached.url : target);
267
+ validate(value, servedUrl, missing);
268
+ if (response.status === 304)
269
+ validate(value, cached.url, missing);
194
270
  const control = response.headers.get('cache-control') ?? (response.status === 304 ? cached?.control : undefined) ?? '';
195
271
  const age = Number(response.headers.get('age') ?? 0);
196
272
  const seconds = Number(/(?:^|,)\s*max-age=(\d+)/i.exec(control)?.[1] ?? 60); // no max-age: 60 s floor (guide: at least a minute)
197
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;
198
- const result = { value, control, url: response.status === 304 ? cached.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 };
199
275
  if (startedEpoch !== epoch)
200
276
  return result;
201
277
  if (!/\bno-store\b/i.test(control))
@@ -208,7 +284,7 @@ export function createMetadataResolver(transport, options = {}) {
208
284
  }
209
285
  try {
210
286
  const result = await task;
211
- validate(result.value, result.url);
287
+ validate(result.value, result.url, result.missing);
212
288
  return structuredClone(result);
213
289
  }
214
290
  finally {
@@ -219,36 +295,50 @@ export function createMetadataResolver(transport, options = {}) {
219
295
  return {
220
296
  invalidate(target) { epoch++; pending.clear(); if (target === undefined) {
221
297
  cache.clear();
222
- authDocuments.clear();
223
298
  }
224
299
  else {
225
300
  for (const key of cache.keys())
226
301
  if (key.endsWith(`:${target}`))
227
302
  cache.delete(key);
228
- authDocuments.clear();
229
303
  } },
230
304
  async resolveCompany(input) {
231
- const startedEpoch = epoch;
232
305
  if (!/^[a-z0-9.-]+(?::\d{1,5})?$/i.test(input))
233
306
  throw new ValidationError('domain');
234
307
  const target = `https://${input}/.well-known/poppy.json`;
235
308
  const found = await get(target, parsePoppyDocument, 'discovery');
236
309
  const document = parsePoppyDocument(found.value, found.url);
237
- 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');
238
323
  return { document, url: found.url };
239
- const auth = await get(authorizationMetadataUrl(document.auth.issuer), value => parseAuthorizationMetadata(value, document), 'discovery');
240
- if (startedEpoch !== epoch)
241
- return { document, authorization: parseAuthorizationMetadata(auth.value, document), url: found.url };
242
- const entries = authDocuments.get(document.auth.issuer) ?? new Map();
243
- const spelling = canonical(document.auth);
244
- const currentDomain = document.organization.domain.toLowerCase();
245
- for (const [otherDomain, entry] of entries)
246
- if (otherDomain !== currentDomain && entry.expires > now() && entry.auth !== spelling)
247
- throw new ValidationError('auth.consistency');
248
- const expiredEntry = (entry) => entry.expires <= now();
249
- remember(entries, currentDomain, { auth: spelling, expires: found.expires }, maxEntries, expiredEntry);
250
- remember(authDocuments, document.auth.issuer, entries, maxEntries, inner => [...inner.values()].every(expiredEntry));
251
- 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);
336
+ },
337
+ async resolveExcludedTasks(document) {
338
+ if (!document.excluded_tasks)
339
+ return [];
340
+ const found = await get(document.excluded_tasks.url, value => parseExcludedTasks(value, document), 'discovery');
341
+ return parseExcludedTasks(found.value, document);
252
342
  },
253
343
  async resolveClient(clientId) { validateClientId(clientId); const found = await get(clientId, value => parseClientMetadata(value, clientId), 'endpoint'); return parseClientMetadata(found.value, clientId); },
254
344
  async resolveJwks(uri, settings = {}) {
@@ -0,0 +1,3 @@
1
+ export declare function loopbackRedirect(value: string): boolean;
2
+ export declare function validRedirect(value: string, clientId: string): boolean;
3
+ export declare function registeredRedirect(value: string, registered: readonly string[], clientId: string): boolean;
@@ -0,0 +1,38 @@
1
+ import { secureUrl } from './transport.js';
2
+ // RFC 8252 loopback callbacks are a return address, never a metadata/fetch exception.
3
+ export function loopbackRedirect(value) {
4
+ if (!/^http:\/\/(127\.0\.0\.1|\[::1\])(?::[0-9]+)?\//.test(value) || /[\\\s\x00-\x1f]/.test(value))
5
+ return false;
6
+ try {
7
+ const url = new URL(value);
8
+ return !url.username && !url.password && !url.hash;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ export function validRedirect(value, clientId) {
15
+ if (/[\\\s\x00-\x1f]/.test(value))
16
+ return false;
17
+ if (loopbackRedirect(value))
18
+ return true;
19
+ try {
20
+ return secureUrl(value).hostname === secureUrl(clientId).hostname;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ export function registeredRedirect(value, registered, clientId) {
27
+ if (!validRedirect(value, clientId))
28
+ return false;
29
+ return registered.some(entry => {
30
+ if (entry === value)
31
+ return true;
32
+ if (!loopbackRedirect(entry) || !loopbackRedirect(value))
33
+ return false;
34
+ // Compare the original URI strings: only the literal port may differ.
35
+ const withoutPort = (uri) => uri.replace(/^(http:\/\/(?:127\.0\.0\.1|\[::1\]))(?::[0-9]+)?(?=\/)/, '$1');
36
+ return withoutPort(entry) === withoutPort(value);
37
+ });
38
+ }
@@ -0,0 +1,23 @@
1
+ import type { PoppyDocument } from './discovery.js';
2
+ export type AlternativeReference = {
3
+ type: 'web';
4
+ url: string;
5
+ } | {
6
+ type: 'openapi';
7
+ url: string;
8
+ operation_id: string;
9
+ } | {
10
+ type: 'mcp';
11
+ url: string;
12
+ tool: string;
13
+ };
14
+ export interface Alternative {
15
+ instructions?: string;
16
+ references?: AlternativeReference[];
17
+ }
18
+ export interface ExcludedTask {
19
+ task: string;
20
+ alternative?: Alternative;
21
+ }
22
+ export declare function parseAlternative(value: unknown, document: PoppyDocument): Alternative;
23
+ export declare function parseExcludedTasks(value: unknown, document: PoppyDocument): ExcludedTask[];
@@ -0,0 +1,45 @@
1
+ import { object, string, ValidationError } from '../protocol/wire.js';
2
+ import { secureUrl } from './transport.js';
3
+ function text(value, field, max) {
4
+ const result = string(value, field);
5
+ if ([...result].length > max)
6
+ throw new ValidationError(field);
7
+ return result;
8
+ }
9
+ export function parseAlternative(value, document) {
10
+ const item = object(value, 'alternative'), result = {};
11
+ if (!('instructions' in item) && !('references' in item))
12
+ throw new ValidationError('alternative');
13
+ if ('instructions' in item)
14
+ result.instructions = text(item.instructions, 'instructions', 2000);
15
+ if ('references' in item) {
16
+ if (!Array.isArray(item.references))
17
+ throw new ValidationError('references');
18
+ result.references = [];
19
+ for (const reference of item.references) {
20
+ const ref = object(reference, 'reference'), type = string(ref.type, 'reference.type');
21
+ if (!['web', 'openapi', 'mcp'].includes(type))
22
+ continue;
23
+ const url = string(ref.url, 'reference.url');
24
+ if (type !== 'web' && !document.apis?.some(api => api.type === type && api.url === url))
25
+ continue;
26
+ secureUrl(url);
27
+ if (type === 'web')
28
+ result.references.push({ type, url });
29
+ else {
30
+ const field = type === 'openapi' ? 'operation_id' : 'tool', name = string(ref[field], field);
31
+ result.references.push(type === 'openapi' ? { type, url, operation_id: name } : { type: 'mcp', url, tool: name });
32
+ }
33
+ }
34
+ }
35
+ return result;
36
+ }
37
+ export function parseExcludedTasks(value, document) {
38
+ const tasks = object(value, 'excluded_tasks').tasks;
39
+ if (!Array.isArray(tasks))
40
+ throw new ValidationError('tasks');
41
+ return tasks.map(value => {
42
+ const item = object(value, 'task');
43
+ return { task: text(item.task, 'task', 200), ...('alternative' in item ? { alternative: parseAlternative(item.alternative, document) } : {}) };
44
+ });
45
+ }