@poppykit/server 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/API.md +11 -3
  2. package/AUTH.md +120 -95
  3. package/CHANGELOG.md +18 -0
  4. package/CONFORMANCE.md +73 -0
  5. package/CONTRIBUTING.md +13 -10
  6. package/DELIVERY.md +21 -1
  7. package/INTEGRATIONS.md +72 -69
  8. package/OPERATIONS.md +142 -0
  9. package/README.md +14 -17
  10. package/RELEASING.md +2 -0
  11. package/SECURITY.md +2 -0
  12. package/STORAGE.md +57 -2
  13. package/dist/adapter-contract.d.ts +18 -0
  14. package/dist/adapter-contract.js +90 -11
  15. package/dist/auth/authenticator.d.ts +3 -1
  16. package/dist/auth/authenticator.js +34 -29
  17. package/dist/auth/authority.d.ts +8 -2
  18. package/dist/auth/authority.js +7 -2
  19. package/dist/auth/session.d.ts +47 -0
  20. package/dist/auth/session.js +94 -0
  21. package/dist/browser.d.ts +15 -13
  22. package/dist/browser.js +80 -46
  23. package/dist/conversation/service.d.ts +18 -8
  24. package/dist/conversation/service.js +60 -28
  25. package/dist/feedback.d.ts +1 -1
  26. package/dist/feedback.js +5 -5
  27. package/dist/index.d.ts +6 -1
  28. package/dist/index.js +3 -0
  29. package/dist/mcp.d.ts +1 -1
  30. package/dist/mcp.js +8 -4
  31. package/dist/network/discovery.d.ts +51 -22
  32. package/dist/network/discovery.js +159 -69
  33. package/dist/network/redirect.d.ts +3 -0
  34. package/dist/network/redirect.js +38 -0
  35. package/dist/network/tasks.d.ts +23 -0
  36. package/dist/network/tasks.js +45 -0
  37. package/dist/operations.d.ts +115 -0
  38. package/dist/operations.js +212 -0
  39. package/dist/postgres.d.ts +6 -0
  40. package/dist/postgres.js +92 -6
  41. package/dist/protocol/operations.d.ts +51 -0
  42. package/dist/protocol/operations.js +98 -0
  43. package/dist/protocol/session.d.ts +28 -0
  44. package/dist/protocol/session.js +93 -0
  45. package/dist/protocol/sse.d.ts +1 -0
  46. package/dist/protocol/sse.js +1 -1
  47. package/dist/protocol/wire.d.ts +20 -15
  48. package/dist/protocol/wire.js +70 -22
  49. package/dist/reference-auth/index.d.ts +17 -12
  50. package/dist/reference-auth/index.js +252 -386
  51. package/dist/reference-auth/store.d.ts +2 -1
  52. package/dist/sqlite.d.ts +11 -0
  53. package/dist/sqlite.js +104 -6
  54. package/examples/operations.mjs +18 -0
  55. package/examples/quickstart.mjs +10 -6
  56. package/migrations/RETIREMENT.md +77 -0
  57. package/package.json +5 -2
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 { parseJson, object } from './protocol/wire.js';
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 name = options.cookieName ?? '__Host-poppy-session', sameSite = options.sameSite ?? 'Lax', lifetime = options.cookieLifetimeSeconds ?? 3600;
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
- const invalid = () => new Error('Invalid browser session');
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 session = await options.authority.current(binding);
49
- if (session.expiresAt <= options.now() || session.clientId !== binding.clientId || session.userId !== binding.userId || session.sessionId !== binding.sessionId || session.issuerGeneration !== binding.issuerGeneration)
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 session.principal;
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, [], async () => {
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 join(request) {
62
+ async authorize(request) {
65
63
  try {
66
- if (request.method !== 'POST' || request.url !== options.endpoint || request.headers.get('content-type')?.split(';')[0] !== 'application/x-www-form-urlencoded')
64
+ if (request.url !== authorizationEndpoint.href || request.method !== 'POST' || request.headers.get('content-type')?.split(';')[0] !== 'application/json')
67
65
  throw invalid();
68
- const fields = new URLSearchParams(await boundedText(request, 16384));
69
- if (fields.getAll('assertion').length !== 1 || [...fields.keys()].some(key => key !== 'assertion'))
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 jwt = fields.get('assertion');
72
- if (jwt.length > 8192)
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
- assertUniqueJwtMembers(jwt);
75
- const untrusted = decodeJwt(jwt);
76
- if (typeof untrusted.iss !== 'string')
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
- const client = await options.metadata.resolveClient(untrusted.iss);
79
- let keys = await options.metadata.resolveJwks(client.jwks_uri);
80
- let verified;
81
- try {
82
- verified = await compactVerify(jwt, createLocalJWKSet(keys), { algorithms: ['ES256', 'RS256'] });
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
- catch (error) {
85
- if (!(error instanceof errors.JWKSNoMatchingKey))
86
- throw error;
87
- keys = await options.metadata.resolveJwks(client.jwks_uri, { refresh: true });
88
- verified = await compactVerify(jwt, createLocalJWKSet(keys), { algorithms: ['ES256', 'RS256'] });
89
- }
90
- const claims = object(parseJson(new TextDecoder().decode(verified.payload)));
91
- if (verified.protectedHeader.typ?.toLowerCase().replace(/^application\//, '') !== 'poppy-browser+jwt' || claims.iss !== client.client_id || claims.aud !== options.endpoint || typeof claims.sub !== 'string' || !claims.sub || claims.sub.length > 256 || typeof claims.session_id !== 'string' || !claims.session_id || typeof claims.jti !== 'string' || !claims.jti || claims.jti.length > 256 || !Number.isSafeInteger(claims.iat) || !Number.isSafeInteger(claims.exp) || claims.exp <= options.now() / 1000 || claims.iat > options.now() / 1000 + 30 || claims.exp <= claims.iat || claims.exp - claims.iat > 60 || typeof claims.return_to !== 'string')
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 destination = secureUrl(claims.return_to);
94
- if (destination.hostname !== options.domain.toLowerCase() && !destination.hostname.endsWith(`.${options.domain.toLowerCase()}`))
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 session = await options.authority.join({ clientId: client.client_id, userId: claims.sub, sessionId: claims.session_id }, { jti: claims.jti, expiresAt: claims.exp * 1000 });
97
- const seconds = Math.floor(Math.min(session.expiresAt - options.now(), lifetime * 1000) / 1000);
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
- const { clientId, userId, sessionId, issuerGeneration } = session;
102
- await options.cookies.put(hash(value), { clientId, userId, sessionId, ...(issuerGeneration === undefined ? {} : { issuerGeneration }), expiresAt: options.now() + seconds * 1000 });
103
- return new Response(null, { status: 303, headers: { Location: claims.return_to, 'Set-Cookie': `${name}=${value}; Path=/; Secure; HttpOnly; SameSite=${sameSite}; Max-Age=${seconds}`, 'Cache-Control': 'no-store' } });
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 { ConversationEvent, JsonObject, Message, Sender, Status } from '../protocol/wire.js';
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: Sender;
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: 1;
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
- /** Scopes that allow reading account-bound history; default ['poppy:read']. A Company offering only custom scopes sets its own. */
80
- historyScopes?: readonly string[];
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 poppy:read. */
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 sender human. Renew it within operatorLeaseMs. */
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.sender !== 'human' && (message.text !== undefined || message.data !== undefined);
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: 1, generation: randomId(''), conversations: {}, bindings: {}, messages: {}, work: [] };
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 ?? ['poppy:read'])];
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 event of conversation.events.slice(start, start + pageSize)) {
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 poppy:read; sending alone does not read history. Other readers fail ownership first.
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('sign_in_required');
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.sender');
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.sender');
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('sign_in_required');
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('sign_in_required');
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
- const parsed = parseMessage(message, { company: true });
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.sender !== conversation.responder)
658
- throw new ValidationError('message.sender');
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, (draft, work, conversation, live) => {
712
+ await publish(ref, noScopes, (_draft, work, conversation) => {
677
713
  if (!firstOutput(work, outputId, canonical({ authorization: scope })))
678
714
  return;
679
- const { accountId, scopes: granted } = work.authority.principal;
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', sender: 'agent', text: 'Sorry, something went wrong and this request could not be completed.' } });
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', sender: 'agent', text: reason } });
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
  },
@@ -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 new Response(null, { status: error.status, headers: error.headers });
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.kind !== 'string' || !value.kind.length || typeof value.task !== 'string' || !value.task.length || [...value.task].length > 200 || typeof value.description !== 'string' || !value.description.length || [...value.description].length > 2000)
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 = { kind: value.kind, task: value.task, description: value.description };
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 fail(429, 'rate_limited');
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 new Response(null, { status: error.status, headers: error.headers });
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, BrowserSession, BrowserSessionAuthority, BrowserCookieStore } from './browser.js';
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: string;
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
- secureUrl(input.issuer);
5
- return { resource: input.resource, authorization_servers: [input.issuer], bearer_methods_supported: ['header'], ...(input.dpop ? { dpop_signing_alg_values_supported: ['ES256'] } : {}) };
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(/, scope="[^"]*"/)?.[0] ?? '';
12
- const challenge = (scheme) => `${scheme} error="${error.code}"${scope}, resource_metadata="${metadataUrl}"`;
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
  }