@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
package/dist/browser.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
-
import { compactVerify, createLocalJWKSet, decodeJwt, errors } from 'jose';
|
|
3
2
|
import { secureUrl } from './network/transport.js';
|
|
4
|
-
import {
|
|
5
|
-
import { assertUniqueJwtMembers } from './auth/dpop.js';
|
|
3
|
+
import { object, parseJson } from './protocol/wire.js';
|
|
6
4
|
import { AuthorityError } from './auth/authority.js';
|
|
7
5
|
const hash = (value) => createHash('sha256').update(value).digest('base64url');
|
|
8
6
|
export async function boundedText(request, max) {
|
|
@@ -32,75 +30,111 @@ export async function boundedText(request, max) {
|
|
|
32
30
|
return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts));
|
|
33
31
|
}
|
|
34
32
|
export function createBrowserSessions(options) {
|
|
35
|
-
secureUrl(options.endpoint);
|
|
36
|
-
const
|
|
33
|
+
const endpoint = secureUrl(options.endpoint), name = options.cookieName ?? '__Host-poppy-session';
|
|
34
|
+
const sameSite = options.sameSite ?? 'Lax', lifetime = options.cookieLifetimeSeconds ?? 3600;
|
|
37
35
|
if (!/^[A-Za-z0-9_-]+$/.test(name) || !['Lax', 'None'].includes(sameSite) || !Number.isSafeInteger(lifetime) || lifetime < 1 || !/^[a-z0-9.-]+$/i.test(options.domain))
|
|
38
36
|
throw new TypeError('browser options');
|
|
39
|
-
|
|
37
|
+
if (name === '__Host-poppy_join' || (endpoint.hostname !== options.domain.toLowerCase() && !endpoint.hostname.endsWith(`.${options.domain.toLowerCase()}`)))
|
|
38
|
+
throw new TypeError('browser options');
|
|
39
|
+
const failure = new Error('Invalid browser session');
|
|
40
|
+
const invalid = () => failure;
|
|
41
|
+
const authorizationEndpoint = new URL(endpoint);
|
|
42
|
+
authorizationEndpoint.pathname = endpoint.pathname.replace(/\/$/, '') + '/authorize';
|
|
40
43
|
async function authenticate(request) {
|
|
41
44
|
const cookies = (request.headers.get('cookie') ?? '').split(';').map(part => part.trim().split('='));
|
|
42
45
|
const matches = cookies.filter(([key]) => key === name);
|
|
43
46
|
if (matches.length !== 1 || matches[0].length !== 2 || !/^[A-Za-z0-9_-]{43}$/.test(matches[0][1]))
|
|
44
47
|
throw invalid();
|
|
45
48
|
const binding = await options.cookies.get(hash(matches[0][1]));
|
|
46
|
-
if (!binding || binding.expiresAt <= options.now())
|
|
49
|
+
if (!binding?.principal || binding.expiresAt <= options.now())
|
|
47
50
|
throw invalid();
|
|
48
|
-
const
|
|
49
|
-
if (
|
|
51
|
+
const principal = binding.principal;
|
|
52
|
+
if (principal.clientId !== binding.clientId || principal.userId !== binding.userId || principal.sessionId !== binding.sessionId)
|
|
50
53
|
throw invalid();
|
|
51
|
-
return
|
|
54
|
+
return options.policy.withCurrent(principal, [], async () => principal);
|
|
52
55
|
}
|
|
53
56
|
return {
|
|
54
57
|
authenticate,
|
|
55
58
|
async withSession(request, scopes, action) {
|
|
56
59
|
const principal = await authenticate(request);
|
|
57
|
-
return options.policy.withCurrent(principal,
|
|
58
|
-
const current = await options.authority.current(principal);
|
|
59
|
-
if (!scopes.every(scope => current.principal.scopes.includes(scope)))
|
|
60
|
-
throw new AuthorityError('insufficient_scope');
|
|
61
|
-
return action(current.principal);
|
|
62
|
-
});
|
|
60
|
+
return options.policy.withCurrent(principal, scopes, () => action(principal));
|
|
63
61
|
},
|
|
64
|
-
async
|
|
62
|
+
async authorize(request) {
|
|
65
63
|
try {
|
|
66
|
-
if (request.
|
|
64
|
+
if (request.url !== authorizationEndpoint.href || request.method !== 'POST' || request.headers.get('content-type')?.split(';')[0] !== 'application/json')
|
|
67
65
|
throw invalid();
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
let value;
|
|
67
|
+
try {
|
|
68
|
+
value = object(parseJson(await boundedText(request, 4096)));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw failure;
|
|
72
|
+
}
|
|
73
|
+
if (typeof value.code !== 'string' || !/^[A-Za-z0-9_-]{1,256}$/.test(value.code))
|
|
70
74
|
throw invalid();
|
|
71
|
-
const
|
|
72
|
-
|
|
75
|
+
const principal = await options.authenticator.authenticate(request);
|
|
76
|
+
const success = await options.policy.withCurrent(principal, [], () => options.cookies.authorizeJoin(hash(value.code), principal, options.now()));
|
|
77
|
+
if (!success)
|
|
73
78
|
throw invalid();
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
return new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } });
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error instanceof AuthorityError)
|
|
83
|
+
return error.response();
|
|
84
|
+
if (error !== failure)
|
|
85
|
+
throw error;
|
|
86
|
+
return Response.json({ error: 'invalid_request' }, { status: 400, headers: { 'Cache-Control': 'no-store' } });
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
async join(request) {
|
|
90
|
+
try {
|
|
91
|
+
if (request.url !== options.endpoint)
|
|
77
92
|
throw invalid();
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
93
|
+
if (request.method === 'GET') {
|
|
94
|
+
const code = randomBytes(32).toString('base64url'), cookie = randomBytes(32).toString('base64url');
|
|
95
|
+
await options.cookies.putJoin(hash(code), { cookieHash: hash(cookie), expiresAt: options.now() + 60000 });
|
|
96
|
+
return new Response(`<!doctype html><title>Connecting</title><form method="post"><input type="hidden" name="code" value="${code}"><button>Continue</button></form>`, { headers: {
|
|
97
|
+
'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store',
|
|
98
|
+
'Content-Security-Policy': "default-src 'none'; form-action 'self'; frame-ancestors 'none'", 'Cross-Origin-Opener-Policy': 'same-origin',
|
|
99
|
+
'Set-Cookie': '__Host-poppy_join=' + cookie + '; Path=/; Max-Age=60; Secure; HttpOnly; SameSite=Strict',
|
|
100
|
+
} });
|
|
83
101
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
102
|
+
if (request.method !== 'POST' || request.headers.get('content-type')?.split(';')[0] !== 'application/x-www-form-urlencoded')
|
|
103
|
+
throw invalid();
|
|
104
|
+
const fields = new URLSearchParams(await boundedText(request, 16384)), codes = fields.getAll('code');
|
|
105
|
+
const issued = [];
|
|
106
|
+
for (const code of new Set(codes))
|
|
107
|
+
issued.push(await options.cookies.consumeJoin(hash(code)));
|
|
108
|
+
if (codes.length !== 1 || [...fields.keys()].some(key => key !== 'code'))
|
|
109
|
+
throw invalid();
|
|
110
|
+
const binding = issued[0];
|
|
111
|
+
const cookies = (request.headers.get('cookie') ?? '').split(';').map(part => part.trim().split('='));
|
|
112
|
+
const matches = cookies.filter(([key]) => key === '__Host-poppy_join');
|
|
113
|
+
if (!binding?.principal || binding.expiresAt <= options.now() || matches.length !== 1 || matches[0].length !== 2 || hash(matches[0][1]) !== binding.cookieHash)
|
|
92
114
|
throw invalid();
|
|
93
|
-
const
|
|
94
|
-
if (
|
|
115
|
+
const origin = request.headers.get('origin'), site = request.headers.get('sec-fetch-site');
|
|
116
|
+
if ((origin !== null && origin !== endpoint.origin) || (site !== null && !['same-origin', 'none'].includes(site)))
|
|
95
117
|
throw invalid();
|
|
96
|
-
const
|
|
97
|
-
const seconds = Math.floor(Math.min(
|
|
98
|
-
if (seconds < 1)
|
|
118
|
+
const principal = { ...binding.principal };
|
|
119
|
+
const seconds = Math.floor(Math.min((principal.sessionExpiresAt ?? principal.tokenExpiresAt) - options.now(), lifetime * 1000) / 1000);
|
|
120
|
+
if (seconds < 1 || (principal.accountId && !options.clearLogin))
|
|
99
121
|
throw invalid();
|
|
122
|
+
principal.tokenExpiresAt = options.now() + seconds * 1000;
|
|
100
123
|
const value = randomBytes(32).toString('base64url');
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
124
|
+
return await options.policy.withCurrent(principal, [], async () => {
|
|
125
|
+
const headers = new Headers({ Location: new URL('/', options.endpoint).href, 'Cache-Control': 'no-store' });
|
|
126
|
+
if (principal.accountId)
|
|
127
|
+
await options.clearLogin(request, headers);
|
|
128
|
+
const previous = cookies.filter(([key]) => key === name);
|
|
129
|
+
if (previous.length > 1)
|
|
130
|
+
throw invalid();
|
|
131
|
+
if (previous[0]?.[1])
|
|
132
|
+
await options.cookies.remove(hash(previous[0][1]));
|
|
133
|
+
await options.cookies.put(hash(value), { clientId: principal.clientId, userId: principal.userId, sessionId: principal.sessionId, ...(principal.issuerGeneration === undefined ? {} : { issuerGeneration: principal.issuerGeneration }), principal, expiresAt: principal.tokenExpiresAt });
|
|
134
|
+
headers.append('Set-Cookie', `${name}=${value}; Path=/; Secure; HttpOnly; SameSite=${sameSite}; Max-Age=${seconds}`);
|
|
135
|
+
headers.append('Set-Cookie', '__Host-poppy_join=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Strict');
|
|
136
|
+
return new Response(null, { status: 303, headers });
|
|
137
|
+
});
|
|
104
138
|
}
|
|
105
139
|
catch {
|
|
106
140
|
return new Response('Invalid browser session', { status: 400, headers: { 'Cache-Control': 'no-store', 'Content-Type': 'text/plain' } });
|
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { OperationApproval, OperationRequest } from '../protocol/operations.js';
|
|
2
|
+
import type { MetadataResolver } from '../network/discovery.js';
|
|
3
|
+
import type { ConversationEvent, JsonObject, Message, AuthorType, Status } from '../protocol/wire.js';
|
|
2
4
|
import type { ConversationStore, ConversationNotifications } from './store.js';
|
|
3
|
-
import type { AcceptedAuthority, AuthorityPolicy, RequestAuthenticator, WorkRevocationPolicy } from '../auth/authority.js';
|
|
5
|
+
import type { AcceptedAuthority, AuthorityPolicy, Principal, RequestAuthenticator, WorkRevocationPolicy } from '../auth/authority.js';
|
|
4
6
|
export interface ConversationRecord {
|
|
5
7
|
id: string;
|
|
6
8
|
issuer: string;
|
|
7
9
|
clientId: string;
|
|
8
10
|
userId: string;
|
|
9
11
|
bindingId: string;
|
|
12
|
+
operationApprovals?: string[];
|
|
10
13
|
lastUserMessage?: Message;
|
|
11
14
|
status: Status;
|
|
12
|
-
responder:
|
|
15
|
+
responder: AuthorType;
|
|
13
16
|
context: JsonObject;
|
|
14
17
|
events: ConversationEvent[];
|
|
15
18
|
eventWindow?: {
|
|
@@ -43,7 +46,7 @@ export interface MessageRecord {
|
|
|
43
46
|
}
|
|
44
47
|
export interface ConversationStoreState {
|
|
45
48
|
cursorStatus?: 'expired' | 'invalid';
|
|
46
|
-
version:
|
|
49
|
+
version: 2;
|
|
47
50
|
generation: string;
|
|
48
51
|
conversations: Record<string, ConversationRecord>;
|
|
49
52
|
bindings: Record<string, {
|
|
@@ -53,6 +56,8 @@ export interface ConversationStoreState {
|
|
|
53
56
|
work: WorkRecord[];
|
|
54
57
|
}
|
|
55
58
|
export interface ConversationOptions {
|
|
59
|
+
/** Enable only with durable business execution and published Company v2 capability. Unsupported clients are refused. */
|
|
60
|
+
operations?: MetadataResolver;
|
|
56
61
|
endpoint: string;
|
|
57
62
|
store: ConversationStore;
|
|
58
63
|
authenticator: RequestAuthenticator;
|
|
@@ -76,8 +81,8 @@ export interface ConversationOptions {
|
|
|
76
81
|
authorizeOperator?: (operator: unknown, conversationId: string) => Promise<void>;
|
|
77
82
|
/** Mandatory for requestAuthorization: the scopes the issuer's Direct Sign-In grants; work may ask only for these. */
|
|
78
83
|
directScopes?: readonly string[];
|
|
79
|
-
/**
|
|
80
|
-
historyScopes
|
|
84
|
+
/** Required nonempty company scopes for account-bound history. */
|
|
85
|
+
historyScopes: readonly string[];
|
|
81
86
|
}
|
|
82
87
|
export interface WorkRef {
|
|
83
88
|
workId: string;
|
|
@@ -92,8 +97,13 @@ export interface ConversationService {
|
|
|
92
97
|
/** Protocol errors become responses; anything else is re-thrown, so hosts must catch it and answer 500. */
|
|
93
98
|
handle(request: Request): Promise<Response>;
|
|
94
99
|
claim(): Promise<WorkClaim | null>;
|
|
100
|
+
operationContext(work: WorkRef): Promise<{
|
|
101
|
+
principal: Principal;
|
|
102
|
+
request: OperationRequest;
|
|
103
|
+
approval?: OperationApproval;
|
|
104
|
+
}>;
|
|
95
105
|
renew(work: WorkRef): Promise<void>;
|
|
96
|
-
/** Conversation history for a model or handler; account-bound history needs the bound account and
|
|
106
|
+
/** Conversation history for a model or handler; account-bound history needs the bound account and configured historyScopes. */
|
|
97
107
|
history(work: WorkRef): Promise<ConversationEvent[]>;
|
|
98
108
|
/** Account-use gate: call before any account read or tool. Checks accepted authority, then binds the shared ownership once. */
|
|
99
109
|
requireAccount(work: WorkRef, scopes: readonly string[]): Promise<{
|
|
@@ -109,7 +119,7 @@ export interface ConversationService {
|
|
|
109
119
|
handoff(work: WorkRef): Promise<void>;
|
|
110
120
|
/** The company closes the conversation (and its open Direct Conversation); idempotent. */
|
|
111
121
|
close(work: WorkRef): Promise<void>;
|
|
112
|
-
/** A person joins: automated work is fenced and the returned claim publishes with
|
|
122
|
+
/** A person joins: automated work is fenced and the returned claim publishes with author.type human. Renew it within operatorLeaseMs. */
|
|
113
123
|
joinHuman(operator: unknown, conversationId: string): Promise<WorkClaim>;
|
|
114
124
|
/** Nobody can join a queued conversation: the agent says why and the conversation leaves the queue. No-op unless queued. */
|
|
115
125
|
noHumanAvailable(operator: unknown, conversationId: string, reason: string): Promise<void>;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { supportsOperations, parseOperationApproval } from '../protocol/operations.js';
|
|
2
|
+
import { companyScopes } from '../network/discovery.js';
|
|
1
3
|
import { randomBytes } from 'node:crypto';
|
|
2
4
|
import { createConversationNotifications } from './store.js';
|
|
3
|
-
import { ValidationError, canonical, parseJson, object, parseConversationRequest, parseMessage, parseReadOptions } from '../protocol/wire.js';
|
|
5
|
+
import { ValidationError, canonical, parseJson, object, parseEvent, parseConversationRequest, parseMessage, parseReadOptions } from '../protocol/wire.js';
|
|
4
6
|
import { secureUrl } from '../network/transport.js';
|
|
5
7
|
import { own } from '../store.js';
|
|
6
8
|
import { AuthorityError } from '../auth/authority.js';
|
|
@@ -13,12 +15,12 @@ export class PublicationError extends Error {
|
|
|
13
15
|
}
|
|
14
16
|
}
|
|
15
17
|
// 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.
|
|
18
|
+
const directWords = (message) => message.author.type !== 'human' && (message.text !== undefined || message.data !== undefined);
|
|
17
19
|
const maxBodyBytes = 1048576;
|
|
18
20
|
const maxWaitSeconds = 30;
|
|
19
21
|
const randomId = (prefix) => prefix + randomBytes(16).toString('base64url');
|
|
20
22
|
export function initialConversationState() {
|
|
21
|
-
return { version:
|
|
23
|
+
return { version: 2, generation: randomId(''), conversations: {}, bindings: {}, messages: {}, work: [] };
|
|
22
24
|
}
|
|
23
25
|
/** For the host's reset transaction: a fresh state with a new generation, so every old fence is stale. */
|
|
24
26
|
export function resetConversationState(draft) {
|
|
@@ -71,7 +73,11 @@ async function readJson(request) {
|
|
|
71
73
|
export function createConversationService(options) {
|
|
72
74
|
const { store, authenticator, policy, clock } = options;
|
|
73
75
|
const pageSize = options.pageSize ?? 100;
|
|
74
|
-
const historyScopes = [...(options.historyScopes
|
|
76
|
+
const historyScopes = [...companyScopes(options.historyScopes)];
|
|
77
|
+
if (options.supportedScopes)
|
|
78
|
+
companyScopes(options.supportedScopes, true);
|
|
79
|
+
if (options.directScopes)
|
|
80
|
+
companyScopes(options.directScopes, true);
|
|
75
81
|
const leaseMs = options.leaseMs ?? 60000;
|
|
76
82
|
const operatorLeaseMs = options.operatorLeaseMs ?? 900000;
|
|
77
83
|
const pollIntervalMs = options.pollIntervalMs ?? 1000;
|
|
@@ -209,7 +215,8 @@ export function createConversationService(options) {
|
|
|
209
215
|
}
|
|
210
216
|
const events = [];
|
|
211
217
|
let bytes = 1024; // Worst-case IDs, status, responder, cursor and JSON envelope.
|
|
212
|
-
for (const
|
|
218
|
+
for (const stored of conversation.events.slice(start, start + pageSize)) {
|
|
219
|
+
const event = parseEvent(stored, !!options.operations);
|
|
213
220
|
const size = Buffer.byteLength(JSON.stringify(event)) + 1;
|
|
214
221
|
if (bytes + size > maxPageBytes)
|
|
215
222
|
break;
|
|
@@ -226,7 +233,7 @@ export function createConversationService(options) {
|
|
|
226
233
|
function ack(conversation) {
|
|
227
234
|
return { conversation_id: conversation.id, status: conversation.status, responder: conversation.responder };
|
|
228
235
|
}
|
|
229
|
-
// Account-bound history needs
|
|
236
|
+
// Account-bound history needs the configured scopes; sending alone does not read history. Other readers fail ownership first.
|
|
230
237
|
const scopesFor = (state, principal, conversation, history) =>
|
|
231
238
|
// Only an owner may see a scope error; for anyone else ownership fails first with 404, so existence never leaks.
|
|
232
239
|
conversation && history && conversation.issuer === principal.issuer && conversation.clientId === principal.clientId && principal.accountId !== undefined
|
|
@@ -241,7 +248,7 @@ export function createConversationService(options) {
|
|
|
241
248
|
return conversation;
|
|
242
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.
|
|
243
250
|
if (accountId !== undefined && conversation.userId === principal.userId)
|
|
244
|
-
throw new AuthorityError('
|
|
251
|
+
throw new AuthorityError(principal.accountId ? 'insufficient_scope' : 'account_required', { scope: historyScopes.join(' ') });
|
|
245
252
|
throw notFound();
|
|
246
253
|
}
|
|
247
254
|
// Runs change under withCurrent; target() and history() decide the scopes from the state being checked.
|
|
@@ -266,11 +273,13 @@ export function createConversationService(options) {
|
|
|
266
273
|
async function accept(request, principal, url, conversationId) {
|
|
267
274
|
const read = parseReadOptions(url, maxWaitSeconds);
|
|
268
275
|
const waiting = url.searchParams.has('wait');
|
|
269
|
-
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');
|
|
270
279
|
const parentId = conversationId === undefined ? body.parent_conversation_id : undefined;
|
|
271
280
|
// A Direct Conversation carries the User's own words (7.10).
|
|
272
281
|
if (parentId !== undefined && directWords(body.message))
|
|
273
|
-
throw new ValidationError('message.
|
|
282
|
+
throw new ValidationError('message.author.type');
|
|
274
283
|
const key = JSON.stringify([principal.issuer, principal.clientId, principal.userId, body.message.id]);
|
|
275
284
|
const fingerprint = canonical({ target: conversationId ?? null, request: body });
|
|
276
285
|
const authority = JSON.parse(JSON.stringify({ principal, acceptedAt: clock.now(), revocationPolicy }));
|
|
@@ -326,11 +335,21 @@ export function createConversationService(options) {
|
|
|
326
335
|
if (conversation.childId !== undefined)
|
|
327
336
|
throw directOpen(conversation.childId);
|
|
328
337
|
if (conversation.parentId !== undefined && directWords(body.message))
|
|
329
|
-
throw new ValidationError('message.
|
|
338
|
+
throw new ValidationError('message.author.type');
|
|
330
339
|
if (read.cursor !== undefined)
|
|
331
340
|
page(conversation, read.cursor, draft.cursorStatus);
|
|
332
341
|
conversation.authority = authority;
|
|
333
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
|
+
}
|
|
334
353
|
pruneWork(draft);
|
|
335
354
|
const message = body.message;
|
|
336
355
|
conversation.lastUserMessage = message;
|
|
@@ -339,7 +358,7 @@ export function createConversationService(options) {
|
|
|
339
358
|
addEvent(conversation, { type: 'message', message: { ...message, role: 'user' } });
|
|
340
359
|
// A context-only message asks nothing. Work always records this message's own accepted authority; while queued
|
|
341
360
|
// for or answered by a person it is held for them and reaches automation only if they never answer it.
|
|
342
|
-
if ('text' in message || 'data' in message) {
|
|
361
|
+
if ('text' in message || 'data' in message || 'approval' in message) {
|
|
343
362
|
const held = conversation.responder === 'human' || conversation.status === 'queued';
|
|
344
363
|
draft.work.push({ id: randomId('wrk_'), conversationId: conversation.id, message, authority, phase: held ? 'held' : 'pending', outputs: {} });
|
|
345
364
|
if (!held && conversation.status === 'idle')
|
|
@@ -492,6 +511,8 @@ export function createConversationService(options) {
|
|
|
492
511
|
if (request.method !== method)
|
|
493
512
|
throw new HttpError(405, { error: 'method_not_allowed' }, { Allow: method });
|
|
494
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"' });
|
|
495
516
|
if (!match)
|
|
496
517
|
return accept(request, principal, url);
|
|
497
518
|
const [, id, action] = match;
|
|
@@ -597,7 +618,7 @@ export function createConversationService(options) {
|
|
|
597
618
|
return json(400, { error: 'invalid_request' });
|
|
598
619
|
// Status/code/headers only, never the raw message.
|
|
599
620
|
if (error instanceof AuthorityError)
|
|
600
|
-
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);
|
|
601
622
|
throw error;
|
|
602
623
|
}
|
|
603
624
|
},
|
|
@@ -619,7 +640,7 @@ export function createConversationService(options) {
|
|
|
619
640
|
work.phase = 'claimed';
|
|
620
641
|
work.fence = newFence(draft);
|
|
621
642
|
work.leaseUntil = now + leaseMs;
|
|
622
|
-
return { workId: work.id, fence: work.fence, conversationId: work.conversationId, message: 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 };
|
|
623
644
|
}
|
|
624
645
|
return null;
|
|
625
646
|
});
|
|
@@ -635,8 +656,8 @@ export function createConversationService(options) {
|
|
|
635
656
|
return publish(ref, (state, work) => boundAccount(state, work) === undefined ? [] : historyScopes, (draft, work, conversation) => {
|
|
636
657
|
const accountId = boundAccount(draft, work);
|
|
637
658
|
if (accountId !== undefined && work.authority.principal.accountId !== accountId)
|
|
638
|
-
throw new AuthorityError('
|
|
639
|
-
return linkedHistory(draft, conversation);
|
|
659
|
+
throw new AuthorityError('insufficient_scope', { scope: historyScopes.join(' ') });
|
|
660
|
+
return linkedHistory(draft, conversation).map(event => parseEvent(event, !!options.operations));
|
|
640
661
|
}, never, true);
|
|
641
662
|
},
|
|
642
663
|
requireAccount(ref, scopes) {
|
|
@@ -645,17 +666,32 @@ export function createConversationService(options) {
|
|
|
645
666
|
const binding = draft.bindings[conversation.bindingId];
|
|
646
667
|
// Parent and child share this record, so the first account use wins for both and no other account can.
|
|
647
668
|
if (accountId === undefined || (binding.accountId ?? accountId) !== accountId)
|
|
648
|
-
throw new AuthorityError('
|
|
669
|
+
throw new AuthorityError('insufficient_scope', { scope: (scopes.length ? scopes : historyScopes).join(' ') });
|
|
649
670
|
binding.accountId = accountId;
|
|
650
671
|
return { accountId };
|
|
651
672
|
});
|
|
652
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
|
+
},
|
|
653
683
|
async reply(ref, outputId, message) {
|
|
654
|
-
|
|
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 });
|
|
655
691
|
const payload = canonical({ reply: parsed });
|
|
656
692
|
await publish(ref, noScopes, (draft, work, conversation) => {
|
|
657
|
-
if (parsed.
|
|
658
|
-
throw new ValidationError('message.
|
|
693
|
+
if (parsed.author.type !== conversation.responder)
|
|
694
|
+
throw new ValidationError('message.author.type');
|
|
659
695
|
if (!firstOutput(work, outputId, payload))
|
|
660
696
|
return;
|
|
661
697
|
// A person's reply answers the messages held for them so far.
|
|
@@ -673,14 +709,10 @@ export function createConversationService(options) {
|
|
|
673
709
|
if (!Array.isArray(scopes) || !scopes.length || !scopes.every(scope => typeof scope === 'string' && direct.includes(scope)))
|
|
674
710
|
throw new TypeError('scopes');
|
|
675
711
|
const scope = [...new Set(scopes)].join(' ');
|
|
676
|
-
await publish(ref, noScopes, (
|
|
712
|
+
await publish(ref, noScopes, (_draft, work, conversation) => {
|
|
677
713
|
if (!firstOutput(work, outputId, canonical({ authorization: scope })))
|
|
678
714
|
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 });
|
|
715
|
+
addEvent(conversation, { type: 'authorization', error: 'insufficient_scope', scope });
|
|
684
716
|
}, always);
|
|
685
717
|
},
|
|
686
718
|
async requestUser(ref, outputId, reason) {
|
|
@@ -701,7 +733,7 @@ export function createConversationService(options) {
|
|
|
701
733
|
delete work.leaseUntil;
|
|
702
734
|
// Spec 7.8: 'human' means a person's own words, so a person's failed work gets no canned apology.
|
|
703
735
|
if (outcome === 'failed' && !work.human) {
|
|
704
|
-
addEvent(conversation, { type: 'message', message: { id: randomId('msg_'), role: 'company',
|
|
736
|
+
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
737
|
}
|
|
706
738
|
if (conversation.status === 'working')
|
|
707
739
|
setState(conversation, idleOrWorking(draft, conversation.id));
|
|
@@ -727,7 +759,7 @@ export function createConversationService(options) {
|
|
|
727
759
|
const source = conversation.lastUserMessage ?? last?.message;
|
|
728
760
|
if (!source)
|
|
729
761
|
throw new PublicationError('stale_work');
|
|
730
|
-
const { role: _role, ...message } = source;
|
|
762
|
+
const { role: _role, ...message } = parseMessage(source, { operations: !!options.operations });
|
|
731
763
|
const work = { id: randomId('wrk_'), conversationId, message, authority, phase: 'claimed', fence: newFence(draft), leaseUntil: clock.now() + operatorLeaseMs, human: true, outputs: {} };
|
|
732
764
|
conversation.humanUntil = work.leaseUntil;
|
|
733
765
|
draft.work.push(work);
|
|
@@ -746,7 +778,7 @@ export function createConversationService(options) {
|
|
|
746
778
|
throw new PublicationError('stale_work');
|
|
747
779
|
// Messages sent while queued were meant for a person; the agent answers them now.
|
|
748
780
|
release(draft, conversationId);
|
|
749
|
-
addEvent(conversation, { type: 'message', message: { id: randomId('msg_'), role: 'company',
|
|
781
|
+
addEvent(conversation, { type: 'message', message: { id: randomId('msg_'), role: 'company', author: { type: 'agent' }, text: reason } });
|
|
750
782
|
setState(conversation, idleOrWorking(draft, conversationId), 'agent');
|
|
751
783
|
});
|
|
752
784
|
},
|
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,7 +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
|
+
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
|
}
|