@poppykit/client 0.4.0 → 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.
@@ -1,6 +1,6 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
2
  import { initializeSqlite } from '../sqlite-init.js';
3
- import { initialClientAuthState } from './client-auth.js';
3
+ import { initialClientAuthState, upgradeClientAuthState } from './client-auth.js';
4
4
  const tables = ['identities', 'connections', 'grants', 'operations', 'cleanup'];
5
5
  function json(value) {
6
6
  return JSON.stringify(value, (_key, item) => {
@@ -42,6 +42,20 @@ export class SqliteAuthStore {
42
42
  }
43
43
  select(selection) {
44
44
  const state = { ...JSON.parse(this.db.prepare('SELECT body FROM auth_meta WHERE id=1').get().body), identities: {}, connections: {}, grants: {}, operations: {}, cleanup: {} };
45
+ if (state.version === 1) {
46
+ for (const table of tables)
47
+ for (const row of this.db.prepare(`SELECT id,body FROM auth_${table}`).all())
48
+ state[table][row.id] = JSON.parse(row.body);
49
+ upgradeClientAuthState(state);
50
+ for (const table of tables)
51
+ for (const [id, value] of Object.entries(state[table]))
52
+ this.db.prepare(`UPDATE auth_${table} SET body=? WHERE id=?`).run(json(value), id);
53
+ this.db.prepare('UPDATE auth_meta SET body=? WHERE id=1').run(json({ version: state.version, epoch: state.epoch, owner: state.owner }));
54
+ for (const table of tables)
55
+ state[table] = {};
56
+ }
57
+ if (state.version !== 2)
58
+ throw new Error('unsupported auth state version');
45
59
  const get = (table, id) => {
46
60
  const row = this.db.prepare(`SELECT body FROM auth_${table} WHERE id=?`).get(id);
47
61
  if (row)
@@ -85,7 +99,7 @@ export class SqliteAuthStore {
85
99
  return state;
86
100
  }
87
101
  async read(selection) {
88
- this.db.exec('BEGIN');
102
+ this.db.exec('BEGIN IMMEDIATE');
89
103
  try {
90
104
  const state = this.select(selection);
91
105
  this.db.exec('COMMIT');
@@ -139,10 +139,20 @@ export function createConversationClient(options) {
139
139
  throw new ConversationError('unknown_operation');
140
140
  if (record.endpoint !== endpoint)
141
141
  throw new ConversationError('invalid_target');
142
- if (record.phase === 'accepted')
143
- return record.response;
142
+ if (record.phase === 'accepted') {
143
+ try {
144
+ return record.response && 'events' in record.response ? parseEventPage(record.response, record.read?.cursor) : parseConversationState(record.response);
145
+ }
146
+ catch {
147
+ throw new ConversationError('stored_operation_reset_required');
148
+ }
149
+ }
144
150
  if (record.phase === 'rejected')
145
151
  throw new ConversationError(record.error.code, record.error.status, undefined, undefined, record.error.conversationId);
152
+ // A legacy pending body may already be accepted remotely. Never rewrite or replay it as a new contract.
153
+ if ('sender' in record.request.message)
154
+ throw new ConversationError('legacy_message_uncertain');
155
+ parseConversationRequest(record.request);
146
156
  const url = new URL(record.target === null ? endpoint : conversationUrl(record.target, 'messages'));
147
157
  if (record.read?.cursor !== undefined)
148
158
  url.searchParams.set('cursor', record.read.cursor);
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { createClientAuth, initialClientAuthState, AuthFailure } from './auth/cl
2
2
  export type { ClientAuth, ClientAuthOptions, SignOutOutcome, ClientAuthState, AuthStore, AuthSelection, SecretStore, IdentitySigner, Connection, ConnectionStatus, AuthOutcome, OperationStatus, ConversationTarget } from './auth/client-auth.js';
3
3
  export { createNodeTransport } from './network/transport.js';
4
4
  export type { HttpTransport, NetworkOptions } from './network/transport.js';
5
- export { createMetadataResolver } from './network/discovery.js';
5
+ export { createMetadataResolver, signInMethods } from './network/discovery.js';
6
6
  export type { MetadataResolver } from './network/discovery.js';
7
7
  export { createConversationClient, initialConversationClientState, ConversationError } from './conversation.js';
8
8
  export type { ConversationClientOptions, ConsumerCheckpoint, ConversationClientState, OutboundInput, OutboundRecord, ReadOptions, ReadParams } from './conversation.js';
@@ -16,3 +16,6 @@ export { authChallenge } from './auth/challenge.js';
16
16
  export type { AuthChallenge } from './auth/challenge.js';
17
17
  export type { ApiTarget, AgentBrowserController, FeedbackReport } from './integrations.js';
18
18
  export { protectedResourceMetadataUrl } from './integrations.js';
19
+ export type { Author, AuthorType, Message, RecordedMessage, ConversationRequest, ConversationState, ConversationEvent, KnownEvent, EventPage } from './protocol/wire.js';
20
+ export type { Alternative, AlternativeReference, ExcludedTask } from './network/tasks.js';
21
+ export type { ScopeHint } from './network/discovery.js';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export { createClientAuth, initialClientAuthState, AuthFailure } from './auth/client-auth.js';
2
2
  export { createNodeTransport } from './network/transport.js';
3
- export { createMetadataResolver } from './network/discovery.js';
3
+ export { createMetadataResolver, signInMethods } from './network/discovery.js';
4
4
  export { createConversationClient, initialConversationClientState, ConversationError } from './conversation.js';
5
5
  export { parseEventStream } from './protocol/sse.js';
6
6
  export { authChallenge } from './auth/challenge.js';
@@ -10,13 +10,18 @@ export interface ApiTarget {
10
10
  }
11
11
  export interface AgentBrowserController {
12
12
  readonly kind: 'agent-controlled';
13
+ loadJoinForm(url: string): Promise<{
14
+ code: string;
15
+ }>;
13
16
  postForm(input: {
14
17
  url: string;
15
18
  fields: {
16
- assertion: string;
19
+ code: string;
20
+ proof: string;
17
21
  };
18
22
  validateRedirect(status: number, location: string): string;
19
23
  }): Promise<void>;
24
+ navigate(url: string): Promise<void>;
20
25
  }
21
26
  export interface FeedbackReport {
22
27
  kind: 'missing';
@@ -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,4 +1,4 @@
1
- import { initialClientAuthState } from './auth/client-auth.js';
1
+ import { initialClientAuthState, upgradeClientAuthState } from './auth/client-auth.js';
2
2
  import { PostgresRuntime, json, synchronous } from './postgres-runtime.js';
3
3
  export { createPostgresPool, migratePostgres, postgresMigration, postgresSchemaVersion } from './postgres-runtime.js';
4
4
  const tables = ['identities', 'connections', 'grants', 'operations', 'cleanup'];
@@ -27,6 +27,19 @@ export class PostgresAuthStore {
27
27
  for (const row of await this.db.query(`SELECT id,body FROM poppy_client.records WHERE ns=$1 AND kind=$2 ${where}`, [this.namespace, table, ...values]))
28
28
  state[table][row.id] = JSON.parse(row.body);
29
29
  };
30
+ if (state.version === 1) {
31
+ for (const table of tables)
32
+ await rows(table, '');
33
+ upgradeClientAuthState(state);
34
+ for (const table of tables)
35
+ for (const [id, value] of Object.entries(state[table]))
36
+ await this.put(table, id, value);
37
+ await this.put('auth_meta', 'meta', { version: state.version, epoch: state.epoch, owner: state.owner });
38
+ for (const table of tables)
39
+ state[table] = {};
40
+ }
41
+ if (state.version !== 2)
42
+ throw new Error('unsupported auth state version');
30
43
  if ('issuer' in selection)
31
44
  await one('identities', selection.issuer);
32
45
  else {
@@ -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;
@@ -50,7 +50,26 @@ function positive(value, field) {
50
50
  export function parseMessage(value, options = {}) {
51
51
  const message = { ...object(value, 'message') };
52
52
  id(message.id, 'message.id');
53
- member(message.sender, ['agent', 'human'], 'message.sender');
53
+ const author = object(message.author, 'message.author');
54
+ member(author.type, ['agent', 'human'], 'message.author.type');
55
+ if ('sender' in message)
56
+ throw new ValidationError('message.sender');
57
+ for (const key of ['id', 'name', 'image'])
58
+ if (key in author) {
59
+ if (!options.company && !(options.recorded && message.role === 'company'))
60
+ throw new ValidationError('message.author.' + key);
61
+ string(author[key], 'message.author.' + key);
62
+ }
63
+ if ('image' in author) {
64
+ try {
65
+ const url = new URL(author.image);
66
+ if (url.protocol !== 'https:' || url.username || url.password)
67
+ throw 0;
68
+ }
69
+ catch {
70
+ throw new ValidationError('message.author.image');
71
+ }
72
+ }
54
73
  if (!['text', 'data', 'context'].some(key => key in message))
55
74
  throw new ValidationError('message.content');
56
75
  if ('text' in message)
@@ -126,9 +145,9 @@ export function parseEvent(value) {
126
145
  member(event.responder, ['agent', 'human'], 'event.responder');
127
146
  break;
128
147
  case 'authorization':
129
- member(event.error, ['sign_in_required', 'insufficient_scope'], 'event.error');
130
- if (event.error === 'insufficient_scope' || 'scope' in event)
131
- string(event.scope, 'event.scope', false);
148
+ member(event.error, ['insufficient_scope'], 'event.error');
149
+ if (!/^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/.test(string(event.scope, 'event.scope')))
150
+ throw new ValidationError('event.scope');
132
151
  break;
133
152
  case 'user_requested':
134
153
  string(event.reason, 'event.reason', false);
@@ -161,10 +180,12 @@ export function parseEventPage(value, requestCursor) {
161
180
  export function parseTokenResponse(value) {
162
181
  const token = object(value);
163
182
  string(token.access_token, 'access_token');
164
- string(token.session_id, 'session_id');
165
- string(token.scope, 'scope', false);
183
+ if ('scope' in token) {
184
+ const scope = string(token.scope, 'scope', false);
185
+ if (scope !== '' && (!/^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/.test(scope) || new Set(scope.split(' ')).size !== scope.split(' ').length))
186
+ throw new ValidationError('scope');
187
+ }
166
188
  member(token.token_type, ['DPoP', 'Bearer'], 'token_type');
167
- member(token.signed_in, [true, false], 'signed_in');
168
189
  positive(token.expires_in, 'expires_in');
169
190
  if ('refresh_token' in token)
170
191
  string(token.refresh_token, 'refresh_token');
@@ -215,6 +236,11 @@ export function parseAssertionClaims(value) {
215
236
  const claims = object(value, 'claims');
216
237
  for (const key of ['iss', 'sub', 'aud', 'jti'])
217
238
  string(claims[key], key);
239
+ if ('agent_id' in claims) {
240
+ string(claims.agent_id, 'agent_id');
241
+ if (claims.agent_id.length > 256)
242
+ throw new ValidationError('agent_id');
243
+ }
218
244
  // 128 random bits is at least 16 bytes; cap length since jti lands in the replay store. Encoding is not constrained.
219
245
  if (claims.jti.length < 16 || claims.jti.length > 256)
220
246
  throw new ValidationError('jti');
@@ -72,13 +72,6 @@ export interface ProviderOptions {
72
72
  subject: string;
73
73
  allowedCompanies: string[];
74
74
  } | null>;
75
- /** Required for browser assertions: independently prove session belongs to this client AND derived user. */
76
- verifySession?(binding: {
77
- clientId: string;
78
- userId: string;
79
- issuer: string;
80
- sessionId: string;
81
- }): Promise<boolean>;
82
75
  now?: () => number;
83
76
  maxEnrollments?: number;
84
77
  }
@@ -137,13 +130,13 @@ export declare function createProviderClient(options: {
137
130
  beginDirect: (input: {
138
131
  operationId: string;
139
132
  issuer: string;
140
- sessionId: string;
133
+ sessionKey: string;
141
134
  state: string;
142
135
  }) => Promise<Row>;
143
136
  takeResult: (input: {
144
137
  operationId: string;
145
138
  issuer: string;
146
- sessionId: string;
139
+ sessionKey: string;
147
140
  }) => Promise<Row>;
148
141
  cancel: (operationId: string) => Promise<Row>;
149
142
  revokeEnrollment: () => Promise<Row>;