@poppykit/server 0.4.0 → 0.5.1
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 +10 -3
- package/AUTH.md +120 -124
- package/CHANGELOG.md +20 -1
- package/CONFORMANCE.md +73 -0
- package/CONTRIBUTING.md +12 -11
- package/DELIVERY.md +2 -0
- package/INTEGRATIONS.md +74 -81
- package/OPERATIONS.md +142 -0
- package/README.md +11 -20
- package/RELEASING.md +2 -0
- package/SECURITY.md +2 -0
- package/STORAGE.md +62 -17
- package/dist/adapter-contract.d.ts +18 -0
- package/dist/adapter-contract.js +79 -0
- 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 -16
- package/dist/browser.js +65 -33
- package/dist/conversation/service.d.ts +11 -1
- package/dist/conversation/service.js +39 -9
- package/dist/feedback.d.ts +1 -1
- package/dist/feedback.js +5 -5
- package/dist/index.d.ts +5 -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 +31 -13
- package/dist/network/discovery.js +103 -66
- package/dist/operations.d.ts +115 -0
- package/dist/operations.js +212 -0
- package/dist/postgres-runtime.js +12 -4
- package/dist/postgres.d.ts +6 -0
- package/dist/postgres.js +72 -4
- 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 +8 -4
- package/dist/protocol/wire.js +37 -15
- package/dist/reference-auth/index.d.ts +10 -16
- package/dist/reference-auth/index.js +225 -430
- package/dist/sqlite.d.ts +11 -0
- package/dist/sqlite.js +84 -4
- package/examples/operations.mjs +18 -0
- package/examples/quickstart.mjs +8 -5
- package/migrations/RETIREMENT.md +77 -0
- 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
|
-
|
|
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);
|
package/dist/feedback.d.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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 = {
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
5
|
-
|
|
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(
|
|
12
|
-
const
|
|
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
|
|
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,
|
|
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)
|
|
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
|
-
|
|
48
|
-
|
|
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 ('
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (
|
|
140
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
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)
|