@poppykit/server 0.3.0 → 0.4.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 +1 -0
- package/AUTH.md +36 -7
- package/CHANGELOG.md +11 -0
- package/CONTRIBUTING.md +6 -4
- package/DELIVERY.md +19 -1
- package/INTEGRATIONS.md +21 -11
- package/README.md +12 -4
- package/RELEASING.md +1 -1
- package/STORAGE.md +19 -3
- package/dist/adapter-contract.js +11 -11
- package/dist/browser.d.ts +9 -6
- package/dist/browser.js +32 -30
- package/dist/conversation/service.d.ts +7 -7
- package/dist/conversation/service.js +26 -24
- package/dist/index.d.ts +1 -0
- package/dist/network/discovery.d.ts +20 -9
- package/dist/network/discovery.js +74 -21
- 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/postgres.js +22 -4
- package/dist/protocol/wire.d.ts +12 -11
- package/dist/protocol/wire.js +33 -7
- package/dist/reference-auth/index.d.ts +16 -5
- package/dist/reference-auth/index.js +130 -59
- package/dist/reference-auth/store.d.ts +2 -1
- package/dist/sqlite.js +22 -4
- package/examples/quickstart.mjs +2 -1
- package/package.json +2 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { companyScopes } from '../network/discovery.js';
|
|
1
2
|
import { randomBytes } from 'node:crypto';
|
|
2
3
|
import { createConversationNotifications } from './store.js';
|
|
3
|
-
import { ValidationError, canonical, parseJson, object, parseConversationRequest, parseMessage, parseReadOptions } from '../protocol/wire.js';
|
|
4
|
+
import { ValidationError, canonical, parseJson, object, parseEvent, parseConversationRequest, parseMessage, parseReadOptions } from '../protocol/wire.js';
|
|
4
5
|
import { secureUrl } from '../network/transport.js';
|
|
5
6
|
import { own } from '../store.js';
|
|
6
7
|
import { AuthorityError } from '../auth/authority.js';
|
|
@@ -13,12 +14,12 @@ export class PublicationError extends Error {
|
|
|
13
14
|
}
|
|
14
15
|
}
|
|
15
16
|
// Spec 7.10: text or data in a Direct Conversation is the User's own words; context-only messages may come from the agent.
|
|
16
|
-
const directWords = (message) => message.
|
|
17
|
+
const directWords = (message) => message.author.type !== 'human' && (message.text !== undefined || message.data !== undefined);
|
|
17
18
|
const maxBodyBytes = 1048576;
|
|
18
19
|
const maxWaitSeconds = 30;
|
|
19
20
|
const randomId = (prefix) => prefix + randomBytes(16).toString('base64url');
|
|
20
21
|
export function initialConversationState() {
|
|
21
|
-
return { version:
|
|
22
|
+
return { version: 2, generation: randomId(''), conversations: {}, bindings: {}, messages: {}, work: [] };
|
|
22
23
|
}
|
|
23
24
|
/** For the host's reset transaction: a fresh state with a new generation, so every old fence is stale. */
|
|
24
25
|
export function resetConversationState(draft) {
|
|
@@ -71,7 +72,11 @@ async function readJson(request) {
|
|
|
71
72
|
export function createConversationService(options) {
|
|
72
73
|
const { store, authenticator, policy, clock } = options;
|
|
73
74
|
const pageSize = options.pageSize ?? 100;
|
|
74
|
-
const historyScopes = [...(options.historyScopes
|
|
75
|
+
const historyScopes = [...companyScopes(options.historyScopes)];
|
|
76
|
+
if (options.supportedScopes)
|
|
77
|
+
companyScopes(options.supportedScopes, true);
|
|
78
|
+
if (options.directScopes)
|
|
79
|
+
companyScopes(options.directScopes, true);
|
|
75
80
|
const leaseMs = options.leaseMs ?? 60000;
|
|
76
81
|
const operatorLeaseMs = options.operatorLeaseMs ?? 900000;
|
|
77
82
|
const pollIntervalMs = options.pollIntervalMs ?? 1000;
|
|
@@ -209,7 +214,8 @@ export function createConversationService(options) {
|
|
|
209
214
|
}
|
|
210
215
|
const events = [];
|
|
211
216
|
let bytes = 1024; // Worst-case IDs, status, responder, cursor and JSON envelope.
|
|
212
|
-
for (const
|
|
217
|
+
for (const stored of conversation.events.slice(start, start + pageSize)) {
|
|
218
|
+
const event = parseEvent(stored);
|
|
213
219
|
const size = Buffer.byteLength(JSON.stringify(event)) + 1;
|
|
214
220
|
if (bytes + size > maxPageBytes)
|
|
215
221
|
break;
|
|
@@ -226,7 +232,7 @@ export function createConversationService(options) {
|
|
|
226
232
|
function ack(conversation) {
|
|
227
233
|
return { conversation_id: conversation.id, status: conversation.status, responder: conversation.responder };
|
|
228
234
|
}
|
|
229
|
-
// Account-bound history needs
|
|
235
|
+
// Account-bound history needs the configured scopes; sending alone does not read history. Other readers fail ownership first.
|
|
230
236
|
const scopesFor = (state, principal, conversation, history) =>
|
|
231
237
|
// Only an owner may see a scope error; for anyone else ownership fails first with 404, so existence never leaks.
|
|
232
238
|
conversation && history && conversation.issuer === principal.issuer && conversation.clientId === principal.clientId && principal.accountId !== undefined
|
|
@@ -241,7 +247,7 @@ export function createConversationService(options) {
|
|
|
241
247
|
return conversation;
|
|
242
248
|
// 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.
|
|
243
249
|
if (accountId !== undefined && conversation.userId === principal.userId)
|
|
244
|
-
throw new AuthorityError('
|
|
250
|
+
throw new AuthorityError('insufficient_scope', { scope: historyScopes.join(' ') });
|
|
245
251
|
throw notFound();
|
|
246
252
|
}
|
|
247
253
|
// Runs change under withCurrent; target() and history() decide the scopes from the state being checked.
|
|
@@ -270,7 +276,7 @@ export function createConversationService(options) {
|
|
|
270
276
|
const parentId = conversationId === undefined ? body.parent_conversation_id : undefined;
|
|
271
277
|
// A Direct Conversation carries the User's own words (7.10).
|
|
272
278
|
if (parentId !== undefined && directWords(body.message))
|
|
273
|
-
throw new ValidationError('message.
|
|
279
|
+
throw new ValidationError('message.author.type');
|
|
274
280
|
const key = JSON.stringify([principal.issuer, principal.clientId, principal.userId, body.message.id]);
|
|
275
281
|
const fingerprint = canonical({ target: conversationId ?? null, request: body });
|
|
276
282
|
const authority = JSON.parse(JSON.stringify({ principal, acceptedAt: clock.now(), revocationPolicy }));
|
|
@@ -326,7 +332,7 @@ export function createConversationService(options) {
|
|
|
326
332
|
if (conversation.childId !== undefined)
|
|
327
333
|
throw directOpen(conversation.childId);
|
|
328
334
|
if (conversation.parentId !== undefined && directWords(body.message))
|
|
329
|
-
throw new ValidationError('message.
|
|
335
|
+
throw new ValidationError('message.author.type');
|
|
330
336
|
if (read.cursor !== undefined)
|
|
331
337
|
page(conversation, read.cursor, draft.cursorStatus);
|
|
332
338
|
conversation.authority = authority;
|
|
@@ -619,7 +625,7 @@ export function createConversationService(options) {
|
|
|
619
625
|
work.phase = 'claimed';
|
|
620
626
|
work.fence = newFence(draft);
|
|
621
627
|
work.leaseUntil = now + leaseMs;
|
|
622
|
-
return { workId: work.id, fence: work.fence, conversationId: work.conversationId, message: work.message, context: conversation.context };
|
|
628
|
+
return { workId: work.id, fence: work.fence, conversationId: work.conversationId, message: parseMessage(work.message), context: conversation.context };
|
|
623
629
|
}
|
|
624
630
|
return null;
|
|
625
631
|
});
|
|
@@ -635,8 +641,8 @@ export function createConversationService(options) {
|
|
|
635
641
|
return publish(ref, (state, work) => boundAccount(state, work) === undefined ? [] : historyScopes, (draft, work, conversation) => {
|
|
636
642
|
const accountId = boundAccount(draft, work);
|
|
637
643
|
if (accountId !== undefined && work.authority.principal.accountId !== accountId)
|
|
638
|
-
throw new AuthorityError('
|
|
639
|
-
return linkedHistory(draft, conversation);
|
|
644
|
+
throw new AuthorityError('insufficient_scope', { scope: historyScopes.join(' ') });
|
|
645
|
+
return linkedHistory(draft, conversation).map(parseEvent);
|
|
640
646
|
}, never, true);
|
|
641
647
|
},
|
|
642
648
|
requireAccount(ref, scopes) {
|
|
@@ -645,7 +651,7 @@ export function createConversationService(options) {
|
|
|
645
651
|
const binding = draft.bindings[conversation.bindingId];
|
|
646
652
|
// Parent and child share this record, so the first account use wins for both and no other account can.
|
|
647
653
|
if (accountId === undefined || (binding.accountId ?? accountId) !== accountId)
|
|
648
|
-
throw new AuthorityError('
|
|
654
|
+
throw new AuthorityError('insufficient_scope', { scope: (scopes.length ? scopes : historyScopes).join(' ') });
|
|
649
655
|
binding.accountId = accountId;
|
|
650
656
|
return { accountId };
|
|
651
657
|
});
|
|
@@ -654,8 +660,8 @@ export function createConversationService(options) {
|
|
|
654
660
|
const parsed = parseMessage(message, { company: true });
|
|
655
661
|
const payload = canonical({ reply: parsed });
|
|
656
662
|
await publish(ref, noScopes, (draft, work, conversation) => {
|
|
657
|
-
if (parsed.
|
|
658
|
-
throw new ValidationError('message.
|
|
663
|
+
if (parsed.author.type !== conversation.responder)
|
|
664
|
+
throw new ValidationError('message.author.type');
|
|
659
665
|
if (!firstOutput(work, outputId, payload))
|
|
660
666
|
return;
|
|
661
667
|
// A person's reply answers the messages held for them so far.
|
|
@@ -673,14 +679,10 @@ export function createConversationService(options) {
|
|
|
673
679
|
if (!Array.isArray(scopes) || !scopes.length || !scopes.every(scope => typeof scope === 'string' && direct.includes(scope)))
|
|
674
680
|
throw new TypeError('scopes');
|
|
675
681
|
const scope = [...new Set(scopes)].join(' ');
|
|
676
|
-
await publish(ref, noScopes, (
|
|
682
|
+
await publish(ref, noScopes, (_draft, work, conversation) => {
|
|
677
683
|
if (!firstOutput(work, outputId, canonical({ authorization: scope })))
|
|
678
684
|
return;
|
|
679
|
-
|
|
680
|
-
const bound = boundAccount(draft, work);
|
|
681
|
-
// More scopes only helps a live sign-in on the right account; signed out, another account or revoked all need sign-in.
|
|
682
|
-
const narrow = live && accountId !== undefined && (bound ?? accountId) === accountId && !scopes.every(item => granted.includes(item));
|
|
683
|
-
addEvent(conversation, { type: 'authorization', error: narrow ? 'insufficient_scope' : 'sign_in_required', scope });
|
|
685
|
+
addEvent(conversation, { type: 'authorization', error: 'insufficient_scope', scope });
|
|
684
686
|
}, always);
|
|
685
687
|
},
|
|
686
688
|
async requestUser(ref, outputId, reason) {
|
|
@@ -701,7 +703,7 @@ export function createConversationService(options) {
|
|
|
701
703
|
delete work.leaseUntil;
|
|
702
704
|
// Spec 7.8: 'human' means a person's own words, so a person's failed work gets no canned apology.
|
|
703
705
|
if (outcome === 'failed' && !work.human) {
|
|
704
|
-
addEvent(conversation, { type: 'message', message: { id: randomId('msg_'), role: 'company',
|
|
706
|
+
addEvent(conversation, { type: 'message', message: { id: randomId('msg_'), role: 'company', author: { type: 'agent' }, text: 'Sorry, something went wrong and this request could not be completed.' } });
|
|
705
707
|
}
|
|
706
708
|
if (conversation.status === 'working')
|
|
707
709
|
setState(conversation, idleOrWorking(draft, conversation.id));
|
|
@@ -727,7 +729,7 @@ export function createConversationService(options) {
|
|
|
727
729
|
const source = conversation.lastUserMessage ?? last?.message;
|
|
728
730
|
if (!source)
|
|
729
731
|
throw new PublicationError('stale_work');
|
|
730
|
-
const { role: _role, ...message } = source;
|
|
732
|
+
const { role: _role, ...message } = parseMessage(source);
|
|
731
733
|
const work = { id: randomId('wrk_'), conversationId, message, authority, phase: 'claimed', fence: newFence(draft), leaseUntil: clock.now() + operatorLeaseMs, human: true, outputs: {} };
|
|
732
734
|
conversation.humanUntil = work.leaseUntil;
|
|
733
735
|
draft.work.push(work);
|
|
@@ -746,7 +748,7 @@ export function createConversationService(options) {
|
|
|
746
748
|
throw new PublicationError('stale_work');
|
|
747
749
|
// Messages sent while queued were meant for a person; the agent answers them now.
|
|
748
750
|
release(draft, conversationId);
|
|
749
|
-
addEvent(conversation, { type: 'message', message: { id: randomId('msg_'), role: 'company',
|
|
751
|
+
addEvent(conversation, { type: 'message', message: { id: randomId('msg_'), role: 'company', author: { type: 'agent' }, text: reason } });
|
|
750
752
|
setState(conversation, idleOrWorking(draft, conversationId), 'agent');
|
|
751
753
|
});
|
|
752
754
|
},
|
package/dist/index.d.ts
CHANGED
|
@@ -9,3 +9,4 @@ export type { BrowserBinding, BrowserSession, BrowserSessionAuthority, BrowserCo
|
|
|
9
9
|
export { createFeedbackHandler } from './feedback.js';
|
|
10
10
|
export type { FeedbackReport } from './feedback.js';
|
|
11
11
|
export { mcpResourceMetadata, mcpAuthorizationError } from './mcp.js';
|
|
12
|
+
export type { Author, AuthorType, Message, RecordedMessage, ConversationRequest, ConversationState, ConversationEvent, KnownEvent, EventPage } from './protocol/wire.js';
|
|
@@ -1,19 +1,18 @@
|
|
|
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
15
|
issuer: string;
|
|
11
|
-
direct?: {
|
|
12
|
-
scopes: string[];
|
|
13
|
-
};
|
|
14
|
-
device?: {
|
|
15
|
-
scopes: string[];
|
|
16
|
-
};
|
|
17
16
|
mediated?: {
|
|
18
17
|
endpoint: string;
|
|
19
18
|
fields: {
|
|
@@ -23,7 +22,8 @@ export interface PoppyDocument {
|
|
|
23
22
|
}[];
|
|
24
23
|
scopes: string[];
|
|
25
24
|
};
|
|
26
|
-
|
|
25
|
+
scopes?: Record<string, string>;
|
|
26
|
+
scope_hints?: Record<string, ScopeHint>;
|
|
27
27
|
[key: string]: unknown;
|
|
28
28
|
};
|
|
29
29
|
agent?: {
|
|
@@ -45,6 +45,9 @@ export interface PoppyDocument {
|
|
|
45
45
|
browser_session_endpoint?: string;
|
|
46
46
|
[key: string]: unknown;
|
|
47
47
|
};
|
|
48
|
+
excluded_tasks?: {
|
|
49
|
+
url: string;
|
|
50
|
+
};
|
|
48
51
|
feedback?: {
|
|
49
52
|
endpoint: string;
|
|
50
53
|
[key: string]: unknown;
|
|
@@ -60,7 +63,9 @@ export interface AuthorizationMetadata {
|
|
|
60
63
|
issuer: string;
|
|
61
64
|
token_endpoint: string;
|
|
62
65
|
revocation_endpoint: string;
|
|
63
|
-
|
|
66
|
+
protected_resources: string[];
|
|
67
|
+
grant_types_supported: string[];
|
|
68
|
+
scopes_supported?: string[];
|
|
64
69
|
authorization_endpoint?: string;
|
|
65
70
|
device_authorization_endpoint?: string;
|
|
66
71
|
dpop_signing_alg_values_supported?: string[];
|
|
@@ -86,12 +91,18 @@ export interface MetadataResolver {
|
|
|
86
91
|
resolveJwks(uri: string, options?: {
|
|
87
92
|
refresh?: boolean;
|
|
88
93
|
}): Promise<JsonObject>;
|
|
94
|
+
resolveExcludedTasks?(document: PoppyDocument): Promise<ExcludedTask[]>;
|
|
89
95
|
invalidate?(target?: string): void;
|
|
90
96
|
}
|
|
97
|
+
export declare function companyScopes(value: unknown, allowEmpty?: boolean): string[];
|
|
91
98
|
export declare function authorizationMetadataUrl(issuer: string): string;
|
|
92
99
|
export declare function validateClientId(clientId: string): URL;
|
|
93
100
|
export declare function parsePoppyDocument(value: unknown, servingUrl: string): PoppyDocument;
|
|
94
101
|
export declare function parseAuthorizationMetadata(value: unknown, document: PoppyDocument): AuthorizationMetadata;
|
|
102
|
+
export declare function signInMethods(metadata: CompanyMetadata): {
|
|
103
|
+
method: 'direct' | 'device' | 'mediated';
|
|
104
|
+
scopes: string[];
|
|
105
|
+
}[];
|
|
95
106
|
export declare function parseClientMetadata(value: unknown, clientId: string): ClientMetadata;
|
|
96
107
|
export declare function createMetadataResolver(transport: HttpTransport, options?: {
|
|
97
108
|
now?: () => number;
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
import { validRedirect } from './redirect.js';
|
|
1
2
|
import { object, string, member, canonical, 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) || scope.startsWith('poppy:'))
|
|
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())
|
|
@@ -36,13 +45,10 @@ export function parsePoppyDocument(value, servingUrl) {
|
|
|
36
45
|
if ('auth' in doc) {
|
|
37
46
|
const auth = object(doc.auth, 'auth');
|
|
38
47
|
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
48
|
if ('mediated' in auth) {
|
|
43
49
|
const mediated = object(auth.mediated, 'mediated');
|
|
44
50
|
url(mediated.endpoint, 'mediated.endpoint');
|
|
45
|
-
|
|
51
|
+
companyScopes(mediated.scopes);
|
|
46
52
|
if (!Array.isArray(mediated.fields) || !mediated.fields.length)
|
|
47
53
|
throw new ValidationError('mediated.fields');
|
|
48
54
|
for (const field of mediated.fields) {
|
|
@@ -52,8 +58,21 @@ export function parsePoppyDocument(value, servingUrl) {
|
|
|
52
58
|
member(item.secret, [true, false], 'field.secret');
|
|
53
59
|
}
|
|
54
60
|
}
|
|
55
|
-
if ('
|
|
56
|
-
|
|
61
|
+
if ('scope_hints' in auth) {
|
|
62
|
+
const hints = object(auth.scope_hints, 'scope_hints');
|
|
63
|
+
for (const [scope, value] of Object.entries(hints)) {
|
|
64
|
+
companyScopes([scope]);
|
|
65
|
+
const hint = object(value, 'scope_hint');
|
|
66
|
+
string(hint.changes, 'changes');
|
|
67
|
+
member(hint.personal_data, [true, false], 'personal_data');
|
|
68
|
+
hints[scope] = { changes: ['none', 'reversible', 'irreversible'].includes(hint.changes) ? hint.changes : 'irreversible', personal_data: hint.personal_data };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if ('scopes' in auth)
|
|
72
|
+
for (const [scope, description] of Object.entries(object(auth.scopes, 'auth.scopes'))) {
|
|
73
|
+
companyScopes([scope]);
|
|
74
|
+
string(description, 'auth.scopes');
|
|
75
|
+
}
|
|
57
76
|
}
|
|
58
77
|
if ('agent' in doc) {
|
|
59
78
|
const protocols = object(doc.agent, 'agent').protocols;
|
|
@@ -79,6 +98,8 @@ export function parsePoppyDocument(value, servingUrl) {
|
|
|
79
98
|
url(item.resource, 'api.resource');
|
|
80
99
|
}
|
|
81
100
|
}
|
|
101
|
+
if ('excluded_tasks' in doc)
|
|
102
|
+
url(object(doc.excluded_tasks).url, 'excluded_tasks.url');
|
|
82
103
|
if ('feedback' in doc)
|
|
83
104
|
url(object(doc.feedback).endpoint, 'feedback.endpoint');
|
|
84
105
|
if ('extensions' in doc)
|
|
@@ -95,21 +116,46 @@ export function parseAuthorizationMetadata(value, document) {
|
|
|
95
116
|
const auth = document.auth;
|
|
96
117
|
if (metadata.issuer !== auth.issuer)
|
|
97
118
|
throw new ValidationError('issuer');
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
if (!
|
|
101
|
-
throw new ValidationError('
|
|
119
|
+
const resources = strings(metadata.protected_resources, 'protected_resources');
|
|
120
|
+
resources.forEach(value => url(value, 'protected_resources'));
|
|
121
|
+
if (!resources.some(value => /^https:\/\/[^/:?#]+$/.test(value) && value.slice(8).toLowerCase() === document.organization.domain.toLowerCase()))
|
|
122
|
+
throw new ValidationError('protected_resources');
|
|
123
|
+
const grants = strings(metadata.grant_types_supported, 'grant_types_supported');
|
|
124
|
+
for (const grant of ['urn:ietf:params:oauth:grant-type:jwt-bearer', 'refresh_token'])
|
|
125
|
+
if (!grants.includes(grant))
|
|
126
|
+
throw new ValidationError('grant_types_supported');
|
|
102
127
|
for (const key of ['token_endpoint', 'revocation_endpoint'])
|
|
103
128
|
url(metadata[key], key);
|
|
104
|
-
for (const [key,
|
|
105
|
-
if (
|
|
129
|
+
for (const [key, grant] of [['authorization_endpoint', 'authorization_code'], ['device_authorization_endpoint', 'urn:ietf:params:oauth:grant-type:device_code']])
|
|
130
|
+
if (grants.includes(grant) || key in metadata)
|
|
106
131
|
url(metadata[key], key);
|
|
132
|
+
if ('pushed_authorization_request_endpoint' in metadata)
|
|
133
|
+
url(metadata.pushed_authorization_request_endpoint, 'pushed_authorization_request_endpoint');
|
|
134
|
+
const signIn = grants.includes('authorization_code') || grants.includes('urn:ietf:params:oauth:grant-type:device_code') || !!auth.mediated;
|
|
135
|
+
const supported = signIn || 'scopes_supported' in metadata ? companyScopes(metadata.scopes_supported, true) : [];
|
|
136
|
+
if (auth.mediated && !auth.mediated.scopes.every(scope => supported.includes(scope)))
|
|
137
|
+
throw new ValidationError('mediated.scopes');
|
|
138
|
+
// Scope descriptions may instead live in a listed OpenAPI document.
|
|
139
|
+
if (!document.apis?.some(api => api.type === 'openapi') && supported.some(scope => !Object.hasOwn(auth.scopes ?? {}, scope)))
|
|
140
|
+
throw new ValidationError('auth.scopes');
|
|
107
141
|
if ('dpop_signing_alg_values_supported' in metadata && !strings(metadata.dpop_signing_alg_values_supported, 'dpop_signing_alg_values_supported').includes('ES256'))
|
|
108
142
|
throw new ValidationError('dpop_signing_alg_values_supported');
|
|
109
143
|
if ('client_id_metadata_document_supported' in metadata)
|
|
110
144
|
member(metadata.client_id_metadata_document_supported, [true, false], 'client_id_metadata_document_supported');
|
|
111
145
|
return metadata;
|
|
112
146
|
}
|
|
147
|
+
export function signInMethods(metadata) {
|
|
148
|
+
const auth = metadata.authorization, methods = [];
|
|
149
|
+
if (!auth)
|
|
150
|
+
return methods;
|
|
151
|
+
if (auth.grant_types_supported.includes('authorization_code') && auth.authorization_endpoint)
|
|
152
|
+
methods.push({ method: 'direct', scopes: [...auth.scopes_supported ?? []] });
|
|
153
|
+
if (auth.grant_types_supported.includes('urn:ietf:params:oauth:grant-type:device_code') && auth.device_authorization_endpoint)
|
|
154
|
+
methods.push({ method: 'device', scopes: [...auth.scopes_supported ?? []] });
|
|
155
|
+
if (metadata.document.auth?.mediated)
|
|
156
|
+
methods.push({ method: 'mediated', scopes: [...metadata.document.auth.mediated.scopes] });
|
|
157
|
+
return methods;
|
|
158
|
+
}
|
|
113
159
|
export function parseClientMetadata(value, clientId) {
|
|
114
160
|
const parsed = validateClientId(clientId);
|
|
115
161
|
const metadata = object(value);
|
|
@@ -118,9 +164,8 @@ export function parseClientMetadata(value, clientId) {
|
|
|
118
164
|
string(metadata.client_name, 'client_name');
|
|
119
165
|
member(metadata.token_endpoint_auth_method, ['private_key_jwt'], 'token_endpoint_auth_method');
|
|
120
166
|
const redirects = strings(metadata.redirect_uris, 'redirect_uris', true);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
throw new ValidationError('client_endpoint_domain');
|
|
167
|
+
if (secureUrl(url(metadata.jwks_uri, 'jwks_uri')).hostname !== parsed.hostname || redirects.some(endpoint => !validRedirect(endpoint, clientId)))
|
|
168
|
+
throw new ValidationError('client_endpoint_domain');
|
|
124
169
|
if ('logo_uri' in metadata)
|
|
125
170
|
url(metadata.logo_uri, 'logo_uri');
|
|
126
171
|
return metadata;
|
|
@@ -189,13 +234,15 @@ export function createMetadataResolver(transport, options = {}) {
|
|
|
189
234
|
catch {
|
|
190
235
|
throw new ValidationError('metadata.json');
|
|
191
236
|
}
|
|
192
|
-
const servedUrl = response.status === 304 ? cached.url :
|
|
237
|
+
const servedUrl = response.url || (response.status === 304 ? cached.url : target);
|
|
193
238
|
validate(value, servedUrl);
|
|
239
|
+
if (response.status === 304)
|
|
240
|
+
validate(value, cached.url);
|
|
194
241
|
const control = response.headers.get('cache-control') ?? (response.status === 304 ? cached?.control : undefined) ?? '';
|
|
195
242
|
const age = Number(response.headers.get('age') ?? 0);
|
|
196
243
|
const seconds = Number(/(?:^|,)\s*max-age=(\d+)/i.exec(control)?.[1] ?? 60); // no max-age: 60 s floor (guide: at least a minute)
|
|
197
244
|
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:
|
|
245
|
+
const result = { value, control, url: servedUrl, expires, etag: response.headers.get('etag') ?? cached?.etag, modified: response.headers.get('last-modified') ?? cached?.modified };
|
|
199
246
|
if (startedEpoch !== epoch)
|
|
200
247
|
return result;
|
|
201
248
|
if (!/\bno-store\b/i.test(control))
|
|
@@ -250,6 +297,12 @@ export function createMetadataResolver(transport, options = {}) {
|
|
|
250
297
|
remember(authDocuments, document.auth.issuer, entries, maxEntries, inner => [...inner.values()].every(expiredEntry));
|
|
251
298
|
return { document, authorization: parseAuthorizationMetadata(auth.value, document), url: found.url };
|
|
252
299
|
},
|
|
300
|
+
async resolveExcludedTasks(document) {
|
|
301
|
+
if (!document.excluded_tasks)
|
|
302
|
+
return [];
|
|
303
|
+
const found = await get(document.excluded_tasks.url, value => parseExcludedTasks(value, document), 'discovery');
|
|
304
|
+
return parseExcludedTasks(found.value, document);
|
|
305
|
+
},
|
|
253
306
|
async resolveClient(clientId) { validateClientId(clientId); const found = await get(clientId, value => parseClientMetadata(value, clientId), 'endpoint'); return parseClientMetadata(found.value, clientId); },
|
|
254
307
|
async resolveJwks(uri, settings = {}) {
|
|
255
308
|
const validate = (value) => {
|
|
@@ -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
|
+
}
|
package/dist/postgres.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { initialConversationState } from './conversation/service.js';
|
|
2
|
-
import { initialIssuerState } from './reference-auth/index.js';
|
|
2
|
+
import { initialIssuerState, upgradeIssuerState } from './reference-auth/index.js';
|
|
3
3
|
import { clientRevisionKey, accountRevisionKey } from './reference-auth/store.js';
|
|
4
4
|
import { PostgresRuntime, json } from './postgres-runtime.js';
|
|
5
5
|
export { createPostgresPool, migratePostgres, postgresMigration, postgresSchemaVersion } from './postgres-runtime.js';
|
|
@@ -43,6 +43,8 @@ export class PostgresCompanyDatabase {
|
|
|
43
43
|
conversations(namespace) {
|
|
44
44
|
const load = async (selection) => {
|
|
45
45
|
const state = { ...await this.meta(namespace, 'conversation_meta', () => { const { version, generation } = initialConversationState(); return { version, generation }; }), conversations: {}, bindings: {}, messages: {}, work: [] };
|
|
46
|
+
if (state.version !== 2)
|
|
47
|
+
throw new Error('conversation reset required for draft 0.3');
|
|
46
48
|
if (selection.messageKey)
|
|
47
49
|
await this.db.lock(JSON.stringify([namespace, 'message', selection.messageKey]));
|
|
48
50
|
let id = selection.conversationId;
|
|
@@ -186,10 +188,10 @@ export class PostgresCompanyDatabase {
|
|
|
186
188
|
};
|
|
187
189
|
}
|
|
188
190
|
issuer(namespace) {
|
|
189
|
-
const kinds = ['sessions', 'grants', 'refreshTokens', 'authorizations', 'codes', 'signIns', 'accessTokens', 'replay'];
|
|
191
|
+
const kinds = ['sessions', 'sessionKeys', 'grants', 'refreshTokens', 'authorizations', 'codes', 'signIns', 'accessTokens', 'replay'];
|
|
190
192
|
const load = async (selection) => {
|
|
191
193
|
await this.db.lock(JSON.stringify([namespace, 'issuer']));
|
|
192
|
-
const state = { ...await this.meta(namespace, 'issuer_meta', () => { const { version, generation, nonces } = initialIssuerState(); return { version, generation, nonces }; }), sessions: {}, grants: {}, refreshTokens: {}, authorizations: {}, codes: {}, signIns: {}, accessTokens: {}, replay: {} };
|
|
194
|
+
const state = { ...await this.meta(namespace, 'issuer_meta', () => { const { version, generation, nonces } = initialIssuerState(); return { version, generation, nonces }; }), sessions: {}, sessionKeys: {}, grants: {}, refreshTokens: {}, authorizations: {}, codes: {}, signIns: {}, accessTokens: {}, replay: {} };
|
|
193
195
|
const get = async (kind, id) => {
|
|
194
196
|
if (!id)
|
|
195
197
|
return;
|
|
@@ -201,6 +203,23 @@ export class PostgresCompanyDatabase {
|
|
|
201
203
|
for (const row of await this.rows(namespace, kind, where, params))
|
|
202
204
|
state[kind][row.id] = JSON.parse(row.body);
|
|
203
205
|
};
|
|
206
|
+
if (state.version === 3) {
|
|
207
|
+
for (const kind of kinds)
|
|
208
|
+
await find(kind, '');
|
|
209
|
+
const before = structuredClone(state);
|
|
210
|
+
upgradeIssuerState(state);
|
|
211
|
+
for (const kind of kinds)
|
|
212
|
+
await this.save(namespace, kind, before[kind], state[kind]);
|
|
213
|
+
await this.put(namespace, 'issuer_meta', 'meta', { version: state.version, generation: state.generation, nonces: state.nonces });
|
|
214
|
+
for (const kind of kinds)
|
|
215
|
+
state[kind] = {};
|
|
216
|
+
}
|
|
217
|
+
if (state.version !== 4)
|
|
218
|
+
throw new Error('unsupported issuer state version');
|
|
219
|
+
if ((selection.kind === 'token' || selection.kind === 'sign_in') && selection.sessionKey) {
|
|
220
|
+
await get('sessionKeys', selection.sessionKey);
|
|
221
|
+
await get('sessions', state.sessionKeys[selection.sessionKey]);
|
|
222
|
+
}
|
|
204
223
|
if (selection.kind === 'access')
|
|
205
224
|
await get('accessTokens', selection.hash);
|
|
206
225
|
if (selection.kind === 'sign_in') {
|
|
@@ -226,7 +245,6 @@ export class PostgresCompanyDatabase {
|
|
|
226
245
|
await get('signIns', selection.revocationKey);
|
|
227
246
|
}
|
|
228
247
|
if (selection.kind === 'token') {
|
|
229
|
-
await get('sessions', selection.sessionId);
|
|
230
248
|
await get('codes', selection.codeHash);
|
|
231
249
|
await get('refreshTokens', selection.refreshHash);
|
|
232
250
|
for (const key of selection.replayKeys)
|
package/dist/protocol/wire.d.ts
CHANGED
|
@@ -4,11 +4,18 @@ export type Json = null | boolean | number | string | Json[] | {
|
|
|
4
4
|
export type JsonObject = {
|
|
5
5
|
[key: string]: Json;
|
|
6
6
|
};
|
|
7
|
-
export type
|
|
7
|
+
export type AuthorType = 'agent' | 'human';
|
|
8
|
+
export interface Author {
|
|
9
|
+
type: AuthorType;
|
|
10
|
+
id?: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
image?: string;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
8
15
|
export type Status = 'working' | 'idle' | 'queued' | 'closed';
|
|
9
16
|
export interface Message {
|
|
10
17
|
id: string;
|
|
11
|
-
|
|
18
|
+
author: Author;
|
|
12
19
|
text?: string;
|
|
13
20
|
data?: JsonObject;
|
|
14
21
|
context?: JsonObject;
|
|
@@ -25,7 +32,7 @@ export interface ConversationRequest {
|
|
|
25
32
|
export interface ConversationState {
|
|
26
33
|
conversation_id: string;
|
|
27
34
|
status: Status;
|
|
28
|
-
responder:
|
|
35
|
+
responder: AuthorType;
|
|
29
36
|
[key: string]: unknown;
|
|
30
37
|
}
|
|
31
38
|
export interface ConversationEvent {
|
|
@@ -40,11 +47,7 @@ export type KnownEvent = (ConversationEvent & {
|
|
|
40
47
|
}) | (ConversationEvent & {
|
|
41
48
|
type: 'state';
|
|
42
49
|
status: Status;
|
|
43
|
-
responder:
|
|
44
|
-
}) | (ConversationEvent & {
|
|
45
|
-
type: 'authorization';
|
|
46
|
-
error: 'sign_in_required';
|
|
47
|
-
scope?: string;
|
|
50
|
+
responder: AuthorType;
|
|
48
51
|
}) | (ConversationEvent & {
|
|
49
52
|
type: 'authorization';
|
|
50
53
|
error: 'insufficient_scope';
|
|
@@ -65,9 +68,7 @@ export interface TokenResponse {
|
|
|
65
68
|
access_token: string;
|
|
66
69
|
token_type: 'DPoP' | 'Bearer';
|
|
67
70
|
expires_in: number;
|
|
68
|
-
scope
|
|
69
|
-
session_id: string;
|
|
70
|
-
signed_in: boolean;
|
|
71
|
+
scope?: string;
|
|
71
72
|
refresh_token?: string;
|
|
72
73
|
refresh_token_expires_in?: number;
|
|
73
74
|
[key: string]: unknown;
|