@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.
- package/API.md +11 -3
- package/AUTH.md +120 -95
- package/CHANGELOG.md +18 -0
- package/CONFORMANCE.md +73 -0
- package/CONTRIBUTING.md +13 -10
- package/DELIVERY.md +21 -1
- package/INTEGRATIONS.md +72 -69
- package/OPERATIONS.md +142 -0
- package/README.md +14 -17
- package/RELEASING.md +2 -0
- package/SECURITY.md +2 -0
- package/STORAGE.md +57 -2
- package/dist/adapter-contract.d.ts +18 -0
- package/dist/adapter-contract.js +90 -11
- package/dist/auth/authenticator.d.ts +3 -1
- package/dist/auth/authenticator.js +34 -29
- package/dist/auth/authority.d.ts +8 -2
- package/dist/auth/authority.js +7 -2
- package/dist/auth/session.d.ts +47 -0
- package/dist/auth/session.js +94 -0
- package/dist/browser.d.ts +15 -13
- package/dist/browser.js +80 -46
- package/dist/conversation/service.d.ts +18 -8
- package/dist/conversation/service.js +60 -28
- package/dist/feedback.d.ts +1 -1
- package/dist/feedback.js +5 -5
- package/dist/index.d.ts +6 -1
- package/dist/index.js +3 -0
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +8 -4
- package/dist/network/discovery.d.ts +51 -22
- package/dist/network/discovery.js +159 -69
- package/dist/network/redirect.d.ts +3 -0
- package/dist/network/redirect.js +38 -0
- package/dist/network/tasks.d.ts +23 -0
- package/dist/network/tasks.js +45 -0
- package/dist/operations.d.ts +115 -0
- package/dist/operations.js +212 -0
- package/dist/postgres.d.ts +6 -0
- package/dist/postgres.js +92 -6
- package/dist/protocol/operations.d.ts +51 -0
- package/dist/protocol/operations.js +98 -0
- package/dist/protocol/session.d.ts +28 -0
- package/dist/protocol/session.js +93 -0
- package/dist/protocol/sse.d.ts +1 -0
- package/dist/protocol/sse.js +1 -1
- package/dist/protocol/wire.d.ts +20 -15
- package/dist/protocol/wire.js +70 -22
- package/dist/reference-auth/index.d.ts +17 -12
- package/dist/reference-auth/index.js +252 -386
- package/dist/reference-auth/store.d.ts +2 -1
- package/dist/sqlite.d.ts +11 -0
- package/dist/sqlite.js +104 -6
- package/examples/operations.mjs +18 -0
- package/examples/quickstart.mjs +10 -6
- package/migrations/RETIREMENT.md +77 -0
- 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.
|
|
9
|
+
protocol_version: '0.3';
|
|
5
10
|
organization: {
|
|
6
11
|
name: string;
|
|
7
12
|
domain: string;
|
|
8
13
|
};
|
|
9
14
|
auth?: {
|
|
10
|
-
issuer
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
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.
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 ('
|
|
56
|
-
Object.
|
|
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 ('
|
|
67
|
-
|
|
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
|
-
|
|
96
|
-
if (metadata.issuer !== auth.issuer)
|
|
103
|
+
if (!issuer || metadata.issuer !== issuer)
|
|
97
104
|
throw new ValidationError('issuer');
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
if (!
|
|
101
|
-
throw new ValidationError('
|
|
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,
|
|
105
|
-
if (
|
|
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 ('
|
|
108
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
193
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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,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
|
+
}
|