@poppykit/server 0.3.1 → 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.
@@ -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.sender !== 'human' && (message.text !== undefined || message.data !== undefined);
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: 1, generation: randomId(''), conversations: {}, bindings: {}, messages: {}, work: [] };
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 ?? ['poppy:read'])];
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 event of conversation.events.slice(start, start + pageSize)) {
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 poppy:read; sending alone does not read history. Other readers fail ownership first.
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('sign_in_required');
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.sender');
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.sender');
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('sign_in_required');
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('sign_in_required');
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.sender !== conversation.responder)
658
- throw new ValidationError('message.sender');
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, (draft, work, conversation, live) => {
682
+ await publish(ref, noScopes, (_draft, work, conversation) => {
677
683
  if (!firstOutput(work, outputId, canonical({ authorization: scope })))
678
684
  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 });
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', sender: 'agent', text: 'Sorry, something went wrong and this request could not be completed.' } });
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', sender: 'agent', text: reason } });
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.2';
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
- custom_scopes?: Record<string, string>;
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
- poppy_domains: string[];
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 scopes(value) { for (const scope of strings(value, 'scopes'))
7
- if (!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope))
8
- throw new ValidationError('scopes'); }
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.2'], 'protocol_version');
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
- scopes(mediated.scopes);
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 ('custom_scopes' in auth)
56
- Object.values(object(auth.custom_scopes)).forEach(value => string(value, 'custom_scopes', false));
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 domains = strings(metadata.poppy_domains, 'poppy_domains');
99
- domains.forEach(domain);
100
- if (!domains.some(item => item.toLowerCase() === document.organization.domain.toLowerCase()))
101
- throw new ValidationError('poppy_domains');
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, required] of [['authorization_endpoint', !!auth.direct], ['device_authorization_endpoint', !!auth.device]])
105
- if (required || key in metadata)
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
- for (const endpoint of [url(metadata.jwks_uri, 'jwks_uri'), ...redirects])
122
- if (secureUrl(endpoint).hostname.toLowerCase() !== parsed.hostname.toLowerCase())
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 : response.url || target;
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: response.status === 304 ? cached.url : servedUrl, expires, etag: response.headers.get('etag') ?? cached?.etag, modified: response.headers.get('last-modified') ?? cached?.modified };
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,3 @@
1
+ export declare function loopbackRedirect(value: string): boolean;
2
+ export declare function validRedirect(value: string, clientId: string): boolean;
3
+ export declare function registeredRedirect(value: string, registered: readonly string[], clientId: string): boolean;
@@ -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)
@@ -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 Sender = 'agent' | 'human';
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
- sender: Sender;
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: Sender;
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: Sender;
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: string;
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;